AST解混淆与JS签名算法Python复现实战指南 1. 这不是“又一个爬虫教程”而是一份2026年仍在生效的反混淆作战手记你肯定见过这样的场景凌晨三点刚写好的爬虫突然返回一串乱码控制台里满屏undefined is not a functionF12切到Sources面板看到的不是JS源码而是一整页密密麻麻的_0x4a7b[0](_0x4a7b[1], _0x4a7b[2])用格式化工具一按代码缩进整齐了但变量名还是_0x123a、_0x5f7c像一本用十六进制密码写成的日记。更糟的是你尝试替换字符串、模拟调用结果接口直接返回{code:403,msg:invalid signature}——连请求都发不出去。这不是玄学是2026年主流站点仍在大规模部署的AST级动态混淆运行时算法校验双保险机制。它不依赖简单的User-Agent或Cookie而是把核心签名逻辑编译成抽象语法树后打散、重命名、插入死代码、再注入运行时环境检测让静态分析失效让动态调试卡在无限debugger断点里。本文标题里的“AST解混淆算法还原Python复现”不是三个并列动作而是一条不可跳过的链式流程先从AST层面逆向出原始控制流与数据流再从中剥离出真正参与签名计算的纯函数逻辑最后用Python实现等价数学模型绕过JS引擎依赖。适合两类人一是已能熟练写Selenium和Requests但总在关键接口卡壳的中级爬虫工程师二是想系统理解现代前端保护机制、不再靠“找加密入口扣JS”的安全/逆向初学者。全文不讲概念只拆真实对抗过程——从Chrome DevTools里第一行报错开始到本地Python脚本稳定输出合法sign每一步都带原理、带陷阱、带我亲手踩过的坑。2. 为什么必须从AST入手——传统“扣JS”在2026年已彻底失效2.1 传统方案的三大死穴调试器失效、字符串不可信、环境检测无解过去我们习惯的“扣JS”流程是F12 → 找到发起请求的JS文件 → CtrlF搜索sign、token、signature→ 定位生成函数 → 复制整个函数到Python里用exec或PyExecJS执行。这套方法在2026年面对主流电商、金融、政企类站点时失败率接近100%。原因有三且层层递进第一调试器被主动反制。现在90%以上的混淆JS会在入口处插入if (window.navigator.webdriver || window.outerHeight 100) { debugger; }但更致命的是debugger语句本身已被混淆为eval(\x64\x65\x62\x75\x67\x67\x65\x72)甚至嵌套在setTimeout里延迟触发。你以为关掉断点就安全了实际代码会检测console.log.toString()是否被重写、performance.now()调用间隔是否异常、甚至检查DevTools窗口是否处于激活状态通过document.hasFocus()配合visibilitychange事件。我实测某券商APP的登录签名JS只要DevTools打开超过8秒就会触发window.location.reload()强制刷新页面——你根本来不及下断点。第二字符串常量被动态拼接且含校验逻辑。比如一个看似简单的sign md5(timestamp secret data)在混淆后可能变成var _0x1a2b [md5, timestamp, secret, data, split, join]; function _0x3c4d(_0x5e6f) { var _0x7g8h _0x5e6f[_0x1a2b[4]](); return _0x7g8h[_0x1a2b[5]](_0x1a2b[0]); } var sign _0x3c4d(_0x1a2b[1] _0x1a2b[2] _0x1a2b[3]);表面看只是字符串数组索引但_0x1a2b数组本身可能在运行时被atob()解码、reverse()翻转甚至根据当前时间戳动态生成。更狠的是_0x3c4d函数内部会校验传入字符串是否包含timestamp字面量若直接传1712345678则返回空字符串——你复制的JS在Python里跑通了但结果永远是错的。第三环境检测与算法强耦合。2026年的签名算法不再是独立函数而是与浏览器环境深度绑定。例如某政务平台的getSign()函数其核心逻辑是function getSign(data) { const t Date.now(); const r Math.random().toString(36).substr(2, 9); // 关键这里调用了一个隐藏的WebAssembly模块 const w wasmModule.calc(t, r, navigator.userAgent); return md5(data w t); }你扣JS时能看到wasmModule.calc但wasmModule是通过fetch(xxx.wasm)动态加载的且WASM二进制文件本身经过LLVM-OBFUSCATOR加壳内存中解密后才初始化。这意味着不启动完整浏览器环境你连wasmModule对象都拿不到而启动浏览器又触发前述的环境检测。死循环。提示当你发现JS里出现WebAssembly.instantiateStreaming、new Worker(xxx.js)、import(./chunk-xxx.js)等动态加载模式时立刻放弃“扣JS”思路。这标志着你面对的是AST级混淆必须回归语法树层面。2.2 AST解混淆为什么它是唯一破局点ASTAbstract Syntax Tree抽象语法树是JS引擎将源码解析后的中间表示。它剥离了所有语法糖、空格、注释只保留程序的结构本质哪些是变量声明、哪些是函数调用、哪些是条件分支、哪些是二元运算。混淆器对JS的操作本质上都是对AST节点的变换字符串数组提取将abc变成_0x123[0]对应AST中MemberExpression节点指向ArrayExpression的索引控制流扁平化把if (a) { b() } else { c() }变成switch(_0x456) { case 1: b(); break; case 2: c(); break; }对应AST中SwitchStatement替代IfStatement死代码插入添加if (false) { console.log(dead); }对应AST中IfStatement的test属性为Literal(false)标识符重命名将function calcSign()变成function _0x789()对应AST中Identifier节点的name属性变更。关键在于这些变换都在AST层面完成而AST是确定性的、可逆的。只要你能拿到混淆前的原始AST或足够接近的形态就能通过遍历节点、识别变换模式、应用逆向规则逐步还原出原始逻辑。这不像动态调试受制于环境也不像字符串替换受制于上下文它是纯粹的程序结构分析。我用一个真实案例说明某招聘网站的简历投递接口其签名函数混淆后有237行含12层嵌套try/catch和7个eval调用。用传统方法我在Chrome里调试了6小时最终发现eval里执行的字符串是atob(aHR0cHM6Ly9hcGkueHh4LmNvbS9zaWduYXR1cmU)解码后是https://api.xxx.com/signature——这根本不是算法而是又一个网络请求而用AST解析器如acorn加载该JS遍历所有CallExpression节点过滤出callee.name atob的调用直接提取其arguments[0].value30秒内就拿到了原始URL。这就是AST的力量它不关心代码怎么运行只关心代码长什么样。2.3 2026年可用的AST工具链轻量、可靠、免环境2026年我们不需要复杂的IDE或在线服务。一套本地化、命令行友好的工具链足以应对绝大多数场景。核心组件只有三个全部用npm安装无任何浏览器依赖acornv8.10业界最轻量、最标准的JS解析器。它将JS源码解析为符合ESTree规范的AST对象体积仅120KB解析速度比Babel快3倍。关键优势是零副作用——它不执行代码不加载模块不访问网络只做纯文本到树的转换。安装命令npm install acorn。escodegenv2.4acorn的反向工具将AST对象重新生成可读JS代码。它支持自定义生成规则比如强制展开所有MemberExpression为字面量、删除所有DebuggerStatement节点。安装命令npm install escodegen。estraversev5.3AST遍历器。提供traverse方法让你以深度优先顺序访问每个节点并在enter/leave钩子中修改节点属性。它是实现“逆向变换”的核心。安装命令npm install estraverse。这三者组合构成一个完整的AST处理流水线原始混淆JS → acorn.parse() → AST对象 → estraverse.traverse() → 修改节点 → escodegen.generate() → 还原JS为什么不用Babel因为Babel的babel/parser虽强大但默认启用大量插件如JSX、TypeScript解析速度慢且错误提示不友好。而acorn专精JS对ES2023语法支持完善报错时直接指出字符位置如Unexpected token } at 123:45排查效率极高。我对比过10个主流站点的混淆JSacorn平均解析耗时86msbabel/parser为210ms且后者在遇到export default未声明时会抛出模糊的SyntaxError: Unexpected token而acorn明确提示export is not allowed here。注意不要试图用正则表达式“匹配混淆字符串”。我曾见有人写/_[0-9a-f]{4}\[\d\]/g来替换结果因混淆器插入的/* comment */导致正则跨行失效修复后又因_0x123[01]这种动态索引崩溃。AST是结构化数据正则是字符串模式二者不在同一维度。坚持用AST这是2026年爬虫工程师的基本素养。3. AST解混淆实战从237行乱码到12行可读函数3.1 第一步获取原始混淆JS并确认AST可解析性对抗始于第一步确保你能拿到干净、完整的混淆JS。很多人卡在这一步以为F12里看到的就是全部其实不然。现代站点常用以下三种方式加载混淆逻辑内联Script标签HTML中scriptvar _0x123[a,b];.../script。这是最简单的情况右键“查看网页源代码”CtrlF搜索script复制内容即可。外部JS文件script src/static/js/chunk-456.js/script。此时需在Network面板中筛选JS类型找到对应文件右键“Open in Sources panel”再右键“Copy content”。动态Importimport(./chunk-789.js).then(m m.signFunc())。这种情况最棘手因为chunk-789.js的URL可能是动态生成的如/static/js/chunk-${Math.floor(Math.random()*1000)}.js。解决方案是在Application → Service Workers中禁用所有Service Worker然后清空缓存并硬性刷新CtrlF5再抓包。你会发现首次加载时HTML里会明文写出script src/static/js/chunk-789.js?v20260401——版本号就是破解钥匙。拿到JS后先用acorn验证是否可解析# 将JS保存为 obfuscated.js node -e const acorn require(acorn); const fs require(fs); try { const code fs.readFileSync(obfuscated.js, utf8); const ast acorn.parse(code, { ecmaVersion: 2023, sourceType: module }); console.log(✅ AST解析成功共, ast.body.length, 个顶层节点); } catch (e) { console.error(❌ AST解析失败:, e.message); }如果报错Unexpected token大概率是混淆器插入了非法字符如零宽空格\u200b。用VS Code打开文件开启“显示所有字符”CtrlShiftP → “Toggle Render Whitespace”删除所有非打印字符。2026年约15%的混淆JS会故意插入零宽字符破坏解析这是初级反制手段。3.2 第二步识别并还原字符串数组String Array Reconstruction这是AST解混淆的第一道关卡。几乎所有混淆器如javascript-obfuscator、Obfuscator.io都会将字符串常量提取到一个全局数组中再用索引访问。原始代码function getSign(data) { return md5(data secret_key Date.now()); }混淆后var _0x123 [md5, secret_key, getTime, now, Date, data, ]; function _0x456(_0x789) { return _0x123[0](_0x789 _0x123[1] _0x123[4][_0x123[2]]()[_0x123[3]]()); }目标将_0x123[1]还原为secret_key_0x123[4][_0x123[2]]()[_0x123[3]]()还原为Date.now()。用estraverse遍历AST定位VariableDeclarator节点变量声明检查其init属性是否为ArrayExpressionconst acorn require(acorn); const estraverse require(estraverse); const fs require(fs); const code fs.readFileSync(obfuscated.js, utf8); const ast acorn.parse(code, { ecmaVersion: 2023 }); // 步骤1找到字符串数组声明如 var _0x123 [a,b]; let stringArrayName null; let stringArrayValues []; estraverse.traverse(ast, { enter(node) { if (node.type VariableDeclarator node.id.type Identifier node.init?.type ArrayExpression) { stringArrayName node.id.name; stringArrayValues node.init.elements.map(el el?.type Literal ? el.value : null ); // 找到即退出避免重复赋值 this.break(); } } }); console.log( 识别到字符串数组:, stringArrayName, 共, stringArrayValues.length, 项); // 输出 识别到字符串数组: _0x123 共 7 项接着遍历所有MemberExpression节点即obj[prop]形式当object.name等于stringArrayName且property.type为Literal字面量索引时将其替换为对应的字符串字面量estraverse.traverse(ast, { enter(node, parent) { // 匹配 _0x123[0] 形式 if (node.type MemberExpression node.object.type Identifier node.object.name stringArrayName node.property.type Literal) { const index node.property.value; const replacement stringArrayValues[index]; if (replacement ! undefined replacement ! null) { // 创建新的Literal节点替换原MemberExpression const newLiteral { type: Literal, value: replacement, raw: ${replacement} }; // 替换父节点中的该子节点 if (parent.type BinaryExpression parent.left node) { parent.left newLiteral; } else if (parent.type BinaryExpression parent.right node) { parent.right newLiteral; } else if (parent.type CallExpression parent.callee node) { parent.callee newLiteral; } // 强制跳过后续遍历避免重复处理 this.skip(); } } } });这段代码的核心是精准定位并原地替换。注意this.skip()的使用——它防止遍历器继续深入已替换的节点避免因节点结构变化导致崩溃。实测某电商JS此步骤可将237行代码压缩至189行消除所有_0x123[0]类引用。3.3 第三步剥离死代码与无用分支Dead Code Elimination混淆器常插入大量if (false) {...}、while (0) {...}、try { throw 0; } catch(e) {}等死代码目的是增加静态分析难度。AST层面它们对应IfStatementtest为Literal(false)、WhileStatementtest为Literal(0)、TryStatementbody中仅有ThrowStatement。我们的策略是遍历所有IfStatement检查其test属性。若test.type Literal且test.value false则直接移除整个IfStatement节点estraverse.traverse(ast, { enter(node, parent) { // 移除 if (false) { ... } if (node.type IfStatement node.test.type Literal node.test.value false) { // 在父节点中移除该IfStatement if (parent.type BlockStatement) { const index parent.body.indexOf(node); if (index -1) { parent.body.splice(index, 1); } this.skip(); } } // 移除 while (0) { ... } if (node.type WhileStatement node.test.type Literal node.test.value 0) { if (parent.type BlockStatement) { const index parent.body.indexOf(node); if (index -1) { parent.body.splice(index, 1); } this.skip(); } } } });更复杂的是try/catch。有些混淆器会将真实逻辑放在catch块中而try块里是throw new Error(fake)。这时不能简单删除需判断catch块是否被外部引用。安全做法是只删除try块为空、且catch块中无return或throw的节点。我封装了一个函数function isSafeToRemoveTryCatch(node) { // try块为空 const isEmptyTry node.block.body.length 0; // catch块中无return/throw const hasReturnOrThrow node.handler?.body?.some(stmt stmt.type ReturnStatement || stmt.type ThrowStatement ) true; return isEmptyTry !hasReturnOrThrow; } // 在遍历中调用 if (node.type TryStatement isSafeToRemoveTryCatch(node)) { if (parent.type BlockStatement) { const index parent.body.indexOf(node); if (index -1) { parent.body.splice(index, 1); } this.skip(); } }此步骤后代码行数通常减少30%-40%。某招聘网站JS经此处理从189行降至112行所有debugger语句和console.log调用均被清除。3.4 第四步还原控制流扁平化Control Flow Deobfuscation这是最考验功底的一步。控制流扁平化将线性代码打散为switchwhile(true)结构例如// 原始 function calc(a, b) { if (a b) return a * 2; else return b * 3; } // 混淆后 function calc(a, b) { var _0x1 0; while (true) { switch (_0x1) { case 0: if (a b) _0x1 1; else _0x1 2; break; case 1: return a * 2; case 2: return b * 3; } } }目标将switch结构还原为原始if/else。核心思路是构建控制流图CFG遍历所有SwitchCase记录每个case的_0x1值即consequent中的AssignmentExpression右侧值和跳转目标。然后将case按顺序重组为if/else if/else链。由于篇幅限制此处给出关键逻辑完整代码见GitHub仓库// 找到 while(true) { switch(...) { ... } } 结构 estraverse.traverse(ast, { enter(node, parent) { if (node.type WhileStatement node.test.type Literal node.test.value true node.body.type BlockStatement node.body.body.length 1 node.body.body[0].type SwitchStatement) { const switchNode node.body.body[0]; const cases switchNode.cases; // 构建跳转映射caseValue - nextCaseValue const jumpMap new Map(); for (let i 0; i cases.length; i) { const caseNode cases[i]; const caseValue caseNode.test?.value; if (caseValue undefined) continue; // 查找case块中唯一的AssignmentExpression如 _0x1 1; const assign caseNode.consequent.find(stmt stmt.type ExpressionStatement stmt.expression.type AssignmentExpression stmt.expression.left.type Identifier stmt.expression.right.type Literal ); if (assign) { const nextValue assign.expression.right.value; jumpMap.set(caseValue, nextValue); } } // 根据jumpMap将cases重排为if/else链 // 具体实现略涉及AST节点重构 this.skip(); } } });此步骤需深度理解AST节点类型IfStatement、ConditionalExpression、BlockStatement并手动构造新节点。我建议初学者先用escodegen.generate()打印中间AST对照原始代码理解节点关系。实测表明正确还原控制流后函数逻辑清晰度提升80%为下一步算法还原奠定基础。4. 算法还原从JS函数到Python数学模型的精确映射4.1 为什么不能直接用PyExecJS——环境差异导致的精度灾难很多开发者认为“JS能跑Python用PyExecJS执行一样JS结果应该一致”。这是2026年最大的认知误区。问题出在浮点数精度、位运算行为、日期处理三大领域浮点数精度JS的Number是IEEE 754双精度但PyExecJS底层V8引擎在不同系统上可能启用--harmony-numeric-seed等实验性标志导致0.1 0.2在某些机器上返回0.30000000000000004另一些机器返回0.3。而签名算法常对浮点结果取整Math.floor(x * 1000)微小差异直接导致sign不匹配。位运算行为JS中无符号右移对负数会先转为无符号32位整数而Python的是算术右移。例如-1 0在JS中是4294967295Python中-1 0仍是-1。某金融平台的签名核心是((a ^ b) 0).toString(16)直接移植会导致哈希值完全错误。日期处理JS的Date.now()返回毫秒时间戳但PyExecJS执行时JS上下文的Date对象可能被混淆器重写如Date function(){return 1234567890;}而Python的time.time()无法模拟这种篡改。我做过严格测试同一段JS签名代码在Chrome DevTools、Node.js、PyExecJSUbuntu、PyExecJSWindows四个环境中运行结果一致率仅为68%。其中32%的差异源于上述底层行为而非算法逻辑。提示当你发现PyExecJS输出的sign偶尔正确、偶尔错误且错误时console.log显示的中间值与Chrome中不一致请立即放弃该方案。这不是你的代码问题是环境不可控。4.2 算法还原三原则纯函数、无副作用、可验证真正的算法还原不是复制JS代码而是提取其数学本质。遵循三个铁律第一纯函数原则还原出的Python函数输入参数必须完全由调用方提供不依赖任何全局变量、window对象、document属性。例如JS中navigator.userAgent必须作为参数传入Python函数而非在函数内调用platform.uname()——因为混淆JS可能篡改navigator而Python无法模拟篡改。第二无副作用原则函数内部不能有print、logging、网络请求、文件IO等任何外部交互。它必须是一个纯粹的数学映射(data, timestamp, user_agent) → sign。这保证了可测试性——你可以用Chrome中捕获的真实参数喂给Python函数验证输出是否100%一致。第三可验证原则每一步中间计算都必须能在Chrome DevTools中console.log出来并与Python中print结果逐行比对。例如JS中var x a b; var y x * 2;Python中必须有x a b; print(x, x); y x * 2; print(y, y)。不验证的还原等于没还原。以某政务平台的真实签名算法为例其JS核心逻辑是function getSign(data, ts) { var key abc123; var s data key ts; var h CryptoJS.SHA256(s).toString(CryptoJS.enc.Hex); var r h.substr(0, 16) h.substr(-16); return r.toUpperCase(); }还原为Python时不能直接调用pycryptodome的SHA256因为CryptoJS.SHA256的输入编码是UTF-16LEJS默认而pycryptodome默认UTF-8。必须显式指定import hashlib def get_sign(data: str, ts: str, key: str abc123) - str: # JS中CryptoJS.SHA256输入是UTF-16LE编码 s (data key ts).encode(utf-16le) h hashlib.sha256(s).hexdigest() r h[:16] h[-16:] return r.upper() # 验证在Chrome中 console.log(getSign(test, 1712345678)) # Python中 print(get_sign(test, 1712345678)) # 两者必须完全相等4.3 关键算法组件的手动实现MD5、Base64、AES的Python等价体2026年混淆JS中高频出现的算法组件往往需要手动实现Python版本而非调用现成库。原因有二一是混淆器可能修改算法细节如MD5的初始向量、AES的填充方式二是第三方库行为与JS库存在隐式差异。MD5手动实现要点JS的CryptoJS.MD5默认对字符串进行UTF-8编码但若字符串含中文CryptoJS.enc.Utf8.parse(str)会先转UTF-8再处理。Python中必须用str.encode(utf-8)。初始向量IV和轮函数round function必须与RFC 1321完全一致。我推荐直接使用hashlib.md5()但需确保输入字节流与JS完全相同。验证方法用CryptoJS.enc.Base64.stringify(CryptoJS.enc.Utf8.parse(hello))得到aGVsbG8Python中base64.b64encode(hello.encode(utf-8)).decode()必须输出相同结果。Base64编解码陷阱 混淆JS常用btoa()/atob()但btoa只接受Latin-1字符。若字符串含中文需先encodeURIComponent再btoa。Python中对应import base64 from urllib.parse import quote, unquote def js_btoa(s: str) - str: # 模拟JS btoa对Unicode的处理 return base64.b64encode( quote(s, safe).encode(utf-8) ).decode(utf-8) def js_atob(s: str) - str: decoded base64.b64decode(s) return unquote(decoded.decode(utf-8))AES-CBC手动实现 某物流平台用CryptoJS.AES.encrypt(data, key, {mode: CryptoJS.mode.CBC, padding: CryptoJS.pad.Pkcs7})。Python中需用pycryptodome但关键参数必须匹配Key长度CryptoJS的key字符串会自动用MD5哈希为128位Python中需hashlib.md5(key.encode()).digest()。IVCryptoJS默认用随机IV但签名算法中IV常固定为000000000000000016字节。PaddingPkcs7填充Python中用PKCS7(128).pad(data.encode(), 16)。完整示例from Crypto.Cipher import AES from Crypto.Util.Padding import pad, unpad from Crypto.Hash import MD5 import base64 def aes_encrypt_js_style(data: str, key: str) - str: # JS中CryptoJS用MD5(key)生成128位key key_bytes MD5.new(key.encode()).digest() iv b0000000000000000 # 固定IV cipher AES.new(key_bytes, AES.MODE_CBC, iv) # PKCS7填充block_size16 padded_data pad(data.encode(utf-8), 16) encrypted cipher.encrypt(padded_data) return base64.b64encode(encrypted).decode(utf-8)4.4 实战案例某电商“秒杀签名校验”的全流程还原我们以某头部电商的“限时秒杀”接口为例完整走一遍从AST解混淆到Python复现的流程。接口URL为POST https://api.xxx.com/seckill/sign请求体含{ itemId: 123456, userId: 789012, ts: 1712345678 }响应要求sign字段。Step 1AST解混淆获取JSNetwork中找到seckill-sign.js大小1.2MB含3个eval调用。acorn解析成功识别出主字符串数组_0xabcdef共142项。还原字符串后代码降至893行剥离死代码后剩621行控制流还原后剩417行。最终定位到核心函数_0x123456(data, ts)23行。Step 2算法分析函数核心逻辑function _0x123456(data, ts) { var _0x789 CryptoJS.enc.Utf8.parse(data); var _0xabc CryptoJS.enc.Utf8.parse(salt_2026); var _0xdef CryptoJS.SHA256(_0x789.concat(_0xabc)).toString(); var _0xghi CryptoJS.enc.Base64.parse(_0xdef); var _0xjkl CryptoJS.enc.Base64.stringify(_0xghi); return _0xjkl.substring(0, 16) _0xjkl.substring(16, 32); }关键发现CryptoJS.enc.Base64.parse和stringify是互逆操作_0xjkl其实就是_0xdef的Base64编码。而_0xdef是SHA256哈希值的十六进制字符串长度为64。因此_0xjkl是64字节字符串的Base64编码长度≈86substring(0,16)取前16字符substring(16,32)