



プロローグ
Codegate 2025 Finalにおいて、FullChainチャレンジ(RCE、LPE、およびSBX)が出題されました。
Codegateチャレンジのアイデアをブレインストーミングしている際に、RCE、SBX、LPEの課題を作成し、それらすべてを解決したチームにボーナスポイント(FullChain)を提供するというアイデアを思いつきました。そうして、FullChainシリーズが誕生しました。
このシリーズは計4つの問題で構成されていました。3つの個別チャレンジと、これら3つ(RCE、LPE、SBX)をすべて解いた後に、シンプルに連結させることで解決できるFullChainボーナスチャレンジです。
始める前に、チャレンジを解いた方々に拍手を送ります 🙂
RCE: BunkyoWesterns (First Blood!), The Duck, Blue Water
SBX: なし 😞
LPE: GYG (First Blood!), BunkyoWesterns, CyGoN, Blue Water
また、チャレンジの作成者の方々にも拍手を送ります 🙂 !!
Hyungil Moon (@mhibio-ptw), Jongseong Kim (@nevul37) and Dongjun Kim (@smlijun)
すべてのチャレンジとエクスプロイトコードはこちらです [リンク]
パート1:RCE

Challenge Overview
The goal of the challenge is to find vulnerabilities in the renderer process and develop an exploit code by analyzing the provided rce-sbx-138-0-7204-97.patch file.
The patch file creates a new Blink module called minishell in the renderer. It provides various shell functions and file writing and saving, and the file data is managed through Codegate File System (CFS), which is a browser API.
The available commands are:
They are similar to the basic shell commands. Commands such as exec are not implemented, but there are several file operations.
When a file is opened, it is managed through file_descriptor_ in the form of a FileBuffer class until Save.
A user can invoke the minishell as follows:
Callable methods can be bound in the *idl file.
In short, one user can have multiple shells and execute each command in one shell.
Vulnerability
We can see the main functionality in mini_shell.cc.
However, the vulnerability is pretty simple compared to the file size. The following shows the FileBuffer structure.
In here, we can see the fixed-size buffer. Let’s check the part which uses it.
There is a size check for the input data vector, but there is no any bound check for idx_ so an out-of-bounds (OOB) read/write occurs.
Although the vulnerability is simple, we need to obtain arbitrary address read / write primitives with this relative address read / write, and finally achieve Arbitrary Code Execution.
Exploit - AAR/W
Now, we have relative read / write primitive of uint64_t size. In fact, there is no difference in the method to achieve arbitrary address read / write.
However, in order to access an arbitrary address, we must know the address of the current object. This is because we need to measure the distance to move to the target.
There are various ways to leak the address of a controllable object.
In this challenge, it is difficult to achieve address leakage with just a simple OOB read because there is no valid address area written anywhere in the heap area. Among them, we tried using brand new technique that can stably leak objects by utilizing the characteristics of Oilpan GC.
Oilpan GC
The Heap object of Oilpan GC has the following structure [link].
Oilpan GC uses a different allocation method than PartitionAlloc (PA), which is mark-and-sweep and space. Unlike PA, which uses slot-bucket, Oilpan allocates space for the heap and divides (i.e., allocates) the heap object as much as requested size from the space when a request comes in.
In other words, without a fixed slot, it dynamically allocates multiple sizes in each space.
When they lose their reference and are GC reclaims them, they take the form of FreeList::Entry.
When an object in the space is freed, the HeapObject changes to a FreeList::Entry, and additional next_ fields are created to point to the next freed object.
Leak Idea
The idea is as follows:
Loop the action below enough times to allocate new space
Spray shell object
Spray File in each shell
Trigger gc()
Read the
next_of the header of the next adjacent chunk ofFileBufferin theN-thSprayed ObjectLeak
(N-1)thSprayed_object
Since each shell has only one File Buffer, N shells are needed to spray N File Buffers.
Considering the characteristics of the Oilpan GC described above, consider the following chunk situation.

