各位观众,大家好!我是你们今天的反调试讲师,江湖人称“代码猎人”。今天咱们不聊虚头巴脑的理论,直接上干货,聊聊那些让调试器头疼的反调试技巧。
咱们今天的议题是:如何像福尔摩斯一样,揪出那些偷偷摸摸的调试器!主要聚焦在debugger语句、console.log重写和Timing Attacks这三个方面。
一、Debugger 语句:暗藏杀机的陷阱
debugger
语句,听起来很无辜,但它可是反调试的一大利器。当你代码里埋下debugger
,一旦调试器运行到这里,程序就会自动中断。
1.1 简单粗暴的debugger
检测
最简单的反调试方法,就是检测debugger
语句的存在。但是直接搜索字符串“debugger”太low了,容易被绕过。我们要玩点高级的。
function checkDebugger() {
try {
eval('debugger;'); // 尝试执行debugger语句
return true; // 如果没有触发调试器,说明可能没有调试器
} catch (e) {
return false; // 如果触发异常,说明可能存在调试器
}
}
if (checkDebugger()) {
console.log("检测到可能没有调试器!");
// 执行一些反调试的操作,比如:
// 1. 修改关键变量的值
// 2. 进入死循环
// 3. 抛出异常
} else {
console.log("检测到有调试器!");
// 正常运行,或者做一些迷惑调试器的操作
}
原理:
eval('debugger;')
:尝试执行debugger
语句。- 有调试器: 调试器会捕获
debugger
语句,程序中断,不会抛出异常。 - 无调试器: JavaScript引擎会忽略
debugger
语句,继续执行,try...catch
块不会捕获任何异常。
优点: 简单易懂,容易实现。
缺点: 容易被绕过,比如通过修改eval
函数或者直接注释掉debugger
语句。
1.2 高级一点的:函数调用栈分析
我们可以利用JavaScript的错误处理机制,结合函数调用栈,来判断是否命中了debugger
语句。
function checkDebuggerAdvanced() {
let result = false;
try {
throw new Error('Test'); // 抛出一个错误
} catch (e) {
// 分析错误堆栈信息
const stack = e.stack;
if (stack.includes('debugger eval code')) {
result = true; // 堆栈信息中包含 'debugger eval code',说明命中了debugger语句
}
}
return result;
}
if (checkDebuggerAdvanced()) {
console.log("高级检测:检测到有调试器!");
// 执行反调试操作
} else {
console.log("高级检测:可能没有调试器!");
// 正常运行
}
原理:
- 抛出一个
Error
对象,并捕获它。 - 获取
Error
对象的stack
属性,它包含了函数调用栈的信息。 - 如果调用栈中包含
'debugger eval code'
,说明程序执行到了debugger
语句。
优点: 比简单的debugger
检测更难绕过,因为即使注释掉debugger
语句,也可能通过其他方式触发调试器。
缺点: 依赖于浏览器的实现细节,不同的浏览器可能返回不同的堆栈信息。
1.3 更隐蔽的:时间差检测
当debugger
语句被触发时,程序会暂停执行。我们可以利用这个时间差来检测调试器的存在。
function checkDebuggerTime() {
let startTime = new Date().getTime();
debugger; // 触发debugger语句
let endTime = new Date().getTime();
let duration = endTime - startTime;
const threshold = 100; // 设定一个时间阈值,单位是毫秒
if (duration > threshold) {
return true; // 如果执行时间超过阈值,说明可能存在调试器
} else {
return false; // 如果执行时间小于阈值,说明可能没有调试器
}
}
if (checkDebuggerTime()) {
console.log("时间检测:检测到有调试器!");
// 执行反调试操作
} else {
console.log("时间检测:可能没有调试器!");
// 正常运行
}
原理:
- 记录执行
debugger
语句前后的时间。 - 计算时间差。
- 如果时间差超过预设的阈值,则认为存在调试器。
优点: 隐蔽性强,不容易被静态分析检测到。
缺点: 受机器性能影响较大,阈值的设定需要根据实际情况调整。容易出现误判。
二、Console.log 重写:瞒天过海的障眼法
console.log
是调试的常用工具,但它也可以成为反调试的帮凶。通过重写console.log
,我们可以阻止调试器输出信息,或者输出一些迷惑性的信息。
2.1 阻止 console.log
输出
最简单的方法,就是直接把console.log
设置为空函数。
console.log = function() {}; // 直接覆盖console.log
原理:
- 将
console.log
指向一个空函数,这样任何对console.log
的调用都不会产生任何输出。
缺点: 太明显了,容易被发现。
2.2 输出迷惑性的信息
我们可以让console.log
输出一些假的信息,混淆调试器的视线。
let originalLog = console.log; // 保存原始的console.log
console.log = function(message) {
originalLog.apply(console, ["Fake Log: " + message]); // 输出带有"Fake Log"前缀的信息
};
原理:
- 保存原始的
console.log
函数。 - 重写
console.log
函数,使其在输出信息前添加一个前缀。
优点: 可以迷惑调试器,使其难以区分真实信息和虚假信息。
缺点: 容易被发现,因为所有输出都带有相同的前缀。
2.3 更高级的:条件性重写
我们可以根据一些条件,来决定是否重写console.log
。比如,只有在检测到调试器存在时,才重写console.log
。
function checkDebugger() {
// 假设这里有一个检测调试器的函数
return (window.devtools && window.devtools.open); //简化版,真实情况需要更复杂的检测逻辑
}
if (checkDebugger()) {
let originalLog = console.log;
console.log = function(message) {
// 只在检测到调试器时才重写console.log
originalLog.apply(console, ["DEBUGGER DETECTED: " + message]);
};
}
原理:
- 首先检测是否存在调试器。
- 如果存在调试器,则重写
console.log
函数,否则保持不变。
优点: 可以避免在没有调试器的情况下,影响程序的正常输出。
缺点: 依赖于调试器检测的准确性。
2.4 检测 console.log 是否被重写
既然我们可以重写 console.log
,那自然也可以检测它是否被重写了。
function isConsoleOverridden() {
let originalLog = console.log;
console.log("Test message"); // 输出一条测试信息
// 检查console.log是否仍然是原始函数
if (console.log === originalLog) {
console.log("恢复console.log");
return false; // 没有被重写
} else {
console.log = originalLog; // 恢复console.log
console.log("恢复console.log");
return true; // 被重写了
}
}
if (isConsoleOverridden()) {
console.log("console.log 被重写了!");
// 执行相应的反调试操作
} else {
console.log("console.log 没有被重写.");
}
原理:
- 保存原始的
console.log
函数。 - 输出一条测试信息。
- 比较当前的
console.log
函数和原始的函数是否相同。 - 如果不同,则说明
console.log
被重写了。 - 重要: 记得恢复
console.log
函数,避免影响后续的调试。
三、Timing Attacks (时间攻击):细微之处见真章
时间攻击是一种利用程序执行时间差异来推断程序内部状态的技术。在反调试中,我们可以利用时间攻击来检测调试器的存在。
3.1 简单的时间攻击
我们可以测量一段代码的执行时间,如果在调试器环境下,这段代码的执行时间会明显增加。
function checkDebuggerTimingSimple() {
let startTime = new Date().getTime();
let sum = 0;
for (let i = 0; i < 1000000; i++) {
sum += i;
}
let endTime = new Date().getTime();
let duration = endTime - startTime;
const threshold = 500; // 设定一个时间阈值,单位是毫秒
if (duration > threshold) {
return true; // 如果执行时间超过阈值,说明可能存在调试器
} else {
return false; // 如果执行时间小于阈值,说明可能没有调试器
}
}
if (checkDebuggerTimingSimple()) {
console.log("简单时间攻击:检测到可能存在调试器!");
// 执行反调试操作
} else {
console.log("简单时间攻击:可能没有调试器!");
// 正常运行
}
原理:
- 测量一段密集计算的代码的执行时间。
- 如果在调试器环境下,由于调试器的介入,这段代码的执行时间会明显增加。
优点: 简单易懂,容易实现。
缺点: 受机器性能影响较大,阈值的设定需要根据实际情况调整。容易出现误判。
3.2 更精确的时间攻击
为了提高时间攻击的准确性,我们可以使用高精度计时器,并进行多次测量,取平均值。
function checkDebuggerTimingPrecise() {
const iterations = 10; // 测量次数
let totalDuration = 0;
for (let i = 0; i < iterations; i++) {
let startTime = performance.now(); // 使用高精度计时器
let sum = 0;
for (let j = 0; j < 100000; j++) {
sum += j;
}
let endTime = performance.now();
totalDuration += (endTime - startTime);
}
let averageDuration = totalDuration / iterations;
const threshold = 5; // 设定一个时间阈值,单位是毫秒
if (averageDuration > threshold) {
return true; // 如果平均执行时间超过阈值,说明可能存在调试器
} else {
return false; // 如果平均执行时间小于阈值,说明可能没有调试器
}
}
if (checkDebuggerTimingPrecise()) {
console.log("精确时间攻击:检测到可能存在调试器!");
// 执行反调试操作
} else {
console.log("精确时间攻击:可能没有调试器!");
// 正常运行
}
原理:
- 使用
performance.now()
高精度计时器。 - 多次测量代码的执行时间,并计算平均值。
- 通过多次测量,可以减少误差,提高准确性。
优点: 比简单的时间攻击更准确。
缺点: 仍然受机器性能影响,阈值的设定需要根据实际情况调整。
3.3 利用特定 API 的时间差
有些API在调试器环境下执行速度会变慢,我们可以利用这些API的时间差来检测调试器。例如,Date.now()
在某些调试器环境下会受到影响。
function checkDebuggerTimingAPI() {
let startTime = Date.now();
// 执行一些操作,例如访问本地存储
localStorage.setItem('testKey', 'testValue');
localStorage.removeItem('testKey');
let endTime = Date.now();
let duration = endTime - startTime;
const threshold = 2; // 设定时间阈值,单位毫秒
if(duration > threshold) {
return true; // 怀疑存在调试器
} else {
return false; // 正常环境
}
}
if (checkDebuggerTimingAPI()) {
console.log("API时间攻击:检测到可能存在调试器!");
// 执行反调试操作
} else {
console.log("API时间攻击:可能没有调试器!");
// 正常运行
}
原理:
- 某些API在调试模式下性能会受到影响,导致执行时间增加。
- 通过测量这些API的执行时间,可以判断是否存在调试器。
- 访问本地存储是一个示例,其他可能受到影响的API包括网络请求、DOM操作等。
优点:
- 利用了调试器对特定API的影响,更具针对性。
缺点:
- 依赖于调试器的具体实现,不同的调试器可能影响不同的API。
- 需要仔细选择用于测量的API,并设置合适的阈值。
总结:反调试的攻防之道
反调试是一场永无止境的攻防游戏。攻击者不断寻找新的调试方法,防御者也不断开发新的反调试技术。
技术点 | 优点 | 缺点 | 绕过方法 |
---|---|---|---|
Debugger 语句检测 | 简单易懂,容易实现。 | 容易被绕过,比如通过修改eval 函数或者直接注释掉debugger 语句。 |
1. 注释掉debugger 语句。2. 修改 eval 函数,使其不执行debugger 语句。3. 使用动态注入的方式,在运行时删除 debugger 语句。 |
Console.log 重写 | 可以阻止调试器输出信息,或者输出一些迷惑性的信息。 | 容易被发现,比如通过检测console.log 是否被重写,或者通过观察输出信息是否异常。 |
1. 恢复原始的console.log 函数。2. 使用其他方式输出调试信息,比如自定义的日志函数。 3. 使用浏览器提供的调试工具,比如开发者工具的Network面板。 |
Timing Attacks (时间攻击) | 隐蔽性强,不容易被静态分析检测到。 | 受机器性能影响较大,阈值的设定需要根据实际情况调整。容易出现误判。 | 1. 使用虚拟机或者模拟器,降低机器性能的影响。 2. 禁用调试器的断点功能,减少调试器的介入。 3. 优化代码,减少执行时间。 |
反调试的原则:
- 隐蔽性: 尽可能地隐藏反调试代码,避免被静态分析检测到。
- 适应性: 能够适应不同的调试器和环境。
- 可维护性: 方便修改和维护。
记住,反调试不是目的,而是手段。目的是保护你的代码,防止被恶意篡改和破解。
今天的讲座就到这里,希望大家有所收获!咱们下次再见!