
# CVE-2026-2763 개념 증명 익스플로잇 Mozilla JavaScript 엔진의 use-after-free 취약점에 대한 개념 증명 익스플로잇으로, 제한된 1비트 쓰기 프리미티브를 시연하여 out-of-bounds 읽기/쓰기로 이어집니다.
이 취약점은 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]
}
범위 진입 시, [1]에서 PropertyIteratorObject를 생성하기 위해 ValueToIterator가 호출됩니다. 그런 다음 인터프리터는 [2]에서 이를 인터프리터 스택에 저장(푸시)합니다. 범위가 종료되면 [3]에서 PropertyIteratorObject가 제거됩니다.
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바이트 정렬된) 주소에 대한 1비트 쓰기입니다.
NativeIterator는 GC 할당자를 통해 할당되지 않습니다. 대신 js::MallocArena에서 할당됩니다. 결과적으로, 제어된 GC 힙 객체를 인접하게 배치하기 위한 일반적인 JS 객체 스프레이를 진행할 수 없습니다.
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에서 할당되는 유용한 객체 중 하나는 JS 바이트코드를 저장하는 ImmutableScriptData입니다.
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바이트 값을 읽어 [2]에서 배열의 InitializedLength를 설정하는 데 사용합니다.
1비트 쓰기를 [1]의 바이트코드에 적용하면, 배열의 실제 용량을 초과하도록 InitializedLength를 설정하는 것이 가능해집니다. 여기서부터 익스플로잇은 OOB 읽기/쓰기 프리미티브를 활용하여 진행됩니다.