Currently, there is only my object in space. When an object is dynamically divided(i.e., allocated) from space, gc() is executed and the small areas between each object will be treated as Free Entry, forming a FreeList as shown above.
We can now read the chained Free Entry by reading temp = sizeof(FileBuffer) + 0x8 from the Sprayed2 object, and leak the Sprayed 1 address through heap_leak = temp - sizeof(FileBuffer)
This allows us to leak the address of the object with only spray and out-of-bounds, regardless of how big the distance is between Sprayed 1 and Sprayed 2 whether there is a stable address.
Since we have the address of the Sprayed 1 object and the relative address read / write, we can perform arbitrary address read / write.
In the exploit, after sufficient spray, it triggers gc() and then leaks objects 90th to 89th.
Exploit - Arbitrary Code Execution
Now, we obtain the arbitrary address read / write primitives.
In a typical V8 engine, addrof is used to obtain address of a Wasm RWX Page. However, we only have OOB, and it seems difficult to create an addrof primitive.
So what should we do?
Overwrite the vtable of HeapMojoRemote to call 0x4141414141414141?
The challenge says that it should be exploited on chrome.exe running on Windows 11 24H2. That is, in order to achieve arbitrary function calls in the challenge, CFG Bypass must be accompanied. Of course, considering the huge size of the code base, there may be many gadgets that can bypass CFG.
Also, Function::Invoker Chaining, a well-known technique, can bypass CFG.
We wanted to find a more stable method, and after auditing the code, we found that there is a LazyInstance Getter for WasmCodePointerObject. We can leak Wasm RWX Page by reading WasmCodePointerTable → entrypoint_.
Let's overwrite RWX Page with arbitrary shellcode and execute wasm exports function.
In the end, we can stably execute arbitrary shellcode while maintaining persistence. An interesting fact is that the bug of the SBX challenge can be triggered even in the Renderer. However, triggering the vulnerability requires a slight race condition in the SBX Challenge, we are unsure whether UAF Object can be reliably occupied in Blink.
パート2:SBX

