Windowsでは通る署名検証がWineでは失敗する
同じDLLを二つの環境で読み込む。一方は仮想マシン上のWindows、もう一方はmacOS上で動くWineだ。
Windowsでは何も起きない。DLLは読み込まれてプロセスは先へ進み、ごく普通の起動になる。
Wineでは起動した直後にプロセスが消え、終了コードの0xc0000142はDLLの初期化失敗を示している。
落ちているのは実行ファイルではなく、実行ファイルが読み込む依存DLLのDllMainが失敗を返している。
DllMainはDLLが読み込まれた瞬間に一度だけ走る初期化関数で、ここはまだ起動の一歩目にすぎない。何かを始める前に終わっている。
鍵コンテナと暗号処理を原因候補から外す
鍵コンテナ、暗号処理、初期化状態を確認し、署名検証より前の原因ではないことを除外した。
欠けている鍵を偽造して埋める棄却
起動ログに開けない暗号鍵コンテナが1つあり、正常に開く鍵の隣に開かない鍵が並んでいる。中身をコピーして偽の鍵を作り同じ場所へ置いた。
偽の鍵は開き、エラーも消えた。それでもプロセスは同じ場所で落ちるので、鍵はこのクラッシュと関係がない。
暗号処理そのものを疑う棄却
偽の鍵でも本物の鍵でも暗号ライブラリの呼び出しは全て成功していて、失敗しているのは暗号処理ではない。鍵の中身を調べるより先に呼び出しの成否を見るだけで消えた仮説だ。
まだ有効化されていないから起動しない棄却
初期化がまだ済んでいないから止まるのではないか。次はそう疑ったが、落ちる位置は状態を確認するどのコードよりも手前にある。確認へ辿り着く前に死んでいる。
常駐して見えた過去の観測誤読
以前の記録ではこのDLLを使うプロセスが常駐しているように見えたが、プロセス一覧を追い直すと起動直後に同じ理由で死んでいる。常駐していたのは別のサービスで、観測を取り違えていた。
ImageGetDigestStreamの失敗地点を捕捉する
ログから分かるのは失敗した関数名までなので、呼び出しをその場で捕まえて実行中のコードを読みにいった。
DLLは自分のファイルから公開鍵と署名を取り出し、ファイルの中身からハッシュを計算して、そのハッシュと署名を照合する。
呼ばれていたのはimagehlpのImageGetDigestStreamとadvapi32のCryptVerifySignatureAだった。
照合の戻り値は0、つまり失敗だ。直後に例外が飛んでDllMainが打ち切られ、FALSEが返った瞬間にプロセスが終わる。
逆アセンブルでも一致し、照合を呼んだ直後の分岐が失敗側で例外送出へ飛んでいる。ここで失敗が決まる。
このDLLが起動時に自分自身の署名を検証するのは改ざん検知として正当な作りで、問題はWine側にあった。
ImageGetDigestStreamが出すdigestはWindowsの計算と一致しない。この関数はWine本体から受け継いだコードで、フォークで新しく書いた部分ではなかった。すぐ近くには元の実装者が残した注記もある。この計算はまだ正確ではない。本人がそう書き残していた。
標準のPE digest計算では結果が一致しない
ソースを読むと不正確さは2か所あり、まずヘッダで本来ゼロにすべきでない項目までゼロにしていた。さらにセクションをファイル順ではなく名前順に並べ替え、一部を取りこぼしていた。
Authenticodeの標準的な計算に沿って書き直した。チェックサムと署名格納領域だけをゼロにし、あとはファイル先頭から連続したバイト列としてハッシュを取る。ビルドし直して配備し、実行した。
ImageGetDigestStreamはエラーなく値を返した。
それでも照合は通らない。戻り値は0のままで、プロセスは同じ場所で落ちた。
計算は動いている。それでも一致しない。疑うべきは計算式ではなく、呼び出し側が何を要求しているかだった。
DigestLevelから期待値を復元する
捕捉ログをもう一段深く読むと、渡されていた引数が見えた。
DigestLevelが0x6。RESOURCESとALL_IMPORT_INFOを合わせた値だ。標準のAuthenticodeはこのフラグを見ないので、呼び出し側はフラグで内容が変わるWindows独自の計算を期待している。
ではWindowsは何を計算しているのか。推測するより速い道があり、答えは署名から取り出せばよかった。
署名は秘密鍵で暗号化されたハッシュだ。埋め込まれた公開鍵でRSA検証を手で回してパディングを剥がすと、Windowsが期待するハッシュがそのまま出てくる。あとはこの値を生むバイト列がどれかを探すだけになる。
正解は元のper-section構造の側にあった。直すべき点は2つで、ゼロにするのはチェックサムとSECURITYエントリだけ、そしてセクションはテーブル順に全部つなぐ。
実装し直して配備すると、CryptVerifySignatureAの戻り値が0から1へ変わった。DLLを読み込む2つのプロセスの両方で確認し、0xc0000142は消えた。
復元したdigestだけがWindowsと一致する
観測 A ── 標準の計算式では戻り値が変わらない
CryptVerifySignatureA(...) = 0 NTE_BAD_SIGNATURE DllMain -> throw -> status c0000142
標準の計算式に直した後も戻り値は0のままだった。
観測 B ── フラグが計算方式を指定していた
ImageGetDigestStream(handle,
DigestLevel=0x6, ...)
0x6 = RESOURCES(0x2) | ALL_IMPORT_INFO(0x4)
呼び出し側はフラグ依存のセクション単位の計算を求めていた。
観測 C ── 修正後は両プロセスで反転
CryptVerifySignatureA(...) 0 -> 1 NTE_BAD_SIGNATURE = 0件 status c0000142 = 0件
常駐サービスと、それが起動するプログラムの両方で同じ反転を確認した。
Windows比較から計算範囲を逆算する
本物のWindowsとの突き合わせから始めて安い仮説を除き、捕捉と逆算を順に重ねた。推測に頼らず答えへ着いた。
本物のWindowsと突き合わせる
同じDLLを仮想マシン上のWindowsで読み込み、これがWine固有の問題であることを先に確定させた。
実験1回で済む仮説を先に消す
ソースを深く読む前に、実験1回で白黒がつく仮説から先に消した。鍵の偽造も暗号呼び出しの成否もそこで片付いた。
捕捉と逆アセンブルで現場を押さえる
ログだけでは足りない。呼び出しをその場で捕まえ、実行コードを読み、失敗が決まる一行まで絞った。
照合の成否で判定する
関数がエラーなく返っても、それが正しい値である証明にはならない。判定は照合の成否で行った。
呼び出し側の引数を読む
最初は計算式だけを見ていた。渡されたフラグを読むことで、要求されている方式が特定できた。
署名から逆算する
公開鍵で署名を割って期待ハッシュを取り出し、それを生むバイト列を後から組み立てた。
成功コードだけで署名検証の正しさを判断しない
成功コードは正しさの証明ではない
関数が成功を返したことと照合が通ったことは別の基準なので、内部の成功コードではなく最終的な判定で確かめる。
安い仮説から潰す
鍵の偽造は数分で白黒がついた。ソースを深く読む前に、実験1回で消せる仮説を先に処理する。
APIを再現するなら引数の意味まで再現する
同じ関数名でも渡すフラグ次第で期待される中身は変わるので、代表的な形だけを実装しても足りない。
答えが分からなければ痕跡から逆算する
期待値そのものが署名の中に暗号化されて埋まっていたので、推測を重ねるより公開鍵で割って取り出す方が速い。
修正済みの範囲と一般化しない条件
原因と修正は確認済みだが、結果を未検証の範囲へ広げることはしない。
- ImageGetDigestStreamをWindowsと一致するdigestに実装し直した。
- 実行中のプロセスで署名照合が0から1へ変わり、0xc0000142は再現しなくなった。
- 共有ランタイムへビルドして配備し、配備後の実行でも同じ結果を確認した。
- 変更したのはWine側だけ。読み込まれるDLLには手を触れていない。
- 修正した実装はWine同梱のライブラリとして読み込ませる必要がある。設定でOS側のコピーを優先すると、そこにこの実装は無く、読み込み自体が別の理由で失敗する。
- 署名検証を通過した先の処理は、この記録の範囲外。
- 通過した直後に、32bitと64bitでレジストリの参照先が食い違う別の起動阻害が見つかった。原因も対処も独立している。
- 検証対象は特定のDLL 1本。自己署名検証を行う他のプログラムへ無条件には広げない。