利用WebKit JSPropertyNameEnumerator越界读取漏洞(CVE-2021-1789)
August 19, 2022 · 13 min · Đỗ Minh Tuấn (@tuanit96)
目录
最初,我们的团队成员Đỗ Minh Tuấn想撰写关于APT组织使用的CVE-2021-1870的根因分析(RCA)。但Maddie Stone指出实际上是CVE-2021-1789。尽管如此,我们仍想分享Đỗ Minh Tuấn完成的分析。
该漏洞在Safari 14.0.3的安全内容中被分配为CVE-2021-1789。我们成功在WebKitGTK <= 2.30.5或等效的WebKit版本上利用了它。
JSPropertyNameEnumerator是一个内部对象,帮助JSC处理for…in循环。JavaScriptCore(JSC)使用此对象缓存我们放入循环的基础对象的信息。JSC还允许遍历基础对象的原型链,这意味着它可以遍历带有陷阱回调的代理。然而,JSC在迭代后未检查基础对象的最终大小,导致越界读取。
背景
这些知识基于撰写时的最新提交。目前可能有些过时。
WebKit堆 - 截至此提交
JSC虚拟内存是内存池的集合,每个池持有特定类型的指针:
jsValueGigaCageAuxiliarySpace
存储用户输入,如数组中的值
cellSpace
存储对象指针 - 具有唯一大小的对象分别分配给唯一的块目录
主堆有两种类型的子空间,帮助管理分配:
IsoSubspace
:包含基本的JSValue,如数组、函数等
CompleteSubspace
:包含butterfly、对象等
在本文中,我们将只关注CompleteSubspace。每次我们从JS代码创建对象时:
1
|
var obj = {a: 1, b: 2, c: 3, d: 4}
|
WebKit调用CompleteSubspaceInlines.h中的CompleteSubspace::allocateNonVirtual
来为此对象找到可用的分配器:
1
2
3
4
5
6
7
|
if constexpr (validateDFGDoesGC)
vm.heap.verifyCanGC();
if (Allocator allocator = allocatorForNonVirtual(size, AllocatorForMode::AllocatorIfExists)) {
return allocator.allocate(vm.heap, deferralContext, failureMode);
}
return allocateSlow(vm, size, deferralContext, failureMode);
|
Allocator是每个块上内存分配的处理程序。WebKit长期使用简单的隔离存储堆结构来处理中小型对象(最大约8KB):
- 中小型对象从隔离的空闲列表分配。给定所需的对象大小,WebKit执行表查找以找到适当的空闲列表,然后从此列表中弹出第一个对象
- 内存分为16KB块。每个块包含单元格。块中的所有单元格具有相同的单元格大小,称为块的尺寸类
- 在任何时候,尺寸类的活动空闲列表仅包含来自单个块的对象。当我们耗尽空闲列表中的对象时,我们找到该尺寸类中的下一个块并扫描它以提供空闲列表
开始时,Webkit初始化一些默认分配器用于大小:16、32、64等,并将其存储到m_allocatorForSizeStep
中:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
|
Allocator CompleteSubspace::allocatorForSlow(size_t size)
{
...
std::unique_ptr<LocalAllocator> uniqueLocalAllocator =
makeUnique<LocalAllocator>(directory);
LocalAllocator* localAllocator = uniqueLocalAllocator.get();
m_localAllocators.append(WTFMove(uniqueLocalAllocator));
Allocator allocator(localAllocator);
index = MarkedSpace::sizeClassToIndex(sizeClass);
for (;;) {
if (MarkedSpace::s_sizeClassForSizeStep[index] != sizeClass)
break;
m_allocatorForSizeStep[index] = allocator;
if (!index--)
break;
}
...
return allocator;
}
|
当我们需要分配具有新大小的内存时,也会使用此函数。请注意,Allocator不是原始内存,它只是一个处理程序,当使用分配器分配时,它将检索内存中的空闲指针列表:
1
2
3
4
5
6
7
8
9
10
|
ALWAYS_INLINE void* LocalAllocator::allocate(Heap& heap, GCDeferralContext* deferralContext, AllocationFailureMode failureMode)
{
if constexpr (validateDFGDoesGC)
heap.verifyCanGC();
return m_freeList.allocate(
[&] () -> HeapCell* {
sanitizeStackForVM(heap.vm());
return static_cast<HeapCell*>(allocateSlowCase(heap, deferralContext, failureMode));
});
}
|
如果我们消耗了空闲列表中的所有指针,WebKit将进入慢路径,向操作系统请求新内存或窃取其他空块:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
|
void* LocalAllocator::allocateSlowCase(Heap& heap, GCDeferralContext* deferralContext, AllocationFailureMode failureMode)
{
...
void* result = tryAllocateWithoutCollecting(); // "steal" things
if (LIKELY(result != nullptr))
return result;
Subspace* subspace = m_directory->m_subspace;
if (subspace->isIsoSubspace()) {
if (void* result = static_cast<IsoSubspace*>(subspace)->tryAllocateFromLowerTier()) // request new memory through "bmalloc api"
return result;
}
MarkedBlock::Handle* block = m_directory->tryAllocateBlock(heap); // request new memory through "bmalloc api"
if (!block) {
if (failureMode == AllocationFailureMode::Assert)
RELEASE_ASSERT_NOT_REACHED();
else
return nullptr;
}
m_directory->addBlock(block);
result = allocateIn(block); // divide block into pointers in free list return return the pointer on top list
ASSERT(result);
return result;
}
|
进入tryAllocateWithoutCollecting()
函数,首先WebKit在当前目录中查找可用块,这意味着它与我们的请求大小相同(1)。如果没有,WebKit从另一个目录窃取块,但它必须在同一子空间中(2):
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
|
void* LocalAllocator::tryAllocateWithoutCollecting()
{
...
for (;;) {
MarkedBlock::Handle* block = m_directory->findBlockForAllocation(*this); // (1)
if (!block)
break;
if (void* result = tryAllocateIn(block))
return result;
}
if (Options::stealEmptyBlocksFromOtherAllocators()) { // This option was enabled by default
if (MarkedBlock::Handle* block = m_directory->m_subspace->findEmptyBlockToSteal()) { // (2)
RELEASE_ASSERT(block->alignedMemoryAllocator() == m_directory->m_subspace->alignedMemoryAllocator());
block->sweep(nullptr);
block->removeFromDirectory();
m_directory->addBlock(block);
return allocateIn(block);
}
}
return nullptr;
}
|
简要来说,工作流程如下:
[工作流程图]
WebKit垃圾回收器
垃圾回收器用于收集未使用的单元格。为了确定应收集哪些单元格,WebKit在每个块的页脚维护一个位图:1表示存活,0表示死亡。每次收集后,GC通过调用sweep(&m_freelist)
将死单元格放回空闲列表:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
|
template<...>
void MarkedBlock::Handle::specializedSweep(...)
{
...
MarkedBlock& block = this->block();
MarkedBlock::Footer& footer = block.footer();
...
FreeCell* head = nullptr;
size_t count = 0;
uintptr_t secret;
cryptographicallyRandomValues(&secret, sizeof(uintptr_t));
bool isEmpty = true;
Vector<size_t> deadCells;
auto handleDeadCell = [&] (size_t i) {
HeapCell* cell = reinterpret_cast_ptr<HeapCell*>(&block.atoms()[i]);
if (destructionMode != BlockHasNoDestructors)
destroy(cell);
if (sweepMode == SweepToFreeList) {
FreeCell* freeCell = reinterpret_cast_ptr<FreeCell*>(cell);
if (scribbleMode == Scribble)
scribble(freeCell, cellSize);
freeCell->setNext(head, secret);
head = freeCell;
++count;
}
};
for (size_t i = 0; i < m_endAtom; i += m_atomsPerCell) {
if (emptyMode == NotEmpty
&& ((marksMode == MarksNotStale && footer.m_marks.get(i))
|| (newlyAllocatedMode == HasNewlyAllocated && footer.m_newlyAllocated.get(i)))) {
isEmpty = false;
continue;
}
if (destructionMode == BlockHasDestructorsAndCollectorIsRunning)
deadCells.append(i);
else
handleDeadCell(i);
}
...
}
|
然后垃圾回收后,将检索目录的摘要。每个-
代表目录中的一个块:
1
2
3
4
5
6
7
8
9
10
|
{DoesNotNeedDestruction, JSCell}
Live: 1---------------
Empty: ----------------
Allocated: ----------------
CanAllocateButNotEmpty: 1---------------
Destructible: ----------------
Eden: ----------------
Unswept: 1---------------
MarkingNotEmpty: 1---------------
MarkingRetired: ----------------
|
请注意,MarkingRetired
表示仍有一些可用单元格,但如果使用的单元格数大于默认阈值(0.9),则该块将不接受任何新分配。例如,标准JSObject的大小为64,每个块包含16000字节,则每个JSObject块可能包含251个单元格,我们不能分配超过225个单元格。
我们做所有这些麻烦是为了使标记更快:这样,标记知道何时使用js/jns
在m_biasedMarkCount
上退休块。我们偏置标记计数,以便如果m_biasedMarkCount >= 0
,则应退休块。例如,如果一个块有100个对象的空间,并且当90%存活时发生退休,则m_markCountBias
将为-90。这样,当标记开始时,这将导致我们将m_biasedMarkCount
也设置为-90,因为:
m_biasedMarkCount = actualMarkCount + m_markCountBias.
标记对象将增加m_biasedMarkCount
。一旦90个对象被标记,我们将有m_biasedMarkCount = 0
,这将触发退休。
那么GC如何将单元格标记为死亡或存活?GC通常是一个图搜索问题,堆只是一个图。根是局部变量,它们的值是指向对象的定向边,这些对象有字段创建指向其他对象的边。WebKit的垃圾回收器还允许DOM、编译器和类型推断系统安装约束回调。
1
2
3
4
5
6
7
8
9
10
11
12
13
|
void Heap::addCoreConstraints()
{
m_constraintSet->add(
"Cs", "Conservative Scan",
...
visitor.append(conservativeRoots);
...);
m_constraintSet->add(
"Msr", "Misc Small Roots",
...);
...
}
|
这些约束允许查询哪些对象被标记,并且它们允许标记对象。WebKit GC算法执行这些约束直到不动点。当所有标记的对象都被访问并且没有约束想要标记更多对象时,GC终止。例如,Conservative Scan意味着WebKit从堆栈内存开始,收集所有指针并访问所有引用。在收集之前,WebKit保存最后标记的单元格的信息,然后清除所有标记。在收集过程中,WebKit基于约束和引用访问所有单元格并标记它们,因此最终,未标记的单元格成为死单元格。
For…in循环
for...in
语句迭代对象本身的所有可枚举属性以及对象从其原型链继承的属性(较近原型的属性优先于原型链中较远的原型的属性)。假设我们有以下代码:
1
2
3
4
5
6
7
|
function foo(o) {
for (let p in o) {
return o[p];
}
}
var obj = {a: 1, b: 2, c: 3, d: 4};
foo(obj);
|
使用jsc二进制文件转储字节码,我们得到以下指令:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
|
foo#Doaaf8:[0x7f222fac4120->0x7f222fae5100, NoneFunctionCall, 174]: 55 instructions (0 16-bit instructions, 1 32-bit instructions, 7 instructions with metadata); 294 bytes (120 metadata bytes); 2 parameter(s); 14 callee register(s); 6 variable(s); scope at loc4
bb#1
[ 0] enter
[ 1] get_scope loc4
[ 3] mov loc5, loc4
[ 6] check_traps
[ 7] mov loc6, <JSValue()>(const0)
[ 10] mov loc7, arg1
[ 13] get_property_enumerator loc8, loc7
[ 16] get_enumerable_length loc9, loc8
[ 19] mov loc10, Int32: 0(const1)
Successors: [ #2 ]
...
bb#7
[ 61] mov loc10, Int32: 0(const1)
[ 64] enumerator_structure_pname loc11, loc8, loc10
Successors: [ #8 ]
bb#8
[ 68] loop_hint
[ 69] check_traps
[ 70] stricteq loc12, loc11, Null(const2)
[ 74] jtrue loc12, 55(->129)
Successors: [ #13 #9 ]
bb#9
[ 77] has_structure_property loc12, loc7, loc11, loc8
[ 82] jfalse loc12, 36(->118)
Successors: [ #11 #10 ]
bb#10
[ 85] mov loc6, loc11
[ 88] check_tdz loc6
[ 90] **get_direct_pname loc13, arg1, loc6, loc10, loc8
[ 116] ret loc13
Successors: [ ]
...
|
通常,JSC将首先创建一个JSPropertyNameEnumerator对象,并使用其缓存的属性数检索所有必要的对象。这些操作码中的每一个都有两个代码路径:慢和快。例如,以下是操作码get_direct_pname
的两个路径:
慢路径:
1
2
3
4
5
6
7
8
9
10
11
12
|
SLOW_PATH_DECL(slow_path_get_direct_pname)
{
BEGIN();
auto bytecode = pc->as<OpGetDirectPname>();
JSValue baseValue = GET_C(bytecode.m_base).jsValue();
JSValue property = GET(bytecode.m_property).jsValue();
ASSERT(property.isString());
JSString* string = asString(property);
auto propertyName = string->toIdentifier(globalObject);
CHECK_EXCEPTION();
RETURN(baseValue.get(globalObject, propertyName));
}
|
快路径:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
|
void JIT::emit_op_get_direct_pname(const Instruction* currentInstruction)
{
auto bytecode = currentInstruction->as<OpGetDirectPname>();
VirtualRegister dst = bytecode.m_dst;
VirtualRegister base = bytecode.m_base;
VirtualRegister index = bytecode.m_index;
VirtualRegister enumerator = bytecode.m_enumerator;
// Check that the base is a cell
emitLoadPayload(base, regT0);
emitJumpSlowCaseIfNotJSCell(base);
// Check the structure
emitLoadPayload(enumerator, regT1);
load32(Address(regT0, JSCell::structureIDOffset()), regT2);
addSlowCase(branch32(NotEqual, regT2, Address(regT1, JSPropertyNameEnumerator::cachedStructureIDOffset())));
// Compute the offset
emitLoadPayload(index, regT2);
// If the index is less than the enumerator's cached inline storage, then it's an inline access
Jump outOfLineAccess = branch32(AboveOrEqual, regT2, Address(regT1, JSPropertyNameEnumerator::cachedInlineCapacityOffset()));
addPtr(TrustedImm32(JSObject::offsetOfInlineStorage()), regT0);
load32(BaseIndex(regT0, regT2, TimesEight, OBJECT_OFFSETOF(JSValue, u.asBits.tag)), regT1);
load32(BaseIndex(regT0, regT2, TimesEight, OBJECT_OFFSETOF(JSValue, u.asBits.payload)), regT0);
Jump done = jump();
// Otherwise it's out of line
outOfLineAccess.link(this);
loadPtr(Address(regT0, JSObject::butterflyOffset()), regT0);
sub32(Address(regT1, JSPropertyNameEnumerator::cachedInlineCapacityOffset()), regT2);
neg32(regT2);
int32_t offsetOfFirstProperty = static_cast<int32_t>(offsetInButterfly(firstOutOfLineOffset)) * sizeof(EncodedJSValue);
load32(BaseIndex(regT0, regT2, TimesEight, offsetOfFirstProperty + OBJECT_OFFSETOF(JSValue, u.asBits.tag)), regT1);
load32(BaseIndex(regT0, regT2, TimesEight, offsetOfFirstProperty + OBJECT_OFFSETOF(JSValue, u.asBits.payload)), regT0);
done.link(this);
emitValueProfilingSite(bytecode.metadata(m_codeBlock));
emitStore(dst, regT1, regT0);
}
|
当JSC枚举基础对象以读取其属性值时,JSC将检查我们是否从具有与基础对象相同结构的目标