公司动态
猿人学第13题逆向实战:破解JS控制流平坦化与反调试
1. 项目概述猿人学第13题的核心挑战最近在猿人学逆向反混淆练习平台上刷题做到第13题时发现它和前面几道题目的风格又不太一样了。这道题的核心不再是简单的参数加密或者请求头校验而是将加密逻辑巧妙地隐藏在了JavaScript的“控制流平坦化”和“字符串混淆”之下并且还附带了一个不那么显眼的“控制台检测”机制。如果你直接打开F12开发者工具可能会发现脚本执行异常或者断点根本打不上这就是所谓的“一叶障目”式检测在起作用。很多朋友卡在这里并不是加密算法有多复杂而是第一步的环境就没准备好导致后续的分析无从下手。今天我就结合自己趟过的坑把这道题从环境搭建、反混淆、到核心加密逻辑分析的完整过程拆解一遍目标是让你不仅能解出这道题更能掌握一套应对此类“反混淆环境检测”组合拳的通用方法论。这道题典型的应用场景就是我们在进行Web数据采集或接口调试时遇到前端参数被复杂JS加密的情况。服务端返回的数据或提交的请求参数都需要通过一段被重重保护的JavaScript代码运算得出。猿人学这个平台把它做成了练习题非常贴近实战。我们最终要实现的就是能够独立补全一个可执行的环境并从中剥离出纯净的加密函数用于我们的爬虫脚本或调试工具中。接下来我会先从如何绕过环境检测、搭建一个稳定的分析环境讲起。2. 环境准备与反调试绕过工欲善其事必先利其器。在开始逆向任何JS之前尤其是像猿人学这种设置了障碍的题目一个干净、可控且“隐形”的分析环境是成功的一半。很多新手会直接打开浏览器开发者工具就开干结果发现代码被混淆得面目全非或者刚下断点页面就自动刷新问题就出在环境上。2.1 理解“一叶障目”式控制台检测所谓“一叶障目”在这里是一种形象的说法指的是网站通过检测开发者工具是否打开来改变或终止某些关键代码的执行路径从而干扰我们的分析和调试。第13题就用了类似的手法。它的检测原理通常基于以下几点console.log对象特征检测在DevTools打开时console对象的方法如log,dir与默认状态下的函数引用不同。有些代码会通过console.log.toString()的长度或内容来判断。调试器关键字检测使用debugger;语句并配合setInterval频繁检查代码执行是否被断点暂停如果暂停时间过长则判定为正在被调试可能触发反制措施如无限循环、跳转。窗口大小与特性检测DevTools打开会改变窗口布局可以通过检查窗口外宽高outerWidth - innerWidth来判断侧边栏是否打开。对于这道题我们观察到的现象是直接打开F12用于加密的核心JS文件可能加载不全或者其中的函数被替换成了空函数。因此我们的首要目标不是硬刚而是“欺骗”它让它认为我们处在一个纯净的普通用户环境中。2.2 使用Proxy代理进行环境补全最优雅和彻底的解决方案不是去一个个修改检测点而是从根源上“补全”或“净化”整个运行环境。这就是“补环境”的核心思想。我们将使用Node.js配合puppeteer或playwright这样的无头浏览器工具但更关键的是在页面上下文中注入我们的“保护层”。这里我强烈推荐使用Proxy代理对象来钩子Hook关键属性。Proxy是ES6引入的元编程特性可以创建一个对象的代理从而拦截并重新定义该对象的基本操作如属性读取、赋值、函数调用等。我们的策略是在页面加载任何业务JS之前先执行我们的环境补全脚本。这个脚本会使用Proxy包裹关键的检测对象如console、window、document甚至Function.prototype.toString让它们的表现与无DevTools环境时完全一致。下面是一个基础的环境补全脚本示例你可以将其作为油猴脚本Tampermonkey注入或者在puppeteer的page.evaluateOnNewDocument中执行// UserScript // name 猿人学第13题环境净化 // namespace http://tampermonkey.net/ // version 0.1 // description 绕过控制台检测净化JS环境 // author You // match *://*.yuanrenxue.com/* // grant none // run-at document-start // /UserScript (function() { use strict; // 1. 保护 console 对象 const rawConsole window.console; const fakeConsole new Proxy(rawConsole, { get(target, prop) { // 如果访问的是 toString 或类似方法返回一个固定的、无害的函数字符串 if (prop toString || prop log || prop dir) { return function() { // 什么都不做或者可以偷偷记录到我们自己的日志里 // console._realLog console._realLog.apply(rawConsole, arguments); }.toString(); // 返回函数体字符串用于检测 } // 其他属性正常返回 const value target[prop]; return typeof value function ? value.bind(target) : value; }, set(target, prop, value) { // 阻止任何对console对象的修改 return true; } }); Object.defineProperty(window, console, { value: fakeConsole, writable: false, configurable: false }); // 2. 处理 debugger 语句 (一种方案重写 Function 构造函数) const originalFunction window.Function; window.Function new Proxy(originalFunction, { construct(target, args) { const code args[args.length - 1]; // 最后一个参数是函数体字符串 // 检查函数体内是否包含 debugger 关键字可以选择性移除或替换 if (typeof code string code.includes(debugger)) { const cleanedCode code.replace(/debugger;?/g, // debugger removed;); args[args.length - 1] cleanedCode; } // 使用 Reflect.construct 来创建实例 return Reflect.construct(target, args); }, apply(target, thisArg, args) { // 处理 Function() 直接调用的情况 return target.apply(thisArg, args); } }); // 3. 钩住 Object.defineProperty防止其用于设置不可修改的属性来锁定检测状态 const originalDefineProperty Object.defineProperty; Object.defineProperty function(obj, prop, descriptor) { // 如果尝试锁定某些关键对象的属性如 console 的某个方法我们可以拦截 if (obj window prop console) { return obj; // 静默失败或返回我们伪造的console } // 其他情况正常执行 return originalDefineProperty.call(this, obj, prop, descriptor); }; console.log(环境净化脚本已注入。); })();注意上述脚本是一个通用框架具体拦截哪些属性、如何伪造返回值需要根据目标网站的实际检测代码进行动态调整。最有效的方法是在遇到检测后通过断点分析它具体检查了哪个对象的哪个属性然后针对性地进行Proxy代理。2.3 使用无头浏览器构建自动化分析环境对于稳定的逆向和后续的算法提取我更喜欢使用Node.js脚本配合无头浏览器。这样可以将环境补全、代码提取、算法执行全部自动化。这里以puppeteer为例const puppeteer require(puppeteer); (async () { const browser await puppeteer.launch({ headless: false, // 设为true则不显示浏览器窗口 devtools: true, // 自动打开开发者工具方便手动调试 args: [ --disable-blink-featuresAutomationControlled, // 禁用自动化控制特征 --window-size1920,1080 ] }); const page await browser.newPage(); // 关键步骤在页面加载任何脚本之前注入我们的环境补全代码 await page.evaluateOnNewDocument(() { // 这里放置上面那个完整的环境补全脚本内容 // ... (上述Proxy脚本代码) }); // 导航到目标页面猿人学第13题 await page.goto(https://match.yuanrenxue.com/match/13); // 此时页面环境应该已经被“净化”可以安全地进行手动或自动分析了 // 例如可以在这里执行 page.evaluate 来提取解密后的函数 // 注意不要立即关闭浏览器留出手动调试时间 // await browser.close(); })();通过以上步骤我们建立了一个“隐形”的分析基地。在这个环境里网站的检测机制基本失效我们可以清晰地看到原本被混淆和保护的JavaScript代码。接下来我们就可以直面那团被“控制流平坦化”和“字符串混淆”的代码了。3. 反混淆技术与核心逻辑提取绕过环境检测后我们面对的就是一道经典的JS逆向题混淆。第13题主要使用了两种混淆技术字符串混淆和控制流平坦化。我们的任务是将这团“乱麻”理清找到最终的加密入口函数。3.1 识别与处理字符串混淆字符串混淆是最常见的保护手段它将代码中的字符串如API地址、密钥、函数名进行加密存储在运行时动态解密。这导致你在静态代码中搜索关键词变得困难。常见形式字符串被分割成数组如_0x1234 [\x48\x65\x6c\x6c\x6f]然后通过下标访问。字符串经过简单的位运算如XOR或编码如Base64后存储使用时调用一个解密函数。应对策略定位解密函数在混淆代码中搜索charCodeAt、fromCharCode、parseInt、^(异或)、、-等操作通常它们会集中在一个函数里比如_0xdecrypt。动态执行提取最直接的方法是在我们补好的环境中直接执行这段解密函数或者更粗暴地将整个混淆的JS代码在Node.js或浏览器控制台中运行一遍。运行后内存中的变量就已经是解密后的真实字符串了。使用工具自动化可以编写脚本识别出这种模式自动替换。例如找到形如_0x1234[0x1]的引用回溯到_0x1234数组的定义和解密过程计算出最终值并替换。对于这道题我们可以在净化后的浏览器控制台里直接输入包含字符串数组的变量名查看其解密后的内容。通常关键的sign、token、api等词汇就会显现出来。3.2 破解控制流平坦化控制流平坦化是更高级的混淆它打破了代码原本的线性或分支结构将所有代码块塞进一个巨大的switch-case或if-else调度器中由一个“分发器”变量来决定下一个执行哪一块代码。这使得代码逻辑跳转极其混乱难以阅读。核心结构识别 你会看到一个主循环或主函数里面有一个巨大的switch语句case非常多几十上百个。同时会有一个状态变量比如叫_0xstate、index控制着跳转到哪个case。// 简化示例 var _0xstate 0x0; while (true) { switch (_0xstate) { case 0x0: // 代码块 A _0xstate 0x3; break; case 0x1: // 代码块 B _0xstate 0x5; break; case 0x2: // 代码块 C _0xstate 0x1; break; // ... 很多很多 case default: return; } }破解思路 我们的目标不是去理解这个调度器而是还原出原始代码的执行顺序。动态跟踪法推荐这是最有效的方法。利用我们准备好的净化环境在调度器入口通常是while循环或switch语句开始处下断点。然后单步执行并记录下每次_0xstate的值变化以及执行的case编号。通过多次运行可能需要触发不同分支你可以绘制出代码块的执行流程图。许多浏览器的Sources面板支持“记录调用栈”或“代码覆盖”功能也能辅助分析。静态还原工具有一些开源工具如de4js、jsnice的插件或一些AST解析库编写的脚本可以尝试自动化还原控制流平坦化。但对于复杂的、自定义的平坦化效果可能有限且需要一定的JavaScript AST知识。“黑盒”提取法如果我们只关心最终的加密函数而不关心中间的所有流程可以尝试直接定位到加密函数被调用的那一刻。通过搜索加密算法常见的关键词如MD5、SHA、AES、encrypt、CryptoJS、sign等或者通过Hook关键API如JSON.stringify、Date.now、Math.random来定位加密发生的位置。找到后直接将其所在的作用域和依赖函数整体抠出来。对于本题的实操 在净化环境中打开Sources面板搜索switch或大的while循环。找到后在switch语句上方一行下断点。刷新页面让断点触发。然后不要一步步跟而是采用“步过”和“步入”结合的方式重点关注那些涉及参数计算、返回结果赋值的代码块。同时观察Network面板当有API请求发出时调用栈Call Stack会非常清晰地显示出发起请求的函数这个函数往往就是加密逻辑的终点。从终点反向追溯就能理清关键的代码路径。4. 加密算法分析与代码还原经过反混淆我们终于可以看到清晰的加密逻辑了。第13题的加密方式根据过往经验很可能是一种自定义的哈希或对称加密用于生成请求参数中的sign或token值。4.1 定位加密入口与参数首先我们需要确定什么被加密了以及加密结果用在了哪里。Network分析在题目页面点击提交或触发数据请求。在Network面板中查看请求通常是XHR或Fetch。重点关注请求的Query Parameters或Form Data。你会看到一个明显的加密参数比如sign、token、m等它的值是一长串看似随机的十六进制或Base64字符串。堆栈追踪在这个请求的Initiator列点击它可以查看调用栈。调用栈的最顶部是浏览器内置的发送函数往下找第一个非内置的、来自网页脚本的函数很可能就是执行加密并发出请求的函数。点击它可以直接跳转到源码位置。参数回溯在加密函数入口下断点查看它的参数。通常加密函数的输入是当前时间戳、页面固定值、或某个接口返回的nonce等数据的组合。你需要记录下这些输入值的具体内容和格式。4.2 算法识别与简化跳转到加密函数后我们可能会看到一个包含各种位运算、数组操作、循环的函数。我们的目标是理解它并最终用Python或Node.js重写它。常见算法特征MD5/SHA1会引入外部库如CryptoJS或有一个固定的初始化常量数组如MD5的[0x67452301, 0xEFCDAB89, ...]和固定的轮函数结构。AES/DES涉及S盒、密钥扩展、多轮加密。可能会用到CryptoJS或Web Crypto API。Base64有固定的编码表ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789/以及处理填充的逻辑。自定义哈希可能是对输入字符串的字符编码进行加减、异或、循环移位等操作的组合。分析步骤提取核心函数将加密函数及其所有依赖的辅助函数如字节转换、补位函数等的代码全部复制出来。确保复制的是一个完整、独立的代码块不依赖外部未定义的变量除了像Date、Math这样的标准对象。在Node.js中模拟执行创建一个新的Node.js脚本将复制出来的代码粘贴进去。定义一个全局对象window或global如果原代码运行在浏览器环境并补上可能缺失的document、navigator等对象的必要属性用空对象或模拟值代替。然后调用加密函数传入你之前断点记录下的参数看是否能生成与浏览器中一致的加密结果。// 模拟环境 global.window global; global.document {}; global.navigator { userAgent: Mozilla/5.0 ... }; // 粘贴加密函数及其依赖 function encryptData(data) { // ... 混淆后的加密代码现在应该是清晰的了 } // 测试 const testInput 参数1value1¶m2value2×tamp1234567890; const result encryptData(testInput); console.log(加密结果:, result); console.log(预期结果:, 从浏览器Network面板复制来的sign值);算法简化与重写如果模拟成功恭喜你你已经拥有了可用的JS代码。但为了集成到Python爬虫中我们通常需要将其重写为Python版本。逐行翻译对于简单的位运算和逻辑Python和JavaScript几乎可以一一对应。注意JavaScript的无符号右移在Python中需要用(x 0xffffffff) n来模拟。处理字符编码JavaScript中字符串是UTF-16charCodeAt()返回的是16位编码单元。Python 3中字符串是Unicode可以使用ord()获取Unicode码点但对于大于0xFFFF的字符两者处理方式不同需要特别注意。如果加密只涉及ASCII字符则问题不大。引入加密库如果识别出是标准算法如MD5、HMAC-SHA256直接使用Python的hashlib库即可无需重写JS逻辑。复杂自定义算法如果算法非常复杂可以考虑使用execjs、PyExecJS或Node.js子进程来直接执行你提取出来的纯净JS函数。这是最稳妥但效率较低的方法。4.3 本题加密逻辑实例解析假设通过分析我们发现第13题的加密是一个对时间戳 固定密钥进行MD5后再与某个动态值进行异或的自定义签名。核心JS代码可能如下已反混淆function generateSign(timestamp, key) { // 1. 拼接字符串 var rawStr timestamp _ key; // 2. 使用 CryptoJS.MD5 (假设网站引入了CryptoJS) var hash CryptoJS.MD5(rawStr).toString(); // 3. 取前8位和后8位中间插入一个固定字符 var part1 hash.substr(0, 8); var part2 hash.substr(-8); var dynamic window.globalDynamicValue || 0xabcd; // 这是一个从其他接口获取的值 // 4. 简单的异或操作示例 var xorResult ; for (var i 0; i part1.length; i) { var charCode part1.charCodeAt(i) ^ dynamic.charCodeAt(i % dynamic.length); xorResult String.fromCharCode(charCode); } // 5. 最终sign var sign xorResult part2; return btoa(sign); // 或者可能是 hex 编码 }对应的Python实现import hashlib import base64 def generate_sign(timestamp, key, dynamic_value): # 1. 拼接字符串 raw_str f{timestamp}_{key} # 2. MD5哈希 hash_obj hashlib.md5(raw_str.encode(utf-8)) hash_hex hash_obj.hexdigest() # JavaScript的 toString() 默认是hex # 3. 取部分 part1 hash_hex[:8] part2 hash_hex[-8:] # 4. 异或操作 (注意编码处理) xor_result dynamic_str str(dynamic_value) for i in range(len(part1)): # 确保字符编码在0-255范围内简单处理 char_code ord(part1[i]) ^ ord(dynamic_str[i % len(dynamic_str)]) xor_result chr(char_code) # 5. 组合并Base64编码 sign xor_result part2 # 注意JavaScript的 btoa 默认对Latin1字符编码如果xor_result包含非Latin1字符会出错。 # 更稳妥的方式是模拟JS的 btoa(unescape(encodeURIComponent(s))) # 这里假设结果在Latin1范围内简化处理 try: encoded_sign base64.b64encode(sign.encode(latin-1)).decode(ascii) except: # 如果不行可能需要使用复杂一点的编码转换 encoded_sign base64.b64encode(sign.encode(utf-8)).decode(ascii) return encoded_sign # 测试 timestamp 1640995200000 key yuanrenxue_13 dynamic_value 0xabcd # 这个值需要从页面或其他接口获取 result generate_sign(timestamp, key, dynamic_value) print(f生成的sign: {result})关键注意事项在JS到Python的转换中字符编码和字符串处理是最大的坑。务必使用相同的编码通常是UTF-8或Latin-1并使用ord()/chr()和charCodeAt()/String.fromCharCode()进行精确对应。对于btoa/atob要理解它只支持Latin-1字符集在Python中可能需要用base64.b64encode(s.encode(latin-1))来模拟。5. 完整流程集成与自动化当我们成功提取并验证了加密算法后最后一步就是将其集成到一个完整的自动化流程中用于批量获取题目要求的数据。5.1 构建请求流程猿人学题目的典型流程是访问题目页面获取可能存在的初始token或key。计算加密参数通常是sign其中会用到步骤1的token、当前时间戳等。携带加密参数请求数据接口如/api/match/13。从返回的JSON数据中提取目标数据如data字段下的值并进行求和或其他计算。我们需要用Python脚本模拟这个流程。以requests库为例import requests import time import hashlib import json # 导入我们上面写好的 generate_sign 函数 # from your_crypto_module import generate_sign def get_page_token(): 获取初始页面的token或密钥 url https://match.yuanrenxue.com/match/13 headers { User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 } resp requests.get(url, headersheaders) # 通常token隐藏在页面HTML的某个script变量中或者是一个初始接口的响应中 # 这里需要根据实际页面结构用正则或解析库提取 # 假设我们通过正则提取到了一个变量 window.key abc123; import re match re.search(rwindow\.key\s*\s*([^]), resp.text) if match: return match.group(1) else: # 也可能是一个固定的值 return default_fixed_key def get_data(sign): 携带签名请求数据接口 api_url https://match.yuanrenxue.com/api/match/13 params { page: 2, # 假设是第二页 sign: sign, t: int(time.time() * 1000) # 当前时间戳 } headers { User-Agent: Mozilla/5.0 ..., Referer: https://match.yuanrenxue.com/match/13 } resp requests.get(api_url, paramsparams, headersheaders) return resp.json() def main(): # 1. 获取密钥 key get_page_token() print(f获取到的密钥: {key}) # 2. 生成签名 (假设dynamic_value也需要从某个地方获取这里简化) timestamp int(time.time() * 1000) dynamic_value 0xabcd # 这个值可能需要从第一个接口或页面中解析 sign generate_sign(timestamp, key, dynamic_value) print(f生成的签名: {sign}) # 3. 请求数据 data_json get_data(sign) print(f接口返回: {json.dumps(data_json, indent2, ensure_asciiFalse)}) # 4. 提取并计算数据 (假设题目要求对data.value求和) if data_json.get(data): total sum(item[value] for item in data_json[data]) print(f数据求和结果: {total}) else: print(未获取到有效数据) if __name__ __main__: main()5.2 错误处理与调试在实际运行中你可能会遇到各种错误签名错误这是最常见的。原因可能是时间戳格式不对JS可能是Date.now()毫秒或Math.floor(Date.now()/1000)秒需要保持一致。密钥或动态值获取错误检查提取key和dynamic_value的逻辑是否正确是否漏掉了某些请求。编码问题确保Python和JS的字符串编码、Base64编码方式完全一致。强烈建议将JS加密函数在Node.js环境中运行的结果与Python函数运行的结果进行逐字节比对。算法还原有误重新检查加密函数的每一个步骤特别是循环边界、位运算的优先级和类型转换。请求被拒绝403/412可能是请求头不完整。除了User-Agent和Referer有时还需要Cookie特别是会话信息、X-Requested-With等。使用浏览器开发者工具的Network面板仔细对比你的Python请求和浏览器请求的Headers差异并逐一补全。IP或频率限制猿人学平台可能会对高频请求进行限制。需要在请求间增加随机延时如time.sleep(random.uniform(1, 3))或者考虑使用代理IP池。5.3 效率优化与实战技巧缓存与复用如果key或dynamic_value在短时间内不变可以将其缓存起来避免每次请求都去获取页面。并发请求在需要爬取多页数据时可以使用asyncioaiohttp或concurrent.futures进行并发请求但务必注意控制频率避免触发反爬。本地执行JS对于极其复杂、难以用Python重写的加密逻辑直接使用execjs调用剥离出来的JS函数是最快最准的方式。虽然会损失一些性能但开发效率极高。import execjs with open(encrypt.js, r, encodingutf-8) as f: js_code f.read() ctx execjs.compile(js_code) sign ctx.call(generateSign, timestamp, key, dynamic_value)逆向工程没有一成不变的套路每一道题都是新的挑战。猿人学第13题融合了环境检测、混淆和自定义加密是一个非常好的综合练习。核心思路永远是先创造一个稳定的分析环境然后动态跟踪、逐步简化最后精准还原。当你成功跑通整个流程看到那个正确的求和数字时那种成就感就是最好的回报。记住耐心和细致的观察力是逆向工程师最重要的工具。