Overview

High
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactOut of bounds read and write in V8
DescriptionOut of bounds read and write in V8
ComponentV8
Bug ClassOOB
Tracker531503216
Fix commitfba00590cb03 (v8/v8) +50/-2
CISA KEVNot listed
CreditedOpenAI Codex Security (amyb)
Disclosed2026-07-16

Files Changed

  • src/compiler/simplified-lowering.cc
  • test/mjsunit/regress/regress-531503216.js
From fba00590cb03c58aa01fe18dd8f0cfedf0e94077 Mon Sep 17 00:00:00 2001
From: Victor Gomes <[email protected]>
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 <[email protected]>
Auto-Submit: Victor Gomes <[email protected]>
Reviewed-by: Nico Hartmann <[email protected]>
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));
Loading diff…

Regression Test / PoC

shipped with the fix
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));
Loading diff…

Original Bug Report

reported by [email protected]

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:

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:

https://github.com/v8/v8/blob/a90254a7f732826783513c7b58de48debce4cd0d/src/compiler/use-info.h#L35-L40

https://github.com/v8/v8/blob/a90254a7f732826783513c7b58de48debce4cd0d/src/compiler/use-info.h#L275-L286

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:

https://github.com/v8/v8/blob/a90254a7f732826783513c7b58de48debce4cd0d/src/compiler/simplified-lowering.cc#L1876-L1888

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

https://github.com/v8/v8/blob/a90254a7f732826783513c7b58de48debce4cd0d/src/compiler/simplified-lowering.cc#L4431-L4478

} 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:

https://github.com/v8/v8/blob/a90254a7f732826783513c7b58de48debce4cd0d/src/compiler/js-call-reducer.cc#L1337-L1362

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:

https://github.com/v8/v8/blob/a90254a7f732826783513c7b58de48debce4cd0d/src/builtins/builtins-string-gen.cc#L1727-L1733

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:

https://github.com/v8/v8/blob/a90254a7f732826783513c7b58de48debce4cd0d/src/builtins/builtins-string-gen.cc#L2072-L2132

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:

https://github.com/v8/v8/blob/a90254a7f732826783513c7b58de48debce4cd0d/src/codegen/code-stub-assembler.cc#L9371-L9412

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.

https://github.com/v8/v8/blob/a90254a7f732826783513c7b58de48debce4cd0d/src/regexp/regexp.cc#L231-L380

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:

  1. make the first pass see a source containing only one unescaped /, so the escaped result is allocated with essentially the original length;
  2. make the second pass see a different neighboring byte pattern containing many \n or \u2028 characters; and
  3. let the writer emit \\n or \\u2028 sequences 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.

View on issue tracker
Links in the report