チャレンジ概要
このチャレンジは、実際の事例から着想を得ています。そのため、SBXはFullChainシリーズの中で最も難しいチャレンジであると思われます。
TL;DR
脆弱性をトリガーして
UAFを発生させるRace(競合状態)とSpray(スプレー)によってUAFオブジェクトを占有する任意の読み込み / 書き込み / 呼び出しのプリミティブを作成する
このチャレンジの目標は、ブラウザプロセス内の脆弱性を発見し、提供された rce-sbx-138-0-7204-97.patch fileを分析して、サンドボックス回避(エスケープ)のエクスプロイトコードを作成することです。
このパッチファイルは、ブラウザ内に Codegate File Systemという新しいMojo Endpoint Implを作成します。
CFSは DirectoryImplと FileImplを実装しています。これらは、File System Managerの配下にあるルートディレクトリ(Root Directory)を最上位とするツリー構造になっています。ファイルは Read / Write / Edit / Close の操作を実行でき、ディレクトリは Create / Delete / Change などの操作を持ちます。
以下は、mojomインターフェースを定義している cfs.mojom ファイルです。
脆弱性
ChangeItemLocation 関数における脆弱性は、本質的には単純ですが、エクスプロイトでの利用は複雑です。filename_src / filename_dst に対する不正確な入力検証が原因でスマートポインタが機能不全に陥り、UAFが発生します。
ChangeItemLocation 関数は、ValidateChangeLocation [1] を介してソースとデスティネーションを検証し、destination_directoryを返します。次に、現在のディレクトリからsrcアイテムを削除し [2]、移動先のディレクトリに対して AddItemInternal[3] を実行します。
まだ脆弱性を見つけるのが難しいため、ValidateChangeLocation 関数をさらに詳しく見てみましょう。
これから、いくつかのことが分かります。
srcファイルとdstファイルは常に存在しなければならない。
srcファイルは、現在のディレクトリまたは親ディレクトリであってはならない。
dstの位置は、親ディレクトリまたは現在のディレクトリにすることができる。
ファイル名である場合、ファイルがディレクトリであるかどうかを確認し、ポインタを返す。
すべての検証を適切に行っているように見えますが、1つの点が欠けています。
srcとdstのアイテムが同じである場合のチェックが存在しません。
つまり、以下の状況 [1] において、次の呼び出し [2] は有効な呼び出しになります。
これをUAFに紐付けるために、ChangeItemLocation に戻りましょう。
srcファイルとdstファイルを同一に設定できるようになったため、[3] が実行される直前の時点で、file_to_move と destination_directory は同じオブジェクトを指すことになります。
この状況で file_to_move を解放できれば、destination_directory をダングリング(浮遊ポインタ)状態にし、UAF として利用できます。
file_to_move を解放するために、AddItemInternal 関数を見てみましょう。
destination_directory において、file_to_move の所有権は destination_directory→item_list が所有権を持ってバインドできるように、std::move()[4] によって AddItemInternal に渡されます。
しかし、引数として所有権を持って渡された new_item がどこにもバインドされずに関数が終了した場合、参照数は0になり、関数の終了時に解放されます。
AddItemInternal 関数は、移動先ディレクトリに同じ名前のファイルが既に存在するかどうかを確認し、存在する場合はfalseを返します。
これは、上記の解放シナリオにとって好都合な動作です。
トリガー
ここで、以下の状況を考慮して再度 ChangeItemLocation を呼び出してみましょう。
file_to_move(src) と destination_directory(dst) は /root/dir1 を指しています。
AddItemInternal がトリガーされ、./root/dir1 内の重複するファイル名をチェックします。
重複するファイル名 ./root/dir1/dir1 が存在するため、関数はfalseを返して終了します。
この時点で、file_to_move はその参照を失い、解放されます。
file_to_move が解放されたため、destination_directory も解放されました。
AddItemInternal がfalseを返したため、PostTask が実行されます。その後、解放された destination_directory を引数として RecoverItem に渡すため、RecoverItem が実行されたときにUAFが発生します。
ヒープスプレー
エクスプロイトの最初のステップは、解放されたオブジェクトを占有することです。
実際のブラウザのエクスプロイトには様々なヒープスプレー技術がありますが、これは「CTFチャレンジ」であるため、挑戦者が容易にアクセスできるプリミティブが存在する可能性があります。
CodegateFileImpl は、このチャレンジで提供されているヒープスプレープリミティブです。
CodegateFile→Write() は、入力された配列データを std::vector<uint8_t> として格納します。
これは、任意のサイズの制御可能なヒープオブジェクトを無制限に作成できることを意味します。
これを利用して、UAFオブジェクトの占有を試みてみましょう。
占有
占有するためには、ThreadRunnerにポストされた RecoverItem が実行される前にオブジェクトをスプレーする必要があります。
しかし、これを達成するためのプロセスは少し複雑です。
チャレンジはReleaseバージョンであるため、多くのコードスニペットが除外されており、速度が非常に高速です。これは、
ChangeItemLocationの終了から、ポストされたRecoverItemの実行までの競合ウィンドウ(Race Window)が極めて短いことを意味します。他のスレッドからスプレー可能な、まったく同じサイズのオブジェクトを見つけること? これは競合ウィンドウが短い場合に非常に役立ちますが、スプレー可能なオブジェクトを時間内に見つけることは難しく、
Thread cacheのバイパスを達成するためにはさらなる努力が必要になる場合があります。
幸いなことに、インターフェースの呼び出しやディレクトリとファイルの作成に制限はないため、与えられた条件を満たすことで占有を試みましょう。
Mojo IPC Call が入ると、IO Thread がそれを受信し、Implの識別や入力検証などの処理を行った後、適切な関数を各ImplのThreadRunnerにPostTaskします。
すべての Codegate*** IPC呼び出しは同じスレッドで実行されるため、ChangeItemLocation が実行されている間に CodegateFile でUAFオブジェクトを上書きすることは不可能です。
そこで、ChangeItemLocation と RecoverItem の間に時間を稼ぐことを試みましょう。

正常に動作する ChangeItemLocation 関数を複数回呼び出した後、UAFをトリガーできるChangeItemLocationを呼び出しました。
上記は SequenceTaskRunner のキューです。これまでにポストされ、実行を待っているタスクが表示されています。
このように、UAF Trigger が実行される前に、ChangeItemLocation が実行されている時間分、追加のMojo IPC呼び出しを送信することができます。
この間に CodegateFile::Write をN回挿入すると、

