3
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

RHEL9運用環境で解体不要!Pythonによるjarバイトコード解析でデッドコードを証明する

3
Posted at

はじめに

保守運用の現場では、「この処理、本当に使われているの?」を証明しなければならない場面がときどきあります。

先日、あるWebシステムで認証まわりの保管方式を見直す案件があり、その付帯調査として既存のハッシュ化処理を棚卸ししていました。その中で、外部連携用ライブラリに定義された MD5ハッシュ+DES暗号の認証処理(いまとなっては暗号強度的に推奨されない構成)が見つかります。

ソースコードを全文検索すると、呼び出し元は単体テストだけ。「たぶんデッドコードだろう」とは思えるのですが、セキュリティ調査の資料に「本番未使用」と書くには、それでは弱い。手元のソースと本番で動いているバイナリが完全に一致している保証はありませんし、レビューする側も「実機で動いているjarで使われていない」ことを求めてきます。

そこで、実機にデプロイされているjarそのものを解析して、呼び出し元がゼロであることを証明することにしました。ところが、その実機には解析ツールが何もなかった——というのが本記事の出発点です。

記事中のサーバ名・クラス名・メソッド名・パスはすべて一般化した仮のものです。

問題:調査対象サーバに開発ツールがない

対象は商用系の検証サーバ(RHEL 9)。Javaアプリの実行環境ですが、状況はこうでした。

  • JREのみでJDKなしjavap(クラスファイル逆アセンブラ)が使えない
  • unzipjar コマンドもない → jarを展開できない
  • 商用系のため、資材を手元に持ち出して解析するのも手続き上のハードルが高い
  • 当然、新しいツールを勝手にインストールすることもできない

詰んだように見えますが、ひとつだけ武器がありました。RHEL 9には python3 が標準で入っていることです。

発想の転換:jarはただのZIP、呼び出しは定数プールに残る

jarは展開しなくても読める

jarファイルの実体はZIPアーカイブです。つまりPython標準ライブラリの zipfile で、ディスク上に展開することなく中身を直接読めます。展開先の容量や書き込み権限を気にする必要もありません。

メソッド呼び出しの痕跡は定数プールにある

ここが本記事の核です。Javaのclassファイルは先頭付近に**定数プール(constant pool)**という領域を持っていて、そのクラスが使う文字列・クラス名・メソッド参照などがすべてここに登録されます。

あるクラスが他クラスのメソッドを呼び出すと、コンパイル時に次のような参照チェーンが定数プールに記録されます。

CONSTANT_Methodref ──> CONSTANT_Class       ──> CONSTANT_Utf8 "呼び先クラス名"
                  └──> CONSTANT_NameAndType ──> CONSTANT_Utf8 "メソッド名"
                                            └─> CONSTANT_Utf8 "シグネチャ"

重要なのは、**「クラスAがメソッド m を呼ぶなら、Aのclassファイルの定数プールに必ず文字列 m と呼び先クラス名の両方が存在する」**という性質です。

この対偶を取ると、こうなります。

classファイルのバイト列に文字列 m が存在しない ⇒ そのクラスはメソッド m を呼んでいない

つまり、jar内の全classファイルを走査してメソッド名の文字列を検索し、ヒットするのが定義クラス自身だけなら、呼び出し元はjar内に存在しない=デッドコードと証明できます。

設計判断:なぜ厳密なパーサを書かず、バイト列検索で済ませたか

定数プールはフォーマットが公開されているので、真面目にパースするスクリプトも書けます。しかし今回はあえてclassファイルのバイト列に対する単純な部分一致検索を選びました。理由は、証明したい命題が「呼び出し元が存在しないこと」だからです。

  • バイト列検索は、定数プール以外の場所(文字列リテラルなど)にもヒットし得るため、偽陽性はあり得ます
  • しかし定数プールもバイト列の一部である以上、偽陰性はありません。検索して0件なら、確実に呼ばれていない

「不存在の証明」に使う限り、誤りが安全側(過剰検出側)にしか倒れない、ということです。

偽陽性への手当ては、パーサを書く代わりに判定を2軸にすることで済ませました。前述のとおり、メソッドを呼ぶクラスの定数プールには「メソッド名」と「呼び先クラス名」の両方が載ります。そこで、

  1. メソッド名でヒットしたクラスから、定義クラス自身を除外する
  2. 残ったクラスのうち、呼び先クラス名(LegacyAuthUtils)も含むものだけを「呼び出し元候補」とする

