1. 项目概述:当JS成为攻防主战场
在Web安全领域,前端JavaScript代码早已不是简单的交互点缀,而是承载着核心业务逻辑、加密算法、用户凭证和敏感接口的关键载体。因此,针对JS应用的攻防对抗,其激烈程度丝毫不亚于后端。标题中的“Day64”暗示了一个持续性的学习或实战系列,而“WEB攻防-JS应用&反调试分析&代码混淆&AST加密还原&本地覆盖&断点条件”则精准地勾勒出了一幅从攻击者(或安全研究者)视角出发,对前端JS防线进行层层剖析的路线图。这不仅仅是简单的代码阅读,更是一场在浏览器这个“沙箱”环境中,与开发者布下的重重迷雾进行的智力博弈。
核心目标非常明确:理解、穿透并最终控制目标JS应用的执行逻辑。无论是为了安全审计、漏洞挖掘,还是逆向分析某个加密参数的生成本质,我们都需要一套系统的方法来应对现代前端开发中常见的防护手段——反调试、代码混淆、AST(抽象语法树)层级的加密变换。同时,作为攻击方,我们也要掌握“本地覆盖”和“条件断点”这类动态调试技巧,以便在运行时精准拦截和修改程序状态。简单来说,这就是一场“矛”与“盾”的较量,我们既要学会打造坚固的“盾”(理解防御原理),更要精通如何锻造锋利的“矛”(掌握攻击与逆向技术)。
2. 核心防御技术拆解:开发者如何保护JS代码
在深入攻击技术之前,我们必须先理解防御者构筑的工事。现代前端,尤其是涉及敏感操作(如登录、支付、数据加密)的页面,通常会采用多种技术组合来增加代码分析的难度。
2.1 代码混淆:让代码“面目全非”
代码混淆是最基础、最广泛使用的防护手段。它的目的不是防止执行,而是极大增加人工阅读和理解的难度。混淆器会对代码进行各种语义等价但结构复杂的变换。
常见混淆类型:
- 标识符混淆:将有意义的变量名、函数名替换为短而无意义的字符,如
a,b,_0x1a2b3c。// 混淆前 function calculateTotalPrice(price, quantity) { return price * quantity; } // 混淆后 function a(b, c) { return b * c; } - 字符串混淆:将字符串常量进行加密或拆分,在运行时还原。
// 混淆前 var apiEndpoint = "https://api.example.com/login"; // 混淆后 var apiEndpoint = atob("aHR0cHM6Ly9hcGkuZXhhbXBsZS5jb20vbG9naW4="); // Base64解码 // 或更复杂的拆分、Unicode转义 var _0x5c38=['\x68\x74\x74\x70\x73\x3a\x2f\x2f','\x61\x70\x69','\x2e\x65\x78']; // 十六进制转义数组拼接 - 控制流平坦化:这是“大杀器”之一。它打破代码原有的线性或分支结构,将所有代码块放到一个大的
switch或if-else调度器中,通过一个“分发器”变量来决定下一个执行哪块代码。这使得即便去除了变量混淆,代码的执行流程依然像一团乱麻,难以追踪。 - 死代码注入与不透明谓词:插入大量永远不会被执行到的代码片段,以及条件永远为真或为假的判断语句,干扰分析者的视线。
注意:混淆会显著增加代码体积和执行开销,因此需要在安全性和性能之间权衡。高强度的混淆可能影响页面加载速度。
2.2 反调试:阻止开发者工具窥探
如果说混淆是让代码“难看懂”,那么反调试就是让代码“难被调试”。它的核心是检测浏览器开发者工具(DevTools)的存在或状态,并采取对抗措施。
常见反调试技巧:
- 检测DevTools打开状态:
debugger语句循环:在关键代码前插入debugger;语句,并配合setInterval循环触发,导致调试器不断暂停,无法正常执行。setInterval(function(){ debugger; }, 100);- 检测控制台API:尝试重写
console.log或检测console对象与默认对象的差异。 - 检测窗口大小:DevTools打开时,浏览器窗口的可用内宽/内高(
window.innerWidth/Height)或外宽/外高(window.outerWidth/Height)会有特定变化,代码可以监听resize事件进行检测。
- 干扰断点:
- 通过
Function.prototype.constructor动态生成函数,使断点难以设置在正确的行号上。 - 使用
eval或new Function执行核心代码,这些动态执行的代码在Sources面板中不易定位。
- 通过
- 时间差检测:在代码关键路径前后记录时间戳。在调试模式下,单步执行会导致代码运行时间远超正常值,从而触发反调试逻辑(如跳转到错误页面、清空数据)。
const start = performance.now(); // ... 一些复杂的计算或加密操作 ... const end = performance.now(); if (end - start > 1000) { // 假设正常执行应小于1秒 console.error('检测到调试行为!'); window.location.href = '/anti-debug.html'; }
2.3 AST加密与代码变换:更高级的“改头换面”
这是近年来更高级的防护方式,其核心在于操作JavaScript代码的抽象语法树(AST)。AST是代码的树状结构表示,包含了所有的语法信息。通过对AST进行自定义的遍历、修改和重组,可以实现比传统字符串替换式混淆更复杂、更难以还原的变换。
AST加密的常见操作:
- 常量折叠与展开:将简单的计算(如
1+2)在AST层面替换为结果3,或者反向将常量展开为复杂的表达式。 - 控制流变换:在AST层面实现更彻底的控制流平坦化,插入不透明谓词。
- 自定义编码:将字符串、数字等字面量,在AST生成阶段就转换为自定义的编码函数调用,在运行时解码。
- 代码结构打乱:随机调整函数声明、变量声明的顺序,但保持其作用域和依赖关系。
这类防护通常需要使用专门的工具(如Babel插件、UglifyJS插件等)在构建阶段完成。还原的难度极大,因为变换规则是自定义的,且可能多层嵌套。
3. 攻击与逆向技术:穿透防御的利刃
面对重重防御,安全研究人员或攻击者需要一套组合拳。我们的目标是在浏览器环境中,动态地观察、干预并最终理解代码逻辑。
3.1 动态调试基础:控制执行流
这是所有逆向分析的起点,核心工具就是浏览器自带的开发者工具(Chrome DevTools / Firefox Developer Tools)。
关键面板与操作:
- Sources面板:核心战场。在这里可以查看加载的JS文件,设置断点。
- Console面板:执行任意JS代码,查看变量,与页面上下文交互。这是执行“本地覆盖”和探测环境的主要窗口。
- Network面板:监控所有网络请求,查看请求参数、响应数据,是分析加密接口的入口。
- 断点类型:
- 行断点:最常用,点击行号即可设置。
- 条件断点:右键行断点,选择“Edit breakpoint”,可以输入一个JS表达式。只有当表达式为真时,断点才会暂停。这是过滤无关执行、精准定位关键代码的神器。例如,在遍历数组的函数中,可以设置条件
item.id === 'target'。 - 事件监听器断点:在Sources面板右侧的“Event Listener Breakpoints”中,可以勾选特定事件(如click, XHR),当这些事件被触发时自动暂停。
- DOM断点:在Elements面板,右键DOM节点,可以设置当其子树修改、属性修改或节点移除时暂停。
- 函数断点:在Sources面板右侧的“Watch”下方,点击“+”,输入函数名(如
encryptData)即可对该函数的任何调用设置断点。
3.2 本地覆盖:实时修改代码逻辑
这是动态分析中极具威力的一招。它允许你在不刷新页面的情况下,用你自己修改后的JS文件替换掉服务器加载的原始文件。这样,你可以插入日志、绕过检测、或者修复错误以继续执行流程。
操作步骤(以Chrome为例):
- 在Sources面板,找到目标JS文件。
- 在文件内容区域右键,选择“Save for overrides”。
- 浏览器会提示你选择一个本地文件夹作为“工作区”。
- 之后,该文件会出现在左侧的“Overrides”选项卡下。你可以直接在这个面板里编辑代码,编辑后按
Ctrl+S保存。 - 刷新页面,浏览器将加载你本地修改后的版本,而不是网络版本。
实战应用场景:
- 绕过反调试:找到检测DevTools的代码段,直接将其注释掉或修改为
false。 - 注入日志:在关键的加密函数入口和出口,添加
console.log,打印出输入参数、中间变量和最终结果。 - 劫持函数:重写某个关键函数,例如
XMLHttpRequest.prototype.send或fetch,以拦截和查看所有网络请求的详细数据。 - 修复错误继续执行:有时代码因环境差异报错,导致流程中断。你可以本地修改代码,跳过错误部分,让流程继续走下去,以便观察后续逻辑。
实操心得:使用Overrides时,务必注意浏览器缓存。有时修改未生效,可能是缓存所致。可以打开DevTools的“Network”面板,勾选“Disable cache”。另外,Overrides是基于文件URL的,如果文件带哈希(如
app.a1b2c3.js),那么哈希变化后覆盖会失效。
3.3 断点条件与单步调试技巧
设置断点后,如何高效地跟踪代码执行是门学问。
单步执行控制:
- F10 (Step Over):执行当前行,如果该行有函数调用,不会进入函数内部,直接得到结果。
- F11 (Step Into):执行当前行,如果该行有函数调用,会进入该函数内部。
- Shift+F11 (Step Out):快速执行完当前函数内剩余的所有代码,并跳出到调用该函数的位置。
- F8 (Continue):继续执行,直到遇到下一个断点。
作用域与调用栈观察:
- 在右侧的“Scope”窗格,可以查看当前断点处的局部作用域、闭包作用域和全局作用域中的所有变量值。
- 在“Call Stack”窗格,可以看到函数调用链,点击可以跳转到对应的上下文中,这对于理解代码执行路径至关重要。
条件断点的高级用法:
- 基于调用栈的条件:例如,你只想在某个函数被特定父函数调用时才暂停。可以在条件中使用
console.trace()先查看调用栈,然后提炼条件。 - 基于数据特征的条件:分析加密请求时,发现参数
data是一个长字符串。可以设置条件断点:data.length > 100,这样只有在处理大型加密数据时才会暂停,过滤掉大量无关的初始化调用。
- 基于调用栈的条件:例如,你只想在某个函数被特定父函数调用时才暂停。可以在条件中使用
3.4 对抗反调试策略
遇到反调试时,不能硬碰硬,需要一些技巧来绕过。
禁用无限Debugger循环:
- 方法一(推荐):在遇到
debugger语句暂停时,在Sources面板右侧,找到该行对应的行号,右键选择“Never pause here”。这样,这个特定的debugger语句就会被忽略。 - 方法二:在Console中执行以下代码,重写
debugger关键字(注意,这可能会影响页面正常功能):Function.prototype.constructor = function() {}; // 或者更彻底地,禁用所有debugger语句的执行 var _debugger = debugger; debugger = function(){}; - 方法三:在DevTools设置中,找到“Debugger”或“Sources”相关选项,勾选“Deactivate breakpoints”(停用断点)按钮,然后再触发页面逻辑。但这样你自己的断点也会失效。
- 方法一(推荐):在遇到
绕过时间差检测:
- 找到检测时间的代码,通过本地覆盖直接注释或修改判断条件。
- 更隐蔽的方法是,在Console中重写
performance.now或Date.now,使其返回固定的或受控的时间值。const originalNow = performance.now; let timeOffset = 0; performance.now = function() { return originalNow.call(performance) - timeOffset; }; // 当检测到可能进入时间检测代码段时,在Console中增大timeOffset来“压缩”感知到的时间差 // timeOffset += 2000; // 让时间快进2秒
隐身模式与无头浏览器:
- 有些反调试仅针对普通窗口。使用Chrome的“无头模式”或通过Puppeteer、Playwright等自动化工具启动浏览器,有时可以绕过简单的检测。
- 在DevTools中,按
Ctrl+Shift+P打开命令面板,输入disable javascript并执行,可以临时禁用所有JS执行,快速进入页面,然后再启用,有时能打乱检测节奏。
4. 代码还原实战:从混淆到可读
动态调试让我们能看到运行时的数据,但要彻底理解算法,往往需要静态分析一份相对清晰的代码。这就涉及到对混淆代码的还原。
4.1 自动化反混淆工具
对于常见的、已知的混淆器(如obfuscator.io, javascript-obfuscator等),可以尝试使用现成的反混淆工具或插件。
- 浏览器插件:如“Deobfuscator”、“JavaScript Deobfuscator”等,可以一键尝试还原某些格式的混淆代码。
- 在线网站:一些网站提供粘贴混淆代码并还原的服务,但需注意代码安全性,敏感代码勿上传。
- Node.js工具:如
deobfuscator、js-beautify(主要美化格式)等。js-beautify是基础,可以格式化压缩后的代码,使其有缩进和换行,极大提升可读性。npm install -g js-beautify js-beautify ugly.js -o pretty.js
4.2 手动分析与还原策略
当自动化工具失效时(尤其是面对自定义的AST加密),就需要手动分析。
- 格式化代码:使用
js-beautify或DevTools的“Pretty print”功能(Sources面板底部{}图标)格式化代码,这是第一步。 - 识别模式:
- 寻找字符串解密函数:搜索
atob、fromCharCode、join('')、replace等关键字,通常能找到一个集中处理字符串数组的函数。 - 定位入口点:从网络请求的发起处(如
fetch、XMLHttpRequest.send)或事件监听器(如button.addEventListener('click', ...))开始反向追踪。 - 还原控制流平坦化:这是体力活。找到那个巨大的
switch或if-else分发器,以及控制它的“状态变量”。通过动态调试,记录下不同逻辑块执行时该状态变量的值,逐步将分散的代码块重新排列回原本的逻辑顺序。有时可以编写简单的脚本辅助分析。
- 寻找字符串解密函数:搜索
- 使用AST解析库进行半自动还原:对于AST级别的加密,最根本的对抗方法是在AST层面进行还原。可以使用
@babel/parser、@babel/traverse、@babel/generator等库。- 思路:将混淆代码解析成AST,然后编写自己的“反变换”插件,遍历AST,识别出特定的混淆模式(如特定的函数调用模式、节点结构),并将其替换回简化形式。
- 示例(还原简单的字符串数组拼接):
const parser = require('@babel/parser'); const traverse = require('@babel/traverse').default; const generate = require('@babel/generator').default; const types = require('@babel/types'); const code = `var _0x5c38=['\x68\x65\x6c\x6c\x6f']; console.log(_0x5c38[0]);`; const ast = parser.parse(code); traverse(ast, { VariableDeclarator(path) { // 查找类似 _0x5c38=['...'] 的声明 if (types.isIdentifier(path.node.id) && path.node.id.name.startsWith('_0x') && types.isArrayExpression(path.node.init)) { const arrayName = path.node.id.name; const elements = path.node.init.elements; // 存储映射关系,这里简化处理,实际需要处理各种编码 const stringMap = elements.map(el => el.value); // 遍历作用域内所有对该数组的成员引用 path.scope.path.traverse({ MemberExpression(memberPath) { if (types.isIdentifier(memberPath.node.object, {name: arrayName}) && types.isNumericLiteral(memberPath.node.property)) { const index = memberPath.node.property.value; // 用实际的字符串字面量替换成员表达式 memberPath.replaceWith(types.stringLiteral(stringMap[index])); } } }); // 可选:移除原变量声明 // path.remove(); } } }); const output = generate(ast).code; console.log(output); // 输出:console.log("hello"); - 这个过程需要对Babel AST和混淆器的变换规则有深入理解,是最高阶的逆向手段。
5. 实战案例:分析一个简单的加密登录过程
假设我们遇到一个登录页面,密码在提交前被加密,表单数据如下:
username: admin password: (加密后的字符串,如 "a7f3e8d1c5b2")我们的目标:找出加密算法,并能够用脚本模拟加密过程。
分析步骤:
网络抓包:打开Network面板,勾选“Preserve log”,进行登录操作。找到提交登录请求的XHR或Fetch请求,查看其请求负载(Payload)。确认
password字段已被加密。全局搜索:在Sources面板按
Ctrl+Shift+F,在整个项目代码中搜索关键词,如password、encrypt、encode、提交请求的URL端点等。设置断点:
- 在提交请求的代码处(如
fetch或XMLHttpRequest.send调用前)设置行断点。 - 在可能存在的加密函数名上设置函数断点。
- 在表单提交事件上设置事件监听器断点。
- 在提交请求的代码处(如
动态追踪:触发登录操作,代码会在断点处暂停。
- 在Console中查看
password变量此时的值,应该是明文。 - 使用
F11逐步步入,跟踪明文password如何传递、被哪个函数处理、最终变成密文。 - 密切关注右侧“Scope”中变量的变化,特别是经过加密函数后的返回值。
- 在Console中查看
定位加密函数:通过单步调试,最终会进入一个函数,其输入是明文密码,输出是加密后的字符串。这个函数就是我们的核心目标。在函数内部,可能需要继续步进来理解其具体实现(是简单的Base64?MD5?还是自定义的AES?)。
提取与模拟:
- 如果加密是标准算法(如CryptoJS的AES),那么需要找到密钥(key)和初始化向量(iv)。这些可能在代码中硬编码,或从服务器响应获取。
- 如果是自定义算法,就需要仔细分析函数内的每一步操作(可能是异或、位移、查表等)。
- 在Console中,可以尝试直接调用这个加密函数,传入测试密码,看输出是否与网络请求一致。
- 最终,将这段加密逻辑用Python或Node.js重写,完成自动化脚本。
处理反调试:如果在第4步发现不断触发
debugger暂停,则使用“Never pause here”功能禁用那个特定的debugger行。如果遇到时间检测导致流程中断,尝试使用本地覆盖功能,注释掉检测代码。
常见问题与排查:
Q:找不到加密函数,搜索关键词也无果。
- A:加密可能被混淆成极其简短的函数名(如
c()),或内联在提交逻辑中。尝试在发送请求的代码行设置断点,然后仔细查看调用栈(Call Stack),栈中下方的函数可能就是加密逻辑的入口。另外,关注对password变量值进行修改的所有操作。
- A:加密可能被混淆成极其简短的函数名(如
Q:加密函数依赖了浏览器环境特有的对象(如
window.btoa、Crypto.subtle),如何在Node.js中复现?- A:需要寻找替代库。
btoa对应Node.js的Buffer.from(str).toString('base64')。Web Crypto API (crypto.subtle) 在Node.js中对应crypto模块,但API略有不同,需要查阅文档进行适配。
- A:需要寻找替代库。
Q:代码经过高度控制流平坦化,完全无法阅读执行顺序。
- A:优先使用动态调试。通过多次执行和条件断点,记录下“分发器”变量的变化序列,从而推断出真实代码块的执行顺序。这是一个缓慢但有效的过程。也可以考虑寻找并尝试已有的去平坦化工具或脚本。
6. 工具链与资源推荐
工欲善其事,必先利其器。以下工具能极大提升JS逆向分析的效率。
浏览器与插件:
- Chrome DevTools / Firefox Developer Tools:核心,无需多言。
- EditThisCookie:方便地查看和编辑Cookie。
- Requestly:拦截和修改网络请求与响应,用于测试。
- SwitchyOmega:管理代理,配合抓包工具使用。
抓包与调试代理:
- Fiddler / Charles:老牌抓包工具,可以查看和修改HTTP/HTTPS流量,对于分析移动端或桌面应用发出的请求尤其有用。
- mitmproxy:命令行抓包工具,支持Python脚本扩展,自动化能力强。
Node.js逆向相关库:
js-beautify:代码格式化。@babel/parser,@babel/traverse,@babel/generator:进行AST分析和转换的利器。vm2:一个安全的沙箱环境,可以在Node.js中安全地执行不受信任的JS代码,用于测试还原后的代码片段。crypto-js:如果目标代码使用了CryptoJS,这个库可以帮助你在Node.js中模拟。
自动化浏览器:
- Puppeteer / Playwright:通过代码控制无头或有头Chrome/Firefox。可以自动化执行页面操作、绕过简单的反调试(因为开发者工具默认未打开),并提取运行时的数据。对于需要模拟完整用户流程的逆向任务非常有用。
在线资源与社区:
- AST Explorer(https://astexplorer.net/):在线可视化JS代码的AST结构,是学习AST和编写Babel插件的神器。
- Stack Overflow / 相关安全论坛:遇到具体问题时的最佳求助场所。
JS逆向与攻防是一个需要极大耐心和细致观察力的领域。它没有一成不变的银弹,每一个目标都可能带来新的挑战。核心思路永远是:结合静态分析与动态调试,大胆假设,小心求证,利用浏览器的强大能力作为我们的主战场。从简单的格式化代码和设置断点开始,逐步深入到条件断点、本地覆盖,最终挑战AST还原,每一步的突破都会带来巨大的成就感。记住,理解防御是为了更好地进攻,而所有的进攻最终都是为了构建更坚固的防御。在这个猫鼠游戏中,保持学习和探索的心态,是最大的利器。