← research log

entry

CVE-2024-44308: Remote Code Execution via JavaScriptCore

date
2026-08-10
tags
webkit · javascriptcore · rce · ios

Attack

Someone opens a web page in any browser on iOS. Safari, Chrome, Firefox, Edge, Brave; it doesn't matter, because Apple requires every browser on iOS to use the same browser engine, this is called WebKit, and every browser app on iOS has the same JavaScript engine in it. If a page contains JavaScript that triggers a bug in that engine, an attacker can then run their own code inside the browser process.

What does that actually give them? They can read what is on the page, steal cookies and your session tokens, read what you type into forms, and make network requests that look like they came from you. It does not mean they can leave the browser process. They can't read your Messages or Photos, write files to disk, or install anything. They are contained within the browser, so the browser is doing its job as a sandbox. Closing the tab terminates the attacker's code.

And nothing has to be installed for this to work. You do not have to grant a permission, and you don't have to hand over the physical device to anyone. Loading the page is enough, so the attack can be forced on you through a URL. This works on my iPad (9th generation, iPadOS 17.4) because Apple did not release the fix until iPadOS 17.7.2.

WebKit

WebKit is the browser engine. It takes HTML, CSS, and JavaScript from a page and processes them cohesively into what you see and interact with.

On iOS that is a platform rule, not a specific browser choice. A bug in WebKit's JavaScript engine reaches every browser on the device the same way. That makes this a powerful attack vector, because the attacker does not need them to prefer Safari, or to have a particular browser installed. One working page can exploit whatever browser they already use, and on iOS that includes in-app browsers and other WebKit surfaces that load web content the same way. The victim would not get a safer browser by switching apps.

Limited regional entitlements for alternative engines exist in the EU and Japan, but they do not change a normal iPad install like mine.

Vulnerable path

The bug is in JavaScriptCore (JSC), which is the part of WebKit that runs JavaScript. On Apple platforms it's also the framework (JavaScriptCore.framework) that apps can link without a full browser. We are after the browser path though, where it is the engine inside WebKit.

Easy to confuse but this is not a buffer overflow. The compiler was supposed to keep track of what it saved when it derived, and it didn't. The broken compiler here is DFG, this is called Data Flow Graph. It has to finish under time pressure, so it takes shortcuts based on the types it has already seen. Miss the save on one of those shortcuts and you corrupt memory instead of exiting cleanly.

WebKit and JSC are written in C++, the same kind of language used for a lot of low-level malware and rootkits, because of this, it lets you work at a very low level and access process memory directly.

A typed array is numbers packed back to back in memory, the layout you use for things like audio buffers or image pixels. Most stay a fixed length while some can be resized later. Regardless, this is where our broken code path lives, it is a write execution into a typed array which causes the issue documented in the CVE.

Inside DFG that write is called PutByVal, and that is the path this vuln resides.

JIT

JavaScript lets a variable be anything, a number one second and a string the next. Because of this, the engine has to keep checking what type each value actually is, and that costs time.

When a function has been called enough times, JSC stops running it line by line and builds machine code for the usual case. That compiler is a JIT, this is called just-in-time, and DFG sits in the middle of that chain.

If the function always gets integers, the JIT writes integer maths and skips the type checks. That guess is called speculation, because it assumes the types it has already seen will keep showing up. When the guess is wrong, the fast code can't continue, so it leaves for a slower path that can handle any type. That leave is called an OSR exit, this is called On-Stack Replacement exit. Before it leaves, every value the slow path still needs has to be saved. Miss that save and you get garbage where a real value should be.

Registers and spilling

A register is a tiny, fast storage slot inside the CPU, and the CPU only has a handful of them to work with at once. When the compiler needs a free register and they are all taken, it parks an existing value in temporary memory for that function. That memory is the stack, and that move is called a spill. The compiler is supposed to remember where the value went, so later code reads it from the stack instead of from the empty register.

A scratch register is a temporary register for short work. On this path that work is a bounds check: testing that an index falls inside the array before the code reads or writes it. If the notes say a spill happened, but it never ran, later code reads a stack slot that was never written. That is use of an uninitialised value, and this vuln depends on that failure.

Defect

When the DFG compiles a store into an integer typed array, it generates code in this order:

  1. Figure out the value being stored
  2. Emit a jump to the slow path, in case that value can't be handled by the fast path
  3. Allocate a scratch register (called scratch2GPR) used for bounds checking on resizable or shared typed arrays

Step 3 happens after step 2, and that order is the defect.

Because the allocation happens after the slow path jump, the spill code is only in the fast path, and if the slow path is taken the old value never gets saved. The compiler still believes it was saved. Later instructions read that stack slot and get whatever garbage was already there.

Easy to miss, but this should not have been possible. That save check was missing from the beginning on this path, which is what allowed the notes to lie about what had been saved.

Trigger conditions

All of these have to happen together:

  • The store targets an integer typed array (Int32Array, Uint32Array, and similar)
  • That typed array is resizable, or backed by a SharedArrayBuffer
  • The value being stored is a floating-point number that can't be truncated to a signed 32-bit integer (0xffffffff is the usual example)
  • There is enough pressure on registers that allocating scratch2GPR has to spill something

Why these conditions, and not just any typed-array store? Only an integer typed array goes down this store path, so nothing else hits the broken code. scratch2GPR only gets allocated when the typed array is resizable or shared, because those need the extra bounds check. The non-truncatable floating-point value is what forces the slow path, where the spill is missing. And without register pressure there is nothing useful left in the register that fails to get saved. Miss any one of those and this vuln does not trigger.

From bug to RCE

That garbage on the stack gets treated as an object pointer. That means the engine reads it as the memory address of a JavaScript object. The compiled code still thinks it has a known object, so it writes a property into it.

If the attacker can arrange what was sitting on that stack slot first, they choose which object gets written. They do that by running another function whose local variables fill the same stack frame. A stack frame is the slice of stack memory that function is using for its temporaries.

Regardless, they write into an object they were never supposed to touch. From there they can corrupt that object's internal fields, find addresses of other objects in memory, and then read or write arbitrary locations inside the browser process. It does not mean they can leave the browser process. They can't read your Messages or Photos, write files to disk, or install anything. Closing the tab terminates the attacker's code.

Fix

WebKit commit 82abacff moved the scratch2 allocation so it happens before the slow-path jump is generated. Because any spill is now recorded before the code can leave, the notes and the real register state stay aligned. Eight lines changed in DFGSpeculativeJIT.cpp.

References