该漏洞源于 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] 处调用 ValueToIterator 来创建一个 PropertyIteratorObject。解释器随后在 [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 对象,从而导致释放后使用(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 分配。因此,无法通过典型的 JS 对象喷射来将受控的 GC 堆对象放置在其相邻位置。
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 字节值,并在 [2] 处使用该值来设置数组的 InitializedLength。
如果将 1 位写入应用于 [1] 处的字节码,就可以将 InitializedLength 设置为超出数组实际容量。从那里开始,利用过程通过利用越界(OOB)读写原语继续进行。