実行中のTaskRunnerは上記のようになり、時間が経過して UAF がトリガーされると

上記のようになります。これでWrite Sprayの処理が始まり、Nが十分であれば、最終的に 解放されたオブジェクト(Freed Object) を占有することができます。

上書きに成功するまで、これを繰り返すことができます。
エクスプロイトでは、ファイル名やベクトルなどを比較して、上書きが成功したかどうかを確認することで、安定性を向上させることができます。
リーク(情報漏洩)
UAF Object を上書きした後、クラッシュを回避し流れを制御するために、制御可能な領域とアドレスが必要になります。これを達成するための様々な手法がありますが、このチャレンジでは std::vector<> を使用して、オブジェクトを 既知のアドレス(Known Address) に配置します。
(数回試行すると、Mojo Responseを通じてリークを受け取ることは不可能であることがわかります。)
std::vector<> が最大容量に達すると、既存のヒープを解放し、拡張された容量を持つ新しいヒープを割り当てて、既存のデータを移動します。
CodegateFile→Read() を使用することで、UAF Object->CodegateItem→itemname_ をリークさせることができます。さらに、CodegateFile→Edit() を使用して、UAF Object→vector{start, last, end} を操作できます。
次のステップは、サイズ 0x800 の既知のアドレスを作成&リークさせ、その領域を占有することです。
UAF object parents→Rename(uaf_object, "A" * 0x800)を実行するstd::stringは、サイズ0x800のヒープスロットに割り当てられます。この
0x800サイズの領域は、後で偽のオブジェクト(Fake Object)、ROP、一時メモリ(Temp Memory)などに使用されます。
UAF Object→Read()を実行してUAF Object→itemname_をリークさせる。UAF Object Vectorを以下のように修正する:UAF Object→Vector→start=heapleakUAF Object→Vector→last=heapleakUAF Object→Vector→end=heapleak + 0x800

UAF Object→CreateItemを0x800 / 8回実行する。ベクトルは
std::stringを指し、0x800のサイズが満たされます。

UAF Object→CreateItemをもう一度実行する。容量不足のため、既存の領域(
std::string)が解放され、既存のデータが新しいベクトル空間に移動します。

Spray(0x800, spray_cnt)を実行する。解放された 0x800 を移動して占有できるようになり、このアドレスはステップ1と2でリークされたアドレスになります。

このようにして、既知のアドレスの作成、リーク、および占有を達成でき、その領域に 偽のオブジェクト(Fake Objects)、ROPチェーン などを構成できるようになります。
スプレーにrename()自体を単に使用することは適切ではありません。なぜなら、JS → Mojo → Implのエンコーディング変換プロセスがヌル文字や UTF-8 範囲の文字を適切に認識しないためです。そのため、上記の方法を使用する必要があります。Leak についても同様です。
エクスプロイトでは、特定のフィールド(ここでは std::string item_name_)を成功の識別子として使用できます。
AAR/W プリミティブ
CodegateFileImpl は CodegateDirectoryImpl と同じ構造を持ちますが、std::vector の型は uint8_t です。
Fake CodegateFileImpl を作成して UAF Object に追加し、Fake CodegateFileImpl の m_first、m_last、m_end を操作することで、任意のアドレスに対する読み込み / 書き込み(arbitrary address read / write)を達成できます。

以下は、AAR/WのためのシンプルなエクスプロイトPOCです。
任意関数呼び出しプリミティブ
UAF Object→directory_vtable を制御可能なヒープオブジェクトに設定し、CodegateDirectory→ListItems のみを操作することで、任意の関数呼び出しを達成できます。
レンダラーと同様に、ブラウザプロセスでも CFG緩和策(Mitigation)が有効 になっています。
今回は、よく知られた Didwrite → Function Invoker Chain 技術を使用して、CFGのバイパスと任意の関数呼び出しを達成します。
UAF Object のvtableを操作して任意の関数呼び出しを達成したため、rcx(this) は現在 UAF Object を指しています。
変数 ptr[1] は UAF Object + 0x10 の値になり、[2] により新しい 関数呼び出し (ptr+8) を実行できます。
ptr+0x0 の RefCount を1に維持し(ガジェットの制約)、ptr+0x8 に Function Invoker から取得した有用なガジェット(Gadget)を設定します。
上記のガジェットは、私たちの状況に適しているように見えます。
Function Invokerが実行される時点で、rcxは任意のアドレスに操作できるため、取得したHeap Leakに設定することができ、関数と引数(argv)を望み通りに構成することが可能になります。

