CVE-2025-43427
Overview
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.
Attack Path
- 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).
- 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.
- 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).
- 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).
- (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
Changed Functions
| Function | Change | Notes |
|---|---|---|
UnlinkedCodeBlockSource/JavaScriptCore/bytecode/UnlinkedCodeBlock.h |
modified | |
switchJSTests/stress/switch-imm-int32-max.js |
modified |
Files Changed
JSTests/stress/switch-imm-int32-max.jsSource/JavaScriptCore/bytecode/UnlinkedCodeBlock.hSource/JavaScriptCore/bytecompiler/BytecodeGenerator.cppSource/JavaScriptCore/llint/LowLevelInterpreter32_64.asmSource/JavaScriptCore/llint/LowLevelInterpreter64.asmSource/JavaScriptCore/runtime/CachedTypes.cpp
Audit Directions
- Same-op: op_switch_char / op_switch_string tablesCheck 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()/offsetForValueGrep 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 completenessAudit 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 elsewhereSearch 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.
Patch
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:
+ }
+}
Regression Test / PoC
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:
+ }
+}