High CVSS 5.5 webkit Bypass 🔧 Commit mapped

Overview

High
Severity
5.5
CVSS
No
Exploited ITW
Fixed
Fix Status
DescriptionAn attacker with arbitrary read and write capability may be able to bypass Pointer Authentication
ComponentJSC Runtime
Bug ClassBypass
Tracker272750
Fix commit3e3d0883c849 (WebKit/WebKit) +42/-10
CWECWE-277
CVSS vectorCVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:U/C:N/I:H/A:N
CISA KEVNot listed
CreditedManfred Paul (@_manfp) working with Trend Micro's Zero Day Initiative
Disclosed2024-05-13

Background

Pointer Authentication (PAC) / ARM64E
An ARM64E mitigation that signs pointers (e.g. return addresses) with a cryptographic MAC keyed by a context/modifier, so forged pointers fail authentication.
Signing gadget
A code sequence that signs a pointer using an attacker-controllable modifier register; abused to forge valid PACs for arbitrary pointers.
pacib vs pacizb / diversifier
pacib signs with a register-provided modifier; pacizb signs with a zero modifier. Using zero or the stack pointer removes the attacker-controllable input.

Root Cause Analysis

This hardens JavaScriptCore’s ARM64E pointer-authentication (PAC) usage against signing-gadget abuse, closing a PAC bypass usable by an attacker who already has arbitrary read/write. When the JIT reconstructs inlined call frames on OSR exit (reifyInlinedCallFrames in DFGOSRExitCompilerCommon) and fixes up arity (arityFixupGenerator in ThunkGenerators), it must re-sign the return-PC pointers with PAC.

Pre-patch, this re-signing passed an attacker-influenceable TAG/modifier held in a general-purpose register to the signing instruction (pacib): arityFixupGenerator generated a RANDOM tag (tempReturnPCTag = random()) into a register and signed with it, and reifyInlinedCallFrames used general-purpose temp registers (regT2/regT3/regT4) as the signing diversifier. An attacker with arbitrary read/write can hijack the register holding that modifier to turn the JIT’s signing sequence into a PAC signing gadget — signing an arbitrary pointer under a known/controlled context to forge a validly-signed return PC and defeat PAC-protected control flow.

The fix removes the controllable modifier: arityFixupGenerator now signs with a ZERO modifier via pacizb (jit.tagPtr(NoPtrTag, …), with the comment that passing a tag register to pacib could be hijacked into a PAC-bypass gadget), and reifyInlinedCallFrames, when Options::allowNonSPTagging() is false, uses the STACK POINTER as the signing diversifier (signingTagReg = sp) and LR as the return-PC register, carefully saving/restoring sp around the sequence.

The restored invariant is that JIT pointer re-signing uses a diversifier the attacker cannot control (zero, or the stack pointer), so the signing sequences cannot be repurposed to forge PACs. The regression tests exercise arity-fixup and OSR-exit return-PC signing paths.

Key insight
The JIT re-signed return PCs using an attacker-controllable modifier register (a random tag, or GPR temporaries), making the signing sequence a PAC-forging gadget; signing with a zero modifier (pacizb) or the stack pointer removes the controllable input.

Attack Path

  1. Obtain arbitrary read/write Start from a separate primitive giving arbitrary read/write in the WebContent process (the advisory’s stated precondition).
  2. Reach a JIT signing sequence Drive OSR exit / arity fixup so the JIT executes a return-PC PAC signing sequence.
  3. Hijack the modifier register Corrupt the general-purpose register holding the PAC tag/modifier (the random tag, or a temp register) that the signing instruction consumes.
  4. Forge a signed pointer Use the signing sequence as a gadget to sign an attacker-chosen pointer under a known context — a validly-signed return PC.
  5. Bypass PAC Redirect control flow to the forged pointer, defeating pointer authentication and continuing exploitation.

Impact Assessment

A PAC bypass on ARM64E in the WebContent process: given an existing arbitrary read/write, the JIT’s return-PC signing sequences could be turned into signing gadgets to forge authenticated pointers, defeating a core control-flow-integrity mitigation and enabling code execution. The advisory rates it high; it removes a key barrier that otherwise blocks exploitation of memory-corruption bugs.

Changed Functions

