Medium CVSS 4.3 webkit OOB 🔧 Commit mapped

Overview

Medium
Severity
4.3
CVSS
No
Exploited ITW
Fixed
Fix Status
DescriptionProcessing maliciously crafted web content may lead to an unexpected process crash
ComponentJSC
Sub-componentJSC LLInt
Bug ClassOOB
Tracker298628
Fix commit127c1d5c4d40 (WebKit/WebKit) +24/-3
CWECWE-20, CWE-703 (Improper input validation)
CVSS vectorCVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:N/I:N/A:L
CISA KEVNot listed
CreditedGary Kwong, rheza (@ginggilBesel)
Disclosed2025-11-03

Background

op_switch_imm
The bytecode operation that dispatches a switch over integer cases using a jump table, with a fast path in the LLInt and a C++ slow path.
UnlinkedSimpleJumpTable
The compiled representation of a switch table, either dense (indexed by value - m_min) or list-style (sparse key-offset pairs).
List (sparse) jump table
A switch table used when case values are widely spread; lookups scan key-offset pairs instead of indexing a contiguous array, marked historically by m_min == INT32_MAX.
offsetForValue
UnlinkedSimpleJumpTable’s lookup that returns the branch offset for a case value, choosing dense indexing or list scanning based on isList().
LLInt
JavaScriptCore’s Low-Level Interpreter, whose offlineasm-generated fast paths (LowLevelInterpreter64.asm etc.) implement bytecode ops like op_switch_imm.
Bytecode cache (CachedTypes)
The mechanism that serializes UnlinkedCodeBlock structures (including jump tables) to disk and back, requiring every semantically-relevant field to be encoded/decoded.

Root Cause Analysis

The patch fixes a mis-detection of list-style switch jump tables in JavaScriptCore caused by overloading the sentinel value INT32_MAX. UnlinkedSimpleJumpTable supports two shapes: a dense/immediate table indexed as m_branchOffsets[value - m_min], and a ’list’ (sparse) table of key-offset pairs used when case values are too spread out. Before the patch the code distinguished them implicitly: isList() returned m_min == INT32_MAX, and BytecodeGenerator::endSwitch’s list builder set jumpTable.m_min = INT32_MAX as the marker.

The invariant that was violated is that m_min == INT32_MAX must unambiguously mean ’this is a list table’ — but a legitimate dense immediate switch whose (only/minimum) case value is literally 2147483647 (INT32_MAX) also produces m_min == INT32_MAX, colliding with the sentinel.

The added regression test switch-imm-int32-max.js switches on case 2147483647 across nested try/catch. When such a dense table is misclassified as a list, offsetForValue and the op_switch_imm LLInt fast path mishandle it: the LLInt fast path previously bailed to the slow path only when m_min == INT32_MAX (bieq t3, INT32_MAX, .opSwitchImmSlow), and the C++ isList()/offsetForValue path selects the wrong indexing scheme, so lookups compute value - m_min with a sentinel min or index the wrong structure, producing an incorrect/out-of-range branch offset lookup and a wild control-flow target that crashes.

The fix stops overloading m_min: it adds an explicit int32_t m_isList field to UnlinkedSimpleJumpTable, sets jumpTable.m_isList = true only in the actual list builder (endSwitch), makes isList() return !!m_isList, serializes/deserializes m_isList through CachedSimpleJumpTable (bytecode cache), and changes both LLInt op_switch_imm implementations to branch to the slow path on ‘btinz m_isList[t2]’ instead of comparing m_min to INT32_MAX. This decouples ‘is a list table’ from ‘minimum case value equals INT32_MAX’, so a dense switch with a case of INT32_MAX is no longer treated as a sparse list.

Key insight
Overloading the sentinel m_min == INT32_MAX to mean ’list table’ collides with a legitimate dense switch whose case value is INT32_MAX; the fix is to carry an explicit m_isList flag (and serialize it) rather than infer table shape from a magic minimum value.

Attack Path

  1. Write a switch with case INT32_MAX Author JavaScript containing a switch whose case value is 2147483647 (INT32_MAX) so the generated op_switch_imm jump table has m_min == INT32_MAX, colliding with the old list sentinel (as in switch-imm-int32-max.js).
  2. Shape the table to be dense Arrange the cases so the bytecode generator emits an immediate (dense) jump table rather than intentionally a list, so the sentinel collision causes a dense table to be misclassified as a list.
  3. Execute op_switch_imm Run the switch so op_switch_imm evaluates; the LLInt fast path or the C++ offsetForValue selects the wrong lookup scheme because isList() falsely reports true (or the fast path mis-bails).
  4. Compute a bad branch offset The mismatched indexing yields an out-of-range/incorrect branch offset (value - INT32_MAX or wrong-structure access), sending control flow to an invalid target and crashing the process (the stated impact).
  5. (Inference) Consider caching/tier interactions Because m_isList is now also serialized in the bytecode cache, a pre-patch mismatch between cached and recomputed classification could compound the confusion; the diff shows the cache field was added but does not demonstrate a cache-only exploit, so this is inference.

