Ask HN: Xbox 360ハイパーバイザーのハッキング Part 3: Bad Updateの改善
Ask HN: What your experience with the monthly "who wants to be hired" thread? (news.ycombinator.com)
要約
本記事は、Xbox 360ハイパーバイザーのハッキングシリーズの第3部です。前回のPoC(概念実証)公開後、「Bad Update」エクスプロイトがどのように改善され、実行時間が約20分からほぼ瞬時に、成功率が約30%からほぼ100%になったかが解説されます。Rock Band Blitzのデモ版を利用した新しいエントリポイントや、ハイパーバイザーの競合状態の最適化、キャッシュフラッシュの改良、スレッドスケジューリングの改善、そして暗号化メモリの観測方法の改良について詳細に説明されています。
全文翻訳
Xbox 360ハイパーバイザーのハッキング Part 3: Bad Updateの改善
Xbox 360ハイパーバイザーのハッキングブログシリーズの第3部へようこそ。このパートでは、「Bad Update」のPoC(概念実証)を公開した後、何が起こり、それがどのように約20分の実行時間と約30%の成功率から、ほぼ瞬時でほぼ100%の成功率へと進化したのかを解説します。まだ読んでいない場合は、パート1とパート2を読む(または少なくとも目を通す)ことを強くお勧めします。この投稿では、そこで議論された多くの素材を参照します。このパートは非常に短いですが、近日公開予定のピアプレッシャーエクスプロイトをカバーするパート4の準備ができます。
Rock Band Blitz
「Bad Update」v1.0のリリース後数ヶ月の間に、他の人々によって2つの大きな改善が行われました。最初のものは「ihatecompvir」と「InvoxiPlayGames」によるもので、Rock Band Blitz (RBB) のデモ版にあるDaTArrestと呼ばれる新しいゲームセーブのエントリポイントです。RBBのデモ版は、通常Xbox Liveストアから無料でダウンロードされ、コンソールのHDDまたはメモリユニットに保存されるパッケージファイルです。Xbox 360がUSBスティックをストレージデバイスとしてサポートするようになると、HDDまたはメモリユニットから起動するように署名されたすべてのパッケージが、USBストレージデバイスからも起動できるようになりました。これは、RBBデモパッケージを通常のファイルとしてUSBスティックにコピーし、それをサポートするソフトウェアバージョンを持つどのコンソールでも起動できることを意味します。
ゲームのDLC解析コードにあるいくつかのメモリ破損の脆弱性を悪用することで、ロード時に「Bad Update」ステージ2 ROPチェーンをブートストラップし、ハイパーバイザーエクスプロイトチェーンを開始できるゲームセーブファイルを作成することができました。RBBをエントリポイントとして使用することで、Tony Hawk American Wastelandのゲームディスクは不要になりました。これで、「Bad Update」エクスプロイトの実行は、USBスティックにいくつかのファイルをコピーし、それをコンソールに接続し、ダッシュボードからRock Band Blitzを起動するのと同じくらい簡単になりました。
[画像: Rock Band BlitzがBad Updateエクスプロイトファイルを実行している様子]
ハイパーバイザー競合状態の最適化
RBBエントリポイントが発見された直後、「Máté」からプルリクエストを受け取りました。彼はハイパーバイザー競合状態の部分にいくつかの改善を加えていました。これらの改善には、悪意のある暗号文値を事前計算して保存するためのハッシュテーブル、より良いキャッシュフラッシュプリミティブ、そしてXKEペイロードスレッドと暗号文上書きスレッド間の改善されたスレッドスケジューリングが含まれていました。これらの改善の組み合わせにより、実行時間が数秒に短縮され、競合状態の成功率が大幅に向上しました。
暗号文ハッシュテーブル
パート2では、暗号化メモリの攻撃について、ハイパーバイザーAPIを使用して所定のメモリアドレスに暗号化メモリ割り当てを作成し、2つの異なるメモリパスを使用して平文データに対する暗号文をキャプチャした方法について説明しました。このプリミティブを使用して、カーネルモードコード実行を取得するための暗号文データ、およびブートローダーアップデートXKEペイロードのLZX解凍コードを攻撃するための暗号文データを生成できました。
メモリ暗号化スキームで使用されるホワイトニング値は12ビットで、特定の平文データに対して1024通りのユニークな暗号文の組み合わせがあります。しかし、私の元のPoCでは、1024通りの可能なホワイトニング値のうちの1つに対する暗号文しか生成していませんでした。その理由は、私の側の2つの失敗によるものです。最初に暗号文攻撃のルックアップテーブルをプロトタイプしたとき、私は実際に暗号文の組み合わせのためのルックアップテーブルを使用しようとしましたが、うまくいきませんでした。
最初の問題は、非常に古い約2011年頃のハイパーバイザー/カーネルビルド(私の古い研究のすべてが基づいていたもの)を使用していたことです。これは、メモリ暗号化を設定するハイパーバイザー関数で、暗号化されたVA範囲のTLBフラッシュを行っていました。後のハイパーバイザービルドでは、このハイパーバイザー関数をラップするカーネルラッパー関数がすでにVA範囲のTLBフラッシュを行っているため、そのTLBフラッシュは削除されました。しかし、私はカーネルラッパーコードをスキップしてハイパーバイザーシステムコールを直接呼び出すため(TLBフラッシュを行うカーネルラッパーコード)、TLBは遅延ロードされ、ホワイトニングビットは古くなり、メモリから読み取られた結果の暗号文は無効になりました。
[画像: 古いハイパーバイザー(左) vs 新しいハイパーバイザー(右)と欠落したTLBフラッシュ]
17559(最新)バージョンのハイパーバイザーでエクスプロイトをテストしたとき、ルックアップテーブルは問題を引き起こしました。調査する代わりに、後で修正することにして先に進みました。エクスプロイトの終わりに近づくにつれて、CPUキャッシュの問題を追いかけることに夢中になりすぎて、ルックアップテーブルはうまくいかないと確信してしまいました。おそらく行うべきだったこと(ルックアップテーブルの問題が発生したときに調査することに加えて)は、このプロジェクトを数週間休止して、クリアな心で戻ってくることでした。まあ、教訓になりました…
ハッシュテーブルの実装詳細に入ると、期待されるメモリ内の暗号文の最初のNビットを2つの配列のインデックスとして使用します。最初の配列には、ハッシュテーブルで衝突が発生した場合の検証に使用されるオラクル暗号文の最初の64ビットが含まれています。2番目の配列には、メモリに書き込みたい悪意のある暗号文が格納されています。
| | |
| --- | --- |
| 1 2 3 4 5 6 7 8 9 10 11 12 13 | // Index bits of the ciphertext lookup pseudo-hashtable #define HASH_BITS 20 struct CIPHER_TEXT_DATA {
ULONGLONG dec_end_input_pos;
ULONGLONG dec_output_buffer; // New dec_output_buffer pointer (dst address for memcpy)
ULONGLONG OracleCipherText[1 << HASH_BITS]; // Cipher text for the LZX decoder context header, used to detect when to start the race attack
ULONGLONG ReplacementCipherText1[1 << HASH_BITS]; // Cipher text containing our malicious dec_output_buffer pointer (points to hypervisor memory)
ULONGLONG ReplacementCipherText2[1 << HASH_BITS];
};
|
これを可能な限り効率的にするために(CPUとキャッシュで競合していることを思い出してください)、衝突は無視します。ハッシュテーブルのバケットサポートを実装するよりも、いくつかの衝突が発生して競合状態ループの反復をいくつかスキップする必要がある方が良いです。代わりに、ハッシュテーブルキーのビット数を十分に大きく選択することで衝突を補償します。これにより、衝突の数は、たとえあったとしても、単桁のオーダーになります(ほとんどの場合0です)。
しかし、ハッシュテーブルキーのビット数が多いほど、エントリを保持するための大きな配列が必要になり(メモリ使用量が増加)、CPUキャッシュがスレッシュされているためRAMからのフェッチが増加する可能性があるため、私たちに不利に働く可能性があります。Mátéの元のPRは10ビットのキーサイズを使用していましたが、私は後にこれを20ビットに引き上げました。これは、通常衝突がないか、あっても低位の単桁数であるハッピーな場所のようです。両方のハッシュテーブルを保持するために必要なメモリの総サイズは約24MBです。
| | |
| --- | --- |
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 | void BuildCipherTextLookupTable(BYTE* pUpdateData, DWORD UpdateDataSize, DWORD ScratchOffset, CIPHER_TEXT_DATA* pCipherTextData)
{
// Arbitrary virtual address that will get mapped through the encrypted pathway to BaseAddrPhys.
BYTE *pMemoryAddress = (BYTE*)0x8D000000;
...
// Scan all 1024 possible whitening bits.
for (int i = 0; i < 1024; i++)
{
// Allocate encrypted memory.
HvxEncryptedReserveAllocation((DWORD)pMemoryAddress, BaseAddrPhys, UpdateDataSize);
HvxEncryptedEncryptAllocation((DWORD)pMemoryAddress);
// Manually flush the TLB entry for the encrypted allocation address so we pick up the new whitening value.
HvxFlushSingleTb(pMemoryAddress);
// Get the cipher text for the expected header data in the LZX decoder context. This acts as a
// trigger to know when to start the race attack.
memset(pMemoryAddress, 0, 16);
*(ULONG*)(pMemoryAddress + 0) = 0x4349444c; // signature = 'CIDL'
*(ULONG*)(pMemoryAddress + 4) = 0x8000; // windows size = 0x8000
*(ULONG*)(pMemoryAddress + 8) = 1; // cpu type = 1
__dcbst(0, (char *) pMemoryAddress);
KeFlushCacheRange(pMemoryAddress, 0x80);
KeFlushCacheRange(pUpdateData + ScratchOffset, 0x80);
// Use the first N bits of the cipher text data as an index into the lookup table.
ULONGLONG oracleData = *(ULONGLONG *)(pUpdateData + ScratchOffset);
int index = oracleData >> (64 - HASH_BITS);
// Check if we hit a collision in the cipher text lookup table.
if (pCipherTextData->OracleCipherText[index] != 0)
collisionCount++;
// Add the current cipher text entry to the lookup table, overwriting any existing value.
pCipherTextData->OracleCipherText[index] = oracleData;
// Get the corresponding cipher text for our malicious dec_output_buffer pointer which points to the hypervisor code we want to overwrite.
*(ULONGLONG*)(pMemoryAddress + 0x2B20) = pCipherTextData->dec_end_input_pos;
*(ULONGLONG*)(pMemoryAddress + 0x2B28) = pCipherTextData->dec_output_buffer;
__dcbst(0, (char *) pMemoryAddress + 0x2B00);
KeFlushCacheRange(pMemoryAddress + 0x2B00, 0x80);
KeFlushCacheRange(pUpdateData + ScratchOffset + 0x2B00, 0x80);
// Store cipher text for our malicious dec_output_buffer pointer into the lookup table.
pCipherTextData->ReplacementCipherText1[index] = *(ULONGLONG *) (pUpdateData + ScratchOffset + 0x2B20);
pCipherTextData->ReplacementCipherText2[index] = *(ULONGLONG *) (pUpdateData + ScratchOffset + 0x2B28);
// Free the encrypted allocation.
result = HvxEncryptedReleaseAllocation((DWORD)pMemoryAddress);
}
}
|
上記の抜粋を見ると、ハッシュテーブルがどのように構築されているかがわかります。1024回のループで、このメモリページに対するすべての可能なホワイトニング値を網羅します。各反復で、ページを暗号化し(ホワイトニング値が増加します)、メモリ内にあると予想されるLZXデコーダーデータの暗号文と、悪意のあるデータの暗号文を取得し、それらをハッシュテーブルに追加します。15〜16行目には、私のハッシュテーブル使用の試みを妨げたHvxFlushSingleTb呼び出しが見られ、30〜31行目には、期待される暗号文の最初のNビットがハッシュテーブルキーとしてどのように使用されるかが示されています。
キャッシュフラッシュの改善
私の元のPoCでは、Xbox 360 CPUのユニークな機能を使用して、利用可能なL2キャッシュの半分をロックして、できるだけ多くの圧力をかけようとしました。L2ロック技術は元のPoCで極めて重要でしたが、CPUキャッシュにかかる圧力は私が望んでいたほどではありませんでした。CPUキャッシュに圧力をかけることは特に厄介です。なぜなら、カーネルモード(エクスプロイトコードが実行されている場所)では、特権命令でありハイパーバイザーモードでのみ使用できるため、特定のキャッシュフラッシュ命令を使用できないからです。
さらに、ハイパーバイザーは暗号化メモリを操作している場合にのみCPUキャッシュを有効にして実行するため、ハイパーバイザーレベルのキャッシュフラッシュプリミティブを見つけるのは困難です。私は、大量の暗号化メモリを読み取るハイパーバイザーシステムコールを見つけることでこの問題を回避しようとしましたが、その説明に合致するシステムコールを見つけましたが、必要なものには全く足りませんでした。その関数にはロックもあり、複数のスレッドで同時に実行することを妨げていました。
Mátéが見つけた、非常にうまく機能するカーネルモードキャッシュ命令を私は完全に無視していました。それはdcbz、つまりデータキャッシュブロックをゼロに設定する命令です。CPUキャッシュラインをゼロにするだけでなく、この命令には興味深い副作用があります。
If the cache block is already in the data cache, all of its bytes are cleared to zero. If it is not in the cache, the block is allocated and initialized directly in the cache to zero without reading the old contents from main memory.
したがって、この命令が現在CPUキャッシュにないメモリアドレスで使用されると、CPUはそのためのスペースを割り当て、おそらく他のものを追い出してスペースを確保します。dcbz命令を広範囲のメモリにわたって実行することで、キャッシュフラッシュプリミティブとして使用でき、CPUキャッシュ内の古いデータを追い出すことができます。Mátéの元の実装では、彼は0x80000バイトのメモリ領域を割り当て、それをループして、0x80バイト(キャッシュラインのサイズ)ごとにdcbzを呼び出しました。
これは非常にうまく機能しましたが、割り当てサイズを3倍に増やし、3つの異なる場所でdcbzを呼び出すことで、CPUキャッシュにわずかに多くの圧力をかけることができることを発見しました。これは、後で説明する別の改善にとって重要になります。以下は最終的な実装です。
| | |
| --- | --- |
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 | void CacheThrashThreadProc(THREAD_ARGS* pArgs)
{
// Allocate a scratch buffer to help with thrashing L2 cache.
BYTE* pCacheThrashBuffer = (BYTE*)MmAllocatePhysicalMemoryEx(MEM_REGION_TITLE, 0x180000, PAGE_READWRITE | MEM_LARGE_PAGES, NULL, MAXULONG_PTR, 0x20000);
BYTE* pCacheThrashBuffer2 = pCacheThrashBuffer + 0x80000;
BYTE* pCacheThrashBuffer3 = pCacheThrashBuffer + 0x100000;
// Loop until we're told to exit.
while (pArgs->bRunCacheThrashing != FALSE)
{
for (int i = 0; i < 3; i++)
{
// Walk the entire scratch buffer and try to fetch it into CPU cache.
int offset = 0;
while (offset < 0x80000)
{
// Zero three distinct blocks.
__dcbz(offset, pCacheThrashBuffer);
__dcbz(offset, pCacheThrashBuffer2);
__dcbz(offset, pCacheThrashBuffer3);
offset += 0x80;
}
}
}
}
|
スレッドスケジューリングの改善
パート2では、エクスプロイトスレッドを異なるハードウェアスレッドにスケジューリングしようとしたこと、そしてそれらをCPUコア0上のCPUハードウェアスレッド0と1に配置することで最良の結果を得たことについて説明しました。CPUがハードウェアレベルでどのように動作するかについてはほとんど知りませんが、同じコア上の両方のハードウェアスレッドからのロード/ストアメモリトランザクションが、何らかのコアごとの方法でL2クロスバーにキューイングされていると推測していました。したがって、エクスプロイトスレッドを単一コアのハードウェアスレッドに配置することで、エクスプロイトスレッドからの書き込みとXKEペイロードスレッドからの読み取りが一緒にキューイングされ、RAMへのロード/ストアのレイテンシが削減される、といったことだと考えました。
このことを読んでいる人には驚きではないかもしれませんが、CPUにはしばしば「隠された」命令があります。命令セットアーキテクチャ(ISA)で定義されていませんが、CPUメーカーによって実装されているものです。これらは特別な命令エンコーディングを持つこともあれば、既存の命令を無意味な方法で使用してCPUに特別な操作を実行させることもあります。
PowerPCの場合、ISAにはハードウェアスレッドの優先度を変更するためのよく定義された命令があり、これは、目的のハードウェアスレッドの優先度に基づいて、3つのレジスタオペランドすべてを同じ特定のレジスタに設定した特別なOR命令を実行することによって行われます。例えば:
x86
| | |
| --- | --- |
| 1 2 3 | or r1, r1, r1 # Low priority
or r2, r2, r2 # Medium priority
or r5, r5, r5 # High priority
|
Xbox 360 SDKドキュメントには、コードが実行されているハードウェアスレッドの優先度を設定するために使用できるいくつかの組み込みマクロが記載されています:__SetHWThreadPriorityLow、__SetHWThreadPriorityMed、__SetHWThreadPriorityHigh。これらのマクロによって使用される実際のCPU命令は、上記のものとはわずかに異なります(これらのマクロはxenon CPU固有です)が、それは実際には重要ではありません。
SDKドキュメントでは、各コアに32のディスパッチスロットがあり、これらはミディアムプライオリティ(デフォルトプライオリティ)で実行されるハードウェアスレッドに均等に分割されていると説明されています。__SetHWThreadPriorityLowおよび__SetHWThreadPriorityHigh組み込みマクロを使用すると、1つのハードウェアスレッドが32のディスパッチスロットのうち31を使用し、もう1つは1つだけを使用できます。ディスパッチスロットがCPU実行ユニットに供給される命令数とどのように関連しているかはわかりませんが(再び、CPUパイプラインが実際にどのように機能するかはわかりません)、16/16から1/31に移行できるのは、エクスプロイトスレッドのスケジューリングに非常に役立つようです。
これらの組み込みマクロを使用して、Mátéはエクスプロイトスレッドをスケジューリングしました。これにより、XKEペイロードを実行しているハードウェアスレッドは32のディスパッチスロットのうち1つしか取得せず、暗号文上書きスレッドは残りの31スロットを取得しました。その結果、CPUはXKEペイロードを実際に実行するよりも、暗号文検出ループに圧倒的多数の時間を使うようになりました。
暗号文上書き検出の改善
最後の改善は、私が「Peer Pressure」エクスプロイトに取り組んでいる間に思いついたもので、「Bad Update」エクスプロイトを可能な限り安定させるためのものでした。元のPoCでは、XKEペイロードが操作する圧縮ブートローダーデータが15ブロックに圧縮されており、最後のブロックを除くすべてのブロックの解凍サイズが0x8000バイトであることを発見しました。XKEペイロード攻撃が成功した場合、ハイパーバイザーの最後のセグメントの一部が、1つ以上の圧縮ブロックの解凍データで上書きされます。ハイパーバイザーの最後のセグメントの暗号文を2つの異なる場所で観測することで、最後の圧縮ブロックがXKEペイロードによって書き込まれたかどうかを判断できました。
[画像: 暗号文を使用して最後の圧縮ブロックがメモリに書き込まれたタイミングを判断する]
攻撃は、最後の圧縮ブロックがハイパーバイザーの特定のオフセットに書き込まれた場合、ハイパーバイザーのシステムコールの1つを、完全なハイパーバイザーコード実行を取得するために使用できるwrite-what-whereプリミティブに変更するように設定されていました。しかし、暗号文の観測方法に欠陥があり、コンソールをクラッシュさせる偽陽性だけでなく、最後のブロックがメモリに正常に書き込まれた反復を見逃すことにもつながりました。
| | |
| --- | --- |
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 | DWORD RunUpdatePayloadThreadProc(THREAD_ARGS* pArgs)
{
...
// Get the cipher text at the location we want to overwrite and at an offset that is > the size of block 14. This allows
// us to determine when we win the race on block 14 (smallest block in the file) vs any other block.
DWORD* pCipherTextPtr1 = (DWORD*)(0xA0030000 + HV_SEG3_OVERWRITE_OFFSET);
DWORD* pCipherTextPtr2 = (DWORD*)(0xA0030000 + (HV_SEG3_OVERWRITE_OFFSET - BLOCK_14_TARGET_OFFSET) + BLOCK_14_SIZE + 0x80);
DWORD cipherValue1 = *pCipherTextPtr1;
DWORD cipherValue2 = *pCipherTextPtr2;
// Loop and hammer the payload until we hopefully get code exec.
while (true)
{
...
// Execute the payload.
// Status is 0xc8000012 on miss, and 0xc8000006 on hit.
DWORD result = HvxKeysExecute(pArgs->PayloadPhys, pArgs->PayloadSize, pArgs->UpdateDataPhys, pArgs->UpdateDataSize, NULL, NULL);
if (result != 0xc8000012)
DbgPrint("XKE payload returned status %#x\n", result);
// Flush cache on the cipher text pointers.
__dcbf(0, pCipherTextPtr1);
// Check if we got a block overwrite and if it appears to be block 14.
DWORD test1 = *pCipherTextPtr1;
if (cipherValue1 != test1)
{
...
__dcbf(0, pCipherTextPtr2);
// We got a block overwrite, check if it was block 14.
DWORD test2 = *pCipherTextPtr2;
if (cipherValue2 == test2)
{
DbgPrint("Block 14 overwrite hit!\n");
...
}
else
{
DbgPrint("Race hit: 0x%08x\n", test1);
}
// Save the latest block hit values.
cipherValue1 = test1;
cipherValue2 = test2;
}
}
}
|
上記は、ハイパーバイザーの最後のセグメントの暗号文の変化をチェックし、ブロック14が書き込まれたかどうかを確認するエクスプロイトスレッドを実行した元の実装です。暗号文の観測に関連する行を強調表示しました。同じスレッドがXKEペイロードを実行し、暗号文の変化をチェックしているため、暗号文が変更されたかどうかだけを検出でき、変更された回数は検出できません。より簡単に言うと、圧縮ブロックの1つがメモリに書き込まれたかどうかは推測できましたが、書き込まれたブロックの数まではわかりませんでした。それを念頭に置いて、圧縮ブロック14をメモリに書き込もうとしながらXKEペイロードの反復を実行したときに発生する可能性のある2つのシナリオを見てみましょう。
成功した書き込みの見逃し
XKEペイロードを実行中に、あるブロックN(0x8000バイトが書き込まれる)よりも小さいブロックでレース攻撃が成功し、ブロック14でも成功した(0x8000バイト未満)とします。HvxKeysExecuteから戻ると、ターゲット書き込みゾーンの2つの場所の暗号文を観測し、両方の場所の暗号文が変更されたことを確認します。ブロック14がブロックNの後に書き込まれたとは推測できません。なぜなら、暗号文が何回変更されたかを観測する能力がないからです。したがって、成功していたにもかかわらず、これは失敗した反復と見なされます。
[画像: ブロック14と他のブロックが書き込まれたときの暗号文の観測]
偽陽性の書き込み
あるブロックNとブロック14に対して見逃された書き込み動作を示すXKEペイロードの反復を実行したと仮定します。ハイパーバイザーの最後のセグメントには、ブロック14のデータとブロックNのテールデータが含まれています。もう一度XKEペイロードの反復を実行すると、今回はブロックNのみが書き込まれます。この反復の暗号文の変化を観測すると、最初の場所の暗号文は変更されたが、2番目の場所の暗号文は変更されていないことがわかります。なぜなら、前の反復からブロックNのテールデータに対する暗号文がすでに含まれていたからです。
[画像: 見逃し書き込みの後にブロックNが書き込まれたときの暗号文の観測]
これはブロック14の書き込みとして検出され、エクスプロイトは、ターゲットシステムコールの命令パターンがブロック14で見つかったwrite-what-whereパターンに変更されたと仮定して、ハイパーバイザーコードを実行しようとします。しかし、命令パターンは実際にはブロックNに含まれるランダムな命令であり、コンソールをクラッシュさせます。
新しい検出方法
キャッシュフラッシュプリミティブが大幅に改善されたため、ターゲットハイパーバイザーメモリ領域に正常に書き込まれた圧縮データブロックごとに暗号文の変化を観測できるようになりました。ブロック14がメモリに書き込まれたかどうかを検出するために私が考えた解決策は非常にシンプルでした。新しいスレッドを作成し、暗号文の変化を継続的に監視し、観測された変化の数を数えました。
XKEペイロードの実行が完了した後、カウンターが15に達した場合、15すべてのブロックがメモリに書き込まれたことがわかり、したがって最後のブロックも書き込まれたはずです。それ未満を観測した場合は、単に失敗した反復と見なし、再度試行します。以下は、新しい暗号文検出スレッドのコードです。
| | |
| --- | --- |
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 | void CipherTextObservationThreadProc(THREAD_ARGS* pArgs)
{
// Setup our hypervisor cipher text watch pointer.
volatile DWORD* pCipherTextPtr = (volatile DWORD*)(0xA0030000 + HV_SEG3_OVERWRITE_OFFSET);
// Flush cache and fetch the starting value.
__dcbf(0, (const void*)pCipherTextPtr);
DWORD lastCipherTextValue = *pCipherTextPtr;
volatile DWORD* pBlockCounter = (volatile DWORD*)&pArgs->CipherTextBlockCounter;
// Loop until we're told to exit.
while (pArgs->bRunCacheThrashing != FALSE)
{
for (int i = 0; i < 10000; i++)
{
// Flush any cached data for the cipher text observation pointer.
__dcbf(0, (const void*)pCipherTextPtr);
// Fetch the cipher text for the area of the hypervisor we want to overwrite and check if another block has been written.
DWORD test = *pCipherTextPtr;
if (test != lastCipherTextValue)
{
lastCipherTextValue = test;
(*pBlockCounter)++;
}
// Once we observe 15 different cipher text values bail out.
if (*pBlockCounter == 15)
{
goto Exit;
}
}
}
Exit:
ExTerminateThread(0);
}
|
過剰な圧力
新しい暗号文観測関数はうまく機能しましたが、新たな問題が発生しました。CPUキャッシュにかかる圧力の大きさにより、XKEペイロードを実行するとコンソールが約30%の確率でクラッシュしました。XKEペイロードが実行されると、圧縮されたアップデートデータバッファを取得し、非保護メモリから保護メモリに移動するためにインプレースリロケーションを行います。これにより、非同期に改ざんされるのを防ぎます。XKEペイロードがメインロジックを実行した後、アップデートデータを非保護メモリに戻すために別のインプレースリロケーションを行い、戻ります。
XKEペイロードの変更版にいくつかのprintf呼び出しを挿入することで、どこでクラッシュするかを特定できました。コンソールは、アップデートデータを保護メモリから非保護メモリに移動しようとしているときにHvpRelocateProtectedToPhysicalと呼ばれる関数でクラッシュしました。この関数はメモリ領域をループし、各CPUキャッシュライン(128バイト)ごとに以下の処理を行います。
- 保護されたソースアドレスを使用して、キャッシュライン全体をCPUレジスタに読み込みます。
- ソースアドレスのデータキャッシュと命令キャッシュの両方を無効にし、宛先アドレスのキャッシュラインをゼロにします。
- 非保護宛先アドレスを使用して、データをメモリに書き戻します。
以下は、HvpRelocateProtectedToPhysical関数の内部ループのアセンブリコードです。
[画像: HvpRelocateProtectedToPhysical関数のアセンブリコード]
この関数のパッチ適用済みのバージョンにprintf呼び出しを多数追加して、どこでクラッシュするかを監視できるようにしました。
[画像: printfパッチが適用されたHvpRelocateProtectedToPhysicalのアセンブリコード]
数十回の反復を実行した後、CPUが強調表示された行(赤色)の非保護宛先アドレスにデータを書き戻そうとしたときにクラッシュすることがわかりました。前述のキャッシュフラッシュの改善セクションで、Mátéの元の実装に変更を加えてCPUキャッシュへの圧力を高め、新しい暗号文観測方法をより信頼性の高いものにしたと述べました。
CPUが非保護宛先アドレスにデータを書き戻そうとしたときにクラッシュする原因が正確にはわかりません。おそらく、ストア命令が実行される前にキャッシュ無効化命令のいずれかが完全に完了しなかったか、CPUがキャッシュ不足でキャッシュパスウェイのカスタムパスウェイに関するエッジケースがあるのかもしれません。いずれにせよ、この問題に対処する方法を見つけることができました。
新しい検出方法 v2
クラッシュは、15ブロックすべてのデータがハイパーバイザーメモリに書き込まれたことを観測できた後に発生するため、私が考えた解決策は、キャッシュフラッシュスレッドを直ちに停止し、暗号文変更カウンターが15に達したらL2ロックを解除することでした。これにより、HvpRelocateProtectedToPhysicalの呼び出しの前にCPUキャッシュへの圧力が軽減され、クラッシュを防ぐことができます。以下は、暗号文観測スレッドロジックの更新された実装です。
| | |
| --- | --- |
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 | void CipherTextObservationThreadProc(THREAD_ARGS* pArgs)
{
// Lock half of the available L2 cache and pathways to put pressure on the CPU. This causes data in L2 cache to
// age out more quickly and improves cipher text detection rate.
BYTE* pL2LockBuffer = LockAndThrashL2();
// Setup our hypervisor cipher text watch pointer.
volatile DWORD* pCipherTextPtr = (volatile DWORD*)(0xA0030000 + HV_SEG3_OVERWRITE_OFFSET);
// Flush cache and fetch the starting value.
__dcbf(0, (const void*)pCipherTextPtr);
DWORD lastCipherTextValue = *pCipherTextPtr;
volatile DWORD* pBlockCounter = (volatile DWORD*)&pArgs->CipherTextBlockCounter;
// Loop until we're told to exit.
while (pArgs->bRunCacheThrashing != FALSE)
{
for (int i = 0; i < 10000; i++)
{
// Flush any cached data for the cipher text observation pointer.
__dcbf(0, (const void*)pCipherTextPtr);
// Fetch the cipher text for the area of the hypervisor we want to overwrite and check if another block has been written.
DWORD test = *pCipherTextPtr;
if (test != lastCipherTextValue)
{
lastCipherTextValue = test;
(*pBlockCounter)++;
}
// Once we observe 15 different cipher text values stop thrashing cache immediately.
if (*pBlockCounter == 15)
{
// Unlock the L2 cache and free the scratch buffer.
UnlockL2(pL2LockBuffer);
pL2LockBuffer = NULL;
// Flag to stop thrashing cache.
pArgs->bRunCacheThrashing = FALSE;
goto Exit;
}
}
}
Exit:
if (pL2LockBuffer != NULL)
{
// Unlock the L2 cache and free the scratch buffer.
UnlockL2(pL2LockBuffer);
}
// We must call ExTerminateThread since we don't use the normal thread startup routine.
ExTerminateThread(0);
}
|
L2キャッシュロックを暗号文観測スレッドに移動しました。これは5行目で見られます。メモリに書き込まれた15ブロックすべてのデータが観測されたら、36行目でL2ロックを解除し、40行目でキャッシュフラッシュスレッドに直ちに終了するようにシグナルを送ります。
15ブロックすべてが書き込まれたことを観測できず、HvpRelocateProtectedToPhysicalの呼び出し前にCPUキャッシュへの圧力を軽減できないというエッジケースがまだ存在します。しかし、これは問題にならないようです。すべての改善により、エクスプロイトは通常XKEペイロードの最初の実行で成功し、最悪の場合でも2〜5回の反復で済みますが、テスト中にクラッシュに遭遇することはありませんでした。それでも発生する可能性はありますが、発生する可能性は非常に低いと考えています。
結論
ihatecompvir、InvoxiPlayGames、Mátéのすべての改善のおかげで、「Bad Update」エクスプロイトは瞬時になり、成功率はほぼ100%になりました。コンソールをハッキングするには、USBスティックにいくつかのファイルをコピーするだけで済みます。元のPoCをリリースしたとき、「これ以上最適化できないと思う」といった声明を出しましたが、これは主に技術的でない人々からの無関係なアイデアの書き込みを防ぐためでした。真実は、常にさらに最適化できると考えていましたが、現在のレベルに達するとは思っていませんでした。
ここで学んだ最大のことは、Bad Updateの最初のイテレーションを完了した後、休憩を取り、クリアな心で戻ってきて洗練させようとすべきだったということです。Xbox 360ハイパーバイザーの2つのバグのうちの1つを見つけたばかりで、このエクスプロイトを機能させることに大きなプレッシャーを感じていました。多くの努力と眠れない日々を費やしてそれを成し遂げましたが、結局、実際の問題から私をそらした、あまり重要でない問題にトンネルビジョンで追いかけてしまいました。最初のPoCの後に休憩を取り、数週間後に戻ってきたら、たとえ現在ほど洗練されていなくても、最初のリリースでより洗練されたものを提供できたかもしれません。
いずれにせよ、パート3はこれで終わりです。Bad Updateの改善に関するソースコードは私のGitHubで見つけることができます。ピアプレッシャーエクスプロイトをカバーするパート4は近日公開予定ですので、お楽しみに!