FunctionChangeNotes
reifyInlinedCallFrames
Source/JavaScriptCore/dfg/DFGOSRExitCompilerCommon.cpp
modified On ARM64E, when allowNonSPTagging() is false, re-signs the return PC using the stack pointer as the diversifier (signingTagReg = sp) and LR as the PC register, saving/restoring sp, instead of using hijackable general-purpose temp registers as the modifier.
arityFixupGenerator
Source/JavaScriptCore/jit/ThunkGenerators.cpp
modified Signs the return PC with a zero modifier (pacizb via tagPtr(NoPtrTag, ...)) instead of a random tag held in a register, so the signing step cannot be hijacked into a PAC-bypass gadget; adds an allowNonSPTagging() SP-based path.

Files Changed

  • Source/JavaScriptCore/assembler/MacroAssemblerARM64E.h
  • Source/JavaScriptCore/dfg/DFGOSRExitCompilerCommon.cpp
  • Source/JavaScriptCore/jit/ThunkGenerators.cpp
  • Source/JavaScriptCore/llint/LLIntThunks.cpp
  • Source/JavaScriptCore/runtime/Options.cpp
  • Source/JavaScriptCore/runtime/OptionsList.h
  • Source/WTF/wtf/PtrTag.h
  • Source/WebKit/WebProcess/WebProcess.cpp
  • Tools/Scripts/run-jsc-stress-tests

Audit Directions

  • Register-modifier signing
    Grep JSC ARM64E codegen for tagPtr/pacib sequences that take the modifier from a general-purpose register an attacker could corrupt; prefer NoPtrTag (pacizb) or SP.
  • Return-PC re-signing paths
    Audit all OSR-exit / thunk / arity-fixup return-PC untag/validate/tag sequences for use of non-SP tagging under allowNonSPTagging().