Impact Assessment

The commit establishes a jump-table misclassification bug in a core JS control-flow op whose demonstrated effect is an unexpected crash in the WebContent process when a dense switch with a case of INT32_MAX is treated as a sparse list, causing an incorrect branch-offset computation. Because the flawed path can index the jump table with a bogus (value - INT32_MAX) offset, there is a plausible out-of-bounds read of branch offsets and a resulting wild jump; the diff shows the misclassification and the indexing logic but not a working memory-disclosure or control primitive, so beyond the proven crash any exploitation is inference. It is confined to the JS engine in the WebContent process and does not itself cross the sandbox.

Changed Functions

FunctionChangeNotes
UnlinkedCodeBlock
Source/JavaScriptCore/bytecode/UnlinkedCodeBlock.h
modified
switch
JSTests/stress/switch-imm-int32-max.js
modified

Files Changed

  • JSTests/stress/switch-imm-int32-max.js
  • Source/JavaScriptCore/bytecode/UnlinkedCodeBlock.h
  • Source/JavaScriptCore/bytecompiler/BytecodeGenerator.cpp
  • Source/JavaScriptCore/llint/LowLevelInterpreter32_64.asm
  • Source/JavaScriptCore/llint/LowLevelInterpreter64.asm
  • Source/JavaScriptCore/runtime/CachedTypes.cpp

