CVE-2026-28903
Overview
Background
- Air / B3 backend
- JavaScriptCore’s low-level JIT IR (Air) and optimizer that allocates registers and rewrites instructions.
- fixObviousSpills
- An Air pass that replaces spilled stack-slot operands with equivalent registers/constants it knows are aliased.
- Early vs late def
- An early def writes its result before the instruction reads its inputs (clobbering at the early point), while a late def writes after; alias state must clobber each at the right time.
- Aliasing/spill state
- The per-point set of reg/slot/constant equalities fixObviousSpills uses to justify operand substitutions.
Root Cause Analysis
fixObviousSpills is an Air (B3 backend) optimization that tracks which registers, stack slots and constants alias each other so it can replace spilled-slot operands with registers/immediates. Its per-instruction dataflow originally used a single executeInst() that, for each instruction, clobbered all of the instruction’s defs and then added the instruction’s aliases — treating every def uniformly. That ignores the distinction between an instruction’s early defs (registers/slots written before the instruction consumes its inputs, i.e. clobbered at the ’early’ point) and its late defs. Because early defs were not clobbered before fixInst() rewrote the instruction’s arguments, the alias state could still believe a register or slot held a value that the instruction’s early def had already overwritten, so fixInst could replace an operand with a register/constant that no longer holds the expected value. That is a miscompilation: the optimized code reads a stale aliased value.
The fix splits def clobbering into clobberEarlyDefs() (clobber before fixInst) and clobberLateDefs() plus addInstAliases() (after), implemented via clobberDefs(prevInst, nextInst) calling forEachDefWithExtraClobberedRegs/forEachDef with the early (nullptr,&inst) and late (&inst,nullptr) roles; the fixCode loop now does clobberEarlyDefs(); fixInst(); clobberLateDefs(); addInstAliases().
The restored invariant is that the aliasing/spill state accounts for early defs before the instruction is fixed, so no operand is rewritten to a value invalidated by an early def.
Attack Path
- Shape hot code with early-def instructions Run JS (e.g. DataView/ArrayBuffer arithmetic) that the JIT compiles through B3/Air producing instructions with early-def operands and obvious spills.
- Trigger the miscompilation fixObviousSpills replaces an operand with a register/slot/constant it wrongly believes is still live across an early def, baking a stale value into the compiled code.
- Use the wrong value The optimized function computes on the stale aliased value (e.g. an index/length), producing results inconsistent with the intended semantics.
- Corrupt memory A miscomputed index/bound turns into an out-of-bounds or type-confused access in JIT code in the WebContent process.
Impact Assessment
Changed Functions
| Function | Change | Notes |
|---|---|---|
forJSTests/stress/fixobviousspills-earlydefs.js |
modified | |
ifJSTests/stress/fixobviousspills-earlydefs.js |
modified |
Files Changed
JSTests/stress/fixobviousspills-earlydefs.jsSource/JavaScriptCore/b3/air/AirFixObviousSpills.cpp
Audit Directions
- Other passes ignoring early defsgrep Air passes for forEachDef usage that doesn’t distinguish early vs late roles (Arg::Role early/late) where operand rewriting or liveness depends on it.
- Alias/spill state consumersAudit code around m_state.clobber/addAlias in fixObviousSpills and sibling passes (e.g. lowerAfterRegAlloc) for ordering of clobber vs operand substitution.
- forEachDefWithExtraClobberedRegs callersReview callers passing (&inst,&inst) that should instead split into (nullptr,&inst) early and (&inst,nullptr) late to model clobbering correctly.
Patch
diff --git a/Source/JavaScriptCore/b3/air/AirFixObviousSpills.cpp b/Source/JavaScriptCore/b3/air/AirFixObviousSpills.cpp
index 2fcd2fe1fcec..8e75140f6caf 100644
--- a/Source/JavaScriptCore/b3/air/AirFixObviousSpills.cpp
+++ b/Source/JavaScriptCore/b3/air/AirFixObviousSpills.cpp
@@ -55,9 +55,7 @@ public:
void run()
{
- if (AirFixObviousSpillsInternal::verbose)
- dataLog("Code before fixObviousSpills:\n", m_code);
-
+ dataLogLnIf(AirFixObviousSpillsInternal::verbose, "Code before fixObviousSpills:\n", m_code);
computeAliases();
fixCode();
}
@@ -79,11 +77,13 @@ private:
m_block = block;
m_state = m_atHead[block];
- if (AirFixObviousSpillsInternal::verbose)
- dataLog("Executing block ", *m_block, ": ", m_state, "\n");
-
- for (m_instIndex = 0; m_instIndex < block->size(); ++m_instIndex)
- executeInst();
+ dataLogLnIf(AirFixObviousSpillsInternal::verbose, "Executing block ", *m_block, ": ", m_state);
+ for (m_instIndex = 0; m_instIndex < block->size(); ++m_instIndex) {
+ dataLogLnIf(AirFixObviousSpillsInternal::verbose, " Executing ", m_block->at(m_instIndex), ": ", m_state);
+ clobberEarlyDefs();
+ clobberLateDefs();
+ addInstAliases();
+ }
// Before we call merge we must make sure that the two states are sorted.
m_state.sort();
@@ -116,8 +116,10 @@ private:
RELEASE_ASSERT(m_notBottom.quickGet(block->index()));
for (m_instIndex = 0; m_instIndex < block->size(); ++m_instIndex) {
+ clobberEarlyDefs();
fixInst();
- executeInst();
+ clobberLateDefs();
+ addInstAliases();
}
}
}
@@ -181,27 +183,33 @@ private:
}
}
- void executeInst()
+ void clobberDefs(Inst* prevInst, Inst* nextInst)
{
- Inst& inst = m_block->at(m_instIndex);
-
- if (AirFixObviousSpillsInternal::verbose)
- dataLog(" Executing ", inst, ": ", m_state, "\n");
-
- Inst::forEachDefWithExtraClobberedRegs<Reg>(&inst, &inst,
+ Inst::forEachDefWithExtraClobberedRegs<Reg>(prevInst, nextInst,
[&] (const Reg& reg, Arg::Role, Bank, Width, PreservedWidth) {
- if (AirFixObviousSpillsInternal::verbose)
- dataLog(" Clobbering ", reg, "\n");
+ dataLogLnIf(AirFixObviousSpillsInternal::verbose, " Clobbering ", reg);
m_state.clobber(reg);
});
- Inst::forEachDef<StackSlot*>(&inst, &inst,
+ Inst::forEachDef<StackSlot*>(prevInst, nextInst,
[&] (StackSlot* slot, Arg::Role, Bank, Width) {
- if (AirFixObviousSpillsInternal::verbose)
- dataLog(" Clobbering ", *slot, "\n");
+ dataLogLnIf(AirFixObviousSpillsInternal::verbose, " Clobbering ", *slot);
m_state.clobber(slot);
});
+ }
+
+ void clobberEarlyDefs()
+ {
+ clobberDefs(nullptr, &m_block->at(m_instIndex));
+ }
+ void clobberLateDefs()
+ {
+ clobberDefs(&m_block->at(m_instIndex), nullptr);
+ }
+
+ void addInstAliases()
+ {
forAllAliases(
[&] (const auto& alias) {
m_state.addAlias(alias);
@@ -211,9 +219,7 @@ private:
void fixInst()
{
Inst& inst = m_block->at(m_instIndex);
-
- if (AirFixObviousSpillsInternal::verbose)
- dataLog("Fixing inst ", inst, ": ", m_state, "\n");
+ dataLogLnIf(AirFixObviousSpillsInternal::verbose, "Fixing inst ", inst, ": ", m_state);
// Check if alias analysis says that this is unnecessary.
bool shouldLive = true;
@@ -289,14 +295,12 @@ private:
case Width64:
if (alias->mode != RegSlot::AllBits)
return;
- if (AirFixObviousSpillsInternal::verbose)
- dataLog(" Replacing ", arg, " with ", alias->reg, "\n");
+ dataLogLnIf(AirFixObviousSpillsInternal::verbose, " Replacing ", arg, " with ", alias->reg);
arg = Tmp(alias->reg);
didThings = true;
return;
case Width32:
- if (AirFixObviousSpillsInternal::verbose)
- dataLog(" Replacing ", arg, " with ", alias->reg, " (subwidth case)\n");
+ dataLogLnIf(AirFixObviousSpillsInternal::verbose, " Replacing ", arg, " with ", alias->reg, " (subwidth case)");
arg = Tmp(alias->reg);
didThings = true;
return;
@@ -307,8 +311,7 @@ private:
// Revert to immediate if that didn't work.
if (const SlotConst* alias = m_state.getSlotConst(arg.stackSlot())) {
- if (AirFixObviousSpillsInternal::verbose)
- dataLog(" Replacing ", arg, " with constant ", alias->constant, "\n");
+ dataLogLnIf(AirFixObviousSpillsInternal::verbose, " Replacing ", arg, " with constant ", alias->constant);
if (Arg::isValidImmForm(alias->constant))
arg = Arg::imm(alias->constant);
else
diff --git a/JSTests/stress/fixobviousspills-earlydefs.js b/JSTests/stress/fixobviousspills-earlydefs.js
new file mode 100644
index 000000000000..e066e52aca0d
--- /dev/null
+++ b/JSTests/stress/fixobviousspills-earlydefs.js
@@ -0,0 +1,43 @@
+const L = 100;
+const M = 200;
+const S = 0;
+const E = 256;
+const T = (E - S) - 5;
+
+for (let i = 0; i < 2; i++) {
+ let g = (a) => a.size;
+ let d = [];
+
+ for (let c = 0; c < 16; c++) {
+ g(d);
+
+ try {
+ const g = (a) => a.byteLength;
+ const b = new ArrayBuffer(M, { maxByteLength: M });
+ const d = new DataView(b, 0, L);
+
+ for (let c = S; c < E + 11; c++) {
+ g(1);
+
+ if (c != E) {
+ for (let c = S; c < E; c++) {
+ try { if (c == E - T) { g(1); } else { g(d); } } catch(e) { }
+ }
+
+ if (c != E) {
+ for (let c = S; c < E; c++) {
+ try { if (c == E - T) { g(1); } else { g(d); } } catch(e) { }
+ }
+ if (c != E) {
+ for (let c = S; c < E; c++) {
+ try { if (c == E - T) { g(1); } else { g(d); } } catch(e) { }
+ }
+ }
+ }
+ }
+
+ if (c == E) { b.resize(L - 5); } else { g(d); }
+ }
+ } catch (e) { }
+ }
+}
Regression Test / PoC
diff --git a/JSTests/stress/fixobviousspills-earlydefs.js b/JSTests/stress/fixobviousspills-earlydefs.js
new file mode 100644
index 000000000000..e066e52aca0d
--- /dev/null
+++ b/JSTests/stress/fixobviousspills-earlydefs.js
@@ -0,0 +1,43 @@
+const L = 100;
+const M = 200;
+const S = 0;
+const E = 256;
+const T = (E - S) - 5;
+
+for (let i = 0; i < 2; i++) {
+ let g = (a) => a.size;
+ let d = [];
+
+ for (let c = 0; c < 16; c++) {
+ g(d);
+
+ try {
+ const g = (a) => a.byteLength;
+ const b = new ArrayBuffer(M, { maxByteLength: M });
+ const d = new DataView(b, 0, L);
+
+ for (let c = S; c < E + 11; c++) {
+ g(1);
+
+ if (c != E) {
+ for (let c = S; c < E; c++) {
+ try { if (c == E - T) { g(1); } else { g(d); } } catch(e) { }
+ }
+
+ if (c != E) {
+ for (let c = S; c < E; c++) {
+ try { if (c == E - T) { g(1); } else { g(d); } } catch(e) { }
+ }
+ if (c != E) {
+ for (let c = S; c < E; c++) {
+ try { if (c == E - T) { g(1); } else { g(d); } } catch(e) { }
+ }
+ }
+ }
+ }
+
+ if (c == E) { b.resize(L - 5); } else { g(d); }
+ }
+ } catch (e) { }
+ }
+}