CVE-2025-2137
Overview
Background
- `SlicedString`
- a V8 string that references a range of a parent string instead of owning its own character storage.
- `IsOneByteRepresentation()`
- a
Stringaccessor that reports one- vs two-byte encoding from the string’s own map/instance type alone. - `IsOneByteRepresentationUnderneath()`
- a static helper that walks an indirect string (cons/sliced/thin) down to its underlying sequential string to determine the true encoding.
- `JsonStringifier`
- V8’s
JSON.stringifyimplementation that copies string characters into an output buffer.
Root Cause Analysis
Verify before building a PoC
What the commit does not establish: The core mechanism matches the diff and the reporter's output ('bb'→U+6262, 29 chars read). The pre-fix stringifier and SerializeString_ bodies are absent, so the uc16-over-one-byte read is inferred. The 'removed selector' line is actually context, and the map-inl.h move rationale is unsupported. The reporter's repro uses --allow-natives-syntax.
8db16e651ff4. An earlier analysis was withdrawn because it had been written against a commit that was not the security fix (812f91c58212).The original CL being reverted (e3f33e99…) had removed IsOneByteRepresentationUnderneath and replaced its callers with IsOneByteRepresentation(), which reads encoding from the top-level string’s own map only (src/builtins/builtins-json.cc:26 string->IsOneByteRepresentation()). For an indirect string such as a SlicedString, the wrapper’s own encoding flag can disagree with the actual encoding of its parent/underlying string — the reporter notes the slice’s map can become outdated after its parent is updated. In src/json/json-stringifier.cc the serialize/append path decided the copy width from this top-level flag (removed diff lines string->IsOneByteRepresentation() and flat.IsOneByte()): when the wrapper’s flag claimed two-byte while the underlying storage was actually one-byte, the serializer took the two-byte (base::uc16) read path over a one-byte buffer (the SerializeString_<base::uc16, base::uc16, ...> instantiation shown as unchanged context in the json-stringifier.cc @@ -1690 hunk is the two-byte path; its body is not in the material), reading two bytes per character and running length bytes past the end of the stored data. The reporter’s output confirms this direction — "bbbb…" (bytes 0x62 0x62 …) came back as U+6262 (扢) code units, i.e. adjacent one-byte values read as single uint16 characters, which is only possible if one-byte storage was read as uc16; the opposite (reading two-byte data as one-byte for length chars) would stay in bounds and could not leak.
The fix (commit 8db16e65…, a partial revert) re-adds String::IsOneByteRepresentationUnderneath in src/objects/string-inl.h (added block following pre-fix line 368), a loop that follows string->GetUnderlying() while switching on kIsIndirectStringMask | kStringEncodingMask, and restores calls to it at the flattened-string representation decisions the original CL had replaced, including the stringifier copy loops (added diff lines if (String::IsOneByteRepresentationUnderneath(string)) paired with string->GetCharVector<uint8_t>() / <uint16_t>()). This restores the invariant that encoding decisions for flat indirect strings are taken from the underlying storage, so the chosen copy width always matches the real character width. The static-roots fast path introduced by the original CL is deliberately kept; only the function’s removal is reverted.
IsOneByteRepresentationUnderneath so JSON serialization selects the correct one-/two-byte copy path.Attack Path
- Build a one-byte-backed slice with a stale flag
Create an indirect string (e.g. a
SlicedString) whose own encoding flag indicates two-byte while its underlying storage is one-byte. (inferred) - Reach JSON serialization
Pass that string to
JSON.stringifysoJsonStringifierserializes it. - Mismatched copy width
The serializer reads the stale two-byte flag (
string->IsOneByteRepresentation()returns false) and takes theuc16copy path, reading the one-byte storage two bytes at a time (removed diff:flat.IsOneByte()/string->IsOneByteRepresentation()). - Out-of-bounds read
The
uc16read consumeslengthbytes past the one-byte buffer; those out-of-range heap bytes are emitted asuint16code units, matching the reporter’s corrupted output ('bb'→U+6262).
Impact Assessment
JSON.stringify and surface those bytes in the resulting string, an information disclosure within the renderer process. The advisory rates this medium severity and records no in-the-wild exploitation. Escalation beyond an OOB read is not shown by the material.Changed Functions
| Function | Change | Notes |
|---|---|---|
ifsrc/json/json-stringifier.cc |
modified | |
whilesrc/objects/string-inl.h |
modified | |
ifsrc/objects/string-inl.h |
modified | |
whilesrc/objects/string.tq |
modified | |
typeswitchsrc/objects/string.tq |
modified |
Files Changed
src/builtins/builtins-json.ccsrc/builtins/builtins-string.ccsrc/builtins/string-iswellformed.tqsrc/builtins/string-towellformed.tqsrc/json/json-stringifier.ccsrc/numbers/conversions.ccsrc/objects/instance-type-inl.hsrc/objects/js-raw-json.ccsrc/objects/js-regexp.ccsrc/objects/map-inl.hsrc/objects/map.hsrc/objects/string-inl.hsrc/objects/string.hsrc/objects/string.tqsrc/objects/templates-inl.hsrc/regexp/experimental/experimental-interpreter.ccsrc/regexp/regexp-interpreter.ccsrc/regexp/regexp-macro-assembler.ccsrc/regexp/regexp.ccsrc/runtime/runtime-strings.ccsrc/strings/string-builder.ccsrc/strings/uri.cc
Audit Directions
- Indirect-string encodingAudit any code deciding character width from
IsOneByteRepresentation()on a possibly-indirect (cons/sliced/thin) string; after flattening it should useIsOneByteRepresentationUnderneath. - Representation cachingFlag code that caches a string’s encoding across operations able to change the underlying string (externalization, parent update) and later copies using the stale value.
- Static-roots fast pathsReview map-word / static-roots shortcuts that infer encoding without dereferencing the underlying string, confirming they agree with the real representation.
Patch
diff --git a/src/builtins/builtins-json.cc b/src/builtins/builtins-json.cc
index 6773cc1..3cee2e7 100644
--- a/src/builtins/builtins-json.cc
+++ b/src/builtins/builtins-json.cc
@@ -23,7 +23,7 @@
Object::ToString(isolate, source));
string = String::Flatten(isolate, string);
RETURN_RESULT_OR_FAILURE(
- isolate, string->IsOneByteRepresentation()
+ isolate, String::IsOneByteRepresentationUnderneath(*string)
? JsonParser<uint8_t>::Parse(isolate, string, reviver)
: JsonParser<uint16_t>::Parse(isolate, string, reviver));
}
diff --git a/src/builtins/builtins-string.cc b/src/builtins/builtins-string.cc
index 0eb19b3..6c6fa83 100644
--- a/src/builtins/builtins-string.cc
+++ b/src/builtins/builtins-string.cc
@@ -344,7 +344,7 @@
// character is also ASCII. This is currently the case, but it
// might break in the future if we implement more context and locale
// dependent upper/lower conversions.
- if (s->IsOneByteRepresentation()) {
+ if (String::IsOneByteRepresentationUnderneath(*s)) {
// Same length as input.
DirectHandle<SeqOneByteString> result =
isolate->factory()->NewRawOneByteString(length).ToHandleChecked();
diff --git a/src/builtins/string-iswellformed.tq b/src/builtins/string-iswellformed.tq
index 2041efb..d0ff406 100644
--- a/src/builtins/string-iswellformed.tq
+++ b/src/builtins/string-iswellformed.tq
@@ -35,7 +35,7 @@
// InstanceType. See
// https://docs.google.com/document/d/15f-1c_Ysw3lvjy_Gx0SmmD9qeO8UuXuAbWIpWCnTDO8/
const flat = Flatten(s);
- if (flat.IsOneByteRepresentation()) return True;
+ if (flat.IsOneByteRepresentationUnderneath()) return True;
try {
const illFormed = HasUnpairedSurrogate(flat) otherwise Indirect;
return illFormed ? False : True;
diff --git a/src/builtins/string-towellformed.tq b/src/builtins/string-towellformed.tq
index e00abc4d..e47876e 100644
--- a/src/builtins/string-towellformed.tq
+++ b/src/builtins/string-towellformed.tq
@@ -33,7 +33,7 @@
// 4. Let k be 0.
// 5. Let result be the empty String.
const flat = Flatten(s);
- if (flat.IsOneByteRepresentation()) return flat;
+ if (flat.IsOneByteRepresentationUnderneath()) return flat;
let result = flat;
// 6. Repeat, while k < strLen,
diff --git a/src/json/json-stringifier.cc b/src/json/json-stringifier.cc
index b1c0bfc..099981e 100644
--- a/src/json/json-stringifier.cc
+++ b/src/json/json-stringifier.cc
@@ -155,25 +155,29 @@
const DisallowGarbageCollection& no_gc) {
DCHECK_EQ(length, string->length());
DCHECK(encoding_ == String::TWO_BYTE_ENCODING ||
- (string->IsFlat() && string->IsOneByteRepresentation()));
+ (string->IsFlat() &&
+ String::IsOneByteRepresentationUnderneath(string)));
DCHECK(CurrentPartCanFit(length + 1));
- String::FlatContent flat = string->GetFlatContent(no_gc);
if (encoding_ == String::ONE_BYTE_ENCODING) {
- if (flat.IsOneByte()) {
- CopyChars<uint8_t, uint8_t>(one_byte_ptr_ + current_index_,
- flat.ToOneByteVector().begin(), length);
+ if (String::IsOneByteRepresentationUnderneath(string)) {
+ CopyChars<uint8_t, uint8_t>(
+ one_byte_ptr_ + current_index_,
+ string->GetCharVector<uint8_t>(no_gc).begin(), length);
} else {
ChangeEncoding();
- CopyChars<uint16_t, uint16_t>(two_byte_ptr_ + current_index_,
- flat.ToUC16Vector().begin(), length);
+ CopyChars<uint16_t, uint16_t>(
+ two_byte_ptr_ + current_index_,
+ string->GetCharVector<uint16_t>(no_gc).begin(), length);
}
} else {
- if (flat.IsOneByte()) {
- CopyChars<uint8_t, uint16_t>(two_byte_ptr_ + current_index_,
- flat.ToOneByteVector().begin(), length);
+ if (String::IsOneByteRepresentationUnderneath(string)) {
+ CopyChars<uint8_t, uint16_t>(
+ two_byte_ptr_ + current_index_,
+ string->GetCharVector<uint8_t>(no_gc).begin(), length);
} else {
- CopyChars<uint16_t, uint16_t>(two_byte_ptr_ + current_index_,
- flat.ToUC16Vector().begin(), length);
+ CopyChars<uint16_t, uint16_t>(
+ two_byte_ptr_ + current_index_,
+ string->GetCharVector<uint16_t>(no_gc).begin(), length);
}
}
current_index_ += length;
@@ -186,7 +190,8 @@
Tagged<String> string = *string_handle;
const bool representation_ok =
encoding_ == String::TWO_BYTE_ENCODING ||
- (string->IsFlat() && string->IsOneByteRepresentation());
+ (string->IsFlat() &&
+ String::IsOneByteRepresentationUnderneath(string));
if (representation_ok) {
size_t length = string->length();
while (!CurrentPartCanFit(length + 1)) {
@@ -1529,7 +1534,8 @@
vector, &no_extend);
} else {
DCHECK(encoding_ == String::TWO_BYTE_ENCODING ||
- (string->IsFlat() && string->IsOneByteRepresentation()));
+ (string->IsFlat() &&
+ String::IsOneByteRepresentationUnderneath(string)));
size_t uncopied_src_index = 0; // Index of first char not copied yet.
for (size_t i = 0; i < vector.size(); i++) {
SrcChar c = vector.at(i);
@@ -1690,14 +1696,14 @@
DisallowGarbageCollection no_gc;
auto string = *object;
if (encoding_ == String::ONE_BYTE_ENCODING) {
- if (string->IsOneByteRepresentation()) {
+ if (String::IsOneByteRepresentationUnderneath(string)) {
return SerializeString_<uint8_t, uint8_t, raw_json>(string, no_gc);
} else {
ChangeEncoding();
}
}
DCHECK_EQ(encoding_, String::TWO_BYTE_ENCODING);
- if (string->IsOneByteRepresentation()) {
+ if (String::IsOneByteRepresentationUnderneath(string)) {
return SerializeString_<uint8_t, base::uc16, raw_json>(string, no_gc);
} else {
return SerializeString_<base::uc16, base::uc16, raw_json>(string, no_gc);
diff --git a/src/numbers/conversions.cc b/src/numbers/conversions.cc
index 0e9646a..3a9a1ef 100644
--- a/src/numbers/conversions.cc
+++ b/src/numbers/conversions.cc
@@ -343,7 +343,7 @@
bool IsOneByte() const {
if (raw_two_byte_subject_ != nullptr) return false;
return raw_one_byte_subject_ != nullptr ||
- subject_->IsOneByteRepresentation();
+ String::IsOneByteRepresentationUnderneath(*subject_);
}
base::Vector<const uint8_t> GetOneByteVector(
@@ -1365,7 +1365,7 @@
return std::nullopt;
}
- if (object->IsOneByteRepresentation()) {
+ if (String::IsOneByteRepresentationUnderneath(*object)) {
uint8_t buffer[kMaxLengthForConversion];
SharedStringAccessGuardIfNeeded access_guard(isolate);
String::WriteToFlat(*object, buffer, 0, length, access_guard);
diff --git a/src/objects/instance-type-inl.h b/src/objects/instance-type-inl.h
index 50e60eb..7eb127b 100644
--- a/src/objects/instance-type-inl.h
+++ b/src/objects/instance-type-inl.h
@@ -13,6 +13,7 @@
#include "src/base/bounds.h"
#include "src/execution/isolate-utils-inl.h"
#include "src/objects/instance-type-checker.h"
+#include "src/objects/map-inl.h"
// Has to be the last include (doesn't have include guards):
#include "src/objects/object-macros.h"
diff --git a/src/objects/js-raw-json.cc b/src/objects/js-raw-json.cc
index 810dbc7..f7623f7 100644
--- a/src/objects/js-raw-json.cc
+++ b/src/objects/js-raw-json.cc
@@ -20,7 +20,7 @@
ASSIGN_RETURN_ON_EXCEPTION(isolate, json_string,
Object::ToString(isolate, text));
Handle<String> flat = String::Flatten(isolate, json_string);
- if (flat->IsOneByteRepresentation()) {
+ if (String::IsOneByteRepresentationUnderneath(*flat)) {
if (!JsonParser<uint8_t>::CheckRawJson(isolate, flat)) {
DCHECK(isolate->has_exception());
return MaybeDirectHandle<JSRawJson>();
diff --git a/src/objects/js-regexp.cc b/src/objects/js-regexp.cc
index ed46821..624d2ad 100644
--- a/src/objects/js-regexp.cc
+++ b/src/objects/js-regexp.cc
@@ -302,7 +302,7 @@
DirectHandle<String> source) {
DCHECK(source->IsFlat());
if (source->length() == 0) return isolate->factory()->query_colon_string();
- bool one_byte = source->IsOneByteRepresentation();
+ bool one_byte = String::IsOneByteRepresentationUnderneath(*source);
bool needs_escapes = false;
int additional_escape_chars =
one_byte ? CountAdditionalEscapeChars<uint8_t>(source, &needs_escapes)
diff --git a/src/objects/map-inl.h b/src/objects/map-inl.h
index 1a0f6f6..3689a1d 100644
--- a/src/objects/map-inl.h
+++ b/src/objects/map-inl.h
@@ -363,6 +363,17 @@
return AddMissingTransitions(isolate, split_map, descriptors);
}
+InstanceType Map::instance_type() const {
+ // TODO(solanes, v8:7790, v8:11353, v8:11945): Make this and the setter
+ // non-atomic when TSAN sees the map's store synchronization.
+ return static_cast<InstanceType>(
+ RELAXED_READ_UINT16_FIELD(*this, kInstanceTypeOffset));
+}
+
+void Map::set_instance_type(InstanceType value) {
+ RELAXED_WRITE_UINT16_FIELD(*this, kInstanceTypeOffset, value);
+}
+
int Map::UnusedPropertyFields() const {
#if V8_ENABLE_WEBASSEMBLY
DCHECK(!IsWasmObjectMap(*this));
diff --git a/src/objects/map.h b/src/objects/map.h
index a36cf90..bd88404 100644
--- a/src/objects/map.h
+++ b/src/objects/map.h
@@ -1109,20 +1109,6 @@
inline bool IsSpecialReceiverMap(Tagged<Map> map);
inline bool IsCustomElementsReceiverMap(Tagged<Map> map);
-// Define the instance type accessors in the `.h` instead of `-inl.h` to avoid
-// a circular dependency with the instance-type-inl.h header.
-
-InstanceType Map::instance_type() const {
- // TODO(solanes, v8:7790, v8:11353, v8:11945): Make this and the setter
- // non-atomic when TSAN sees the map's store synchronization.
- return static_cast<InstanceType>(
- RELAXED_READ_UINT16_FIELD(*this, kInstanceTypeOffset));
-}
-
-void Map::set_instance_type(InstanceType value) {
- RELAXED_WRITE_UINT16_FIELD(*this, kInstanceTypeOffset, value);
-}
-
} // namespace v8::internal
#include "src/objects/object-macros-undef.h"
diff --git a/src/objects/string-inl.h b/src/objects/string-inl.h
index e484c1a..7ef6664 100644
--- a/src/objects/string-inl.h
+++ b/src/objects/string-inl.h
@@ -368,6 +368,24 @@
return InstanceTypeChecker::IsTwoByteString(map());
}
+// static
+bool String::IsOneByteRepresentationUnderneath(Tagged<String> string) {
+ while (true) {
+ uint32_t type = string->map()->instance_type();
+ static_assert(kIsIndirectStringTag != 0);
+ static_assert((kIsIndirectStringMask & kStringEncodingMask) == 0);
+ DCHECK(string->IsFlat());
+ switch (type & (kIsIndirectStringMask | kStringEncodingMask)) {
+ case kOneByteStringTag:
+ return true;
+ case kTwoByteStringTag:
+ return false;
+ default: // Cons, sliced, thin, strings need to go deeper.
+ string = string->GetUnderlying();
+ }
+ }
+}
+
base::uc32 FlatStringReader::Get(uint32_t index) const {
if (is_one_byte_) {
return Get<uint8_t>(index);
@@ -1102,7 +1120,7 @@
// InstanceType. See
// https://docs.google.com/document/d/15f-1c_Ysw3lvjy_Gx0SmmD9qeO8UuXuAbWIpWCnTDO8/
string = Flatten(isolate, string);
- if (string->IsOneByteRepresentation()) return true;
+ if (String::IsOneByteRepresentationUnderneath(*string)) return true;
DisallowGarbageCollection no_gc;
String::FlatContent flat = string->GetFlatContent(no_gc);
DCHECK(flat.IsFlat());
diff --git a/src/objects/string.h b/src/objects/string.h
index 31fc114..3a4d4510 100644
--- a/src/objects/string.h
+++ b/src/objects/string.h
@@ -234,10 +234,19 @@
inline void set_length(uint32_t hash);
inline void set_length(uint32_t hash, ReleaseStoreTag);
- // Returns whether this string is stored with one-byte chars.
+ // Returns whether this string has only one-byte chars, i.e. all of them can
+ // be one-byte encoded. This might be the case even if the string is
+ // two-byte. Such strings may appear when the embedder prefers
+ // two-byte external representations even for one-byte data.
inline bool IsOneByteRepresentation() const;
inline bool IsTwoByteRepresentation() const;
+ // Cons and slices have an encoding flag that may not represent the actual
+ // encoding of the underlying string. This is taken into account here.
+ // This function is static because that helps it get inlined.
+ // Requires: string.IsFlat()
+ static inline bool IsOneByteRepresentationUnderneath(Tagged<String> string);
+
// Get and set individual two byte chars in the string.
inline void Set(uint32_t index, uint16_t value);
// Get individual two byte char in the string. Repeated calls
diff --git a/src/objects/string.tq b/src/objects/string.tq
index 7b4dcab..0165da4 100644
--- a/src/objects/string.tq
+++ b/src/objects/string.tq
@@ -21,6 +21,33 @@
return IsOneByteStringMap(this.map);
}
+ // Keep this in sync with the C++ String::IsOneByteRepresentationUnderneath.
+ macro IsOneByteRepresentationUnderneath(): bool {
+ let string = this;
+ while (true) {
+ typeswitch (string) {
+ case (cons: ConsString): {
+ dcheck(cons.IsFlat());
+ string = cons.first;
+ }
+ case (thin: ThinString): {
+ // Internalized strings can't change representation.
+ dcheck(
+ thin.IsOneByteRepresentation() ==
+ thin.actual.IsOneByteRepresentation());
+ return thin.IsOneByteRepresentation();
+ }
+ case (slice: SlicedString): {
+ string = slice.parent;
+ }
+ case (str: String): {
+ return str.IsOneByteRepresentation();
+ }
+ }
+ }
+ VerifiedUnreachable();
+ }
+
const length: int32;
}
diff --git a/src/objects/templates-inl.h b/src/objects/templates-inl.h
index dfbe43e..b111c75 100644
--- a/src/objects/templates-inl.h
+++ b/src/objects/templates-inl.h
@@ -6,6 +6,7 @@
#define V8_OBJECTS_TEMPLATES_INL_H_
#include "src/heap/heap-write-barrier-inl.h"
+#include "src/objects/dictionary.h"
#include "src/objects/objects-inl.h"
#include "src/objects/oddball.h"
#include "src/objects/shared-function-info.h"
diff --git a/src/regexp/experimental/experimental-interpreter.cc b/src/regexp/experimental/experimental-interpreter.cc
index fa6a7ef..375f94a 100644
--- a/src/regexp/experimental/experimental-interpreter.cc
+++ b/src/regexp/experimental/experimental-interpreter.cc
@@ -760,7 +760,12 @@
isolate_->StackOverflow();
return RegExp::kInternalRegExpException;
} else if (check.InterruptRequested()) {
- const bool was_one_byte = input_object_->IsOneByteRepresentation();
+ // TODO(mbid): Is this really equivalent to whether the string is
+ // one-byte or two-byte? A comment at the declaration of
+ // IsOneByteRepresentationUnderneath says that this might fail for
+ // external strings.
+ const bool was_one_byte =
+ String::IsOneByteRepresentationUnderneath(input_object_);
Tagged<Object> result;
{
@@ -774,7 +779,8 @@
// If we changed between a LATIN1 and a UC16 string, we need to restart
// regexp matching with the appropriate template instantiation of
// RawMatch.
- if (input_handle->IsOneByteRepresentation() != was_one_byte) {
+ if (String::IsOneByteRepresentationUnderneath(*input_handle) !=
+ was_one_byte) {
return RegExp::kInternalRegExpRetry;
}
diff --git a/src/regexp/regexp-interpreter.cc b/src/regexp/regexp-interpreter.cc
index e1c1363..655f0cf 100644
--- a/src/regexp/regexp-interpreter.cc
+++ b/src/regexp/regexp-interpreter.cc
@@ -294,7 +294,7 @@
return ThrowStackOverflow(isolate, call_origin);
} else if (check.InterruptRequested()) {
const bool was_one_byte =
- (*subject_string_out)->IsOneByteRepresentation();
+ String::IsOneByteRepresentationUnderneath(*subject_string_out);
Tagged<Object> result;
{
AllowGarbageCollection yes_gc;
@@ -307,7 +307,8 @@
// If we changed between a LATIN1 and a UC16 string, we need to
// restart regexp matching with the appropriate template instantiation of
// RawMatch.
... (file diff truncated)
Original Bug Report
OOB read in JsonStringifier::SerializeString
VULNERABILITY DETAILS
After updating the parent of a SlicedString, its map object may become outdated, leading to Out-of-Band memory reads during JSON serialization.
VERSION
V8 commit: be6d95e1118064c533c56e0547627374bd2bd434
REPRODUCTION CASE
Build args:
is_component_build = false
is_debug = false
target_cpu = "arm64"
v8_target_cpu = "arm64"
v8_enable_sandbox = true
v8_enable_backtrace = true
v8_enable_disassembler = true
v8_enable_object_print = true
v8_enable_verify_heap = true
dcheck_always_on = false
Shell args:
./out/arm64.release/d8 --allow-natives-syntax bug.js
Shell output:
expect: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
found: "扢扢扢扢扢扢扢扢扢扢扢扢扢扢啢\u0001切Β\u0000猀牴唀\u0001昀Ἤߺ\u0000猀"
CREDIT INFORMATION Reporter credit: zeroxiaobai@gmail.com