という絞り込みにしました。たまたま同名メソッドを持つ無関係なクラスは2軸目で落ちるので、単純な文字列検索でも実用上十分な精度になります。18,000超のクラスを相手にするのに、これで十分でした。

実装:約18,000クラスを走査する

Step 1: まず全体像を把握する

いきなり検索せず、jarの規模と構造を確認します。ここはワンライナーで足ります。

python3 -c "
import zipfile
z = zipfile.ZipFile('/opt/app/lib/app-with-dependencies.jar')
names = [n for n in z.namelist() if n.endswith('.class')]
print(len(names))
print([n for n in names if 'LegacyAuthUtils' in n])
"

今回の対象は依存ライブラリ込みのfat jarで、約18,000クラス。この時点で「目視やgrepの手作業でどうにかなる規模ではない」ことが確定します。

Step 2: 走査スクリプトを流し込む

実際に検証サーバで実行したスクリプト(一般化)です。サーバにスクリプトファイルを置きたくなかったので、ヒアドキュメントで標準入力から流し込む形にしました。python3 - はスクリプトをstdinから読む指定で、jarのパスは argv で渡します。

JAR=/opt/app/lib/app-with-dependencies.jar

python3 - "$JAR" <<'PY'
import sys, zipfile

jar = sys.argv[1]
z = zipfile.ZipFile(jar)
classes = [n for n in z.namelist() if n.endswith('.class')]
print("total .class entries:", len(classes))

# 定義クラス(調査対象)の所在を確認
auth = [n for n in classes if n.split('/')[-1].startswith('LegacyAuthUtils')]
print("定義クラス:", auth if auth else "見当たらず(=このjarに無い)")

# 調査対象メソッド名(バイト列で持つ)
methods = [b'createAuthHash', b'encryptPassword', b'decryptPassword', b'createDesKey']

print("\n-- (A) メソッド名を含む .class(定義 or 呼び出し側の定数プール)--")
hits = []
for n in classes:
    data = z.read(n)          # 展開せずメモリ上で読む
    found = [m.decode() for m in methods if m in data]
    if found:
        hits.append(n); print("  ", n, found)
if not hits: print("   なし")

print("\n-- (B) 呼び出し元の検出(定義クラス自身を除外し、クラス名参照+メソッド名で突合)--")
callers = []
for n in hits:
    if n in auth: continue                 # 定義クラス自身は当然ヒットするので除外
    if b'LegacyAuthUtils' in z.read(n):    # 呼び先クラス名も含むものだけが呼び出し元候補
        callers.append(n); print("  CALLER?:", n)
print("   → 呼び出し元候補あり" if callers else "   なし(= 呼び出すクラスはjar内に存在しない=本番未使用が確定)")

print("\n-- (C) 現行の連携実装クラス(参考・呼出経路の裏取り)--")
for n in classes:
    b = n.split('/')[-1]
    if b.startswith('External') or 'Soap' in b:
        print("  ", n)
PY

ポイントは4つあります。

  • z.read(n) で1クラスずつメモリに読むので、ディスクへの展開が一切発生しない。運用サーバのディスクを汚さない
  • メソッド名はバイト列(b'...')で比較する。classファイルはバイナリなので、文字列型でのやり取りより素直で速い
  • (B)で「定義クラスの除外」と「クラス名参照の有無」の2軸に絞る。前述の設計判断のとおり、これが偽陽性への実用的な手当てになる
  • (C)で「本命の実装」がjar内に存在することも同時に確認する。「怪しい処理が呼ばれていない」だけでなく「実際の処理は別経路で実装されている」まで示せると、デッドコード判定の説得力が段違いに上がる(陽性対照の考え方です)

Step 3: 実行結果

約18,000クラスの走査は数秒で終わり、出力はこうなりました(クラス数を含め一般化しています)。

total .class entries: 18xxx
定義クラス: ['com/example/gateway/common/LegacyAuthUtils.class']

-- (A) メソッド名を含む .class(定義 or 呼び出し側の定数プール)--
   com/example/gateway/common/LegacyAuthUtils.class ['createAuthHash', 'encryptPassword', 'decryptPassword', 'createDesKey']

-- (B) 呼び出し元の検出(定義クラス自身を除外し、クラス名参照+メソッド名で突合)--
   なし(= 呼び出すクラスはjar内に存在しない=本番未使用が確定)

-- (C) 現行の連携実装クラス(参考・呼出経路の裏取り)--
   com/example/service/ExternalSoapService.class
   com/example/service/ExternalQueryService.class
   com/example/service/ExternalOrderService.class
   (ほか、SOAP連携の実装クラス群)

