Windows 10/11 ShimCache `10ts` の罠: 1バイトの違いで0レコードを返すシグネチャバグ
Windows 10/11 ShimCache 10ts の罠: 1バイトの違いで0レコードを返すシグネチャバグ#
TL;DR#
フォレンジックパーサーが AppCompatCache レジストリ値を3バイトの b"sts" シグネチャで検索している場合、Windows 10 1607+ のすべてのイメージで毎回0レコードしか返しません。実際のマジックは4バイトの b"10ts" (0x31 0x30 0x74 0x73) です。合成フィクスチャでは何ヶ月も検出されなかったバグで、実際の Windows E01 イメージ4枚を Plaso と並列に動かす差分テストで初めて表面化しました。修正は6行のコードと1行のコメント。診断は xxd と Eric Zimmerman の AppCompatCacheParser があれば5分で十分です。
この記事では、(1) ShimCache とは何か、なぜこのバグが「静かに失敗」するのか、(2) 具体的にどのバイトレイアウトのミスだったのか、(3) その隣に潜んでいたもうひとつのミス (path_size が BYTES なのか WCHAR×2 なのか)、(4) 自分のパーサーを検証する方法、(5) 合成フィクスチャではなく実イメージでテストすべき方法論的な理由 を順に取り上げます。
ShimCache とは何か、そしてなぜ「静かな失敗」が危険なのか#
ShimCache (正式名称は Application Compatibility Cache、レジストリの SYSTEM\ControlSet00x\Control\Session Manager\AppCompatCache に格納されます) は、Windows が残す実行証拠の中でも特に有用なソースのひとつです。
- shim エンジンが検査した実行ファイルのフルパスと
$STANDARD_INFORMATIONの最終更新タイムスタンプを記録します。 - バイナリが削除された後でもエントリが残っている場合が多く、インシデントレスポンス (IR) や内部不正調査において「このマシンでこのプログラムが実行された」という1次シグナルとして機能します。
- 再起動をまたいで保持され、シャットダウン時にレジストリへフラッシュされ、
SYSTEMハイブのコピーだけでオフライン解析が可能です。
バイナリレイアウトの正典的なリファレンスは Mandiant のホワイトペーパー Leveraging the AppCompatCache for Forensic Analysis で、その後 Eric Zimmerman の AppCompatCacheParser や Andrew Davis らのコミュニティ作業で更新されてきました。フォーマットは複数回変更されています — XP/2003、Win7/Server 2008R2、Win8/8.1、Win10 RTM、そして Windows 10 1607 (Anniversary Update, 2016) で再度変更され、それ以降は安定しています。
このような場所でパーサーバグが危険なのは、それが 静かに失敗する からです。コードの動きは次の通りです。
- ハイブを開く ✓
AppCompatCacheの値を見つける ✓- バイトを読む ✓
- エントリループを回し、マッチするシグネチャを0個発見する ✓
[]を返す
下流のコンシューマー (タイムライン、IR ピボットテーブル、MITRE ATT&CK T1518.001 検出) からは、この結果は 「この端末には実行証拠がない」 つまりクリーンなマシンと 見分けがつきません。レポートには「プログラム実行証拠は復元されませんでした」と書かれて出ていきます。例外も、ログ行も、カバレッジギャップの警告も発生しません。
バグは8バイトの中にあった#
Win10 1607+ の ShimCache レコード1件のエントリヘッダは次のようになっています (Mandiant + Zimmerman に基づく)。
| オフセット | バイト | フィールド |
|---|---|---|
+0x00 | 31 30 74 73 | DWORD signature ("10ts" ASCII) |
+0x04 | xx xx xx xx | DWORD unknown / padding |
+0x08 | xx xx xx xx | DWORD entryDataSize |
+0x0C | xx xx | WORD pathSize (BYTES、WCHAR ではない) |
+0x0E | <pathSize bytes> | path (UTF-16LE、null 終端なし) |
+... | xx xx xx xx xx xx xx xx | FILETIME lastModified |
+... | xx xx xx xx | DWORD dataSize |
+... | <dataSize bytes> | data (末尾の DWORD == 1 なら executed) |
シグネチャは b"10ts" — バイトで 0x31 0x30 0x74 0x73 です。
バグはこうでした。元のコードは3バイトの部分文字列 b"sts" (バイトで 0x73 0x74 0x73) を検索していました。ところが b"sts" は b"10ts" の中には存在しません。10ts の中に s バイトは1つしかなく、しかも末尾です。検索が blob 全体を走査してもマッチは起こらず、エントリループは entries = [] で終了します。それで終わりです。
3バイトの sts がどうして Win10 用パーサーに紛れ込んだのでしょうか。最も可能性が高いのは、誰かが古いリファレンスからマジックバイトの末尾3バイトを Python の bytes リテラルとして省略形で書き写した、というシナリオです。コンパイルは通り、lint も通り、型チェックも通り、本物の Windows 10 マジックバイトを含まないフィクスチャに対しては単体テストも通ります。本物のレジストリハイブを目の前に置くまで、誰も異議を唱えません。
修正は単純です。
# 4-byte magic per Mandiant / Zimmerman AppCompatCacheParser:
# Win10 1607+ uses ASCII "10ts" (0x31 0x30 0x74 0x73)
sig = data[offset:offset+4]
if sig != b'10ts':
next_sig = data.find(b'10ts', offset)
if next_sig == -1:
break
offset = next_sig
ポイントは2つです。(a) 比較は 4バイト で行います (3バイトではありません)。(b) 現在のオフセットにシグネチャがない場合は、bytes.find で 次の候補までジャンプ します — 1バイトずつインクリメントしません。これによってエントリ末尾のパディングを1バイトずつ再スキャンせずに済みます。
path_size の中に潜んでいた2つ目のバグ#
マジックバイトの修正直後に2つ目の問題が見つかりました。オフセット +0x0C の path_size フィールドは WORD — 2バイトのリトルエンディアンです。一部の古いリファレンスではこの値を「WCHAR 単位の文字数」として文書化しています — つまり、×2 してバイト長を得る、という意味です。しかし実際の Win10 1607+ レイアウトでは、path_size はすでにバイト長そのものです。 ×2 してしまうとエントリの末尾を超えて次のレコード (またはバッファの外) に踏み込みます。UnicodeDecodeError あるいはオフセットオーバーランが発生し、エントリループはそれを catch して skip — 静かに進みます。
Mandiant のホワイトペーパーはこの点について明確で、フィールドはバイト単位として文書化されています。とはいえ、単位の表記が食い違う2つのリファレンスを突き合わせていると、間違った方をリリースしてしまうのがどれほど簡単か、経験のある方ならご存じでしょう。
# WORD path_size は BYTES — UTF-16LE であっても ×2 してはいけない。
# Eric Zimmerman の AppCompatCacheParser ソースで cross-check。
path_len = struct.unpack('<H', data[offset:offset+2])[0]
offset += 2
path = data[offset:offset+path_len].decode('utf-16le', errors='ignore').rstrip('\x00')
offset += path_len
どうやって捕まえたか — 実イメージによる差分テスト#
ShimCache の検証はこれまで合成フィクスチャ (文書化されたレイアウトに合わせて手作業で構築した決定論的な blob) で行っていました。合成フィクスチャは通ります。常に通ります — それらはパーサーに合わせて書かれたものであり、本物の OS に合わせて書かれたものではないからです。
バグが表面化したのは、本物の Windows E01 イメージ4枚を Plaso (Google DFIR チームが保守するリファレンス用パーサー一式) と並べて自社パーサーを走らせた時でした。イメージサイズは 13 GB から 23 GB、すべて Windows 10 1607+ または Windows 11 です。4枚すべてに AppCompatCache の値が埋まっていました。Plaso は 4枚のうち 3枚でレコードを返しました (4枚目は空で、Plaso と自社パーサーの両方が0で一致しました)。ところが自社パーサーは 4枚すべてで0レコードを返し、Plaso が明らかにデータを見つけた3枚でも結果は0でした。
診断はデータが手元に来た後5分で済みました。レジストリ値のバイトをダンプしてマジックを探すだけです。
# illustrative hexdump of an AppCompatCache value, header 0x34
00000000: 3400 0000 0000 0000 0000 0000 0000 0000 4...............
00000010: 0000 0000 0000 0000 0000 0000 0000 0000 ................
00000020: 0000 0000 0000 0000 0000 0000 0000 0000 ................
00000030: 0000 0000 3130 7473 b2c8 7e88 e0e0 0000 ....10ts..~.....
オフセット 0x34 にバイト 31 30 74 73 ("10ts") が見えます。それがマジックです。"sts" ではありません。
マジックバイトと path_size の修正が入った後、データが埋まっていた3枚のイメージはきれいにパースされました。4枚目 (本来空) は0レコードを返し、Plaso と一致 — Plaso 差分テストの判定は MATCH ("両方のツールが0イベントを生成 — 整合") となりました。これは「業界リファレンスと一致している」という Daubert に耐えうるスタンスから期待される結果そのものです。
それと同時に、Mandiant + Zimmerman レイアウトに正確に従って Win10 エントリを構築する合成リグレッションテストも書きました (4バイトの 10ts マジック、BYTES の path_size、FILETIME、実行フラグを含むオプションのデータセグメント)。今では正しいパスと正しい executed ブール値で 3/3 エントリを生成 — 壊れたシグネチャに戻ってしまうことを防ぐガードとして機能します。
自分のパーサーを5分で検証する方法#
自社の ShimCache コードがあるか、古い OSS パーサーをフォークして使っているなら、以下の5分セルフチェックを推奨します。
- 合法的に所有している Windows 10 1607+ のマシンから
SYSTEMハイブを取得します (自分のラップトップでも可)。 reglookup、python-registry、またはregf-toolsでAppCompatCacheレジストリ値のバイトを抽出します。xxdでダンプします。オフセット0x30または0x34を見ます。31 30 74 73("10ts") が見えれば、ハイブはモダンレイアウトを使っています。- 同じハイブに対して自分のパーサーを走らせます。 0レコードが返ってくるのにステップ3で
10tsが見つかったなら、シグネチャ検索が壊れています。 - 同じハイブに対して Eric Zimmerman の AppCompatCacheParser を走らせます。レコード件数は数件以内の差で一致するはずです (一部のパーサーは末尾の truncated エントリの扱いが異なるため、わずかな差は許容、桁違いの差は許容しません)。
フォレンジックツールをリリースしているのに、ステップ5を本物のレジストリハイブに対して一度も走らせたことがないなら、宿題が一つあるということです。
方法論的な含意#
実イメージテストを1ラウンド回しただけで、6つのスキーマドリフトバグを捕まえました。ShimCache 10ts はその一つで、残りはモダンな Android (WhatsApp の chat → jid join で230メッセージが静かに脱落していた件、Telegram が messages → messages_v2 にテーブル名を変えていた件、Contacts スキーマが raw_contacts に分離していた件、Android 11+ で packages.xml が ABX フォーマットに変わっていた件) と モダンな iOS (iOS 17 Safari で hi.title が hv.title にカラムが移った件) にまたがっていました。すべて合成フィクスチャでは通っていました。すべて実イメージテストをスキップしていれば、本番環境に静かに出ていたはずのバグです。
繰り返し現れる失敗パターンは同じです。実 OS のスキーマはリリース間で静かに変わり、フィクスチャ作成者はその変更に追いつかず、パーサーは存在する唯一のテストを「通り続け」、静かな失敗は「このデバイスには証拠がない」と見分けがつかない。 フォレンジック作業における最悪の種類のバグ — 高い確信をもって誤った答えを出すバグです。
教訓は「合成フィクスチャを書くな」ではありません。合成フィクスチャは、我々が想定する レイアウトに対してパーサーを単体テストするには優秀です。ただし 業界リファレンスツールを基準にした実イメージ差分テスト とペアにしなければなりません — クロスプラットフォームには Plaso、Windows レジストリには Eric Zimmerman のツール、iOS/Android には該当するモバイルリファレンス、それぞれに合わせて。ビルドに実イメージ差分のレーンが含まれていなければ、第二の計器なしで飛行しているも同然です。
この修正はどこにあるか#
この修正は unjaena-collector v2.4.0 と、それに対応するサーバー側パーサーに含めてリリースされました。collector は AGPL-3.0 ライセンスです。バイナリレイアウトのリファレンスとしては、Mandiant の Leveraging the AppCompatCache for Forensic Analysis ホワイトペーパーと Zimmerman のリポジトリ の2つが最も信頼できます。
弊社がカバーしている他のアーティファクトで似たスキーマドリフトを見つけた場合は、issue を立ててください。もうひとつの「静かな0」をリリースするより、先に教えていただける方がはるかに助かります。