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-features=AutomationControlled', // 禁用自动化控制特征 '--window-size=1920,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 = '参数1=value1¶m2=value2×tamp=1234567890'; const result = encryptData(testInput); console.log('加密结果:', result); console.log('预期结果:', '从浏览器Network面板复制来的sign值');- 算法简化与重写:如果模拟成功,恭喜你,你已经拥有了可用的JS代码。但为了集成到Python爬虫中,我们通常需要将其重写为Python版本。
- 逐行翻译:对于简单的位运算和逻辑,Python和JavaScript几乎可以一一对应。注意JavaScript的
>>>(无符号右移)在Python中需要用(x & 0xffffffff) >> n来模拟。 - 处理字符编码:JavaScript中字符串是UTF-16,
charCodeAt()返回的是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, headers=headers) # 通常token隐藏在页面HTML的某个<script>变量中,或者是一个初始接口的响应中 # 这里需要根据实际页面结构用正则或解析库提取 # 假设我们通过正则提取到了一个变量 `window.key = 'abc123';` import re match = re.search(r"window\.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, params=params, headers=headers) 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, indent=2, ensure_ascii=False)}") # 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函数运行的结果进行逐字节比对。
- 算法还原有误:重新检查加密函数的每一个步骤,特别是循环边界、位运算的优先级和类型转换。
- 时间戳格式不对:JS可能是
请求被拒绝(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在短时间内不变,可以将其缓存起来,避免每次请求都去获取页面。 - 并发请求:在需要爬取多页数据时,可以使用
asyncio+aiohttp或concurrent.futures进行并发请求,但务必注意控制频率,避免触发反爬。 - 本地执行JS:对于极其复杂、难以用Python重写的加密逻辑,直接使用
execjs调用剥离出来的JS函数是最快最准的方式。虽然会损失一些性能,但开发效率极高。import execjs with open('encrypt.js', 'r', encoding='utf-8') as f: js_code = f.read() ctx = execjs.compile(js_code) sign = ctx.call('generateSign', timestamp, key, dynamic_value)
逆向工程没有一成不变的套路,每一道题都是新的挑战。猿人学第13题融合了环境检测、混淆和自定义加密,是一个非常好的综合练习。核心思路永远是:先创造一个稳定的分析环境,然后动态跟踪、逐步简化,最后精准还原。当你成功跑通整个流程,看到那个正确的求和数字时,那种成就感就是最好的回报。记住,耐心和细致的观察力,是逆向工程师最重要的工具。