読み解くと、

  • (A) 4メソッド名を含むクラスは、約18,000クラス中定義クラス1件のみ(定義クラスの定数プールには自分のメソッド名が載るので、これは当然のヒット)
  • (B) 定義クラスを除くと呼び出し元候補は0件
  • (C) 実際の連携処理はSOAP通信サービス系のクラス群で実装されており、本命の経路が別に存在することも確認

事前に行っていたソースコードの全文検索(呼び出し元は単体テストのみ)とも整合するため、「実機のバイナリ」と「ソース」の両面から、この認証処理はデッドコードであると確定できました。

Before / After

Before(ツールなしの正攻法) After(Python解析)
jarの展開 必要(unzip/jarがなく不可能) 不要
逆アセンブル javapが必要(JDKがなく不可能) 不要
走査対象 約18,000クラス(手作業では非現実的) 全クラスを数秒で走査
得られる結論 「たぶん使われていない」止まり 呼び出し元0件を実機で証明

この結果により、調査資料に「本番未使用(デッドコード)」と根拠付きで明記でき、当該処理を「現用のセキュリティ懸念」のリストから外して「将来のコード整理候補」へ格下げできました。改修スコープの議論が明確にひとつ片付いたわけです。

応用:この手法はその後、正式な試験手順になった

後日談ですが、同じシステムのJDBCドライバ更新案件で「デプロイされたjarに新ドライバのクラスが含まれているか」を確認する必要があり、このzipfile方式がそのまま試験項目書の正式手順として採用されました。

python3 -c "import zipfile; z=zipfile.ZipFile('/opt/app/lib/app.jar'); print([n for n in z.namelist() if 'postgresql' in n.lower()][:10])"

試験項目書の備考欄には「本サーバでは unzip / jar コマンドは使用不可。必ず python3 を使用」という注記が入りました。一度きりのハックではなく、チームの標準手順として定着した形です。

ちなみにローカルのWindows端末でビルド成果物を確認する場合は、PowerShellの System.IO.Compression で同じことができます。

Add-Type -Assembly System.IO.Compression.FileSystem
$zip = [System.IO.Compression.ZipFile]::OpenRead('C:\work\app.jar')
$zip.Entries | Where-Object { $_.FullName -match 'postgresql' } | Select-Object -First 10 -ExpandProperty FullName
$zip.Dispose()

「jarはZIP」という事実さえ押さえておけば、環境ごとに使える道具で同じ調査ができます。

注意点と限界

この手法は万能ではないので、適用範囲を正しく理解しておく必要があります。

  • 証明できるのは「このjarの中に呼び出し元がない」ことまで。別モジュールのjar、JSP、外部スクリプトなど、jarの外からの呼び出しは別途確認が必要です(今回は連携処理の実行ノードにデプロイされる唯一のjarだったため、これで足りました)
  • リフレクション経由の呼び出しは、getMethod("createAuthHash") のようにメソッド名を文字列リテラルで書いていれば定数プールに載るので検出できます。ただし文字列を動的に組み立てて呼び出すケースは検出できません
  • バイト列検索の偽陽性は、(B)の2軸判定(メソッド名+クラス名参照)でかなり抑えられますが、それでも候補が出た場合は、そのクラスだけ定数プールを精査するか、ソースと突き合わせて確認してください。0件のときだけ、そのまま結論に使えます
  • メソッド名が getrun のような超汎用名だと(A)が偽陽性だらけになります。その場合は(B)のクラス名側を主軸にする、スラッシュ区切りの内部名(com/example/...)で検索する、などの絞り込みが有効です

おわりに

「調査したいサーバに開発ツールが何もない」という状況は、保守運用の現場では珍しくありません。ですが、

  • jarはただのZIPであり、zipfile で展開せずに読める
  • メソッド呼び出しの痕跡は、呼び出し側classファイルの定数プールに「メソッド名」と「呼び先クラス名」の両方の文字列として必ず残る
  • 「不存在の証明」なら、偽陰性のない単純なバイト列検索+2軸の突合で十分成立する

この3点を押さえるだけで、python3ひとつで約18,000のクラスを数秒で走査し、「この処理は本番で呼ばれていない」を実機ベースで証明するところまで到達できます。おまけに「本命の実装が別経路に存在する」ことまで同じスクリプトで示せます。

標準ツールが使えないことは、調査を諦める理由にはなりません。どこにでもいるPythonを連れて、バイトコードの中まで見に行きましょう。

3
3
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?