任意の関数呼び出し(Arbitrary Call)が達成されました!
パート3:LPE

この問題で提供されているファイルは最小限です。事前配布されたWindows 11イメージの他に、PoW(Proof-of-Work)コードとMemoryStorage.sysファイルのみが提供されていました。私たちはMemoryStorage.sysドライバーを分析し、これを利用してWindows上での特権昇格を達成する必要があります。
チャレンジの概要
MemoryStorage.sysは、レガシーなWindows Driver Model (WDM)を使用して開発されたWindowsカーネルドライバーです。このドライバーのDriverEntry関数を調べると、機能的なコンポーネントは2つしか含まれていません。1つはカーネルスタッククッキーの初期化を担当する関数で、もう1つはドライバーのメインルーチンを処理する関数です。
sub_14000173C関数内で、ドライバーは\DosDevices\MemoryStorageという名前のシンボリックリンクを作成し、デバイスをカーネルに登録します。このシンボリックリンクにより、ユーザーモードのアプリケーションはリクエストを送信してドライバーと通信できるようになります。
条件分岐ブロックの内部で、ドライバーはディスパッチルーチンを登録します。エントリa1->MajorFunction[0]およびa1->MajorFunction[2]は、デバイスハンドルがオープンまたはクローズされたときに呼び出されるIRP_MJ_CREATEおよびIRP_MJ_CLOSEルーチンに対応します。これらのルーチンはチャレンジを解く上で必須ではないため、補足としてのみ言及しています。
最も重要な部分はa1->MajorFunction[14]への代入であり、これによりsub_140001370関数がIRP_MJ_DEVICE_CONTROL ハンドラーとして登録されます。このルーチンは、I/Oコントロールコード(IOCTL)に基づいてさまざまなコマンドを処理する役割を担っており、脆弱性分析において中心的な役割を果たします。
それでは、sub_140001370関数を詳しく見ていきましょう。この関数は、4つの特定のI/Oコントロールコードに基づいてコマンドを処理するハンドラーとして機能します。
各コードの動作を調べる前に、カーネルドライバーにリクエストを送信する際に使用されるIRP(I/O Request Packet)の構造を簡単に復習しておくと役立ちます。IRPはWindowsにおける基本的なデータ構造であり、I/O操作を表現し、リクエストされた操作、関連するデバイス、および関連バッファに関する情報を保持します。IRPの仕組みを理解することは、ドライバーがユーザーモードのリクエストをどのように処理するかを分析する上で重要です。
IRPの構造
IRP(I/O Request Packet)は、オペレーティングシステムとデバイスドライバーの間でI/Oリクエストを処理するために使用されるカーネルレベルの構造体です。特定の内部レイアウトを持ち、その重要なフィールドの1つに、IO_STACK_LOCATION構造体を指すCurrentStackLocationがあります。この構造体は、ドライバースタックの各レイヤーがIRPを適切に処理するのに役立ちます。
すべてのI/Oリクエストは、IRPおよびそれに関連付けられたIO_STACK_LOCATION構造体に格納されている情報に従って処理されます。これらのフィールドにより、ドライバーはリクエストをその目的に合った方法で処理することができます。

たとえば、sub_140001370関数はI/Oコントロールコードに基づいてリクエストを処理します。この場合、IO_STACK_LOCATION構造体のMajorFunctionフィールドの値は14であり、これはIRP_MJ_DEVICE_CONTROLに対応します。IoControlCodeフィールドが特定の値と一致すると、ドライバーはその特定のI/Oリクエストの動作を実装する関数を実行します。

