Forging Stack Traces With An Unchecked Integer Overflow
A CDP-gated V8 integer wrap that allows you to control reported stack frames (and detect devtools)
Contents
Recently, I was playing around with devtools again. Specifically, I was looking into differential fuzzing with behaviour when CDP is attached.
I figured this was an interesting place to look, since it’s not as straightforward to fuzz behaviour dependent on different states. That, and I just like messing with devtools.
After modifying a fuzzing framework, I took identical generated programs with Runtime.enable and without. I got some interesting results. One was an Error stack limit behaviour, which seemed to be an oversight.
What is CDP Runtime domain?
Chrome DevTools Protocol has different commands to gate specific behaviour, one of these is Runtime.enable. This is the command that activates the Runtime domain for a target (wow). Letting clients like DevTools evaluate JavaScript, inspect objects, receive console messages, and whatever else you can do with the normal runtime.
It’s an interesting place to try to find weirdness. It’s frequently used in automation, so side effects can become detection vectors.
Enabling this also activates detailed stack capture on Error construction. I’d previously found a devtools detection signal nearby. Runtime.enable changes ErrorEvent locations from the throw site to the Error construction site (poc here). When I saw a reported overflow adjacent to that, I got very interested.
An overflow in the stack trace limit
For the Error class, if you set the stackTraceLimit to a huge number:
Error.stackTraceLimit = 858_993_461;
You might expect that this will keep every available frame. Normally, this will be the case. But, if Runtime.enable was sent before the Error was created, V8 will reduce the page-visible stack to exactly one frame.
Without Runtime.enable:
Error
at Object.verifyChallenge (output.js:8:12)
at actualCallerBelowDecoy (output.js:21:22)
at output.js:25:13
With Runtime.enable:
Error
at Object.verifyChallenge (output.js:8:12)
Why does the overflow happen?
This is down to an overflow from Isolate::CaptureAndSetErrorStack:
stack_trace = GetDetailedStackTraceFromCallSiteInfos(
this, raw_data_for_call_site_infos,
static_cast<uint32_t>(
stack_trace_for_uncaught_exceptions_frame_limit_));
if (static_cast<uint32_t>(stack_trace_limit) *
CallSiteInfo::Fields::kCount <
raw_data_for_call_site_infos->ulength().value()) {
call_site_infos_or_formatted_stack = FixedArray::RightTrimOrEmpty(
this, raw_data_for_call_site_infos,
stack_trace_limit * CallSiteInfo::Fields::kCount);
}
V8 stores five raw fields per frame:
enum Fields {
kCode = 0,
kReceiver,
kFunction,
kOffset,
kFlags,
kCount
};
This is defined in call-site-info.h.
The frame limit is multiplied by five before the raw FixedArray is trimmed. That multiplication wraps at 32 bits:
858,993,461 * 5 = 4,294,967,305
4,294,967,305 mod 2^32 = 9
The huge frame limit becomes a raw array length of nine fields.
Error.stackTraceLimit | Product (mod 2^32) | Visible frames |
|---|---|---|
| 858,993,459 | 4,294,967,295 | not triggered |
| 858,993,460 | 4 | 0 |
| 858,993,461 | 9 | 1 |
| 858,993,462 | 14 | 2 |
| 858,993,463 | 19 | 3 |
The 858,993,459 row’s product is UINT32_MAX, so the guard product < raw_data.length cannot fire for any realistic stack.
Shipping Chromium builds use wrapping overflow semantics through -fno-strict-overflow (compiler configuration). UBSan builds may still have issues.
Why does it only keep a prefix?
Nine fields contain one complete CallSite and four fields from the next one. Isolate::GetSimpleStackTrace calculates the frame count like this:
int frame_count =
raw_data->ulength().value() / CallSiteInfo::Fields::kCount;
9 / 5 is one. FixedArray::RightTrimOrEmpty trims from the right, so the remaining frames are always the start of the real stack. As it doesn’t move or splice records this cannot be used to create arbitrary frames. But you can directly control how many frames are exposed.
Building on this
Function.name is configurable. Renaming a function without matching its receiver property exposes the alias:
Object.declaredDecoyName [as actualLeaf]
Matching both removes it:
Object.verifyChallenge
The page controls its name, removes the helper with Error.captureStackTrace, and relies on Runtime attachment to remove the caller context.
Spoofing stack traces
function hiddenCapture() {
const target = {};
Error.captureStackTrace(target, hiddenCapture);
return target;
}
function realImplementation() {
return hiddenCapture();
}
Object.defineProperty(realImplementation, "name", {
value: "verifyChallenge",
configurable: true,
});
const challenge = {
verifyChallenge: realImplementation,
};
function run() {
return challenge.verifyChallenge();
}
Error.stackTraceLimit = 858_993_461;
console.log(run().stack);
Error.captureStackTrace(target, hiddenCapture) removes the capture helper. The function name and receiver property are both verifyChallenge, so the first real frame is formatted as Object.verifyChallenge. The overflow then removes the callers beneath it.
Detecting DevTools
This can also be used to detect devtools. By comparing the stack trace limit to the number of available frames, we can detect when the overflow has occurred.
function leaf() {
return new Error();
}
function caller() {
return leaf();
}
Error.stackTraceLimit = 858_993_461;
const frames = caller().stack.split("\n").length - 1;
const runtimeAttached = frames === 1;
At the time of writing, V8’s raw field layout has 5 fields per frame, so without the overflow the length will be 3, and with it the length will be 1. Any change to that field count shifts the wrap values.
Chromium Fix
V8 should compare frame counts before converting back to raw fields:
const uint32_t fields_per_frame = CallSiteInfo::Fields::kCount;
const uint32_t available_frames =
raw_data_for_call_site_infos->ulength().value() / fields_per_frame;
const uint32_t requested_frames =
static_cast<uint32_t>(stack_trace_limit);
if (requested_frames < available_frames) {
call_site_infos_or_formatted_stack = FixedArray::RightTrimOrEmpty(
this, raw_data_for_call_site_infos,
requested_frames * fields_per_frame);
}
Checked multiplication would also fix it. Comparing frames first matches what the code is trying to do and prevents partial records.
Note
I discovered after writing this, V8 commit 786c1c2d landed with a fix for this issue. Only on V8 main at the time of writing; the regression is present on V8 branch-heads 14.8 through 15.5, so shipping Chrome stable and beta are still affected until the merge lands.
Refs: crbug/565047704, nodejs/node#66074.