现在不少网站会通过 debugger 断点与控制台侧信道来阻断调试或触发风控,例如瑞树的“无限 debugger”,以及 BOSS 的 console.table 检测控制台打开后跳转空白页等。这类检测依赖 V8/DevTools 的默认语义与 Inspector 上报路径,一旦命中就会直接影响分析与回放。
常见的魔改思路是删除 debugger 关键字或粗暴改写其行为,但这种处理容易留下新检测点。本文的方案选择在语法解析阶段介入:让 debugger; 语法依旧合法,但语义变为空语句,从而在不破坏脚本执行流的前提下消除断点副作用,并配合控制台静默与自用通道(vdebugger/vconsole)实现可控调试。
设计目标与约束
debugger仍是关键字,确保eval("typeof debugger")等探测继续抛SyntaxError。debugger;语法合法但执行为空语句,不触发断点。- 新增
vdebugger;语句,作为自控断点入口(仅内部使用)。 console.*默认静默(不向 Inspector/DevTools 上报),降低“DevTools 打开”相关检测面。- 新增
vconsole.log且不可枚举,作为自用的日志输出通道。
最小检测示例
1) 检测"控制台是否打开"(throw Error + getter)
经典手段:通过 throw 一个带有 getter 的 Error 对象。只有当 DevTools 打开并渲染错误对象(或页面代码主动读取 message)时,getter 才会触发;仅 HTML 中未捕获异常的默认上报通常不会触发。
<script>
window.hit = 0;
(() => {
const err = new Error();
err.__defineGetter__("message", () => (window.hit++, "msg"));
setTimeout(() => { throw err; }, 0);
})();
setTimeout(() => {
(document.body || document.documentElement).textContent =
"hit=" + window.hit;
}, 50);
</script>
2) 检测"控制台是否打开"(console.table 时间差)
利用点:console.table 输出大对象时,DevTools 需要做序列化/渲染,会有明显的时间开销;控制台关闭时则几乎无开销。
const bigArray = new Array(10000).fill({ a: 1, b: 2, c: 3 });
const t0 = performance.now();
console.table(bigArray);
const t1 = performance.now();
// DevTools 打开时 t1 - t0 会明显变大(可能 >50ms);关闭时接近 0
改动范围速览
| 文件 | 变更目的 |
|---|---|
v8/src/parsing/parser-base.h | debugger; no-op + vdebugger; 解析为 DebuggerStatement |
v8/src/inspector/v8-console.h | 新增 createInspectorConsole 声明 |
v8/src/inspector/v8-console.cc | 实现 vconsole.log 注入与不可枚举绑定 |
v8/src/inspector/v8-inspector-impl.cc | 禁用默认 console delegate + 注入 vconsole |
关键实现细节
1) debugger; 变成空操作
文件:v8/src/parsing/parser-base.h
核心思路是“语法依旧合法,语义变成空语句”。ParseDebuggerStatement() 直接返回 EmptyStatement(),运行时自然不会暂停:
修改前(片段):
// v8/src/parsing/parser-base.h
ParserBase<Impl>::ParseDebuggerStatement() {
int pos = peek_position();
Consume(Token::kDebugger);
ExpectSemicolon();
return factory()->NewDebuggerStatement(pos);
}
修改后:
// v8/src/parsing/parser-base.h
ParserBase<Impl>::ParseDebuggerStatement() {
int pos = peek_position();
Consume(Token::kDebugger);
ExpectSemicolon();
USE(pos);
return factory()->EmptyStatement();
}
2) 新增 vdebugger; 语句
文件:v8/src/parsing/parser-base.h
为了不破坏语义,这个识别只发生在“语句起始位置”,并且排除标签语句 vdebugger:。同时,必须满足“独立语句形态”(分号/换行/}/EOF)才触发:
修改前(片段):
// v8/src/parsing/parser-base.h
// 直接进入语句分发
switch (peek()) {
case Token::kLeftBrace:
return ParseBlock(labels);
// ...
}
修改后:
// v8/src/parsing/parser-base.h
if (V8_UNLIKELY(peek() == Token::kIdentifier) &&
PeekContextualKeyword(
ast_value_factory()->GetOneByteString("vdebugger"))) {
Token::Value next = PeekAhead();
if (next != Token::kColon) {
const bool is_standalone =
Token::IsAutoSemicolon(next) ||
scanner()->HasLineTerminatorAfterNext();
if (is_standalone) {
int pos = peek_position();
Consume(Token::kIdentifier);
ExpectSemicolon();
return factory()->NewDebuggerStatement(pos);
}
}
}
这样做的直接结果是:
vdebugger;生效;obj.vdebugger、vdebugger()、let vdebugger = 1等不会被误伤;vdebugger:标签语句保持原义。
3) 关闭默认 console delegate
文件:v8/src/inspector/v8-inspector-impl.cc
默认 console 的上报通道会被关闭,从源头上让 console.* 变成“无输出”:
修改前(片段):
// v8/src/inspector/v8-inspector-impl.cc
V8InspectorImpl::V8InspectorImpl(v8::Isolate* isolate,
V8InspectorClient* client)
: m_isolate(isolate),
m_client(client),
m_debugger(new V8Debugger(isolate, this)),
m_lastExceptionId(0),
m_lastContextId(0),
m_isolateId(generateUniqueId()) {
v8::debug::SetInspector(m_isolate, this);
v8::debug::SetConsoleDelegate(m_isolate, console());
}
修改后:
// v8/src/inspector/v8-inspector-impl.cc
V8InspectorImpl::V8InspectorImpl(v8::Isolate* isolate,
V8InspectorClient* client)
: m_isolate(isolate),
m_client(client),
m_debugger(new V8Debugger(isolate, this)),
m_lastExceptionId(0),
m_lastContextId(0),
m_isolateId(generateUniqueId()) {
v8::debug::SetInspector(m_isolate, this);
// Keep the default console silent; vconsole is injected separately.
}
4) 注入 vconsole.log(不可枚举)
文件:v8/src/inspector/v8-console.h/.cc
新增 createInspectorConsole(),返回仅含 log 的对象,并通过 DefineOwnProperty(..., v8::DontEnum) 保证不可枚举:
修改前:无 createInspectorConsole,也没有 vconsole 通道。
修改后:
// v8/src/inspector/v8-console.cc
v8::Local<v8::Object> V8Console::createInspectorConsole(
v8::Local<v8::Context> context) {
v8::Isolate* isolate = context->GetIsolate();
v8::Local<v8::Object> vconsole = v8::Object::New(isolate);
v8::Local<v8::ArrayBuffer> data =
v8::ArrayBuffer::New(isolate, sizeof(CommandLineAPIData));
*static_cast<CommandLineAPIData*>(data->GetBackingStore()->Data()) =
CommandLineAPIData(this, 0);
createNonEnumerableBoundFunctionProperty(
context, vconsole, data, "log",
&V8Console::call<&V8Console::Log>);
return vconsole;
}
5) 全局注入 vconsole
文件:v8/src/inspector/v8-inspector-impl.cc
在 contextCreated() 中挂到全局,并设置 DontEnum:
修改前(片段):
// v8/src/inspector/v8-inspector-impl.cc
DCHECK(contextById->find(contextId) == contextById->cend());
(*contextById)[contextId].reset(context);
forEachSession(
info.contextGroupId, [&context](V8InspectorSessionImpl* session) {
session->runtimeAgent()->addBindings(context);
session->runtimeAgent()->reportExecutionContextCreated(context);
});
修改后:
// v8/src/inspector/v8-inspector-impl.cc
{
v8::HandleScope handle_scope(m_isolate);
v8::Context::Scope context_scope(info.context);
v8::Local<v8::Object> vconsole =
console()->createInspectorConsole(info.context);
v8::Local<v8::String> name =
toV8StringInternalized(m_isolate, "vconsole");
v8::Maybe<bool> did_define = info.context->Global()->DefineOwnProperty(
info.context, name, vconsole, v8::DontEnum);
USE(did_define);
}
行为变化与验证
debugger 与 vdebugger:
debugger;:语法合法但不暂停。vdebugger;:仅在“独立语句形态”触发断点。- 其他上下文的
vdebugger仍是普通标识符。
console 与 vconsole:
console.*:默认静默(不向 DevTools 输出);但仍可能触发格式化占位符的类型转换副作用(与 DevTools 是否打开无关)。vconsole.log(...):可在 DevTools Console 中看到输出。vconsole与vconsole.log均不可枚举,但可被反射 API 发现。
无限debugger 失效:

boss控制台检测失效:


352

被折叠的 条评论
为什么被折叠?