diff --git a/Source/JavaScriptCore/assembler/MacroAssemblerARM64E.h b/Source/JavaScriptCore/assembler/MacroAssemblerARM64E.h
index 6925f04bc3d6..3f9233b2de58 100644
--- a/Source/JavaScriptCore/assembler/MacroAssemblerARM64E.h
+++ b/Source/JavaScriptCore/assembler/MacroAssemblerARM64E.h
@@ -65,6 +65,12 @@ class MacroAssemblerARM64E : public MacroAssemblerARM64 {
 
     ALWAYS_INLINE void tagPtr(PtrTag tag, RegisterID target)
     {
+        if (!tag) {
+            m_assembler.pacizb(target);
+            return;
+        }
+
+        RELEASE_ASSERT(Options::allowNonSPTagging());
         auto tagGPR = getCachedDataTempRegisterIDAndInvalidate();
         move(TrustedImm64(tag), tagGPR);
         m_assembler.pacib(target, tagGPR);
@@ -76,11 +82,17 @@ class MacroAssemblerARM64E : public MacroAssemblerARM64 {
             m_assembler.pacibsp();
             return;
         }
+        RELEASE_ASSERT(Options::allowNonSPTagging());
         m_assembler.pacib(target, tag);
     }
 
     ALWAYS_INLINE void untagPtr(PtrTag tag, RegisterID target)
     {
+        if (!tag) {
+            m_assembler.autizb(target);
+            return;
+        }
+
         auto tagGPR = getCachedDataTempRegisterIDAndInvalidate();
         move(TrustedImm64(tag), tagGPR);
         m_assembler.autib(target, tagGPR);
diff --git a/Source/JavaScriptCore/dfg/DFGOSRExitCompilerCommon.cpp b/Source/JavaScriptCore/dfg/DFGOSRExitCompilerCommon.cpp
index 4e0d005cc08a..a597735bfaf7 100644
--- a/Source/JavaScriptCore/dfg/DFGOSRExitCompilerCommon.cpp
+++ b/Source/JavaScriptCore/dfg/DFGOSRExitCompilerCommon.cpp
@@ -254,6 +254,16 @@ void reifyInlinedCallFrames(CCallHelpers& jit, const OSRExitBase& exit)
     ASSERT(JITCode::isBaselineCode(jit.baselineCodeBlock()->jitType()));
     jit.storePtr(AssemblyHelpers::TrustedImmPtr(jit.baselineCodeBlock()), AssemblyHelpers::addressFor(CallFrameSlot::codeBlock));
 
+    GPRReg returnPCReg = GPRInfo::regT5;
+#if CPU(ARM64E)
+    GPRReg signingTagReg = GPRInfo::regT2;
+    if (!Options::allowNonSPTagging()) {
+        returnPCReg = ARM64Registers::lr;
+        signingTagReg = MacroAssembler::stackPointerRegister;
+        // We could save/restore lr here but we don't need to because the LLInt/Baseline will load it from the stack before returning anyway.
+    }
+#endif
+
     const CodeOrigin* codeOrigin;
     for (codeOrigin = &exit.m_codeOrigin; codeOrigin && codeOrigin->inlineCallFrame(); codeOrigin = codeOrigin->inlineCallFrame()->getCallerSkippingTailCalls()) {
         InlineCallFrame* inlineCallFrame = codeOrigin->inlineCallFrame();
@@ -266,15 +276,25 @@ void reifyInlinedCallFrames(CCallHelpers& jit, const OSRExitBase& exit)
 
         if (!trueCaller) {
             ASSERT(inlineCallFrame->isTail());
-            jit.loadPtr(AssemblyHelpers::Address(GPRInfo::callFrameRegister, CallFrame::returnPCOffset()), GPRInfo::regT3);
+            jit.loadPtr(AssemblyHelpers::Address(GPRInfo::callFrameRegister, CallFrame::returnPCOffset()), returnPCReg);
 #if CPU(ARM64E)
+            if (!Options::allowNonSPTagging()) {
+                JIT_COMMENT(jit, "lldb dynamic execution / posix signals could trash your stack"); // We don't have to worry about signals because they shouldn't fire in WebContent process in this window.
+                jit.move(MacroAssembler::stackPointerRegister, GPRInfo::regT4);
+            }
+
             jit.addPtr(AssemblyHelpers::TrustedImm32(sizeof(CallerFrameAndPC)), GPRInfo::callFrameRegister, GPRInfo::regT2);
-            jit.untagPtr(GPRInfo::regT2, GPRInfo::regT3);
-            jit.addPtr(AssemblyHelpers::TrustedImm32(inlineCallFrame->returnPCOffset() + sizeof(void*)), GPRInfo::callFrameRegister, GPRInfo::regT2);
-            jit.validateUntaggedPtr(GPRInfo::regT3, GPRInfo::regT4);
-            jit.tagPtr(GPRInfo::regT2, GPRInfo::regT3);
+            jit.untagPtr(GPRInfo::regT2, returnPCReg);
+            jit.validateUntaggedPtr(returnPCReg, GPRInfo::regT2);
+            jit.addPtr(AssemblyHelpers::TrustedImm32(inlineCallFrame->returnPCOffset() + sizeof(CPURegister)), GPRInfo::callFrameRegister, signingTagReg);
+            jit.tagPtr(signingTagReg, returnPCReg);
+
+            if (!Options::allowNonSPTagging()) {
+                JIT_COMMENT(jit, "lldb dynamic execution / posix signals are ok again");
+                jit.move(GPRInfo::regT4, MacroAssembler::stackPointerRegister);
+            }
 #endif
-            jit.storePtr(GPRInfo::regT3, AssemblyHelpers::addressForByteOffset(inlineCallFrame->returnPCOffset()));
+            jit.storePtr(returnPCReg, AssemblyHelpers::addressForByteOffset(inlineCallFrame->returnPCOffset()));
             jit.loadPtr(AssemblyHelpers::Address(GPRInfo::callFrameRegister, CallFrame::callerFrameOffset()), GPRInfo::regT3);
             callerFrameGPR = GPRInfo::regT3;
         } else {
@@ -291,10 +311,20 @@ void reifyInlinedCallFrames(CCallHelpers& jit, const OSRExitBase& exit)
             }
 
 #if CPU(ARM64E)
-            jit.addPtr(AssemblyHelpers::TrustedImm32(inlineCallFrame->returnPCOffset() + sizeof(void*)), GPRInfo::callFrameRegister, GPRInfo::regT2);
-            jit.move(AssemblyHelpers::TrustedImmPtr(jumpTarget.untaggedPtr()), GPRInfo::regT4);
-            jit.tagPtr(GPRInfo::regT2, GPRInfo::regT4);
-            jit.storePtr(GPRInfo::regT4, AssemblyHelpers::addressForByteOffset(inlineCallFrame->returnPCOffset()));
+            if (!Options::allowNonSPTagging()) {
+                JIT_COMMENT(jit, "lldb dynamic execution / posix signals could trash your stack"); // We don't have to worry about signals because they shouldn't fire in WebContent process in this window.
+                jit.move(MacroAssembler::stackPointerRegister, GPRInfo::regT4);
+            }
Loading diff…

Original Bug Report

The reporter's bug is still restricted on the tracker.