Разработчик Firefox перепутал два символа — и подарил хакерам RCE-уязвимость в движке JavaScript

Разработчик Firefox перепутал два символа — и подарил хакерам RCE-уязвимость в движке JavaScript

Как один неправильный символ довел Mozilla до истерики.

image

В движке JavaScript SpiderMonkey, который используется в Mozilla Firefox, нашли критическую уязвимость удалённого выполнения кода (RCE). Источник оказался почти анекдотическим: одна опечатка в один символ в коде сборщика мусора для WebAssembly, где разработчик поставил побитовое AND & вместо побитового OR |.

Ошибка находилась в реализации сборки мусора WebAssembly и появилась после рефакторинга метаданных массивов WebAssembly. Уязвимость внесли коммитом fcc2f20e35ec от 19 января 2026 года в файле js/src/wasm/WasmGcObject.cpp. Проблемная строка выглядела так:

oolHeaderOld->word = uintptr_t(oolHeaderNew) & 1;

Хотя по смыслу должна была быть:

oolHeaderOld->word = uintptr_t(oolHeaderNew) | 1;

Разница между & и | здесь принципиальная. | 1 выставляет младший бит в 1, то есть помечает значение. & 1, наоборот, оставляет только младший бит, а все остальные обнуляет. Дальше сработала особенность выравнивания указателей в памяти: адреса объектов, как правило, кратны 2 (и чаще кратны 8 или 16), поэтому младший бит у них равен 0. В результате выражение uintptr_t(oolHeaderNew) & 1 почти всегда давало 0. Вместо «указателя с меткой» в поле заголовка записывался ноль.

Опечатка затронула функцию WasmArrayObject::obj_moved(). Она вызывается, когда сборщик мусора перемещает массивы WebAssembly из одного места в памяти в другое. Для массивов типа out-of-line данные лежат отдельно от «объектной оболочки», в отдельном буфере. При переносе такого массива старый буфер должен получать forwarding pointer, указатель-переадресацию на новый адрес данных. Такой указатель отличают от обычного заголовка простым трюком: выставляют младший бит в 1. Это нужно, чтобы JIT-компилятор Ion (оптимизирующий компилятор SpiderMonkey) мог быстро понять, что перед ним не обычный заголовок, а именно переадресация.

Из-за нулевого значения forwarding pointer начинала ломаться логика распознавания формата массива. В isDataInline() проверка устроена так:

(headerWord & 1) == 0

То есть значение с младшим битом 0 трактуется как inline вариант, где данные считаются встроенными. Ноль идеально подходил под это условие. В итоге OOL-массив после перемещения ошибочно помечался как IL, и сборщик мусора вместе с JIT начинали по-разному понимать, где у массива находятся данные.

Важная деталь: уязвимость проявлялась только в WebAssembly-функциях, которые успел оптимизировать Ion. В Baseline-компиляторе такого механизма обновления кадров стека при moving GC нет, поэтому там баг не триггерился.

Уязвимость обнаружил исследователь по никнейму Erge. Он изучал исходники Firefox 149 Nightly, подбирая идеи для задания в CTF, и сумел довести ошибку до выполнения кода внутри renderer-процесса Firefox.

Дальше Erge собрал proof-of-concept, который превращал логическую ошибку в полноценный захват управления. Цепочка выглядела так:

  1. Провоцировалась «малая» сборка мусора (minor GC), при переносе массива в заголовок старого буфера попадал ноль вместо forwarding pointer.
  2. В wasm::Instance::updateFrameForMovingGC (часть Ion) массив из-за нуля распознавался как inline.
  3. Вместо нового адреса данных функция возвращала старый адрес, из-за чего кадры стека не обновлялись под перенос.
  4. Ion продолжал работать со старой областью памяти, хотя она уже освобождена, возникало use-after-free (UAF).
  5. Дальше шёл heap spraying: исследователь «засорял» кучу значениями вроде 0x41414141, чтобы занять освобождённые блоки контролируемыми данными.
  6. За счёт контроля базы OOL-массива получались примитивы произвольного чтения и записи.
  7. Для обхода ASLR использовался спрей объектов, содержащих указатели с относительной адресацией, привязанные к относительным адресам внутри бинарника, что позволяло восстановить нужные смещения.
  8. Затем перезаписывался vtable, чтобы перехватить поток выполнения (hijack RIP) и вызвать произвольные системные команды.
  9. В финале эксплойт вызывал system() и поднимал shell, запускав /bin/sh.

По срокам история развивалась быстро и уложилась в несколько недель. Ошибка попала в код 19 января 2026 года. Независимый исследователь сообщил о ней в баг-трекер Mozilla как bug 2013739, дату оценивают примерно как 3 февраля 2026 года. Erge оформил свой отчёт (bug 2014014) в течение 72 часов, также 3 февраля. Исправление внесли коммитом 05ffcde 9 февраля 2026 года. 11 февраля награду по баг-баунти выплатили и разделили между двумя исследователями, которые нашли проблему независимо друг от друга.

Ключевой момент для практического риска: уязвимость затрагивала только Firefox 149 Nightly и не дошла до релизных веток. То есть пользователи стабильных версий не получили этот баг через обычные обновления, а окно для массовой эксплуатации осталось закрытым.