
Эксплойт-доказательство концепции для CVE-2026-2763, use-after-free в движке JavaScript от Mozilla, демонстрирующий ограниченный примитив записи в 1 бит, приводящий к чтению/записи за пределами границ.
Уязвимость возникает из-за реализации синтаксиса for-in.
Область действия цикла for-in представлена парой байткодов: JSOP::Iter и JSOP::EndIter.
CASE(Iter) {
MOZ_ASSERT(REGS.stackDepth() >= 1);
HandleValue val = REGS.stackHandleAt(-1);
JSObject* iter = ValueToIterator(cx, val); // [1]
if (!iter) {
goto error;
}
REGS.sp[-1].setObject(*iter); // [2]
}
...
CASE(EndIter) {
MOZ_ASSERT(REGS.stackDepth() >= 2);
CloseIterator(®S.sp[-2].toObject());
REGS.sp -= 2; // [3]
}
При входе в область действия вызывается ValueToIterator в [1] для создания PropertyIteratorObject. Затем интерпретатор сохраняет (помещает в стек) его в стек интерпретатора в [2]. Когда область действия завершается, PropertyIteratorObject удаляется в [3].
static PropertyIteratorObject* GetIteratorImpl(JSContext* cx, HandleObject obj,
bool wantIndices,
bool forObjectKeys) {
...
PropertyIteratorObject* iterobj = CreatePropertyIterator(
cx, obj, keys, supportsIndices, indicesPtr, cacheableProtoChainLength,
ownPropertyCount, forObjectKeys); // [1]
if (!iterobj) {
return nullptr;
}
if (!forObjectKeys) {
RegisterEnumerator(cx, iterobj->getNativeIterator(), obj); // [2]
}
...
return iterobj;
}
static inline void RegisterEnumerator(JSContext* cx, NativeIterator* ni,
HandleObject obj) {
ni->initObjectBeingIterated(*obj);
// Register non-escaping native enumerators (for-in) with the current
// context.
ni->link(cx->compartment()->enumeratorsAddr());
MOZ_ASSERT(!ni->isActive());
ni->markActive();
}
Внутри ValueToIterator создание объекта выполняется, как показано выше. После выделения объекта итератора в [1] он связывается в глобальный связанный список в [2]. На этом этапе единственная ссылка на PropertyIteratorObject хранится в стеке интерпретатора.
void js::CloseIterator(JSObject* obj) {
if (!obj->is<PropertyIteratorObject>()) {
return;
}
// Remove iterator from the active list, which is a stack. The shared iterator
// used for for-in with null/undefined is immutable and unlinked.
NativeIterator* ni = obj->as<PropertyIteratorObject>().getNativeIterator();
if (ni->isEmptyIteratorSingleton()) {
return;
}
ni->unlink();
MOZ_ASSERT(ni->isActive());
ni->markInactive();
ni->clearObjectBeingIterated();
// Reset the enumerator; it may still be in the cached iterators for
// this thread and can be reused.
ni->resetPropertyCursorForReuse();
}
Когда область действия завершается, вызывается CloseIterator, который отвязывает итератор от ранее связанного списка, предотвращая любой доступ к PropertyIteratorObject за пределами области действия.
Однако, поскольку yield обрабатывается некорректно, можно выполнить код за пределами области действия цикла до того, как сработает CloseIterator.
В этой ситуации, поскольку единственная сильная ссылка на PropertyIteratorObject находится в стеке интерпретатора, если выполняется yield и возвращаемый генератор также не имеет ссылок, то при запуске GC PropertyIteratorObject может быть собран (освобождён).
static bool SuppressDeletedPropertyHelper(JSContext* cx, HandleObject obj,
Handle<JSLinearString*> str) {
NativeIteratorListIter iter(obj->compartment()->enumeratorsAddr());
while (!iter.done()) {
NativeIterator* ni = iter.next();
if (!SuppressDeletedProperty(cx, ni, obj, str)) { // [1]
return false;
}
}
return true;
}
Эта функция вызывается при использовании delete. Она проходит по глобальному связанному списку и обновляет объекты итераторов с учётом удалённого свойства. В [1] может быть вызван колбэк; если это вызовет GC, не имеющий ссылок PropertyIteratorObject может быть освобождён. Его финализатор также освободит соответствующий объект ni, что приведёт к use-after-free (UAF).
static bool SuppressDeletedProperty(JSContext* cx, NativeIterator* ni,
HandleObject obj,
Handle<JSLinearString*> str) {
...
// Check whether id is still to come.
Rooted<JSLinearString*> idStr(cx);
IteratorProperty* cursor = ni->nextProperty();
for (; cursor < ni->propertiesEnd(); ++cursor) {
idStr = cursor->asString();
// Common case: both strings are atoms.
if (idStr->isAtom() && str->isAtom()) {
if (idStr != str) {
continue;
}
} else {
if (!EqualStrings(idStr, str)) {
continue;
}
}
...
cursor->markDeleted(); // [1]
ni->markHasUnvisitedPropertyDeletion();
return true;
}
return true;
}
Размер NativeIterator можно контролировать через количество свойств целевого объекта, а cursor — это указатель на слот свойства, который будет помечен как удалённый. Поскольку [1] фактически выполняет операцию OR с одним битом, это даёт ограниченный примитив: запись одного бита по произвольному (выровненному по 8 байт) адресу.
NativeIterator не выделяется через GC-аллокатор; вместо этого он выделяется из js::MallocArena. В результате нельзя продолжить с типичным распылением JS-объектов для размещения контролируемых GC-heap объектов рядом с ним.
js::UniquePtr<ImmutableScriptData> js::ImmutableScriptData::new_(
FrontendContext* fc, uint32_t codeLength, uint32_t noteLength,
uint32_t numResumeOffsets, uint32_t numScopeNotes, uint32_t numTryNotes) {
auto size = sizeFor(codeLength, noteLength, numResumeOffsets, numScopeNotes,
numTryNotes);
if (!size.isValid()) {
ReportAllocationOverflow(fc);
return nullptr;
}
// Allocate contiguous raw buffer.
void* raw = fc->getAllocator()->pod_malloc<uint8_t>(size.value());
MOZ_ASSERT(uintptr_t(raw) % alignof(ImmutableScriptData) == 0);
if (!raw) {
return nullptr;
}
// Constuct the ImmutableScriptData. Trailing arrays are uninitialized but
// GCPtrs are put into a safe state.
UniquePtr<ImmutableScriptData> result(new (raw) ImmutableScriptData(
codeLength, noteLength, numResumeOffsets, numScopeNotes, numTryNotes));
if (!result) {
return nullptr;
}
// Sanity check
MOZ_ASSERT(result->endOffset() == size.value());
return result;
}
Один из полезных объектов, выделяемых в js::MallocArena, — это ImmutableScriptData, который хранит JS-байткод.
static MOZ_ALWAYS_INLINE void InitElemArrayOperation(JSContext* cx,
jsbytecode* pc,
Handle<ArrayObject*> arr,
HandleValue val) {
MOZ_ASSERT(JSOp(*pc) == JSOp::InitElemArray);
// The dense elements must have been initialized up to this index. The JIT
// implementation also depends on this.
uint32_t index = GET_UINT32(pc); // [1]
MOZ_ASSERT(index < arr->getDenseCapacity());
MOZ_ASSERT(index == arr->getDenseInitializedLength());
// Bump the initialized length even for hole values to ensure the
// index == initLength invariant holds for later InitElemArray ops.
arr->setDenseInitializedLength(index + 1); // [2]
if (val.isMagic(JS_ELEMENTS_HOLE)) {
arr->initDenseElementHole(index);
} else {
arr->initDenseElement(index, val);
}
}
Это обработчик байткода, используемый при инициализации JS-массива.
Он считывает 4-байтовое значение из потока байткода и использует его для установки InitializedLength массива в [2].
Если применить запись одного бита к байткоду в [1], становится возможным установить InitializedLength за пределы фактической ёмкости массива. Отсюда эксплуатация продолжается с использованием примитивов OOB чтения/записи.