Audit Directions

  • Same-op: op_switch_char / op_switch_string tables
    Check the analogous jump-table types and their LLInt handlers (op_switch_char, op_switch_string) for any ‘is list’ / dense-vs-sparse decision that keys off a sentinel min/length instead of an explicit flag; grep for INT32_MAX, m_min, and isList across bytecode/UnlinkedCodeBlock and the .asm switch handlers.
  • All consumers of isList()/offsetForValue
    Grep JSC for isList(), offsetForValue(, and m_min == INT32_MAX to ensure every DFG/FTL/Baseline switch lowering now uses m_isList and that none still infer list-ness from the min value or from m_branchOffsets layout.
  • Bytecode-cache field completeness
    Audit CachedTypes.cpp Cached* classes for structs that gained fields (like m_isList) but whose encode/decode may be missing a member, grepping for recently added struct fields versus their Cached counterpart to catch classification/state that does not round-trip through the cache.
  • Sentinel/magic-value overloading elsewhere
    Search JSC for boundary constants used as in-band flags (INT32_MAX, UINT_MAX, -1 as ‘special’) where a legitimate data value could collide, e.g. min/max fields doubling as mode markers, and prefer an explicit boolean/enum as this patch does.
diff --git a/Source/JavaScriptCore/bytecode/UnlinkedCodeBlock.h b/Source/JavaScriptCore/bytecode/UnlinkedCodeBlock.h
index 2035018f8aa3..fd8388c8aeac 100644
--- a/Source/JavaScriptCore/bytecode/UnlinkedCodeBlock.h
+++ b/Source/JavaScriptCore/bytecode/UnlinkedCodeBlock.h
@@ -115,6 +115,7 @@ struct UnlinkedSimpleJumpTable {
     FixedVector<int32_t> m_branchOffsets;
     int32_t m_min { 0 };
     int32_t m_defaultOffset { 0 };
+    int32_t m_isList { 0 };
 
     inline int32_t offsetForValue(int32_t value) const
     {
@@ -135,7 +136,7 @@ struct UnlinkedSimpleJumpTable {
     int32_t defaultOffset() const { return m_defaultOffset; }
 
     // Returns true if this is a list-style jump table (key-offset pairs), used for sparse switches.
-    bool isList() const { return m_min == INT32_MAX; }
+    bool isList() const { return !!m_isList; }
 };
 
 class UnlinkedCodeBlock : public JSCell {
diff --git a/Source/JavaScriptCore/bytecompiler/BytecodeGenerator.cpp b/Source/JavaScriptCore/bytecompiler/BytecodeGenerator.cpp
index 8b561526e282..c5eee8e4c0f9 100644
--- a/Source/JavaScriptCore/bytecompiler/BytecodeGenerator.cpp
+++ b/Source/JavaScriptCore/bytecompiler/BytecodeGenerator.cpp
@@ -4464,6 +4464,7 @@ void BytecodeGenerator::endSwitch(const Vector<Ref<Label>, 8>& labels, Expressio
     auto handleSwitchList = [&](auto bytecode) {
         UnlinkedSimpleJumpTable& jumpTable = m_codeBlock->unlinkedSwitchJumpTable(bytecode.m_tableIndex);
         jumpTable.m_min = INT32_MAX;
+        jumpTable.m_isList = true;
 
         Vector<int32_t> branchOffsets;
         branchOffsets.reserveInitialCapacity(labels.size() * 2);
diff --git a/Source/JavaScriptCore/llint/LowLevelInterpreter32_64.asm b/Source/JavaScriptCore/llint/LowLevelInterpreter32_64.asm
index dbb989b4cf5d..e3359cf1fae6 100644
--- a/Source/JavaScriptCore/llint/LowLevelInterpreter32_64.asm
+++ b/Source/JavaScriptCore/llint/LowLevelInterpreter32_64.asm
@@ -2239,8 +2239,8 @@ llintOpWithJump(op_switch_imm, OpSwitchImm, macro (size, get, jump, dispatch)
 
     bineq t1, Int32Tag, .opSwitchImmNotInt
 
+    btinz UnlinkedSimpleJumpTable::m_isList[t2], .opSwitchImmSlow
     loadi UnlinkedSimpleJumpTable::m_min[t2], t3
-    bieq t3, (constexpr INT32_MAX), .opSwitchImmSlow
 
     subi t3, t0
     loadp UnlinkedSimpleJumpTable::m_branchOffsets + Int32FixedVector::m_storage[t2], t3
diff --git a/Source/JavaScriptCore/llint/LowLevelInterpreter64.asm b/Source/JavaScriptCore/llint/LowLevelInterpreter64.asm
index 2c6a766dd31e..5f87e445d0e8 100644
--- a/Source/JavaScriptCore/llint/LowLevelInterpreter64.asm
+++ b/Source/JavaScriptCore/llint/LowLevelInterpreter64.asm
@@ -2407,8 +2407,8 @@ llintOpWithJump(op_switch_imm, OpSwitchImm, macro (size, get, jump, dispatch)
 
     bqb t1, numberTag, .opSwitchImmNotInt
 
+    btinz UnlinkedSimpleJumpTable::m_isList[t2], .opSwitchImmSlow
     loadi UnlinkedSimpleJumpTable::m_min[t2], t3
-    bieq t3, (constexpr INT32_MAX), .opSwitchImmSlow
 
     subi t3, t1
     loadp UnlinkedSimpleJumpTable::m_branchOffsets + Int32FixedVector::m_storage[t2], t3
diff --git a/Source/JavaScriptCore/runtime/CachedTypes.cpp b/Source/JavaScriptCore/runtime/CachedTypes.cpp
index 7b3c4d958db4..94aa81f90bb3 100644
--- a/Source/JavaScriptCore/runtime/CachedTypes.cpp
+++ b/Source/JavaScriptCore/runtime/CachedTypes.cpp
@@ -890,6 +890,7 @@ public:
     {
         m_min = jumpTable.m_min;
         m_defaultOffset = jumpTable.m_defaultOffset;
+        m_isList = jumpTable.m_isList;
         m_branchOffsets.encode(encoder, jumpTable.m_branchOffsets);
     }
 
@@ -897,12 +898,14 @@ public:
     {
         jumpTable.m_min = m_min;
         jumpTable.m_defaultOffset = m_defaultOffset;
+        jumpTable.m_isList = m_isList;
         m_branchOffsets.decode(decoder, jumpTable.m_branchOffsets);
     }
 
 private:
     int32_t m_min;
     int32_t m_defaultOffset;
+    int32_t m_isList;
     CachedVector<int32_t> m_branchOffsets;
 };
 
diff --git a/JSTests/stress/switch-imm-int32-max.js b/JSTests/stress/switch-imm-int32-max.js
new file mode 100644
index 000000000000..db7949d90eda
--- /dev/null
+++ b/JSTests/stress/switch-imm-int32-max.js
@@ -0,0 +1,16 @@
+//@ runDefault("--useConcurrentJIT=false", "--thresholdForJITAfterWarmUp=10")
+try {
+    switch (1) {
+        case 2147483647:
+        case 2147483647:
+        case 2147483647:
+    }
+} catch(e39) {
+    switch (1) {
+        case 2147483647:
+            try {} catch(e80) {}
+    }
+    switch (1) {
+        case 2147483647:
+    }
+}
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/JSTests/stress/switch-imm-int32-max.js b/JSTests/stress/switch-imm-int32-max.js
new file mode 100644
index 000000000000..db7949d90eda
--- /dev/null
+++ b/JSTests/stress/switch-imm-int32-max.js
@@ -0,0 +1,16 @@
+//@ runDefault("--useConcurrentJIT=false", "--thresholdForJITAfterWarmUp=10")
+try {
+    switch (1) {
+        case 2147483647:
+        case 2147483647:
+        case 2147483647:
+    }
+} catch(e39) {
+    switch (1) {
+        case 2147483647:
+            try {} catch(e80) {}
+    }
+    switch (1) {
+        case 2147483647:
+    }
+}
Loading diff…

Original Bug Report

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