💰 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.

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); // 42This 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 MarkedArgumentBufferfails. 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().MarkedArgumentBuffertrying 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.





