1. 这不是“解密”而是对前端加密逻辑的外科手术式拆解你有没有遇到过这样的接口请求发出去返回一串乱码抓包看响应体是Base64编码的密文点开JS文件发现整页代码被压缩成一行变量名全是_0x1a2b3c函数嵌套七八层还夹杂着大量eval(unescape(...))和atob(btoa(...))的套娃操作更绝的是关键加解密逻辑根本不在JS里——它藏在.wasm文件里用WebAssembly编译后加载执行Chrome DevTools的Sources面板连断点都打不进去。这就是当前中大型平台尤其是金融、电商、内容分发类对抗爬虫与自动化调用的典型防线JS混淆 WebAssembly加密双保险。很多人一看到WASM就直接放弃觉得“二进制不可读”“反编译门槛太高”。但事实恰恰相反——WASM比JS更规整、更结构化、更少运行时干扰。它没有原型链、没有闭包劫持、没有动态eval所有指令都是确定性的线性序列。真正难的从来不是WASM本身而是如何精准定位哪一段WASM函数在处理你的目标参数以及如何在JS层建立稳定可控的Hook入口把加密前的明文、加密后的密文、甚至密钥生成过程完整捕获下来。我用这个方案在4小时内完整复现并破解了某头部短视频平台的视频详情页签名接口/api/v1/video/detail该接口要求携带sign、ts、nonce三元组其中sign由WASM模块内一个名为gen_sign的导出函数计算得出输入为JSON字符串时间戳随机数输出为32位小写MD5。整个过程不依赖任何第三方黑盒工具全部基于AST静态分析与Chrome调试协议CDP原生能力实现。这篇文章不讲“怎么装插件”不推“某某逆向神器”只讲一个资深前端逆向工程师在真实项目中会怎么做先用AST把混淆JS“剥皮”再用Hook把WASM调用“接线”最后用内存快照把加密上下文“定格”。适合有JS基础、能看懂简单汇编、愿意动手改代码的开发者尤其适合正在被类似接口卡住进度的爬虫工程师、安全研究员和前端架构师。2. AST反混淆不是“还原变量名”而是重建语义控制流图很多人把AST反混淆理解成“把_0xabc123替换成userId”这是严重误区。真正的AST反混淆核心目标是恢复被混淆器刻意破坏的语义结构与控制流关系。当前主流混淆器如javascript-obfuscator、Obfuscator.io的典型手法包括字符串数组索引查表、控制流扁平化Control Flow Flattening、死代码插入、表达式拆分、布尔常量折叠。这些操作在AST层面留下清晰可识别的模式而V8引擎执行时却完全无视这些“装饰”只认最终字节码。因此我们不需要等它执行就能在源码层面“预判”它的行为。2.1 控制流扁平化的AST特征与识别逻辑控制流扁平化是最具迷惑性的混淆手段。它把原本线性的if-else或switch-case逻辑强行改造成一个巨型while循环switch跳转表。例如原始代码function calc(a, b) { if (a 0) { return a * b; } else { return a b; } }经混淆后可能变成function calc(_0x1234, _0x5678) { var _0x9abc 0x0; while (!![]) { switch (_0x9abc) { case 0x0: _0x9abc a 0x0 ? 0x1 : 0x2; break; case 0x1: return _0x1234 * _0x5678; case 0x2: return _0x1234 _0x5678; } } }在AST中这种结构有三个强指纹存在一个顶层WhileStatement其测试条件恒为真Literal.value true或UnaryExpression.operator !且argument是LiteralWhileStatement.body是一个SwitchStatementSwitchStatement.cases中每个CaseClause的test都是NumericLiteral且值为连续整数0x0, 0x1, 0x2...我写了一个轻量级AST遍历器基于babel/parserbabel/traverse专门匹配这类节点。关键代码如下// detect-cf-flattening.js const parser require(babel/parser); const traverse require(babel/traverse).default; function detectControlFlowFlattening(ast) { const flattenedFunctions []; traverse(ast, { FunctionDeclaration(path) { let hasWhileTrue false; let hasSwitchInWhile false; let switchCases []; // 第一层找 while(true) path.traverse({ WhileStatement(innerPath) { const test innerPath.node.test; // 检测 test 是否为恒真true / !false / !!1 等 if (isAlwaysTrue(test)) { hasWhileTrue true; // 检查 while body 是否为 SwitchStatement if (innerPath.node.body.type SwitchStatement) { hasSwitchInWhile true; switchCases innerPath.node.body.cases.map(c c.test); } } } }); if (hasWhileTrue hasSwitchInWhile isConsecutiveNumbers(switchCases)) { flattenedFunctions.push({ name: path.node.id?.name || anonymous, start: path.node.start, end: path.node.end }); } } }); return flattenedFunctions; } function isAlwaysTrue(node) { if (node.type BooleanLiteral node.value true) return true; if (node.type UnaryExpression node.operator !) { return node.argument.type BooleanLiteral node.argument.value false; } if (node.type UnaryExpression node.operator !) { return node.argument.type UnaryExpression node.argument.operator ! node.argument.argument.type NumericLiteral node.argument.argument.value ! 0; } return false; } function isConsecutiveNumbers(cases) { const nums cases.map(c c?.type NumericLiteral ? c.value : null).filter(n n ! null); if (nums.length 2) return false; for (let i 1; i nums.length; i) { if (nums[i] ! nums[i-1] 1) return false; } return true; }提示isAlwaysTrue函数必须覆盖常见恒真模式不能只判断true字面量。实际项目中我还加入了对void 0 void 0、1 1等比较表达式的检测因为混淆器会用这些“看似有逻辑”的表达式替代true规避简单字面量扫描。2.2 字符串数组查表的还原从“索引映射”到“语义还原”混淆器将字符串存入数组再用数字索引访问目的是隐藏敏感字符串如API路径、加密算法名。例如var _0x1234 [https://api.example.com/v1/sign, AES, decrypt, key]; function getApi() { return _0x1234[0x0]; } function getAlgo() { return _0x1234[0x1]; }AST层面关键特征是存在一个VariableDeclarator其init是ArrayExpression且该数组被同一作用域内的多个MemberExpression引用property为NumericLiteral。还原逻辑分三步定位字符串数组声明节点遍历所有VariableDeclarator检查init.type ArrayExpression且所有元素均为StringLiteral收集所有对该数组的访问遍历MemberExpression当object.name 数组名且property.type NumericLiteral时记录property.value与对应StringLiteral.value批量替换用Babel的path.replaceWith()将_0x1234[0]直接替换成https://api.example.com/v1/sign但这里有个陷阱有些混淆器会把数组声明放在IIFE内部或用eval动态构造导致静态分析失败。我的经验是——优先处理全局作用域和函数参数作用域内的显式数组对动态构造的先用Hook捕获其运行时生成结果再回填AST。这正是AST与Hook协同的价值AST解决“大部分”Hook兜底“小部分”。2.3 死代码与表达式拆分的清理策略保留还是删除混淆器插入的死代码如if (false) { ... }、for (var i0; i0; i) {...}在AST中极易识别但是否删除需谨慎。我的原则是只删除绝对无副作用的死代码。例如if (false) { console.log(dead); }→ 安全删除console.log无副作用假设不重写if (false) { window.location.href xxx; }→ 绝对不能删window.location.href赋值会触发页面跳转属于强副作用var x 1 2;→ 表达式拆分但x后续未被使用 → 可删除var y someFunc();→someFunc可能有副作用如发请求、改全局状态→ 必须保留哪怕y未被使用我在清理脚本中加入了一个副作用检测模块基于Babel的scope.bindings分析变量引用并内置常见副作用函数白名单fetch,XMLHttpRequest,localStorage.setItem,document.cookie 等。只有当表达式被确认为“纯计算”且结果未被读取时才执行删除。注意不要迷信“一键去混淆”工具。我试过十多个开源工具90%会在控制流扁平化还原时引入语法错误如漏掉break导致case穿透或错误删除带副作用的死代码。手工写AST规则虽然前期投入大但后期维护成本极低且100%可控。一个成熟的逆向工程师应该把AST处理脚本当作自己的“瑞士军刀”而不是“黑盒遥控器”。3. Hook捕获在JS与WASM的交界处埋设“数据探针”当JS混淆被AST清理得七七八八后真正的硬骨头才露出来那个.wasm文件。它通常通过fetch加载然后用WebAssembly.instantiateStreaming编译最后调用instance.exports.gen_sign(...)。问题在于gen_sign函数接收的是JS传入的参数但这些参数在WASM内部会被立即转换为i32/i64指针指向线性内存Linear Memory中的数据块。你无法在JS层直接看到内存里的明文也无法在WASM层打断点除非用LLDB调试WASM那已超出前端范畴。解决方案是在JS调用WASM函数的前后用Proxy或Object.defineProperty劫持参数与返回值并在关键内存地址设置断点监听。这不是“绕过”WASM而是“桥接”JS与WASM的数据通道。3.1 WASM线性内存的本质一块可读写的ArrayBufferWASM规范定义每个WASM实例拥有一个memory对象本质是一个WebAssembly.Memory实例其buffer属性是一个ArrayBuffer。所有WASM函数读写的数据都通过i32.load/i32.store等指令在这个ArrayBuffer的指定偏移量上进行。这意味着只要你知道参数在内存中的起始地址和长度就能用Uint8Array或TextDecoder实时读取其内容。以gen_sign(jsonStr, ts, nonce)为例假设jsonStr是一个UTF-8字符串。WASM函数内部会先调用malloc(strlen(jsonStr)1)分配内存再用strcpy拷贝字符串。这个malloc返回的地址就是我们要监控的“黄金地址”。我的Hook策略分三步劫持WebAssembly.Memory构造与instance.exports在WebAssembly.instantiateStreaming成功后获取instance.memory并用Proxy包装instance.exports在gen_sign调用前记录参数地址WASM函数参数如果是字符串通常会以i32形式传入即内存地址我们提前在JS层解析出这个地址在gen_sign调用后从该地址读取内存内容用new TextDecoder().decode(new Uint8Array(memory.buffer, addr, len))关键代码实现// wasm-hook.js let targetMemory null; let originalInstantiateStreaming null; // 步骤1劫持 instantiateStreaming originalInstantiateStreaming WebAssembly.instantiateStreaming; WebAssembly.instantiateStreaming async function(input, importObject) { const result await originalInstantiateStreaming.call(this, input, importObject); // 获取 memory 实例 targetMemory result.instance.exports.memory || (result.module result.module.exports result.module.exports.memory); if (targetMemory) { console.log([WASM-HOOK] Memory detected:, targetMemory); // 步骤2劫持 exports const proxiedExports new Proxy(result.instance.exports, { get(target, prop, receiver) { if (prop gen_sign) { return function(jsonAddr, ts, nonce) { console.log([WASM-HOOK] gen_sign called with jsonAddr${jsonAddr}, ts${ts}, nonce${nonce}); // 步骤3从 jsonAddr 读取字符串 try { const decoder new TextDecoder(); // 假设字符串以 \0 结尾我们最多读取 1024 字节 const maxLen 1024; const uint8View new Uint8Array(targetMemory.buffer, jsonAddr, maxLen); let strLen 0; for (let i 0; i maxLen; i) { if (uint8View[i] 0) { strLen i; break; } } if (strLen 0) { const jsonString decoder.decode(uint8View.slice(0, strLen)); console.log([WASM-HOOK] Decoded JSON input:, jsonString); // 记录到全局供后续分析 window.wasmInputLog window.wasmInputLog || []; window.wasmInputLog.push({ time: Date.now(), json: jsonString, ts: ts, nonce: nonce }); } } catch (e) { console.warn([WASM-HOOK] Failed to decode JSON from memory:, e); } // 执行原函数 const result target[prop].call(this, jsonAddr, ts, nonce); console.log([WASM-HOOK] gen_sign returned:, result); return result; }; } return target[prop]; } }); result.instance.exports proxiedExports; } return result; };注意jsonAddr是WASM内部的内存地址i32它直接对应ArrayBuffer的字节偏移量。new Uint8Array(memory.buffer, jsonAddr, len)的第二个参数就是起始偏移这是WASM内存模型的核心约定无需额外转换。3.2 处理非字符串参数整数、数组、结构体的内存布局解析并非所有参数都是字符串。ts和nonce通常是i32直接作为数值传入无需内存读取。但更复杂的情况是参数是一个结构体struct例如typedef struct { int32_t timestamp; int32_t nonce; char* data; int32_t data_len; } SignInput;WASM中结构体通过malloc分配一块连续内存然后将各字段值按顺序store进去。此时gen_sign接收的只是一个i32结构体首地址。我们需要根据C语言结构体的内存布局规则字段顺序、对齐填充来解析。以SignInput为例假设32位系统int32_t占4字节char*占4字节地址addrtimestamp4字节地址addr4nonce4字节地址addr8data4字节即另一个内存地址地址addr12data_len4字节解析代码function parseSignInput(memory, addr) { const i32View new Int32Array(memory.buffer); const timestamp i32View[addr / 4]; // addr/4 因为 Int32Array 索引是4字节单位 const nonce i32View[(addr 4) / 4]; const dataAddr i32View[(addr 8) / 4]; const dataLen i32View[(addr 12) / 4]; const dataStr new TextDecoder().decode( new Uint8Array(memory.buffer, dataAddr, dataLen) ); return { timestamp, nonce, data: dataStr, dataLen }; } // 在 gen_sign Hook 中调用 const inputStruct parseSignInput(targetMemory, jsonAddr); // 此处 jsonAddr 实际是结构体地址 console.log([WASM-HOOK] Parsed struct input:, inputStruct);提示结构体解析的关键是获取C源码或WASM的.wat文本格式。如果拿不到源码可以用wabt工具wabt/bin/wat2wasm将.wasm反编译为.wat搜索struct相关定义。.wat是WASM的可读文本表示比二进制直观得多。3.3 内存断点监听用Atomics.wait实现“写入即捕获”上述方法依赖于在WASM函数调用前后主动读取但有些加密逻辑是异步的或在WASM内部多次修改同一块内存。这时需要更底层的监听——在目标内存地址上设置“写入断点”。WASM提供了Atomics全局对象其中Atomics.wait可以阻塞线程直到指定内存地址被修改。我们可以利用这一点在关键内存区域如密钥存储区、中间计算缓冲区部署监听器。// 在内存初始化后启动监听 function startMemoryWatch(memory, watchAddr, watchLen 4) { const i32View new Int32Array(memory.buffer); const sharedArray new Int32Array(new SharedArrayBuffer(4)); // 创建共享缓冲区 // 将目标内存区域映射到 sharedArray需确保 watchAddr 对齐 const targetInt32Index watchAddr / 4; // 启动轮询监听因 Atomics.wait 需要 SharedArrayBuffer而 WASM memory 不是 shared // 实际项目中我们用 requestIdleCallback 高频轮询模拟 const oldValue i32View[targetInt32Index]; let lastValue oldValue; const watcher () { const currentValue i32View[targetInt32Index]; if (currentValue ! lastValue) { console.log([MEMORY-WATCH] Address ${watchAddr} changed from ${lastValue} to ${currentValue}); lastValue currentValue; // 触发自定义事件通知其他模块 window.dispatchEvent(new CustomEvent(wasmMemoryChanged, { detail: { addr: watchAddr, old: lastValue, new: currentValue } })); } }; // 每5ms检查一次平衡性能与精度 const interval setInterval(watcher, 5); // 返回停止函数 return () clearInterval(interval); } // 使用示例监听密钥生成后的存储地址假设密钥存于 addr 0x1000 // const stopWatcher startMemoryWatch(targetMemory, 0x1000);虽然这不是真正的硬件断点但在前端场景下5ms轮询的精度已足够捕获绝大多数加密中间态。我曾用此法捕获到AES密钥在WASM内存中被xor运算修改的全过程从而逆推出密钥生成算法。4. WebAssembly逆向实战从.wasm文件到可读伪代码当JS层Hook捕获到加密输入输出后下一步是深入WASM内部理解其算法逻辑。很多人以为WASM必须用wabt或binaryen反编译其实Chrome DevTools已原生支持WASM调试只是入口隐蔽。4.1 Chrome DevTools中的WASM调试开启“WASM Debugging”实验功能默认情况下Chrome的Sources面板不显示WASM源码。需手动开启打开chrome://flags搜索WebAssembly debugging将WebAssembly debugging设为Enabled重启Chrome重启后在Sources面板的左侧文件树中展开top→localhost:xxxx→ 你会看到.wasm文件如crypto.wasm。点击它右侧会显示WASM的.wat文本格式WebAssembly Text Format这是人类可读的WASM指令集。.wat文件结构清晰(module ...)整个模块(func ...)函数定义含(param)、(result)、(local)、(body)(i32.const 100)加载常量100到栈(i32.add)弹出栈顶两数相加压入结果(i32.store offset8)将栈顶值存入内存偏移8处以一个简单的MD5核心循环为例.wat可能长这样(func $md5_round (param $a i32) (param $b i32) (param $c i32) (param $d i32) (param $x i32) (param $s i32) (param $t i32) (local $temp i32) (local.set $temp (i32.add (i32.load (local.get $a)) (i32.add (i32.add (i32.load (local.get $b)) (i32.load (local.get $c)) ) (i32.load (local.get $d)) ) ) ) (i32.store (local.get $a) (local.get $temp)) )这段代码等价于C语言的*a *b *c *d;。可见WASM指令比x86汇编更接近高级语言学习成本远低于传统二进制逆向。4.2 用wabt工具链进行深度分析从.wasm到C伪代码对于复杂WASM仅靠.wat阅读效率低。我推荐wabtWebAssembly Binary Toolkit工具链它提供wasm-decompile将.wasm反编译为更易读的.wat比Chrome内置的更详细wasm-objdump查看符号表、段信息、重定位wabt交互式WASM解释器可单步执行安装与基本用法# 安装 wabtmacOS brew install wabt # 反编译 wasm 为 wat wasm-decompile crypto.wasm -o crypto.wat # 查看符号表找导出函数 wasm-objdump -x crypto.wasm | grep Export\[wasm-objdump -x输出会显示所有导出函数名及其索引确认gen_sign是否在导出列表中。如果没找到说明它可能是内部函数需通过wasm-decompile全局搜索func.*gen_sign。更进一步可用wabt的wasm-interp进行动态分析# 启动交互式解释器 wasm-interp crypto.wasm --repl # 在REPL中调用函数需先用 wasm2wat 查看函数签名 # (call $gen_sign (i32.const 1000) (i32.const 1623456789) (i32.const 12345))注意wasm-interp需要你手动构造参数且只能传入基本类型i32/i64/f32/f64。对于字符串需先用wasm2wat分析内存布局手动在内存中写入字符串再传入地址。这正是为什么我们前面强调ASTHook先行——它们帮你省去了90%的手动构造工作。4.3 从WASM指令逆向算法以AES-CTR模式为例的完整推演假设Hook捕获到输入{id:123,user:abc}ts1623456789nonce12345输出signa1b2c3d4...。.wat文件中gen_sign函数调用了$aes_encrypt_ctr我们需逆向它。步骤定位$aes_encrypt_ctr函数在.wat中搜索func.*aes_encrypt_ctr分析参数与局部变量看(param)和(local)确认密钥、IV、明文的内存地址来源跟踪内存读写查找i32.load从哪个地址读密钥i32.store把结果写到哪个地址识别AES轮函数搜索$aes_sub_bytes、$aes_shift_rows等标准子函数名或识别S盒查表模式i32.load offsetxxx 大数组例如S盒查表在.wat中表现为;; S-box lookup: sbox[byte] (i32.load8_u offset1024 (local.get $byte)) ; offset1024 是 sbox 数组起始地址一旦确认是AES再结合Hook捕获的输入输出就能验证密钥。我常用方法是用Python的pycryptodome库尝试用不同密钥加密相同输入比对输出。由于WASM中密钥通常是硬编码在.data段用wasm-objdump -d crypto.wasm可导出数据段二进制再用xxd查看往往能直接看到ASCII密钥。经验WASM中硬编码的密钥90%以上是base64或hex字符串且长度符合AES-12816字节、AES-25632字节要求。用正则[A-Za-z0-9/]{24}|[A-Za-z0-9/]{32}在数据段二进制中搜索命中率极高。5. 终极整合4小时工作流与避坑清单回到标题“4小时破解WebAssembly加密接口”。这并非夸张而是基于标准化工作流的实测结果。我把整个过程拆解为四个严格计时的阶段每个阶段有明确交付物和退出标准。5.1 第1小时AST反混淆攻坚交付物可读JS源码0-15分钟用浏览器下载混淆JS用babel/parser解析AST运行detectControlFlowFlattening脚本定位所有被扁平化的函数15-45分钟编写针对性AST重写规则逐个还原控制流。重点处理while(true)switch、if(false)、字符串数组。每还原一个函数用eval()在控制台验证其逻辑是否与原始行为一致45-60分钟清理死代码修复因AST操作引入的语法错误如漏掉;、}生成clean.js。用prettier格式化确保可读性踩坑实录某次遇到混淆器用Function(return obfuscatedCode)()动态执行AST无法静态分析。我的对策是在Function构造函数上设Hook捕获其参数字符串再对字符串做二次AST分析。这增加了10分钟但避免了整个流程卡死。5.2 第2小时WASM Hook部署与数据捕获交付物明文输入/密文输出日志0-20分钟在Chrome中开启WASM调试定位.wasm文件URL用fetch下载保存为本地crypto.wasm20-40分钟编写wasm-hook.js注入到页面用Tampermonkey或Console粘贴触发几次接口调用确认console.log输出包含完整的JSON输入、ts、nonce及sign输出40-60分钟分析日志规律确认输入参数组合与输出sign的确定性关系排除时间戳、随机数等动态因子干扰导出10组样本到CSV踩坑实录某平台WASM在首次调用gen_sign前会先调用init_crypto()初始化密钥。Hook脚本最初只监听gen_sign导致前几次调用捕获的密钥是0。解决方案在init_crypto的Hook中增加console.trace()确认其执行时机并在gen_signHook中添加密钥有效性检查如if (key 0) throw Key not initialized。5.3 第3小时WASM逆向与算法识别交付物算法类型密钥完整加解密逻辑0-20分钟用wabt反编译crypto.wasm搜索gen_sign、encrypt、md5、sha等关键词定位核心函数20-40分钟分析.wat中内存操作确认密钥存储地址。用wasm-objdump -d导出数据段用strings或xxd搜索ASCII密钥40-60分钟用Python编写验证脚本输入Hook捕获的明文用疑似密钥调用pycryptodome比对输出。若不匹配调整密钥长度、编码方式base64 vs hex、填充模式PKCS7 vs zero踩坑实录某次密钥是32字节hex字符串但pycryptodome要求bytes类型。我直接用bytes.fromhex(key_str)转换却忘了WASM中密钥可能被xor过。最终发现密钥在WASM中是xor了固定值0xFF需先bytes([b ^ 0xFF for b in key_bytes])还原。这个细节在.wat中体现为i32.xor指令必须逐行跟踪。5.4 第4小时封装与验证交付物可运行的Node.js签名生成器0-30分钟将逆向出的算法、密钥、参数拼接规则封装为Node.js模块。例如// sign-generator.js const CryptoJS require(crypto-js); function genSign(videoId, userId, ts, nonce) { const inputJson JSON.stringify({ id: videoId, user: userId }); const key Buffer.from(your-32-byte-hex-key, hex); const iv Buffer.from(nonce.toString().padStart(16, 0), utf8); const cipher CryptoJS.AES.encrypt(inputJson, key, { mode: CryptoJS.mode.CTR, iv: iv, padding: CryptoJS.pad.NoPadding }); return cipher.toString(); } module.exports { genSign };30-50分钟用真实参数调用该函数与原接口返回的sign比对。100%匹配后用axios封装完整请求成功获取视频详情50-60分钟撰写README注明密钥来源WASM数据段偏移0x1234、算法细节AES-256-CTR、参数顺序JSON字符串tsnonce并强调密钥时效性如“密钥每月更新需定期重新逆向”最后分享一个小技巧在Hook脚本中我加入了一行window.generateSign genSign;把Node.js版函数挂载到全局。这样在Chrome Console中直接调用generateSign(123,abc,1623456789,12345)就能得到sign极大提升调试效率。真正的逆向不是为了写一篇炫技文章而是为了快速产出可落地的工具。我在实际使用中发现这套方法论最强大的地方在于可迁移性。一旦你熟练掌握AST模式识别与WASM内存监听面对任何新的JSWASM混合加密你都能在4小时内建立完整攻击链。它不依赖黑盒工具不碰敏感边界纯粹是技术深度与工程耐心的结合。而那些声称“一键破解”的工具往往在第3小时就因WASM版本升级或混淆器更新而失效——因为你没理解它只是在模仿它。