Node Js Callback trap

本文探讨了Node.js中常见的回调陷阱,即回调嵌套问题,并介绍了几种解决方法,包括递归、第三方库如Async和Promise,以及使用ES6 Generator特性来优雅地处理异步流程。

前言|nodeJs 回调陷阱是一种回调嵌套现象,给调试代码阅读造成困扰,利用Promise 可以并列书写回调方式

Nodejs最大的亮点就在于事件驱动, 非阻塞I/O 模型,这使得Nodejs具有很强的并发处理能力,非常适合编写网络应用。在Nodejs中大部分的I/O操作几乎都是异步的,也就是我们处理I/O的操作结果基本上都需要在回调函数中处理,比如下面的这个读取文件内容的函数:

复制代码代码如下:

fs.readFile('/etc/passwd', function (err, data) {
  if (err) throw err;
  console.log(data);
});

那,我们读取两个文件,将这两个文件的内容合并到一起处理怎么办呢?大多数接触js不久的人可能会这么干:

复制代码代码如下:

fs.readFile('/etc/passwd', function (err, data) {
  if (err) throw err;
  fs.readFile('/etc/passwd2', function (err, data2) {
    if (err) throw err;
    // 在这里处理data和data2的数据
  });
});

那要是处理多个类似的场景,岂不是回调函数一层层的嵌套啊,这就是大家常说的回调金字塔或回调地狱(http://callbackhell.com/)的问题,也是让js小白最为头疼的问题。

这种层层嵌套的代码给开发带来了很多问题,主要体现在:

1.代码可能性变差
2.调试困难
3.出现异常后难以排查

本文主要是介绍如何优雅的处理以上异步回调问题。

初级方案:通过递归处理异步回调

我们可以使用递归作为代码的执行控制工具。把需要执行的操作封装到一个函数中,在回调函数中通过递归调用控制代码的执行流程,废话不多说,上个代码吧:

复制代码代码如下:

var fs = require('fs');
// 要处理的文件列表
var files = ['file1', 'file2', 'file3'];

function parseFile () {
  if (files.length == 0) {
    return;
  }
  var file = files.shift();
  fs.readFile(file, function (err, data) {
    // 这里处理文件数据
    parseFile();  // 处理完毕后,通过递归调用处理下一个文件
  });
}

// 开始处理
parseFile();

以上代码已依次处理数组中的文件为例,介绍了通过递归的方式控制代码的执行流程。

应用到一些简单的场景中还是不错的,比如:我们将一个数组中的数据,依次保存到数据库中就可以采用这种方式。

通过递归的方式可以解决一些简单的异步回调问题。不过对于处理复杂的异步回调还是显得有些无能为力(如需要同步多个异步操作的结果)。

华丽点:采用Async、Q、Promise等第三方库处理异步回调

为了更好的处理嵌套回调的问题,可以考虑采用一些第三方专门处理异步的库,当然有能力的完全可以自己写个异步处理的辅助工具。

比较常用的处理异步的库有:async,q还有promise。从npmjs.org网站上来看,async的火热程度最高。以前用过async,确实也挺方便的,各种异步处理的控制流实现的也挺好。

我们将最初的同时读取两个文件的代码使用async处理下,示例如下:

复制代码代码如下:

var async = require('async')
  , fs = require('fs');

async.parallel([
  function(callback){
    fs.readFile('/etc/passwd', function (err, data) {
      if (err) callback(err);
      callback(null, data);
    });
  },
  function(callback){
    fs.readFile('/etc/passwd2', function (err, data2) {
      if (err) callback(err);
      callback(null, data2);
    });
  }
],
function(err, results){
  // 在这里处理data和data2的数据,每个文件的内容从results中获取
});

通过async模块,可以很好的控制异步的执行流程了,也算是解决了层层回调的问题,代码比以前算是清晰了些,不过依旧还是离不开回调函数。

想想如果能够在不使用回调函数的情况下,处理异步,岂不是很爽,接下来,我们谈谈使用ES6的新特性来实现这一目标。

优雅点:拥抱ES6,替代回调函数,解决回调地狱问题

话说EcmaScript Harmony (ES6)给js引入了不少新特性,对ES6不太了解的同学,可以自行百度一下。

在nodejs中使用ES6的新特性,需要用v0.11.x以上的版本才行。

本文介绍的是使用Generator特性替代回调函数,对Generator不了解?可以看看这里。

这里用到了co和thunkify两个模块,大家使用npm install命令安装之。

还是以本文刚开始提到的问题为例,使用generator特性的实例代码如下:

复制代码代码如下:

var fs = require('fs')
  , co = require('co')
  , thunkify = require('thunkify');

var readFile = thunkify(fs.readFile);

co(function *() {
  var test1 = yield readFile('test1.txt');
  var test2 = yield readFile('test2.txt');
  var test = test1.toString() + test2.toString();
  console.log(test);
})();

处理代码中的异常也是很简单的,只需要这样就OK了:

复制代码代码如下:

try {
  var test1 = yield readFile('test1.txt');
} catch (e) {
  // 在这里处理异常
}

这种代码是不是优雅很多了?像写同步代码一样处理异步,是不是很爽!

nodejs领域中进行Web开发,最火的框架莫过于express了,值得一提的是express的核心成员TJ大神有领导了一个新的Web框架——koa,宣称是下一代的Web开发框架,koa真是借助了ES6的generator这一特性,让我们在开发Web系统的时候避免陷入层层的回调用。

总结

目录 一、禁用 F12 / Ctrl+Shift+I / 打开控制台的按键绑定 二、无限debugger 2.1 概念 2.2 实现 三、控制台开启状态检测 3.1 基于窗口尺寸差异 3.2 基于执行时间差 3.3 性能分析 (需要做大量测试) 四、内存爆破 五、绕过调试拦截 调试拦截(Debugging Interception) 是一类前端防护手段,目的在于检测并干预通过浏览器常规调试工具(如 DevTools、Console、断点、脚本注入等)对页面 JavaScript 进行观测、分析或修改的行为。当检测到调试器被打开或调试行为发生时,页面会触发一系列响应:显示误导信息、降级功能、限制数据输出、或引导到验证码/挑战页,从而增加逆向与抓取的成本。 注意: 目的不是 "彻底阻止调试",而是 增加分析成本和时间消耗。 一、禁用 F12 / Ctrl+Shift+I / 打开控制台的按键绑定 注意:前端按键拦截很容易被用户关闭或绕过(例如禁用 JS)。适合作为体验层的缓冲,不要作为唯一手段。 // ps: 这些操作同样只能防君子不防小人 // 开发者可以在控制台直接移除这些事件监听器,所以不能作为真正安全的手段 document.addEventListener('keydown', function(e) { // F12 if (e.key === 'F12' || e.keyCode === 123) { e.preventDefault(); e.stopPropagation(); } // Ctrl+Shift+I / Ctrl+Shift+J / Ctrl+Shift+C if (e.ctrlKey && e.shiftKey && (e.key === 'I' || e.key === 'J' || e.key === 'C')) { e.preventDefault(); e.stopPropagation(); } // Ctrl+U (查看源码) 这个好像不行 // if (e.ctrlKey && e.key === 'U') { e.preventDefault(); } }); window.addEventListener('contextmenu', e => e.preventDefault()); // 禁右键菜单(可选) 一键获取完整项目代码 javascript 1 2 3 4 5 6 7 8 9 10 11 12 13 代码解释:addEventListener 方法: document.addEventListener('keydown', function(e) { ... }) 一键获取完整项目代码 javascript 1 addEventListener 是 DOM(文档对象模型)中用于 给元素绑定事件监听器 的方法。上面这行的意思是:给整个网页文档(document)注册一个 键盘按下事件监听器,当用户按下任意键时,就会执行后面的回调函数。语法通用形式: element.addEventListener(eventType, callback, useCapture) // ① element: 要绑定事件的元素(如 document、window、button 等) // ② eventType: 事件类型,如 'click'、'keydown'、'keyup'、'mousemove' // ③ callback: 当事件触发时要执行的函数 // ④ useCapture(可选): 是否使用捕获阶段,默认为 false(冒泡阶段) 一键获取完整项目代码 javascript 1 2 3 4 5 e 是什么?在回调函数里: function(e) { ... } 一键获取完整项目代码 1 这个 e 是事件对象(Event Object),浏览器在触发事件时会自动传入一个包含当前事件所有信息的对象。你可以理解成: "这次按键事件的详细记录"——包括按下了哪个键、是否同时按了 Ctrl/Shift、事件目标是谁等。举例打印看看: document.addEventListener('keydown', e => console.log(e)) 一键获取完整项目代码 javascript 1 按任意键后,你会看到浏览器控制台打印一个对象: 其中每个对象中包含: //我只列举了一部分: // ① key: 当前按下的键的名字(如 "F12"、"u"、"I") // ② keyCode: 对应键的数值编码(如 123 对应 F12) // ③ ctrlKey: 是否同时按下了 Ctrl 键(true / false) // ④ shiftKey: 是否同时按下了 Shift 键 // ⑤ altKey: 是否按下了 Alt 键 // ⑥ metaKey: 是否按下了 Mac 的 command 键 // ⑦ type: 事件类型(keydown / keyup) // 当你按下 F12: e.key // "F12" e.keyCode // 123 // 当你按下 Ctrl + U: e.ctrlKey // true e.key // "u" e.keyCode // 85 一键获取完整项目代码 javascript 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 e.preventDefault() 与 e.stopPropagation(): e.preventDefault(): 阻止浏览器默认行为 // ①阻止 Ctrl+U 打开"查看源码" ②阻止右键菜单 ③阻止链接跳转 ④阻止表单自动提交 意思是: 这次按键事件,不要执行浏览器原本绑定的默认动作 e.stopPropagation(): 阻止事件冒泡 事件在 DOM 结构中默认会从最内层元素往外层一层层传播(称为 "冒泡")。调用这个方法会阻止事件继续向上传递,防止触发其他监听器 意思是: 事件只在当前这一层处理,不要再传给上层元素 一键获取完整项目代码 javascript 1 2 3 4 5 6 7 优点:阻止一般用户误触与懒散的调试尝试。缺点:完全可绕过;对无障碍、正常用户体验有影响(右键禁用会影响拷贝、辅助功能),需慎用并给出替代交互(例如自定义菜单)。 二、无限debugger 2.1 概念 在 JavaScript 里,debugger 是一条专门用于 断点调试 的语句。当浏览器执行到这行代码时,如果开发者工具(DevTools)已经打开,那么执行会 立刻暂停,并跳转到调试界面。简单理解: console.log("A"); debugger; // 如果控制台打开,这里会停住 console.log("B"); 一键获取完整项目代码 javascript 1 2 3 如下图: 当浏览器的 开发者工具(DevTools)处于打开状态时,执行到 debugger 语句会触发断点,脚本暂停并等待调试操作。当开发者工具未打开时,debugger 语句 不会生效,浏览器会 直接跳过并继续执行后续代码。所以,新手常见疑问: "为什么我打开控制台就卡住了,不打开就没事?" 答案就是: debugger 只在调试模式下生效 一键获取完整项目代码 javascript 1 2 为什么 debugger 能 "干扰" 调试? 在正常开发中,debugger 语句用于手动设置断点以方便调试;但在 反爬或反调试场景 下,它却常被用作一种 "陷阱"。当浏览器的开发者工具(DevTools)被打开时,脚本执行到 debugger 会自动触发断点并暂停代码运行,每次都需人工点击 "恢复执行(Resume)"。若脚本中大量插入或循环触发 debugger,就形成了所谓的 “无限 debugger(debugger trap)”:页面会在 DevTools 打开状态下一次次地进入断点暂停,导致调试流程被不断打断、几乎无法顺利分析。利用这种机制,脚本能有效干扰逆向者的调试体验,大幅提升分析难度与时间成本。 2.2 实现 在实现 "无限debugger"之前,先理解浏览器中两个最常用的定时函数很重要:setTimeout 和 setInterval。 // ①: setTimeout(fn, delay, ...args) // 在至少 delay 毫秒后执行一次 fn(只执行一次)。返回一个定时器 id(可传给 clearTimeout 取消) const tid = setTimeout(() => console.log('one-shot'), 1000); // clearTimeout(tid); // 取消 // ② setInterval(fn, delay, ...args) // 每隔大约 delay 毫秒执行一次 fn,直到调用 clearInterval 停止。返回一个定时器 id。 const iid = setInterval(() => console.log('repeat'), 1000); clearInterval(iid); // 停止 // 主要区别(一句话) // setTimeout: 一次性延迟执行 // setInterval: 定期重复执行 一键获取完整项目代码 javascript 1 2 3 4 5 6 7 8 9 10 11 12 13 在浏览器中,最简单的无限 debugger 实现方式是: setInterval(() => { debugger; }, 100); 一键获取完整项目代码 javascript 1 然而,这种实现方式的反调试效果非常有限。分析者只需将鼠标定位在断点处,右击选择 "Never pause here",然后点击 "Resume script execution",即可绕过该无限 debugger,页面脚本将恢复正常执行。也就是说,简单的循环触发无法对熟悉 DevTools 的调试者造成实质阻碍。 变种示例: function debug(){ Function("debugger")() eval("debugger") } setInterval(debug, 500) 一键获取完整项目代码 java 1 2 3 4 5 Function("debugger")():动态构造并立即执行一个新的函数,其函数体是字符串 "debugger"。等价于动态创建 function(){ debugger }(),执行时如果 DevTools 打开会触发断点。eval("debugger"):通过 eval 执行字符串形式的 debugger,其效果同样是在运行时触发断点(前提是 DevTools 已打开)。 这两者都属于运行时动态生成并执行断点代码,放在 debug() 里并由 setInterval 周期性调用,从而达到 "周期性触发断点" 的效果。同理在 DevTools 的断点处右键 → Never pause here(针对具体断点),点击调试器的 Resume script execution(恢复执行)一样可以绕过该无限 "debugger"。用递归 setInterval + 随机间隔(避免规律性容易绕过)----- 抗 Never: const debugStr = "debugger"; // 生成唯一标识,用于 sourceURL(让 DevTools 为每次 eval 显示不同来源) function uniqueTag() { return 'dbg_' + Math.random().toString(36).slice(2) + '_' + Date.now(); } // 在执行 debugger 的同时附带独立 sourceURL(提高静态/来源混淆) function triggerDynamicDebugger() { const tag = uniqueTag(); // 注意: //# sourceURL=... 会在 DevTools 的 Sources 里显示为该名字 const src = `${debugStr};//# sourceURL=${tag}.js`; try { // new Function 与 indirect eval 同时尝试(增加触发面) (new Function(src))(); (0, eval)(src); // indirect eval,避免某些优化 } catch (e) { // 如果 eval/Function 被 CSP / 环境禁止,会抛异常 // 我们在上层会捕获并选择降级策略 } } setInterval(triggerDynamicDebugger, 500); 一键获取完整项目代码 javascript 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 通过给每次 eval 或 Function 生成独立的 sourceURL 并持续触发 debugger,每次断点在 DevTools 中都对应不同的来源标识。这使得分析者无法通过 Never pause here 一次性屏蔽所有断点,每次触发都必须单独处理,从而增加了调试绕过的成本和复杂度。拓展: DevTools 在内部管理断点时,主要依据三类信息: 脚本来源(Script Source) 如果是静态 JS 文件,就是 URL 或文件名。 如果是 <script> 标签或动态生成的代码(eval / Function),DevTools 会自动生成一个内部标识(内部 VM script ID)。 对于动态生成的代码,浏览器会给它分配一个内部标识(内部 VM 脚本 ID),或者你可以通过 //# sourceURL=xxx.js(不一定要是这种格式) 给它起一个 "伪名字",在 DevTools Sources 面板中显示。 行号 / 列号(Line/Column) 断点记录在特定脚本的某一行列位置。 调用帧(Call Frame) debugger 在特定的执行栈位置触发断点时,会记录当前调用帧信息,用于暂停调试。调用帧就是当前 debugger 被哪一条函数调用链触发的上下文信息。 为什么简单动态 debugger 仍然能被 Never pause here 绕过: 没有指定 sourceURL 的情况下,每次 eval / Function 生成的动态脚本,DevTools 可能复用同一个内部 VM 脚本 ID,尤其是在连续 eval / Function 执行的同一作用域下。 这意味着:虽然行号和调用帧可能不同,但 脚本来源(VM script ID)被复用,DevTools 内部会把之前设置的 "Never pause here" 记录在这个内部 ID 上。 因此,你选了 "Never pause here" 后,所有用同一内部 ID 执行的 debugger 语句都会被忽略,即便行号或调用帧不同。 换句话说,动态生成的代码如果没有独立 sourceURL,DevTools 会把它当成 “同一脚本”,就能被一次性屏蔽。对于 Function 或 eval 来说,每次传入的代码参数如果不同(上面说的理解为内部 VM script ID 不同),浏览器会把它当作 新的脚本来源(VM script) 来处理。因此,即便行号或调用帧相同,DevTools 也会认为这是不同的脚本,所以之前设置的 "Never pause here" 无法一次性屏蔽每次触发的 debugger。 在反调试或反爬场景中,debugger 不仅可以直接写在脚本中触发,还可以通过 DOM 节点动态注入 来触发。这种方式的特点是:每次触发都生成新的 <script> 元素,浏览器会认为这是一个全新的脚本来源,从而让 debugger 的触发更加难以被 DevTools 的 "Never pause here" 绕过。下面是一个示例,通过动态创建 <script> 标签并注入 debugger,每 500ms 触发一次: const debugStr = "debugger"; // 生成唯一标识,用于 sourceURL(让 DevTools 为每次 eval 显示不同来源) function uniqueTag() { return 'dbg_' + Math.random().toString(36).slice(2) + '_' + Date.now(); } function debug() { const tag = uniqueTag(); let script = document.createElement('script'); script.innerHTML = `${debugStr};//# sourceURL=${tag}.js`; document.head.appendChild(script); document.head.removeChild(script); } setInterval(debug, 500); 一键获取完整项目代码 javascript 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 除了直接在代码中写 debugger 或通过 DOM/eval 动态触发之外,还可以利用 AST(抽象语法树) 对源代码进行分析和操作:在程序的各类代码块(如函数体、循环体、条件分支等)中随机插入 DebuggerStatement。这种方式能够在源代码层面大规模生成 debugger 语句,从而对调试造成 "污染",显著增加人工分析和逆向的难度。AST 示例: const parser = require('@babel/parser'); const traverse = require('@babel/traverse').default; const generator = require('@babel/generator').default; const t = require('@babel/types'); const code = ` let a = 100; let b, c; let obj = { name: "amo", age: 18, add: function(a, b) { return a + b + 1000; }, mul: function mul(a, b) { return a * b + 1000; } }; obj.add(10, 20); obj.mul(5, 6); `; const insertCount = 20; const maxPerFunction = 5; // 解析 AST const ast = parser.parse(code, { sourceType: 'unambiguous' }); // 收集可插入语句体 const insertPoints = []; // 顶层和非函数体 const funcBodies = []; // 函数体数组 traverse(ast, { Program(path) { insertPoints.push(path.node.body); }, BlockStatement(path) { // 如果父节点是函数,不加入顶层 insertPoints if (path.parent.type !== 'FunctionDeclaration' && path.parent.type !== 'FunctionExpression' && path.parent.type !== 'ObjectMethod') { insertPoints.push(path.node.body); } }, ObjectMethod(path) { if (path.node.body && Array.isArray(path.node.body.body)) { funcBodies.push({ arr: path.node.body.body, count: 0 }); } }, ObjectProperty(path) { if (path.node.value && path.node.value.type === 'FunctionExpression' && path.node.value.body && Array.isArray(path.node.value.body.body)) { funcBodies.push({ arr: path.node.value.body.body, count: 0 }); } } }); // 先尝试在函数体内插入 debugger(限 maxPerFunction) let remaining = insertCount; funcBodies.forEach(f => { const toInsert = Math.min(maxPerFunction, remaining); for (let i = 0; i < toInsert; i++) { const arr = f.arr; const validPos = []; for (let j = 0; j <= arr.length; j++) { if (j === 0 || arr[j - 1].type !== 'ReturnStatement') validPos.push(j); } if (validPos.length === 0) continue; const pos = validPos[Math.floor(Math.random() * validPos.length)]; arr.splice(pos, 0, t.debuggerStatement()); f.count++; remaining--; } }); // 剩下的 debugger 插入顶层或其他非函数体 while (remaining > 0 && insertPoints.length > 0) { const idx = Math.floor(Math.random() * insertPoints.length); const arr = insertPoints[idx]; if (!Array.isArray(arr)) continue; const pos = arr.length === 0 ? 0 : Math.floor(Math.random() * (arr.length + 1)); arr.splice(pos, 0, t.debuggerStatement()); remaining--; } // 生成代码 const out = generator(ast, { compact: false, retainLines: true }).code; console.log(out); 一键获取完整项目代码 javascript 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 效果: 需要注意的是:既然 debugger 可以通过 Function 构造或 eval 动态生成,攻击者/分析者可以通过重写全局 Function、eval 或覆盖某些全局接口来尝试绕过这类防护。为提高抗扰性,可以通过 沿着原型链获取构造函数(例如 (function(){}).__proto__.constructor)或通过多条路径获取 Function,这样即便全局 Function 被污染,仍可能能得到原始构造器并触发断点。换言之,只要能够取得 Function 构造器(无论通过何种路径)并执行带有 debugger 的载荷,动态生成断点就可实现;正因为途径繁多,攻击者的调试与逆向成本也随之增加。小结: // ① 明文触发(最直接) 直接写出 debugger;,在 DevTools 打开时立即产生断点。 // 示例: debugger; // ② 轻度混淆(拼串 / eval) 把 debugger 作为字符串拼接或通过 eval 执行,躲避简单的静态扫描,但运行时效果等同于明文。 // 示例: eval('debug' + 'ger;'); // ③ 重度混淆(构造 + 调用链)——三要素玩法 // 将三项要素——(A)如何得到 Function 构造器 / eval(来源)、(B)debugger 字符串(载荷)、(C)如何执行/触发(call/apply/bind/直接调用)——组合起来,形成大量变体以增加分析难度。常见变体包括: // 通过 Function 构造并直接调用 Function('debugger')(); // 通过 call/apply/bind/constructor 动态触发 Function('debugger').call(); Function('debugger').apply(); Function('debugger').bind()(); Function.constructor('debugger').call('action'); funObj.constructor('debugger').call('action'); // 更复杂的取 constructor 链与 eval 嵌套 (function(){return !![];}['constructor']('debugger')['call']('action')); eval('(function (){}["constructor"]("debugger")["call"]("action"));'); //抽象出的 "三元素模型" // 把上面归纳成一个简单模型便于理解与讨论: // 1.来源(Source): 如何取得可构造/执行代码的入口,比如全局 Function / eval / obj.constructor / 原型链 // 2.载荷(Payload):实际要执行的字符串(通常包含 debugger) // 3.触发方式(Invoke):如何执行载荷:直接调用、.call()、.apply()、.bind() 等 // 4.基于这三元素,可以生成成百上千种变体来混淆静态/动态分析器 一键获取完整项目代码 javascript 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 三、控制台开启状态检测 3.1 基于窗口尺寸差异 浏览器的 开发者工具(DevTools)打开时,会占用一部分屏幕空间,通常会改变浏览器 可视窗口(window.innerWidth / window.innerHeight)与浏览器总窗口(window.outerWidth / window.outerHeight)的差值。 window.outerWidth / outerHeight:浏览器整个窗口的宽高,包括边框、滚动条和 DevTools 面板。 window.innerWidth / innerHeight:页面内容可视区域的宽高,不包括浏览器边框和 DevTools 面板。 示例代码: // 方法 A:基于窗口尺寸差异检测 DevTools 开启状态 function detectDevToolsSizeChange() { const threshold = 160; // 一般控制台面板的最小高度或宽度 const widthDiff = window.outerWidth - window.innerWidth; const heightDiff = window.outerHeight - window.innerHeight; if (widthDiff > threshold || heightDiff > threshold) { console.log("调试工具已打开"); return true; } else { console.log("调试工具未打开"); return false; } } // 每 500ms 检测一次 setInterval(detectDevToolsSizeChange, 500); 一键获取完整项目代码 javascript 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 设置阈值(一般 160px 左右,对应常见 DevTools 面板大小)。当差值超过阈值时,说明有额外空间被占用,很可能是 开发者工具已打开。优点:无需依赖特定浏览器 API,简单、轻量、兼容性好。缺点:对某些小屏幕或高分屏可能误报,用户调整窗口大小也可能触发,无法判断 DevTools 是否隐藏或在外部窗口打开(对 Docked(dock)的 DevTools 比较准确,但对 undocked/外部窗口或特制浏览器不可靠)。 3.2 基于执行时间差 这种检测方式通过 测量代码执行前后的时间差 来判断是否处于调试状态。核心思想是:如果某段代码在执行时耗时异常地长,就可能是被断点暂停或正在被调试。 function detectDevTools() { const start = Date.now(); // 模拟一段正常执行的关键业务逻辑 for (let i = 0; i < 1e6; i++) { Math.pow(i, 2); } const end = Date.now(); const duration = end - start; // 如果执行时间异常增长,说明可能被断点暂停或调试中 if (duration > 100) { console.warn("检测到调试工具可能处于打开状态"); triggerFakeBranch(); } else { console.log("正常执行"); } } // 被检测触发时执行的伪逻辑 function triggerFakeBranch() { console.log("进入伪逻辑分支: 返回错误数据,干扰分析者..."); // 可以设计为干扰逻辑,例如: // window.location.reload(); // 或者返回假的数据结果 } setInterval(detectDevTools, 1000); 一键获取完整项目代码 javascript 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 在代码执行前后分别记录时间戳,若期间 JS 执行被中断(例如打了断点或单步调试),浏览器主线程被挂起,但 Date.now() 依旧在走,所以,结束时间与开始时间的差值会 异常地大。这类检测并 不依赖 debugger; 语句,任何关键代码(例如加密计算、核心逻辑判断、数据生成等)都可以包裹进检测逻辑中,只要执行时间超过阈值,就说明执行被中断或调试器介入,此时就可以进入伪造分支逻辑,比如输出假数据或终止脚本运行。举例说明,分析者在调试过程中打了断点: for (let i = 0; i < 1e6; i++) { Math.pow(i, 2); } // 停在这里 // 此时脚本暂停执行,Date.now() 仍在走; // 恢复执行后检测时间差,就会触发“检测到调试”分支。例如: 返回错误数据、输出伪造结果、或执行混淆的逻辑,让分析陷入误导 一键获取完整项目代码 javascript 1 2 3 3.3 性能分析 (需要做大量测试) 性能分析类的检测思路是:通过 高精度时间测量(如 performance.now())对极轻量级、理应耗时很短的代码片段进行重复采样;当这些片段在短时间内出现异常延迟(远大于正常抖动范围)时,很可能说明脚本执行被人工暂停或被 DevTools/性能分析器介入。关键要点是: 微小工作 + 高精度计时:选取开销极小但可重复的工作,使用 performance.now() 做亚毫秒级测量; 大量采样与统计处理:多次采样并取中位数/去极值平均以降低误报; 阈值与环境校准:在目标机器/浏览器上做大量测试以确定合理阈值(不同设备差异很大); 多信号融合:单一时间检测容易被噪声影响,应与窗口尺寸、console 行为、断点探针等联合判断。 小结:这是一种 概率性 检测,需要大量采样与迭代调参,适合用作触发上报/降级的判断依据,而非唯一判定标准。为什么用 console.table / console.clear 作为实战信号? 在实际工程里,一个简单而有效的放大信号是 向控制台输出"重量级"内容 并测量耗时:当 DevTools 打开时,浏览器会为控制台输出做渲染与格式化,这比单纯的计算更耗时、更稳定地产生可测延迟。console.table 和 console.clear 常被用作这类检测的原因是: console.table(...):把数组/对象渲染为表格,DevTools 会进行额外解析和渲染,开销较大;在控制台打开时多次调用更容易产生明显延迟。 console.clear():在打开的控制台上会触发清理/重绘操作,配合大量输出可以放大延迟效果,并避免产生过多可见日志噪音。 因此,在性能分析检测流程里,常把高频/重负载的 console.table 输出 + clear 作为一个 易于放大差异的观测手段,并对其耗时做多次采样与统计判断来辅助判断 DevTools 是否打开。DevTools 性能检测常用的 console 方法对比: 方法 特征 / 原理 是否可用于检测 console.table() 输出表格数据,会触发 DevTools 对对象结构的解析与渲染;性能开销大。 极常用,延迟最明显。 console.clear() 清空控制台输出,DevTools 打开时会重绘 UI;未打开时几乎无影响。 常与 console.table 连用。 console.log() 输出字符串或对象,轻量级;若内容为复杂对象时 DevTools 需深度遍历。 需配合大对象输出才能体现差异。 console.dir() 打印对象的可枚举属性,DevTools 会构建可展开树结构。 对复杂对象延迟明显。 console.count() 计数输出,内部操作简单;性能差异不大。 基本不适合检测。 console.group() / console.groupEnd() 控制分组显示;当控制台打开时会涉及层级渲染。 有轻微差异,可作为辅助信号。 console.profile() / console.profileEnd() 启动/停止性能分析;DevTools 未打开时通常无效。 可直接检测性能面板是否启用。 console.time() / console.timeEnd() 测量时间,偏轻量;可与其他方法组合。 本身检测力弱,但可辅助测量耗时。 console.trace() 打印调用栈;DevTools 打开时解析、格式化堆栈信息。 可用于检测堆栈解析延迟。 console.warn() / console.error() 输出警告/错误;DevTools 打开时可能触发高亮、堆栈展示。 差异轻微,但可混合作为噪声信号。 示例代码: function detectConsoleDelay() { const start = performance.now(); for (let i = 0; i < 1000; i++) { console['table'](10, 10, 10, 10, 10, 10, 10); } console['clear'](); const duration = performance.now() - start; alert(duration.toFixed(2)); // 显示耗时(毫秒,保留两位小数) if (duration > 30) { // 阈值保持 30ms alert("调试工具已打开"); } else { alert("调试工具未打开"); } } setInterval(detectConsoleDelay, 1000); 一键获取完整项目代码 javascript 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 四、内存爆破 内存爆破 指攻击者通过持续分配/填充内存资源,试图耗尽目标进程或环境的可用内存,导致性能严重下降、页面/进程崩溃或拒绝服务。实现思路通常依赖两个要素:容器:用于累积数据(例如数组、对象、DOM 节点、缓存等);持续性:重复或持续地向容器写入数据,直至资源耗尽。 // 错误的爆破形式。chrome 会对于某些内容存在着一个限制 let a = [] while(1){ a.push(1) } //如何进行隐蔽的爆破 let a = [] for (let j = 0; j <= 500000; j++) { a[j] = [] function memory_blast() { a[j].push(1) } setInterval(memory_blast, 100) } 一键获取完整项目代码 javascript 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 注意: 在明确识别到第三方爬虫正在抓取我们站点并对外发起流量时,若我们以内存耗尽等主动破坏性手段对其进行反制,这种做法本质上构成对对方的攻击(即我们成为攻击者),并可能带来法律与道德风险。 有些逆向/分析流程的常见步骤是:把浏览器里抓到的 JS 拷到本地,使用 IDE 对代码进行格式化和断点调试。格式化会引入明显的语法层面差异(额外换行、缩进和注释位置变化等),这些差异可以作为 "被本地调试/格式化" 的 线索。实现思路:在关键函数处读取其源码(如 fn.toString()),统计换行、缩进或某些 minified 特征的丢失;当检测到源码与线上显著偏离时,可在严格限制和审慎控制下尝试 内存爆破(受控的内存压力探测),以增加本地调试难度并收集证据;务必保证该操作为受限采样且非破坏性,避免耗尽资源或影响正常服务。 // 模拟:未格式化的单行函数(像从页面抓下来的压缩代码) const toDetect_min = function(a,b){return a*b+1}; // 模拟:本地格式化后的版本(多行) function toDetect_pretty(a, b) { // 这是格式化后会看到的多行/缩进形式 const prod = a * b; return prod + 1; } // 判断函数是否被格式化(简单启发式) function isFormatted(fn) { const src = fn.toString(); const newlineCount = (src.match(/\n/g) || []).length; const hasIndent = /(^|\n)[ \t]{2,}\S/m.test(src); // 是否有缩进 return { name: fn.name || '(anonymous)', newlineCount, hasIndent, srcPreview: src.length > 200 ? src.slice(0, 200) + '...': src }; } // 在浏览器中测试 console.log('浏览器环境:'); console.log('未格式化 ->', isFormatted(toDetect_min)); console.log('格式化 ->', isFormatted(toDetect_pretty)); // 另外演示:如果你从页面复制一个已格式化的函数文本并 eval 回来,仍然能检测到换行 const copiedPretty = `(function(x,y){ // 开发者在本地格式化后会像这样 const s = x + y; return s * 2; })`; const fnFromCopied = eval(copiedPretty); console.log('eval 后的格式化函数 ->', isFormatted(fnFromCopied)); 一键获取完整项目代码 javascript 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 通过使用一些特殊字符进行混淆,可以防止代码被格式化或直接复制,从而增加调试和分析的难度。如下图: 如何生成的: let fs = require("fs") !function () { let var_local_use = [] for (let i = 67000; i <= 67800; i++) { try { eval(`var a = "${String.fromCharCode(i)}";`) var_local_use.push(i) } catch (e) { } } let code = "" for (let i of var_local_use) { code += `var a = "${String.fromCharCode(i)}"; /* ${i} */` } fs.writeFileSync("result.js", code, {encoding: "utf-8"}) }() 一键获取完整项目代码 javascript 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 再看: let fs = require("fs") !function () { let var_local_use = [] for (let i = 67000; i <= 67800; i++) { try { eval(`var ${String.fromCharCode(i)} = ${i};`) var_local_use.push(i) } catch (e) { } } ; let code = "" for (let i of var_local_use) { code += `var ${String.fromCharCode(Number(i))} = ${Number(i)};` + "\n" } fs.writeFileSync("result.js", code, {encoding: "utf-8"}) }() 一键获取完整项目代码 javascript 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 Node.js 中执行结果: 将执行结果复制到浏览器控制台中进行调试,并插入 debugger,就像执行普通的 JS 代码一样。然而,你会发现浏览器中看到的源码格式会变成如下样式。我怀疑这是复制过程导致的差异,但不打算深入研究,就保持这样吧。如果在运行时把这段代码包装为函数并调用其 toString(),两者字符串表示通常不一致——这一差异可作为检测复制/粘贴或调试行为的可靠线索。基于此,可以选择在不匹配时触发错误分支、引入 "内容爆破"(memory blow-up)或其他误导性逻辑,从而显著增加逆向分析与调试难度。 举例: //浏览器中生成: // U+0623 是 ARABIC LETTER ALEF WITH HAMZA ABOVE('أ') const name = String.fromCodePoint(0x0623); console.log('var ' + name + ' = 67107;') 一键获取完整项目代码 javascript 1 2 3 4 执行结果: 复制到本地: 五、绕过调试拦截 参考文章:https://blog.youkuaiyun.com/xw1680/article/details/138547184?spm=1011.2415.3001.5331 ———————————————— 版权声明:本文为优快云博主「棒棒编程修炼场」的原创文章,遵循CC 4.0 BY-SA版权协议,转载请附上原文出处链接及本声明。 原文链接:https://blog.youkuaiyun.com/xw1680/article/details/153742917
最新发布
11-07
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值