
Prova de conceito de exploração para CVE-2026-2763, um use-after-free no motor JavaScript da Mozilla, demonstrando uma primitiva de escrita de 1 bit restrita que leva a leitura/escrita fora dos limites.
A vulnerabilidade surge da implementação da sintaxe for-in.
O escopo de um loop for-in é representado por um par de bytecodes: JSOP::Iter e 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]
}
Na entrada do escopo, ValueToIterator é chamado em [1] para criar um PropertyIteratorObject. O interpretador então armazena (empurra) ele na pilha do interpretador em [2]. Quando o escopo termina, o PropertyIteratorObject é removido em [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();
}
Dentro de ValueToIterator, a criação do objeto é realizada como mostrado acima. Após alocar o objeto iterador em [1], ele é vinculado a uma lista encadeada global em [2]. Neste ponto, a única referência ao PropertyIteratorObject é mantida na pilha do interpretador.
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();
}
Quando o escopo termina, CloseIterator é invocado e desvincula o iterador da lista previamente vinculada, impedindo qualquer acesso ao PropertyIteratorObject fora do escopo.
No entanto, como yield não é tratado corretamente, é possível executar código fora do escopo do loop antes que CloseIterator seja executado.
Nessa situação, como a única referência forte ao PropertyIteratorObject está na pilha do interpretador, se yield for executado e o gerador retornado também não for referenciado, então quando o GC for executado, o PropertyIteratorObject pode ser coletado (liberado).
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;
}
Esta função é chamada quando delete é usado. Ela percorre a lista encadeada global e atualiza os objetos iteradores para considerar a propriedade excluída. Em [1], um callback pode ser invocado; se isso acionar o GC, um PropertyIteratorObject não referenciado pode ser liberado. Seu finalizador também liberará o objeto ni correspondente, levando a um 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;
}
Um NativeIterator pode ter seu tamanho controlado por meio do número de propriedades no objeto alvo, e cursor é um ponteiro para o slot de propriedade que será marcado como excluído. Como [1] efetivamente aplica um OR em um único bit, isso produz uma primitiva restrita: uma escrita de 1 bit em um endereço arbitrário (alinhado a 8 bytes).
NativeIterator não é alocado pelo alocador do GC; em vez disso, é alocado a partir de js::MallocArena. Como resultado, você não pode prosseguir com um spray típico de objetos JS para colocar objetos controlados do heap do GC adjacentes a ele.
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;
}
Um objeto útil alocado em js::MallocArena é o ImmutableScriptData, que armazena bytecode 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);
}
}
Este é o manipulador de bytecode usado ao inicializar um array JS.
Ele lê um valor de 4 bytes do fluxo de bytecode e o usa para definir o InitializedLength do array em [2].
Se a escrita de 1 bit for aplicada ao bytecode em [1], torna-se possível definir InitializedLength além da capacidade real do array. A partir daí, o exploit prossegue aproveitando primitivas de leitura/escrita fora dos limites (OOB).