sub_140001370関数に示されている値は、すべてIRP構造体内のフィールドに由来します。IRPには多くのフィールドが含まれているため、ここでそのすべてを説明することは現実的ではありません。詳細なリファレンスについては、こちらで公式ドキュメントを参照してください。
この問題を解く目的において、私たちはIRP->CurrentStackLocationの内部にあるType3InputBufferフィールドのみに注目します。このフィールドはユーザーが提供する入力バッファを指しており、ドライバーはこれを使用してユーザー空間からデータを受け取ります。
Type3InputBufferは、Neither I/O方式を使用してリクエストが送信されるときに入力バッファとして使用されます。これにより、もう1つの重要な概念へとつながります。一体、Neither I/O方式とは何なのでしょうか?
Neither I/O
公式ドキュメント[リンク]によると、Neither I/O方式では、入力または出力バッファにアクセスする際に、SystemBuffer(カーネルモードバッファ)やMDL(Memory Descriptor List)を提供しません。代わりに、ユーザーモードの仮想アドレスを直接使用します。つまり、この方式を使用するI/Oリクエストでは、ユーザーによって提供されたデータはユーザー空間に留まり、カーネルメモリにはコピーされません。
この文脈において、入力バッファはIO_STACK_LOCATION構造体内のType3InputBufferフィールドを介して処理され、出力バッファはIRP構造体内のUserBufferフィールドを介してアクセスされます。これらのポインタはユーザー空間にあるメモリを参照するため、これらを検証して安全に使用することはドライバーの責任となります。

