CVE-2026-15903
Overview
Files Changed
src/compiler/simplified-lowering.cctest/mjsunit/regress/regress-531503216.js
Patch
From fba00590cb03c58aa01fe18dd8f0cfedf0e94077 Mon Sep 17 00:00:00 2001
From: Victor Gomes <victorgomes@chromium.org>
Date: Tue, 07 Jul 2026 11:24:29 +0200
Subject: [PATCH] [turbofan] Keep safe-integer check in ToNumber Word32 lowering
Fixed: 531503216
Change-Id: Icd80dc73acffe448922c6d3a33e7b750662223af
Reviewed-on: https://chromium-review.googlesource.com/c/v8/v8/+/8052504
Commit-Queue: Victor Gomes <victorgomes@chromium.org>
Auto-Submit: Victor Gomes <victorgomes@chromium.org>
Reviewed-by: Nico Hartmann <nicohartmann@chromium.org>
Cr-Commit-Position: refs/heads/main@{#108479}
---
diff --git a/src/compiler/simplified-lowering.cc b/src/compiler/simplified-lowering.cc
index 531c780..8578943b 100644
--- a/src/compiler/simplified-lowering.cc
+++ b/src/compiler/simplified-lowering.cc
@@ -2741,7 +2741,7 @@
Type::BigInt(), Type::NumberOrOddball(), graph()->zone())));
VisitInputs<T>(node);
// TODO(bmeurer): Optimize somewhat based on input type?
- if (truncation.IsUsedAsWord32()) {
+ if (truncation.IsUsedAsWord32() && !truncation.check_safe_integer()) {
SetOutput<T>(node, MachineRepresentation::kWord32);
if (lower<T>()) {
lowering->DoJSToNumberOrNumericTruncatesToWord32(node, this);
@@ -4441,7 +4441,8 @@
if (lower<T>()) {
ChangeOp(node, simplified()->StringToNumber());
}
- } else if (truncation.IsUsedAsWord32()) {
+ } else if (truncation.IsUsedAsWord32() &&
+ !truncation.check_safe_integer()) {
if (InputIs(node, Type::NumberOrOddball())) {
VisitUnop<T>(node, UseInfo::TruncatingWord32(),
MachineRepresentation::kWord32);
diff --git a/test/mjsunit/regress/regress-531503216.js b/test/mjsunit/regress/regress-531503216.js
new file mode 100644
index 0000000..2730be2
--- /dev/null
+++ b/test/mjsunit/regress/regress-531503216.js
@@ -0,0 +1,47 @@
+// Copyright 2026 the V8 project authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+// Flags: --allow-natives-syntax
+
+// When a PlainPrimitiveToNumber result feeds an additive-safe-integer add whose
+// result is truncated to word32, the conversion must keep the safe-integer
+// check: ToNumber(undefined) is NaN, so (NaN + C) | 0 must be 0. Truncating
+// undefined straight to word32 0 would drop the check and forge an integer.
+const C = 0x40101000;
+const TK = -0x40101000;
+
+function f(route, stop) {
+ let n = +(route ? TK : undefined);
+ if (stop) return 0;
+ return (n + C) | 0;
+}
+
+%PrepareFunctionForOptimization(f);
+// Warm the ToNumber with undefined once, returning before the add so the add
+// only ever sees in-range integers and keeps its additive-safe-integer type.
+f(false, true);
+for (let i = 0; i < 200; i++) f(true, false);
+%OptimizeFunctionOnNextCall(f);
+
+// undefined now flows into the add: the result must be 0, not a forged integer.
+assertEquals(0, f(false, false));
+
+// Same invariant for the sibling JSToNumber lowering: an effectful ToNumber
+// (object with valueOf) that yields NaN must keep the safe-integer check too.
+let vv = TK;
+const obj = { valueOf() { return vv; } };
+
+function g(route, stop) {
+ let n = +(route ? TK : obj);
+ if (stop) return 0;
+ return (n + C) | 0;
+}
+
+%PrepareFunctionForOptimization(g);
+g(false, true);
+for (let i = 0; i < 200; i++) g(true, false);
+%OptimizeFunctionOnNextCall(g);
+
+vv = NaN;
+assertEquals(0, g(false, false));
Regression Test / PoC
diff --git a/test/mjsunit/regress/regress-531503216.js b/test/mjsunit/regress/regress-531503216.js
new file mode 100644
index 0000000..2730be2
--- /dev/null
+++ b/test/mjsunit/regress/regress-531503216.js
@@ -0,0 +1,47 @@
+// Copyright 2026 the V8 project authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+// Flags: --allow-natives-syntax
+
+// When a PlainPrimitiveToNumber result feeds an additive-safe-integer add whose
+// result is truncated to word32, the conversion must keep the safe-integer
+// check: ToNumber(undefined) is NaN, so (NaN + C) | 0 must be 0. Truncating
+// undefined straight to word32 0 would drop the check and forge an integer.
+const C = 0x40101000;
+const TK = -0x40101000;
+
+function f(route, stop) {
+ let n = +(route ? TK : undefined);
+ if (stop) return 0;
+ return (n + C) | 0;
+}
+
+%PrepareFunctionForOptimization(f);
+// Warm the ToNumber with undefined once, returning before the add so the add
+// only ever sees in-range integers and keeps its additive-safe-integer type.
+f(false, true);
+for (let i = 0; i < 200; i++) f(true, false);
+%OptimizeFunctionOnNextCall(f);
+
+// undefined now flows into the add: the result must be 0, not a forged integer.
+assertEquals(0, f(false, false));
+
+// Same invariant for the sibling JSToNumber lowering: an effectful ToNumber
+// (object with valueOf) that yields NaN must keep the safe-integer check too.
+let vv = TK;
+const obj = { valueOf() { return vv; } };
+
+function g(route, stop) {
+ let n = +(route ? TK : obj);
+ if (stop) return 0;
+ return (n + C) | 0;
+}
+
+%PrepareFunctionForOptimization(g);
+g(false, true);
+for (let i = 0; i < 200; i++) g(true, false);
+%OptimizeFunctionOnNextCall(g);
+
+vv = NaN;
+assertEquals(0, g(false, false));
Original Bug Report
AdditiveSafeInteger Plain Conversion Loses Range/NaN Guard and Forges Out-of-Range SlicedString Views, Enabling a V8 Heap Out-of-Bounds Write
Security Report: AdditiveSafeInteger Plain Conversion Loses Range/NaN Guard and Forges Out-of-Range SlicedString Views, Enabling a V8 Heap Out-of-Bounds Write
Reporter: OpenAI Codex Security
Organization: OpenAI
Component: V8 JavaScript Engine (TurboFan simplified lowering / String.prototype.slice)
Affected Area: PlainPrimitiveToNumber, SpeculativeAdditiveSafeIntegerAdd, inline String.prototype.slice lowering, SlicedString creation
Bug Class: Lost checked-safe-integer conversion -> typer/current mismatch -> malformed SlicedString -> heap out-of-bounds write
Summary
TurboFan’s additive-safe-integer lowering intentionally requires
CheckedSafeIntTruncatingWord32 when a SpeculativeAdditiveSafeIntegerAdd
result is consumed as Word32. That checked conversion is supposed to
deoptimize when an operand is not an integer or is outside the additive-safe
range.
PlainPrimitiveToNumber does not preserve that requirement. When its consumer
requests a Word32 value, the NumberOrOddball fast path unconditionally lowers
through UseInfo::TruncatingWord32(). That drops the
truncation.check_safe_integer() bit and turns undefined into machine Word32
0, even though JavaScript ToNumber(undefined) is NaN.
The submitted exploit feeds that silent current/target mismatch into the inline
reducer for ordinary String.prototype.slice(start, end). Typer proves the
slice endpoints are strictly negative under JavaScript semantics, so the
reducer selects the negative-index path and then installs unchecked
TypeGuard<UnsignedSmall> nodes before StringSubstring. The malformed
machine current instead supplies non-negative endpoints, so the optimized code
constructs length + start and length + end values that lie beyond the
original string.
With default string_slices behavior enabled, StringSubstring creates a
valid-looking SlicedString whose stored offset extends outside its parent.
That malformed SlicedString is the bug’s resulting memory-safety primitive.
To demonstrate write impact, the PoC feeds it into the ordinary RegExp
constructor. RegExp’s source-escaping helper counts required escapes in one
pass, allocates its result from that count, and then walks the source a second
time to write the escaped bytes. Because the forged slice points outside its
parent, the two passes can observe different neighboring heap contents across
the allocation and GC point. The first pass can size the result for one slash
while the second pass sees thousands of newline or \u2028 characters and
writes the expanded escape sequences past the end of the allocated result
string.
The exploit reproduces that out-of-bounds heap write impact in two
forms: a one-byte version where bytes after the undersized result visibly
contain the continuing escaped output, and a two-byte variant that reaches a
deterministic write fault inside the inlined WriteEscapedRegExpSource() store
sequence in RegExp::Compile.
The same malformed slice also provides a reusable out-of-bounds read through
ordinary charCodeAt() calls. That read primitive is useful for locating and
validating the corrupted neighborhood, but it is not the primary impact claim
of this report.
A no-flag malformed-slice/disclosure PoC was confirmed on current upstream main. The deterministic write witness described below uses native syntax only as a development aid to stabilize and inspect the heap layout around the same unmodified engine bug; it does not rely on the memory corruption API or a patched V8.
Impact
Malicious JavaScript running in the default-configuration V8 engine can turn
the malformed SlicedString produced by this compiler bug into a real heap
out-of-bounds write. The demonstrated sink is ordinary RegExp source
escaping: it allocates from a first-pass size calculation and then emits more
bytes than were reserved during the second pass.
Current-main one-byte writer exploit:
$ out/additivesafe-main-release/d8 --allow-natives-syntax regexp-escape-window-observe.js -- 14250
N=14250 PRE=16383,0 POST=0,8788 RE=16385,0,0,4395 OOB=3@0:6e5c6e0501000003000000004000002f414141414141414141414141414141414141414141414141414141414141414141414141414141414141414141414141
The escaped result still has the short first-pass length (16385), while the
second pass emits thousands of backslash escapes. The bytes immediately after
the nominal result already contain the continuing n\\n escape pattern,
proving that the write crossed the result boundary.
The two-byte variant reaches a deterministic write fault from the same
WriteEscapedRegExpSource() path:
Received signal 11 SEGV_ACCERR 26f201240000
...
RegExp::Compile+0xe17
...
Root Cause (High Level)
TurboFan reasons about the same value through two incompatible views:
consumer view:
AdditiveSafeInteger + Word32 truncation requires a checked-safe conversion
producer view:
PlainPrimitiveToNumber sees only "used as Word32"
-> emits plain TruncatingWord32
-> identifies undefined/NaN with integer 0
The additive consumer explicitly requests a deoptimizing checked conversion,
but PlainPrimitiveToNumber forwards only the Word32 shape and silently drops
the safety bit. The resulting machine current no longer matches the optimistic
type information still attached to the graph.
String.prototype.slice then consumes the stale type facts. The reducer
chooses the negative-index branch, creates unchecked unsigned endpoint guards,
and feeds them directly into StringSubstring. Because the forged current is
non-negative, the generated code produces length + attacker_delta endpoints
that are outside the original string while still satisfying the reducer’s
local ordering checks.
Once the forged slice exists, it can be used against consumers that rely on
string immutability. The demonstrated write sink is RegExp: its first and
second source-escape passes can observe different heap bytes around the
allocation point, so the size calculation and the write pass disagree and the
second pass writes beyond the under-allocated result.
It is likely that this, or some other sink, can be used to achieve more targeted corruption and potentially arbitrary read/write in the v8 heap sandbox, although that has not yet been demonstrated here.
Affected Versions
The vulnerability is present when the following source properties are present:
- SpeculativeAdditiveSafeIntegerAdd propagates CheckedSafeIntTruncatingWord32.
- PlainPrimitiveToNumber lowers NumberOrOddball Word32 uses via TruncatingWord32.
- That branch does not preserve truncation.check_safe_integer().
- String.prototype.slice lowers negative endpoints through TypeGuardUnsignedSmall.
- StringSubstring creates slices from the current endpoints without a bounds check.
- StringCharCodeAt also trusts the forged slice's own length and direct-string offset.
Exploitability was confirmed on:
V8 main: a90254a7f732826783513c7b58de48debce4cd0d
V8 version: 15.2.0 (candidate)
Architecture: x86-64, pointer compression and V8 sandbox enabled
Build: is_debug=false, dcheck_always_on=false, memory_corruption_api=false
The oldest V8 line that contains the buggy lowering is 13.5. The introducing
commit lands after the 13.4 branch point (main@{#98459}) and before the 13.5
branch point (main@{#99020}), so the 13.5 branch is the first line where the
bug exists in source. That line is not default-reachable, however:
additive_safe_int_feedback remains false by default on the 13.5, 13.6,
13.7, 13.8, and 13.9 branches and is only reachable there if an embedder opts
into the feature flag.
The default-on Stable boundary is V8 14.0. The permanent enablement change is:
ea1d2d78e05d90caf4e78fe7dfd6acb0c4f7e07e
[turbofan] Enable additive safe int feedback
Cr-Commit-Position: refs/heads/main@{#100967}
Commit date: 2025-06-23
The 13.9 branch was cut immediately before that change
(main@{#100941}) and still carries:
DEFINE_BOOL(additive_safe_int_feedback, false, ...)
The 14.0 branch was cut later (main@{#101731}) and carries:
DEFINE_BOOL(additive_safe_int_feedback, true, ...)
Therefore this is not limited to Dev/Beta-only builds. The first affected default-configuration Stable V8 release is:
V8 14.0.365.4
Chrome Stable 140.0.7339.80 (Linux)
Release date: 2025-09-02
Relevant release and source references:
- V8 13.9 branch head
- V8 13.9 default-off flag definition
- V8 14.0 branch head
- V8 14.0 default-on flag definition
- Default-on enablement change
- V8 version numbering and Chrome branch mapping
- Chrome 140 Stable desktop release announcement
The submitted archive had originally been validated on:
V8 revision: 5068658e0b754814453e62dfe5898027ee052506
V8 version: 15.1.0 (candidate)
Current main is 607 commits after that initially tested revision, and the same vulnerable lowering structure remains present.
The source change that first makes this mismatch possible is:
0a1fae9e77c6d8e85d8197b4f4396815ec9194b9
[turbofan] Use AdditiveSafeInt feedback for faster int add/sub
Cr-Commit-Position: refs/heads/main@{#98643}
Commit date: 2025-02-11
That change adds the additive-safe feedback mode and the
CheckedSafeIntTruncatingWord32 consumer requirement. The pre-existing
PlainPrimitiveToNumber Word32 path already downgraded
NumberOrOddball inputs to TruncatingWord32, so the commit introduces the
incompatible producer/consumer combination.
The source change is available at:
https://chromium.googlesource.com/v8/v8/+/0a1fae9e77c6d8e85d8197b4f4396815ec9194b9%5E%21/
Detailed Analysis
AdditiveSafeInteger Requires a Checked Word32 Conversion
UseInfo::CheckedSafeIntTruncatingWord32 explicitly carries a
Word32WithSafeIntCheck() truncation and the
kAdditiveSafeInteger type-check kind:
static Truncation Word32WithSafeIntCheck() {
return Truncation(TruncationKind::kWord32, kIdentifyZeros, true);
}
static UseInfo CheckedSafeIntTruncatingWord32(
const FeedbackSource& feedback) {
return UseInfo(MachineRepresentation::kWord32,
Truncation::Word32WithSafeIntCheck(),
TypeCheckKind::kAdditiveSafeInteger, feedback);
}
The additive-safe reducer uses that checked conversion when the result is consumed as Word32:
if (truncation.IsUsedAsWord32() && !TypeOf(node).IsNone()) {
// We *must* propagate the CheckedSafeIntTruncatingWord32 information.
VisitBinop<T>(node,
UseInfo::CheckedSafeIntTruncatingWord32(FeedbackSource{}),
MachineRepresentation::kWord32);
if (lower<T>()) ChangeToPureOp(node, Int32Op(node));
return;
}
The safety invariant is clear: before replacing the operation with pure
wrapping Int32Add, each input must still deoptimize if it is not an integer
or is outside the intended additive-safe range.
PlainPrimitiveToNumber Drops the Safety Bit
On current main, the PlainPrimitiveToNumber Word32 branch checks only
truncation.IsUsedAsWord32() and then lowers NumberOrOddball inputs through
plain TruncatingWord32():
} else if (truncation.IsUsedAsWord32()) {
if (InputIs(node, Type::NumberOrOddball())) {
VisitUnop<T>(node, UseInfo::TruncatingWord32(),
MachineRepresentation::kWord32);
if (lower<T>()) {
DeferReplacement(node, InsertSemanticsHintForVerifier(
node->op(), node->InputAt(0)));
}
}
That branch does not inspect truncation.check_safe_integer() and does not
preserve the checked-safe conversion requested by the consumer. This is
especially conspicuous because several neighboring lowering paths were
recently hardened to guard integer shortcuts with
!truncation.check_safe_integer().
For the submitted trigger:
let n = +(route ? TK : undefined);
let base = (n + C) | 0;
the JavaScript target value for the malicious route is NaN, but the lowered
current becomes Word32 0. The following additive-safe add therefore executes
on a forged integer current even though the graph still carries the
NaN | Range(0, 0) target type.
The Current Trace Still Shows the Bad Conversion on Main
The fresh TurboFan trace captured from the current-main build contains the same decisive chain:
===== V8.TFTypedLowering =====
37 PlainPrimitiveToNumber
58 SpeculativeAdditiveSafeIntegerAdd[AdditiveSafeInteger]
113 TypeGuard[Unsigned30]
126 TypeGuard[Unsigned30]
132 StringSubstring
===== V8.TFSimplifiedLowering =====
58 Int32Add
113 TypeGuard[Unsigned30]
126 TypeGuard[Unsigned30]
132 StringSubstring
151 TruncateNumberOrOddballToWord32
===== schedule =====
151: TruncateNumberOrOddballToWord32(34)
58: Int32Add(151, 153) : (NaN | Range(0, 0))
113: TypeGuard[Unsigned30](107, 86, 41) : Range(0, 536870871)
126: TypeGuard[Unsigned30](120, 113, 41) : Range(0, 536870886)
132: StringSubstring(86, 113, 126, 126, 129) : String
This is the concrete lowering failure: a plain oddball-to-Word32 truncation
directly feeds the additive-safe Int32Add, and the malformed current survives
all the way into StringSubstring.
String.prototype.slice Trusts the Stale Type Facts
The inline reducer for String.prototype.slice computes negative indices as
max(length + index, 0) and then applies only
TypeGuardUnsignedSmall before calling StringSubstring:
TNode<Number> from_untyped =
SelectIf<Number>(NumberLessThan(start_smi, zero))
.Then(_ { return NumberMax(NumberAdd(length, start_smi), zero); })
.Else(_ { return NumberMin(start_smi, length); })
.Value();
TNode<Smi> from = TypeGuardUnsignedSmall(from_untyped);
TNode<Number> to_untyped =
SelectIf<Number>(NumberLessThan(end_smi, zero))
.Then(_ { return NumberMax(NumberAdd(length, end_smi), zero); })
.Else(_ { return NumberMin(end_smi, length); })
.Value();
TNode<Smi> to = TypeGuardUnsignedSmall(to_untyped);
return SelectIf<String>(NumberLessThan(from, to))
.Then(_ { return StringSubstring(receiver_string, from, to); })
.Else(_ { return EmptyStringConstant(); })
.Value();
For the submitted payload, typer proves:
start target = [-4112, -17]
end target = [-4097, -2]
The malformed current instead becomes:
start current = delta
end current = delta + 15
compiled from = string.length + delta
compiled to = string.length + delta + 15
The surviving Uint32LessThan(from, to) proves only ordering. It does not
prove that either endpoint is still within the parent string.
StringSubstring Produces the Malformed Slice Capability
StringSubstring simply forwards the current endpoints to SubString:
TF_BUILTIN(StringSubstring, StringBuiltinsAssembler) {
auto string = Parameter<String>(Descriptor::kString);
auto from = UncheckedParameter<IntPtrT>(Descriptor::kFrom);
auto to = UncheckedParameter<IntPtrT>(Descriptor::kTo);
Return(SubString(string, from, to));
}
SubString checks only the slice width against the source length. For widths
in the normal sliced-string range, it directly allocates a SlicedString
using offset = from + parent_offset:
const TNode<IntPtrT> substr_length = IntPtrSub(to, from);
const TNode<IntPtrT> string_length = LoadStringLengthAsWord(string);
GotoIf(UintPtrGreaterThanOrEqual(substr_length, string_length),
&original_string_or_invalid_length);
...
TNode<IntPtrT> offset = IntPtrAdd(from, to_direct.offset());
...
var_result = AllocateSlicedOneByteString(
Unsigned(TruncateIntPtrToInt32(substr_length)), direct_string,
SmiTag(offset));
Later consumers trust the forged slice’s own length and direct-string offset.
For example, StringCharCodeAt checks the queried index against the forged
slice’s short visible length and then loads from the direct parent plus the
stored offset:
CSA_DCHECK(this, UintPtrLessThan(index, LoadStringLengthAsWord(string)));
...
const TNode<UintPtrT> offset =
UintPtrAdd(index, Unsigned(to_direct.offset()));
...
var_result = Load<Uint8T>(string_data, offset);
In the release build used for validation, the CSA_DCHECK is not a runtime
mitigation. The forged slice remains a JavaScript-visible capability that can
be queried repeatedly and, more importantly for this report, can be handed to
RegExp as if it were an ordinary immutable source string.
Demonstrated Write Impact Through RegExp’s Two-Pass Escaping Writer
This is the primary demonstrated impact of the bug, not the root cause. The
root cause is the lost checked-safe-integer conversion and the resulting
out-of-range SlicedString. That malformed SlicedString can then violate an
assumption used by the source-escaping helper in src/regexp/regexp.cc: the
helper counts required escapes in one pass, allocates the output string from
that count, and then walks the source again to write the escaped bytes.
uint32_t additional_escape_chars =
one_byte ? CountAdditionalEscapeChars<uint8_t>(source, &needs_escapes)
: CountAdditionalEscapeChars<base::uc16>(source, &needs_escapes);
...
ASSIGN_RETURN_ON_EXCEPTION(isolate, result,
isolate->factory()->NewRawOneByteString(length));
return WriteEscapedRegExpSource<uint8_t>(source, result);
CountAdditionalEscapeChars() and WriteEscapedRegExpSource() each obtain a
fresh flat-character view of the same String. That is normally safe because
strings are immutable. It is not safe for a forged slice whose stored parent
offset points outside the parent object. If a GC occurs at the allocation point
between the two passes, the same forged slice can observe different neighboring
heap bytes before and after the allocation.
The practical write setup is:
- make the first pass see a source containing only one unescaped
/, so the escaped result is allocated with essentially the original length; - make the second pass see a different neighboring byte pattern containing
many
\nor\u2028characters; and - let the writer emit
\\nor\\u2028sequences past the end of the under-allocated result object.
The development harness
regexp-escape-window-observe.js repeatedly reproduced the one-byte form on
current main. A representative run showed the same malformed source changing
from one slash plus A bytes to thousands of newline bytes across the
allocation boundary:
PRE=16383,0 POST=0,8788 RE=16385,0,0,4395 OOB=3@0:6e5c6e...
The escaped result still had the short first-pass length (16385), but the
second pass wrote thousands of backslash escape bytes. The immediate bytes
after the result already contained the continuing n\\n pattern, proving a
heap out-of-bounds write rather than merely a stale read or parser oddity.
The two-byte variant in regexp-escape-two-byte-observe.js uses \u2028,
whose escape expands by five extra code units. On current main it reaches a
deterministic write fault inside the inlined WriteEscapedRegExpSource() store
sequence in RegExp::Compile, confirming that the write sink is not an
observer-only artifact and does not depend on a later read primitive.
This write primitive is also the path now being used to turn the bug into a
targeted in-cage corruption primitive. The most useful surgical target is a
nearby FixedDoubleArray::length field: changing that length while preserving
the map lets JavaScript grow the corresponding JSArray.length without
reallocating the backing store, which in turn yields an out-of-bounds double
array suitable for the usual addrof/fakeobj and cage-ARW upgrade. The remaining
engineering work is heap grooming: making the escaped result land immediately
before a live FixedDoubleArray so that the overwrite hits its header rather
than unused tail space.
Exploitation Strategy
The no-flag PoC first warms the additive-safe path and the slice reducer using
ordinary JavaScript. Once optimized, it switches the input from a large
numeric constant to undefined, causing the lost checked conversion to produce
the forged Word32 current.
The exploit keeps the forged slice length fixed at 15 bytes and varies a
12-bit delta coordinate:
let raw = ((base >>> 8) & 0xffff) + (delta & 4095);
let start = raw - 4112;
let end = raw - 4097;
return s.slice(start, end);
The target type still says both endpoints are strictly negative. The machine
current instead places the resulting slice at parent.length + delta, so each
returned SlicedString reads 15 bytes beyond the parent.
For the write exploit, the exploit positions that malformed
slice over a neighboring string layout whose bytes change across the allocation
inside RegExp source escaping. The first pass sees a nearly unescaped source,
the second pass sees escape-heavy data, and the result object is under-allocated
for the bytes that the second pass emits. The one-byte witness shows the
continuing escaped output immediately after the result object; the two-byte
variant drives the same store sequence to a deterministic fault.
The separate disclosure PoC allocates a normal public JSON string and then a closure-held secret token string, scans 600 bytes using forged views, and verifies the recovered token through a closure oracle. That read exploit is supporting evidence that the malformed slice is a stable cross-object capability.
Proof of Concept
The reporting package contains these primary artifacts:
regexp-escape-window-observe.js one-byte heap OOB-write witness
regexp-escape-two-byte-observe.js two-byte write-fault witness
exploit.js no-V8-flag supporting disclosure exploit
verify.py expected-output verifier
trace-trigger.js tracing-only optimized twin
collect-trace.sh trace collection helper
extract-evidence.py scoped graph extractor
evidence/ original submitted trace and output
Current main was built with:
is_debug=false
dcheck_always_on=false
symbol_level=0
v8_enable_memory_corruption_api=false
The generated build configuration additionally confirms:
pointer_compression=true
sandbox=true
memory_corruption_api=false
out/additivesafe-main-release/d8 --allow-natives-syntax regexp-escape-window-observe.js -- 14250
out/additivesafe-main-release/d8 --allow-natives-syntax regexp-escape-two-byte-observe.js -- 14000
Expected one-byte output includes:
N=14250 PRE=16383,0 POST=0,8788 RE=16385,0,0,4395 OOB=3@0:6e5c6e...
The two-byte variant reaches a deterministic fault in the RegExp escape writer. The disclosure witness remains available as:
out/additivesafe-main-release/d8 exploit.js
with expected supporting output:
PROCESS_MEMORY_LEAK_OFFSET=<small positive offset>
LEAKED="TOPSECRET_<runtime milliseconds>_<runtime random>_END"
SUCCESS: private application token passed oracle after OOB exfiltration
The write-witness helpers use --allow-natives-syntax only to make the heap
layout and neighborhood inspection deterministic during development. The
supporting disclosure exploit and the underlying malformed-slice trigger do
not use native syntax, any exposed corruption API, or experimental V8 features.
Suggested Fix
The root issue is that PlainPrimitiveToNumber forwards only the coarse
Word32 shape of the requested truncation and discards the checked-safe-integer
requirement.
The smallest robust fix is to make the PlainPrimitiveToNumber Word32 path
preserve truncation.check_safe_integer(). If the consumer requested
CheckedSafeIntTruncatingWord32, the producer must emit a conversion that
retains the additive-safe check instead of unconditionally selecting
UseInfo::TruncatingWord32().
This should be accompanied by an audit of other truncation-forwarding sites for
the same mistake: several current-main branches already explicitly gate
integer shortcuts with !truncation.check_safe_integer(), and
PlainPrimitiveToNumber should obey the same invariant.
As defense in depth, the string reducers should apply explicit dynamic bounds
checks before constructing direct-string or sliced-string views from optimized
indices. StringSubstring should not be able to allocate a SlicedString from
current values that are outside 0 <= from <= to <= string.length, even when
the typer believed those relations were already proven.
Useful regression coverage would include:
1. A TurboFan test where PlainPrimitiveToNumber feeds
SpeculativeAdditiveSafeIntegerAdd under a checked Word32 truncation.
2. A semantic JS test that verifies undefined does not become an unchecked
integer current before additive-safe arithmetic.
3. A slice-specific test that ensures the optimized trigger deoptimizes before
reaching StringSubstring when the producer loses the required check.
CREDIT INFORMATION
Reporter credit: OpenAI Codex Security (amyb)
Disclaimer
This information is being shared by OpenAI solely for the purpose of improving security and reducing potential harm. This information is presented as-is. We make no representations or warranties, express or implied, as to the completeness, accuracy, or fitness for any particular purpose of the information. This includes, without limitation any suggestions or ideas presented on how to remedy or mitigate an identified vulnerability, including whether such suggestions or ideas would be effective and/or could have other negative impacts.
OpenAI disclaims any liability for direct or indirect damages arising from the reliance on, or use, misuse, or interpretation of this information. Any references to third-party systems, services, or entities are included solely for identification purposes and do not imply endorsement, responsibility, or attribution.
- https://chromereleases.googleblog.com/2025/09/stable-channel-update-for-desktop.html
- https://chromium.googlesource.com/v8/v8/+/0a1fae9e77c6d8e85d8197b4f4396815ec9194b9%5E%21/
- https://chromium.googlesource.com/v8/v8/+/0a1fae9e77c6d8e85d8197b4f4396815ec9194b9^!/
- https://chromium.googlesource.com/v8/v8/+/ea1d2d78e05d90caf4e78fe7dfd6acb0c4f7e07e%5E%21/
- https://chromium.googlesource.com/v8/v8/+/ea1d2d78e05d90caf4e78fe7dfd6acb0c4f7e07e^!/
- https://chromium.googlesource.com/v8/v8/+/refs/heads/13.9-lkgr
- https://chromium.googlesource.com/v8/v8/+/refs/heads/13.9-lkgr/src/flags/flag-definitions.h#726
- https://chromium.googlesource.com/v8/v8/+/refs/heads/14.0-lkgr
- https://chromium.googlesource.com/v8/v8/+/refs/heads/14.0-lkgr/src/flags/flag-definitions.h#746
- https://github.com/v8/v8/blob/a90254a7f732826783513c7b58de48debce4cd0d/src/builtins/builtins-string-gen.cc#L1727-L1733
- https://github.com/v8/v8/blob/a90254a7f732826783513c7b58de48debce4cd0d/src/builtins/builtins-string-gen.cc#L2072-L2132
- https://github.com/v8/v8/blob/a90254a7f732826783513c7b58de48debce4cd0d/src/codegen/code-stub-assembler.cc#L9371-L9412
- https://github.com/v8/v8/blob/a90254a7f732826783513c7b58de48debce4cd0d/src/compiler/js-call-reducer.cc#L1337-L1362
- https://github.com/v8/v8/blob/a90254a7f732826783513c7b58de48debce4cd0d/src/compiler/simplified-lowering.cc#L1876-L1888
- https://github.com/v8/v8/blob/a90254a7f732826783513c7b58de48debce4cd0d/src/compiler/simplified-lowering.cc#L4431-L4478
- https://github.com/v8/v8/blob/a90254a7f732826783513c7b58de48debce4cd0d/src/compiler/use-info.h#L275-L286
- https://github.com/v8/v8/blob/a90254a7f732826783513c7b58de48debce4cd0d/src/compiler/use-info.h#L35-L40
- https://github.com/v8/v8/blob/a90254a7f732826783513c7b58de48debce4cd0d/src/regexp/regexp.cc#L231-L380
- https://v8.dev/docs/version-numbers