A Type Confusion in V8’s Super Property Inline Cache
CVE-2022-1134 is a type confusion in V8, the JavaScript engine in Chrome, which I reported in March 2022 as bug 1308360. It was patched in Chrome 100.0.4896.60. The bug, which lives in the super inline cache (SuperIC) feature—an area with a history of exploitable issues—enables remote code execution (RCE) inside the renderer sandbox from a single visit to a malicious page. Understanding the flaw requires some background on how inline caches work and how V8 interacts with Blink, the Chrome renderer.
How V8 Uses Inline Caches
V8 accelerates property access in bytecode produced by Ignition, its interpreter, through inline caching. When a JavaScript function executes, Ignition compiles it into bytecode, which gathers profiling data and feedback on each run. That feedback drives the JIT compiler when it later produces optimized machine code.
Every JavaScript object in V8 carries a map as its first property, used to distinguish object types:
DebugPrint: 0x282908049499: [JS_OBJECT_TYPE]
- map: 0x282908207939 <Map(HOLEY_ELEMENTS)> [FastProperties]
...
0x282908207939: [Map]
- type: JS_OBJECT_TYPE
- instance size: 16
- inobject properties: 1
- elements kind: HOLEY_ELEMENTS
- unused property fields: 0
- enum length: 1
...
The map records essential details such as the object’s type and the offsets of each property. Objects sharing a map have identical memory layouts, so their properties sit at the same offsets. That allows property access to be optimized once an object’s map is known. Roughly: when bytecode for a property access runs, the maps of input objects are noted, and an optimized handler is built for each map. On subsequent runs, if an object with a known map appears, the matching handler retrieves the property directly.
Bytecode-Level Property Handling
Consider this function:
function f(a) {
return a.x
}
Running it in V8 with the print-bytecode flag reveals the generated bytecode:
[generated bytecode for function: f (0x11e7001d36cd <SharedFunctionInfo f>)]
...
Bytecode Age: 0
0x11e7001d3886 @ 0 : 2d 03 00 00 GetNamedProperty a0, [0], [0]
0x11e7001d388a @ 4 : a9 Return
The property access a.x becomes the GetNamedProperty bytecode. V8 splits property access into NamedProperty (e.g., a.x) and KeyedProperty (e.g., numerically indexed access like a[1]). This example:
function f(a) {
return a[1]
}
produces GetKeyedProperty instead:
[generated bytecode for function: f (0x1e8d001d36cd <SharedFunctionInfo f>)]
...
Bytecode Age: 0
0x1e8d001d386a @ 0 : 0d 01 LdaSmi [1]
0x1e8d001d386c @ 2 : 2f 03 00 GetKeyedProperty a0, [0]
0x1e8d001d386f @ 5 : a9 Return
Each of these bytecodes has an IGNITION_HANDLER. For GetNamedProperty, the handler is here:
IGNITION_HANDLER(GetNamedProperty, InterpreterAssembler) {
...
accessor_asm.LoadIC_BytecodeHandler(¶ms, &exit_point);
BIND(&done);
{
SetAccumulator(var_result.value());
Dispatch();
}
}
This handler delegates to LoadIC_BytecodeHandler, which examines the feedback collected for that bytecode and determines how to access the property. On the first execution, no feedback exists, so the operation falls back to the slow runtime path and records feedback while caching optimized handlers for the object map it has seen:
void AccessorAssembler::LoadIC_BytecodeHandler(const LazyLoadICParameters* p,
ExitPoint* exit_point) {
...
GotoIf(IsUndefined(p->vector()), &no_feedback);
...
BIND(&no_feedback); //<---------- no feedback, falls back to runtime implementation
{
Comment("LoadIC_BytecodeHandler_nofeedback");
// Call into the stub that implements the non-inlined parts of LoadIC.
exit_point->ReturnCallStub(
Builtins::CallableFor(isolate(), Builtin::kLoadIC_NoFeedback),
p->context(), p->receiver(), p->name(),
SmiConstant(FeedbackSlotKind::kLoadProperty));
}
...
}
With feedback available, the handler searches for a cached optimized property handler matching the current input:
void AccessorAssembler::LoadIC_BytecodeHandler(const LazyLoadICParameters* p,
ExitPoint* exit_point) {
...
// Inlined fast path.
{
Comment("LoadIC_BytecodeHandler_fast");
TVARIABLE(MaybeObject, var_handler);
Label try_polymorphic(this), if_handler(this, &var_handler);
TNode<MaybeObject> feedback = TryMonomorphicCase( //<-------- Look for cached handler
p->slot(), CAST(p->vector()), lookup_start_object_map, &if_handler,
&var_handler, &try_polymorphic);
BIND(&if_handler); //<--------- handler found
HandleLoadICHandlerCase(p, CAST(var_handler.value()), &miss, exit_point); //<------- try to use optimized handler
...
}
}
Found handlers are used to accelerate the access. Mismatches—such as a previously unseen map—cause a cache miss and trigger a bailout to the slow path.
Building and Applying Handlers
Cache misses route to *IC_Miss runtime functions. For loads, that is LoadIC_Miss:
RUNTIME_FUNCTION(Runtime_LoadIC_Miss) {
...
FeedbackSlotKind kind = vector->GetKind(vector_slot);
if (IsLoadICKind(kind)) {
LoadIC ic(isolate, vector, vector_slot, kind);
...
RETURN_RESULT_OR_FAILURE(isolate, ic.Load(receiver, key));
} ...
It creates a LoadIC object and calls its Load method, which not only performs the runtime load but also builds and caches a new optimized handler for future cases. The handler is tailored using the object’s map and other properties:
MaybeHandle<Object> LoadIC::Load(Handle<Object> object, Handle<Name> name,
bool update_feedback,
Handle<Object> receiver) {
...
PropertyKey key(isolate(), name);
LookupIterator it = LookupIterator(isolate(), receiver, key, object);
...
if (it.IsFound() || !ShouldThrowReferenceError()) {
// Update inline cache and stub cache.
if (use_ic) {
UpdateCaches(&it); //<--------- update inline cache
} ...
}...
UpdateCaches next calls ComputeHandler to build the handler and refresh the inline cache:
Handle<Object> LoadIC::ComputeHandler(LookupIterator* lookup) {
...
case LookupIterator::ACCESSOR: {
Handle<JSObject> holder = lookup->GetHolder<JSObject>();
...
FieldIndex field_index;
if (Accessors::IsJSObjectFieldAccessor(isolate(), map, lookup->name(),
&field_index)) {
TRACE_HANDLER_STATS(isolate(), LoadIC_LoadFieldDH);
return LoadHandler::LoadField(isolate(), field_index); //<-- Creates new handler
}
...
}
...
}
ComputeHandler considers the property accessor type (e.g., a plain data property or a getter/setter pair) as determined by the object’s map and the property name. For example, if the property is an ACCESSOR implemented via a getter/setter and the name is length on an Array or String—the condition verified by IsJSObjectFieldAccessor—then LoadHandler::LoadField returns a handler of kind kField with the field’s offset encoded as the field_index.
On subsequent runs, AccessorAssembler::LoadIC is invoked for the GetNamedProperty bytecode. After TryMonomorphicCase locates the cached handler, it is applied via HandleLoadICSmiHandlerLoadNamedCase:
void AccessorAssembler::HandleLoadICSmiHandlerLoadNamedCase(
const LazyLoadICParameters* p, TNode<Object> holder,
TNode<IntPtrT> handler_kind, TNode<WordT> handler_word, Label* rebox_double,
TVariable<Float64T>* var_double_value, TNode<Object> handler, Label* miss,
ExitPoint* exit_point, ICMode ic_mode, OnNonExistent on_nonexistent,
ElementSupport support_elements) {
...
GotoIf(WordEqual(handler_kind, LOAD_KIND(kField)), &field);
...
BIND(&field);
{
...
HandleLoadField(CAST(holder), handler_word, var_double_value, rebox_double,
miss, exit_point); //<----- loads the field from an offset encoded in `handler_word`
...
}
...
}
Here the handler (handler_word) is a kField handler carrying the field offset. HandleLoadField then reads the field directly from that offset, skipping a call to the getter entirely.
The safety of inline caching depends on the assumptions present when a handler is built remaining valid when that handler is matched later. The vulnerability stems from a breakdown in this guarantee for super property accesses.
JavaScript Inheritance and the super Property
The super property in JavaScript behaves differently than in class-based languages like Java or C++. Developers used to those languages might expect super.foo to reach a parent class field. JavaScript, however, treats data properties differently:
class A {
int foo = 1;
}
class B extends A {
public B() {
super();
super.foo; //<---- 1
}
}
The same logic in JavaScript leaves super.foo as undefined:
class A {
foo = 1;
}
class B extends A {
constructor() {
super();
super.foo; //<------ undefined
}
}
For data fields, super.foo resembles this.foo, returning undefined unless the field also appears on the object calling super.foo. For property accessors—getters and setters—the semantics mirror other languages more closely: the accessor from the parent class executes with the calling object as the this receiver:
class A {
get prop() {
return this.a;
}
}
class B extends A {
constructor() {
super();
this.a = 'B';
}
m() {
return super.prop;
}
}
var b = new B();
b.m(); //<------ 'B'
Because JavaScript classes are built on prototypes, the same behavior can be expressed without class syntax:
class B {
m() {
return super.prop;
}
}
B.prototype.__proto__ = {get prop() {return this.x}};
var b = new B();
b.x = 1;
b.m() //<-------- 1
In B.prototype.__proto__, B functions as a constructor. The prototype property of a constructor function becomes the prototype of the objects it creates:
%DebugPrint(B.prototype);
DebugPrint: 0x1c120004af39: [JS_OBJECT_TYPE]
- map: 0x1c1200207d29 <Map(HOLEY_ELEMENTS)> [FastProperties]
- prototype: 0x1c12001c4281 <Object map = 0x1c12002021e9>
- elements: 0x1c1200002261 <FixedArray[0]> [HOLEY_ELEMENTS]
- properties: 0x1c120004afb9 <PropertyArray[2]>
- All own properties (excluding elements): {
0x1c1200004619: [String] in ReadOnlySpace: #constructor: 0x1c120004aefd <JSFunction B (sfi = 0x1c12001d374d)> (const data field 0), location: properties[0]
0x1c12001d3669: [String] in OldSpace: #m: 0x1c120004af1d <JSFunction m (sfi = 0x1c12001d3781)> (const data field 1), location: properties[1]
}
Objects built with B as a constructor inherit a prototype holding a constructor field pointing to B and a method m defined by the class. That prototype, being an ordinary JavaScript object, can itself have a prototype, designated by __proto__. The combination B.prototype.__proto__ captures a class hierarchy: objects made via B inherit properties and methods from B.prototype.__proto__, which acts as the template for a parent class instance. The class syntax spells this out, as shown here:
class A {
get prop() {
return this.a;
}
}
class B extends A {
}
%DebugPrint(B.prototype.__proto__)
With the expected V8 output:
DebugPrint: 0x24750004adf1: [JS_OBJECT_TYPE]
...
- All own properties (excluding elements): {
prop: 0x2475001d3a85 <AccessorPair> (accessor, dict_index: 2, attrs: [W_C])
constructor: 0x24750004adb5 <JSFunction A (sfi = 0x2475001d3745)> (data, dict_index: 1, attrs: [W_C])
}
So B.prototype.__proto__ is an object constructed by class A. The key difference between class syntax and the manual prototype approach is that the latter lets you supply a concrete object as the parent class template. As a result, you can access data properties of B.prototype.__proto__ through super:
class B {
m() {
return super.prop;
}
}
B.prototype.__proto__ = {prop : 1};
var b = new B();
b.m() //<-------- 1
More critically, the object and its parent class template can have entirely different JavaScript types:
class B {
m() {
return super.length;
}
}
var b = new B();
B.prototype.__proto__ = new Int8Array(1);
b.m(); //<---- throw TypeError
This code throws a TypeError: the length accessor from TypedArray (via Int8Array) is invoked on object B, whose type is JS_OBJECT_TYPE, not JS_TYPED_ARRAY_TYPE. That type check is essential—the length accessor assumes the receiver has the memory layout of a TypedArray, so applying it to a differently laid-out object (such as a JS_OBJECT) would cause type confusion. This detail is central to the vulnerability.
When Super’s receiver isn’t the object being looked up
Super property access in V8 is routed through the GetNamedPropertyFromSuper bytecode and handled by the LoadSuperIC function, which closely mirrors LoadIC except for one crucial wrinkle: property lookup starts on a parent object rather than the receiver. The inline cache must therefore validate types not just for the receiver but for a separate lookup_start_object. Mixing up these two parameters is a recurring source of type confusion vulnerabilities.
The first notable case, CVE-2021-30517, occurred when an inline cache hit for a call_handler was applied to the receiver instead of the lookup_start_object. A call_handler is only created when the target is a built-in property like String.length or Function.prototype, and it relies on the object’s concrete layout matching the expected type. Checking the map of the lookup_start_object was correct, but the subsequent function call used the receiver, which could be a completely different type. At first glance, that mismatch is hard to trigger: within a class method, super.prototype seemed bound to the same receiver map.
Megamorphic caches widen the blast radius
The escape hatch is the megamorphic inline cache. Once a property access site sees too many distinct maps, the cache stops being a per-function, monomorphic or polymorphic structure and becomes shared. With a megamorphic cache, handlers created from one JavaScript function can be reused by another. By repeatedly defining new classes whose prototypes have fresh maps, you can arrange for a super.prototype access in one method to pick up a handler created for a plain f.prototype access in another. The type confusion then becomes exploitable against an object whose map was never validated against that handler’s expectations. A second, related vulnerability in the same area — CVE-2021-38001 — was demonstrated at the Tianfu Cup and allowed Chrome remote code execution.
The accessor property case
This current bug is a third instance in the SuperIC family, this time involving property accessors. When constructing a handler for a super accessor property, V8 performs two key map checks if the accessor is a simple_api_call. A simple_api_call is a C++ function exposed from the embedder (Blink, PDFium, etc.) through the V8 API. These callbacks receive V8 objects and internally cast them to the embedder’s expected C++ class. The map checks are meant to guarantee the argument has the memory layout the embedder will assume. However, the code checks the map of the lookup_start_object — where the accessor actually lives — but the function’s this is bound to the receiver. In a super call, the receiver can be the derived class instance, not the prototype where the getter is declared. That mismatch makes the receiver an object whose map was never validated, while the getter runs against it with the assumption that it is safe.
The relevance isn’t hypothetical: this is exactly the path that allows a crafted accessor to be invoked with a receiver of an unexpected type. The original fix proposed by the V8 team was quickly reverted because the receiver-versus-lookup-start-object distinction is subtle even for the platform’s own developers. For anyone who finds this section a little dizzying, you’re in good company.
Blink Objects and V8 Wrappers
Blink implements the Web API—objects like the DOM window that aren't part of standard JavaScript but are exposed to scripts. These objects live in C++ but are reachable as JavaScript objects through V8. A concrete example is DOMRectReadOnly, a simple data object with fields like x, y, width, and height.
When JavaScript creates a DOMRectReadOnly, two objects are produced. First, Blink's DOMRectReadOnly::Create runs, building a native Blink object. That object is then wrapped in V8 as a JS_API_OBJECT. Two pointers in this wrapper are significant: one at 0xc points to the static wrapper_type_info_ field, identifying the wrapped Blink type, and one at 0x10 points to the actual DOMRectReadOnly instance in Blink.
Accessing a property like x from JavaScript doesn't go straight to Blink. Instead, generated bindings code—stored in files like gen/third_party/blink/renderer/bindings/core/v8/v8_dom_rect_read_only.cc—acts as an intermediary. For the x property, the generated code registers an XAttributeGetCallback as the getter. When invoked, this callback first checks, via CanHaveInternalField, that the V8 receiver is a JS_OBJECT, JS_API_OBJECT, or JS_SPECIAL_API_OBJECT. It then reads the pointer at 0x10, casts it directly to a DOMRectReadOnly, and calls the C++ method to fetch the double value.
Dangerous Assumptions in the Call Path
This flow works because V8 normally defends the boundary: the HandleApiCallHelper routine in v8/src/builtins/builtins-api.cc checks that the receiver is a wrapper for the exact Blink type the getter expects. If not, it throws a TypeError rather than mis-casting the object pointer.
The vulnerability described in "the vulnerability" section bypasses that check. Because the super property access path doesn't consistently enforce the type check when constructing its inline cache, an attacker can invoke a Blink getter on a wrapper of an entirely different Blink object type. The cast at offset 0x10 then produces a type-confused pointer—a powerful primitive.
Notably, the type error is avoidable even during cache construction. The megamorphic inline cache can be populated in a different function to sidestep the check entirely; a trysuper.x access achieves the same result without needing a megamorphic cache. This makes the bug reliably triggerable from script.
Turning the primitives into read/write
With the object layout information in hand, exploiting the type confusion becomes a matter of locating Blink objects with suitable memory layouts. The exploit proceeds in three stages:
- Build an arbitrary read primitive using
DeviceMotionEvent. - Leak the address of a V8 object so that addresses of subsequently allocated objects can be derived.
- Construct a fake V8 object to gain out-of-bounds (OOB) read/write.
Once those primitives exist, achieving code execution follows well-known steps.
Arbitrary read with DeviceMotionEvent
The interval accessor of DeviceMotionEvent reads the interval_ member from an offset relative to the object's device_motion_data_ field:
class DeviceMotionEvent final : public Event {
DEFINE_WRAPPERTYPEINFO();
public:
double DeviceMotionEvent::interval() const {
//reads the field `interval_` from `device_motion_data_`
return device_motion_data_->Interval();
}
...
private:
Member<const DeviceMotionData> device_motion_data_;
}
class MODULES_EXPORT DeviceMotionData final
: public GarbageCollected<DeviceMotionData> {
public:
...
double Interval() const { return interval_; }
...
private:
...
double interval_;
};
By using the type confusion to invoke this getter on a different Blink object, an attacker can control what memory address is read. Objects that are pure data containers are ideal for this purpose. DOMMatrix is one such object, exposing sixteen contiguous double fields (m11 through m44) with no hidden pointers:
Calling DeviceMotionEvent::interval on a DOMMatrix allows reading eight bytes from an arbitrary address and returning them as a double.
Deriving a V8 object address
To obtain a V8 object address, the exploit uses ImageData. It can be constructed with a Uint8ClampedArray as its backing store:
var imgData = new Uint8ClampedArray(48);
var img = new ImageData(imgData, 8, 6);
The constructor stores a pointer to the DOMUint8ClampedArray (Blink's representation of the V8 Uint8ClampedArray) in the data_u8_ field. A DOMUint8ClampedArray is a ScriptWrappable; its main_world_wrapper_ field can be dereferenced to get the V8 heap address of the actual Uint8ClampedArray object.
Confusing a DOMMatrix with ImageData and then reading back a double from the appropriate field leaks the data_u8_ pointer:
With data_u8_ known, the arbitrary read primitive reads main_world_wrapper_ at an offset within the DOMUint8ClampedArray, and then reads the address of the V8 imgData object from there. Since V8's heap is linear, addresses of all objects allocated after imgData can be calculated from this base address.
Fabricating a V8 object
The getter-based leak is one-way; to get a write primitive the confusion must be applied to an object returned by a property accessor. Many Blink objects expose JavaScript objects as properties. Request, for example, has a signal property that returns signal_ as a V8 object through generated bindings code:
void SignalAttributeGetCallback(
const v8::FunctionCallbackInfo<v8::Value>& info) {
RUNTIME_CALL_TIMER_SCOPE_DISABLED_BY_DEFAULT(info.GetIsolate(),
"Blink_Request_signal_Getter");
BLINK_BINDINGS_TRACE_EVENT("Request.signal.get");
v8::Local<v8::Object> v8_receiver = info.This();
Request* blink_receiver = V8Request::ToWrappableUnsafe(v8_receiver);
auto&& return_value = blink_receiver->signal();
bindings::V8SetReturnValue(info, return_value, blink_receiver);
}
Here, blink_receiver->signal() returns a ScriptWrappable (an AbortSignal). The binding code converts it to a V8 object by reading its main_world_wrapper_ field. The trick is to use a type confusion between Request and AudioData. An AudioData object has a timestamp_ field at the same offset as Request::signal_, and it can be set to an arbitrary 64-bit value on construction. The offset of timestamp_ is identical to that of signal_; the confusion makes the value of timestamp_ be interpreted as a pointer to a ScriptWrappable. By pointing timestamp_ at attacker-controlled data, the content of that data is treated as the main_world_wrapper_ of the returned object.
The attacker begins by constructing several JavaScript variables in a specific order:
var imgDataStore = new ArrayBuffer(48)
var imgData = new Uint8ClampedArray(imgDataStore);
var doubleArr = [1.1, 2.2, 3.3, 4.4, 5.5];
var objArr = [imgData];
var img = new ImageData(imgData, 8, 6);
The address of img's Uint8ClampedArray backing store (the DOMUint8ClampedArray) is already known from the previous step. That object stores a pointer to its raw backing store in raw_base_address_ at offset 0x10. If timestamp_ is set to data_u8_ + 0x8, then the raw_base_address_ of the DOMUint8ClampedArray is treated as the main_world_wrapper_ of the fake return_value object:
The first eight bytes of the Uint8ClampedArray's backing store are now interpreted as the address of the V8 object returned by Request::signal. By placing controlled data at that address, the exploit crafts a fake V8 object.
To fake the object, the exploit uses the element store of doubleArr. For small arrays, V8 inlines the elements either directly before or after the array object — the layout depends on the elements type. The exact offset can be derived from %DebugPrint output:
var doubleArr = [1.1, 2.2, 3.3, 4.4, 5.5];
%DebugPrint(doubleArr)
DebugPrint: 0x20870004c869: [JSArray]
...
- elements: 0x20870004c839 <FixedDoubleArray[5]> [PACKED_DOUBLE_ELEMENTS]
...
The elements field contains the address of the inline storage. Given the address of imgData already leaked, the address of doubleArr's elements is also computable. The fake V8 object's contents are written into doubleArr:
With that in place, the fake object becomes usable as an Array with an oversized length, which yields OOB read and write. In V8, a JavaScript Array has this generic layout:
The map field determines the object type. Placing the map of a double Array in the fake object makes V8 treat it as one. Setting elements to point to doubleArr's backing store and length to a large value extends access beyond the store. Note that map, properties, elements, and length are all 4 bytes wide because V8 addresses are stored as compressed 32-bit pointers. The top 32 bits of a V8 heap address are constant; they are supplied by a registry when a compressed pointer is dereferenced.
With native OOB read and write achieved, gaining code execution follows standard methodology. The fake signal object — now a double Array with an enormous length — lets the exploit read or overwrite any V8 object allocated after doubleArr. The exploitation steps are:
- Allocate an
ObjectArrayafterdoubleArrand use the OOB read to leak the addresses of the objects stored within it. - Create a
WebAssembly.Instance, locate it via the OOB read, and read the pointer to its compiledwasmcode from its address. This pointer points to anRWXpage that is executed when the instance'smainfunction runs. (Such instances live in Old space, so they can't be reached directly with the OOB read.) - Allocate a
TypedArrayafterdoubleArrand overwrite itsdata_ptrwith the leakedRWXaddress. - Because
data_ptrpoints to theTypedArray's backing store, writing to the array now writes to thewasmcode page. Placing shellcode in theTypedArrayyields execution when theWebAssembly.Instance's function is invoked.
As detailed in previous analyses, a wasm-memory-protection-keys flag was added to mitigate wasm RWX regions. That mitigation is bypassable by directly overriding the flag value, a technique demonstrated in earlier exploits.
The full working exploit with setup instructions is available from the project repository.
History repeats across V8's tiers
V8 frequently implements the same functionality multiple times to support different optimization levels. When a bug exists in one implementation, the same flaw often lurks in the others. This was the case with Reflect.construct, where a missing prototype field on the Proxy constructor led to an out-of-bounds access. The issue first appeared in the slow runtime path (CVE-2018-18359), then resurfaced months later in the JIT path (CVE-2019-5843), and again in the Torque implementation (CVE-2019-5877), which was exploited in a full Android exploit chain. The super property access bug follows a similar pattern: the JIT compiler contains the same flaw we found in the runtime.
When the JIT compiles optimized code for simple API property access, it consults the map of the receiver object via AccessorAccessInfoHelper. The check appears sound on its face—property accessors operate on the receiver, not the lookup_start_object, so validating the receiver's map seems correct. The problem is that the receiver_map variable used here is not actually the map of the receiver.
PropertyAccessInfo AccessorAccessInfoHelper(
Isolate* isolate, Zone* zone, JSHeapBroker* broker,
const AccessInfoFactory* ai_factory, MapRef receiver_map, NameRef name,
MapRef map, base::Optional<JSObjectRef> holder, AccessMode access_mode,
AccessorsObjectGetter get_accessors) {
...
CallOptimization::HolderLookup lookup;
Handle<JSObject> holder_handle = broker->CanonicalPersistentHandle(
optimization.LookupHolderOfExpectedType(
broker->local_isolate_or_isolate(), receiver_map.object(), //<------- checks that the receiver_map is compatible
&lookup));
AccessorAccessInfoHelper is invoked from ReducedNameAccess to build a PropertyAccessInfo:
Reduction JSNativeContextSpecialization::ReduceNamedAccess(
Node* node, Node* value, NamedAccessFeedback const& feedback,
AccessMode access_mode, Node* key) {
...
ZoneVector<MapRef> inferred_maps(zone());
if (!InferMaps(lookup_start_object, effect, &inferred_maps)) { //<----------- 1.
for (const MapRef& map : feedback.maps()) {
inferred_maps.push_back(map);
}
}
...
{
ZoneVector<PropertyAccessInfo> access_infos_for_feedback(zone());
for (const MapRef& map : inferred_maps) {
...
PropertyAccessInfo access_info = broker()->GetPropertyAccessInfo(
map, feedback.name(), access_mode, dependencies()); //<------------ 2.
access_infos_for_feedback.push_back(access_info);
The map argument supplied to GetPropertyAccessInfo is ultimately forwarded as receiver_map into AccessorAccessInfoHelper. However, that map is inferred from the lookup_start_object, not the actual receiver. Meanwhile, the code generation path in BuildPropertyLoad uses the true receiver object when emitting the property load call:
base::Optional<JSNativeContextSpecialization::ValueEffectControl>
JSNativeContextSpecialization::BuildPropertyLoad(
Node* lookup_start_object, Node* receiver, Node* context, Node* frame_state,
Node* effect, Node* control, NameRef const& name,
ZoneVector<Node*>* if_exceptions, PropertyAccessInfo const& access_info) {
...
Node* value;
if (access_info.IsNotFound()) {
value = jsgraph()->UndefinedConstant();
} else if (access_info.IsFastAccessorConstant() ||
access_info.IsDictionaryProtoAccessorConstant()) {
...
value =
InlinePropertyGetterCall(receiver, receiver_mode, context, frame_state, //<---- receiver used for making getter call
&effect, &control, if_exceptions, access_info);
} else if (access_info.IsModuleExport()) {
This mismatch means the JIT implementation checks the wrong map, mirroring the flaw in the runtime implementation. I reported this as bug 1309467 with a proof of concept demonstrating that it evades the original fix. It shipped in Chrome 102.0.5005.61 as CVE-2022-1869.
Lessons from the Blink boundary
CVE-2022-1134 and its variants highlight the complexity of V8's property access system. The recurrence of this bug across different implementations—including one used in a prominent exploit contest—shows how subtle the distinction between receiver and lookup_start_object remains even after multiple fixes. What sets this variant apart from earlier ones is that it crosses the Blink–V8 boundary and can't be discovered by fuzzing V8 in isolation.
Most published research focuses on bugs confined to either V8 or Blink individually. The recent trend of in-the-wild exploits that leverage Blink objects to violate V8's assumptions—such as CVE-2021-30551 and CVE-2022-1096—signals an area where security research needs more attention. Exploiting these cross-boundary bugs demands deep expertise in both codebases, offering a glimpse into the resources and knowledge that well-funded adversaries possess.