I/OコントロールルーチンがNeither I/O方式を使用しているかどうかを識別するのは簡単です。I/Oコントロールコードを4で割った余りが3である場合、そのルーチンがNeither I/O方式を使用していることを示します。
より簡単に言うと、I/Oコントロールコードの最後の半バイト(ニブル)が 3, 7, B, F のいずれかの値であれば、対応するルーチンはNeither I/Oを使用しています。
sub_140001370 の分析の継続
それでは、sub_140001370関数の分析を続けましょう。先ほど説明したように、I/Oコントロールコード 0x7101003 と 0x7101007 はNeither I/O方式を使用していると判断できます。これは、これらのコードを処理するルーチン内で、ドライバーがType3InputBufferフィールドを入力バッファとして使用することを意味します。
Neither I/Oはユーザーモードの仮想アドレスをドライバーに直接渡すため、Type3InputBufferが参照するバッファはカーネルメモリではなくユーザー空間に存在します。したがって、ドライバーが明示的に検証するか、安全な領域にコピーしない限り、このポインタを使用したデータアクセスはユーザーのアドレス空間で発生します。
根本原因の分析:スタックベースのバッファオーバーフロー
I/Oコントロールコード 0x7101003 を処理する sub_140001274 関数を分析してみましょう。この関数は、ユーザーが提供したメモリ領域から Dst というローカルカーネルバッファに、次の5つの主要なステップに従ってデータをコピーします。
関数はまず、
Type3InputBufferが有効なユーザーモードポインタであるか、そしてそのサイズが少なくとも0x10バイトであるかをチェックします。次に、
Type3InputBufferの最初の2バイトを調べ、値が0x40より大きく、かつゼロでないことをチェックします。関数は、
Type3InputBufferから8バイトのオフセットにあるアドレスから8バイトの値を読み取ります。この値(もう1つのポインタを表す)は、ローカル変数v4に格納されます。ProbeForReadを使用して、v4に格納されたアドレスが読み取り可能なユーザーモードアドレスであるかを検証します。上記のすべてのチェックがパスすると、関数は
v4が指すメモリから、ステップ2で読み取った値に等しいバイト数をローカル変数Dstにコピーします。
このプロセスを通じて、ドライバーはユーザー提供のデータを読み取ってコピーしようとしますが、後で見るように、検証が不完全であるか悪用された場合、このロジックは脆弱性となります。
一見すると、ユーザーが提供したすべてのアドレスと値が適切に検証されているため、この関数は安全であるように思われます。しかし、Type3InputBufferがユーザーモードメモリを指していることを忘れないでください。
このコードの欠陥はステップ [2] で発生します。このステップでは、関数はType3InputBufferの最初の2バイトを読み取り、値が有効な範囲内にあるかをチェックします。しかし、ステップ [5] で memcpy を呼び出す際、以前に保存した v3 の値を使用しません。代わりに、Type3InputBufferから再度読み取りを行うため、ダブルフェッチ(Double Fetch)が発生します。
これにより、ユーザーはレースコンディションを引き起こし、ローカル変数 Dst に 0x40 バイトを超えるデータをコピーさせることが可能になります。
しかし、このスタックベースのバッファオーバーフロー脆弱性を悪用するためには、カーネルアドレスのリークが不可欠です。では、カーネルアドレスの漏洩を許してしまう脆弱性はどこに存在するのでしょうか?
根本原因の分析:情報漏洩(Information Disclosure)
I/Oコントロールコード 0x7101007 を処理する sub_14000113C 関数を分析してみましょう。このルーチンもNeither I/O方式を使用しています。この関数は、以下の4つのステップに分解できます。
まず、
Type3InputBufferがNULLでないこと、および入力バッファの長さが少なくとも8バイトであることをチェックします。次に、ProbeForRead関数を使用して、Type3InputBufferが有効なユーザーモードアドレスであることを検証します。Type3InputBufferの最初の2バイトの値がゼロではなく、0x40以下であることをチェックします。サイズ
0x10000バイトのメモリプールを割り当てます。Type3InputBufferの最初の2バイトで指定されたサイズを使用して、ローカル変数Dstの内容を割り当てられたプールにコピーします。その後、割り当てられたプールはグローバル配列
qword_140003080に格納されます。
この関数も、先の関数と同様に、ステップ [2] と [4] の間でダブルフェッチの脆弱性があります。このため、Dst 変数の 0x40 バイトを超えるデータをプールにコピーさせることができます。問題は、このコピーされたデータをどこから読み取ることができるかです。
その答えは、I/Oコントロールコード 0x7101010 を処理する sub_1400014CC 関数にあります。この関数は LoggingMemoryInformationForInternalMemoryStorageDriver という名前で、以下のステップを実行します。
まず、セクション(Section)を作成します。次に、セクションに 0x10000 バイトのメモリをマップします。その後、グローバル配列 qword_140003080 の内容をマップされたセクションメモリにコピーします。最後に、セクションのマップを解除(unmap)し、カーネルモードでそのハンドルを閉じます。
ここで疑問が生じます。セクションがすぐにアンマップされて閉じられる場合、その内部のデータにどのようにアクセスできるのでしょうか。
その答えは、Windowsにおいて、ユーザーモードプロセスが事前に対象のセクションを共有モードでオープンしている場合、カーネルはセクションを完全にアンマップして閉じることができないという点にあります。ドライバーがセクションを解放する前にユーザーモードからセクションを占有しておくことで、漏洩したカーネルアドレスを含む、内部に格納されたデータにアクセスすることが可能になります。
参考までに、リークされたカーネルアドレスの一部を以下に示します。ntoskrnlとカーネルドライバーの両方のアドレスの一部がリークしているため、スタックROPチェーンを構築する上で問題はないはずです 👍
エクスプロイト
必要な情報がすべて揃ったので、ROPチェーンを使用してスタックベースのバッファオーバーフローを悪用し、SYSTEM権限を取得できます。ROPチェーンは以下の順序で動作します。
ROPチェーンに入る前に、Mediumインテグリティで実行される
cmdプロセスが作成されます。このプロセスは、curlコマンドを使用してC:\Windows\System32\flag.txtの読み取りを継続的に試みます。cmdプロセスのPIDを使用して、ROPチェーンはPsLookupProcessByProcessId関数を呼び出し、cmdプロセスのEPROCESSアドレスを取得します。同じ関数を再度呼び出して、SystemプロセスのEPROCESSアドレスを取得します。(Windowsでは、SystemプロセスのPIDは常に4です。)
その後、
cmdプロセスのトークン(Token)値が、Systemプロセスのトークン値で上書きされます。
このチェーンが完了すると、最初に作成された cmd プロセスはSYSTEM権限で実行されるようになります。しかし、処理はまだ完了していません。ROPチェーンはカーネルコンテキストで実行されたため、制御をユーザーコンテキストに戻す必要があります。さらに、cmd プロセスが flag.txt を読み込んで内容を外部に送信するまでに、ある程度の時間が必要です。
ユーザーコンテキストに戻るために KiKernelSysretExit のような関数を使用することも可能ですが、この特定のエクスプロイトでは、データ送信を行うための短い遅延のみが必要でした。これを達成するため、ROPチェーンの最後に単純な \xEB\xFE ガジェットが使用されました。これにより無限ループ(セルフジャンプ)が発生し、実質的にカーネルをストールさせ、ユーザーモードのプロセスがタスクを完了するのに十分な時間を稼ぐことができます。
パート4: フルチェーン

