Skip to content

N-Day Analysis CVE-2026-5865

Kaiser Ashworth
Published date:

Table of Contents

Open Table of Contents

First Talk

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, which Google had already disabled before. 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 the 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 this function is called and what it is used for. I wrote a small test script which the main purpose is to call that function and we will have a backtrace 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, elides, or emits the Smi 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 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 try to run in gdb we can also reach the BuildCheckSmi function:

alt text

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))   // wtf

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().

So in short, EnsurePhiInputsTagged() is a repair step inside Phi untagging: when a Phi remains tagged, it retags any Phi inputs that were already untagged.

Update soon …

Next
1-Day Analysis CVE-2026-3910