RCE to SBX
既存のRCE-SBX連鎖手法は、enable_mojo_jsをTrueに変更することでした。しかし、1年前、Chromeは悪用を防ぐために新しい緩和策[link]を導入しました。
緩和策の詳細
mojo_jsを有効にするための既存の手法は以下の通りでした:
Chromeバイナリ内で現在の
RenderFrameImplを特定する。RenderFrameImplのメンバ変数enable_mojo_jsを上書きする。
しかし、新しい緩和策では以下が行われます:
ScriptContextが完了していない場合、
ProtectMemoryを介してenable_mojo_js領域にReadOnly権限を付与する。ScriptContextが完了している場合にのみ、Mojoバインディングを有効にできる。
つまり、スクリプト実行中(=エクスプロイトの実行中)にmojo_js_bindingを上書きすることは不可能です。
そのため、既存の手法で連鎖させることが困難になっています。
バイパス
任意コード実行を達成した後であれば、無限の手段があります。例えば、「ReadOnlyメモリをReadWriteメモリにする」ことです。
先述したように、enable_mojo_jsが有効かどうかはExecuteContextで管理されるようになりました。
新しいExecuteContextに適用されるデフォルトのフラグは、ProtectedMemoryが適用された状態でグローバルに管理されています。
言い換えれば、(ReadOnly)ProtectedMemoryによって管理されているグローバルなenable_mojo_jsデフォルトフラグをtrueに設定し、新しいスクリプトコンテキストを作成(リロード)すれば、そのコンテキストはmojoバインディングを使用できるようになります。
私たちはbase::AutoWritableMemoryBase::SetMemoryReadWriteを使用してプロテクトされたメモリにrw権限を付与し、base::AutoWritableMemoryBase::SetMemoryReadを使用してプロテクトされたメモリにreadonly権限を付与することができます。
これを実行するシェルコードを実行した後にwindows.reloadを実行すれば、正常にmojo_js_bindingが有効化されたコンテキストを取得できます。
デモ
エピローグ
Webブラウザは依然として、数多くの実際のサイバー攻撃において一貫して悪用される価値の高いターゲットであり続けています。Chromeが長年にわたり導入してきた広範なセキュリティ対策にもかかわらず、私たちのフルチェーンエクスプロイトは、これらの保護手段を回避することが依然として可能であることを示しています。同様に、WindowsのControl Flow Guard(CFG)が堅牢な保護メカニズムを提供しているとしても、これらの防御策を効果的に回避するための高度な技術が存在します。
このCTFチャレンジシリーズの作成は非常にやりがいのある経験であり、私たちが開発を楽しんだのと同様に、参加者の皆様がこれらの課題に取り組むことを楽しんでいただけたなら幸いです。私たちの目標は、魅力的で技術的に複雑な課題を提供するだけでなく、実際の攻撃環境で採用されている最新のトレンドや回避技術を紹介する、実世界の脆弱性悪用シナリオを反映することでもありました。
今後も、私たちは新たな脆弱性悪用技術の探求を続け、最新のサイバーセキュリティトレンドに合わせた課題を開発することに引き続き取り組んでまいります。私たちの研究の旅は現在も続いており、ペースを落とすつもりはありません。今後の投稿では、実際のインターネット上で見られる脆弱性の分析をさらに深く掘り下げ、実世界のバグ連鎖(バグチェイニング)技術の実演や、より広いセキュリティコミュニティとの知見や手法の共有を目指していきます。
すべての参加者および読者の皆様、ご関心とお取り組みをいただきありがとうございました。今後の画期的な研究や実践的な知見にもぜひご期待ください。

人気の記事








