JS逆向入门实战:猿人学第一题时间戳签名与反爬机制解析
第一次打开猿人学第一题的页面你大概率会愣一下——明明是去拿接口数据响应里却蹲着一大段被eval包了好几层、变量名全部被打乱的JS代码。这就是很多人的JS逆向第一课也是我在研究反爬机制时真正“开窍”的地方。猿人学这个系列题在国内JS逆向圈子里流传很广第一题属于标准的“入门练手”难度但它的技术栈组合非常典型接口返回混淆JS、前端动态生成时间戳签名、后端同时校验Cookie和User-Agent。把这题从抓包到落地的全过程拆开讲清楚后面再遇到“怎么都抓不到数据”的场景你会有非常明确的排查思路。这篇文章适合正在学爬虫想进阶JS逆向的人也适合刚接触反爬、想系统理解前端防护逻辑的朋友。我会把第一题涉及的每一个环节都展开讲包括抓包分析、混淆代码还原、签名算法推导、浏览器环境补齐以及两个不同的落地执行方案。最后还会整理我在做题过程中踩过的几个坑这些细节往往是教程里不会专门提的。1. 猿人学第一题到底在考什么先看清题目背后的技术栈先明确一下题目目标猿人学第一题要求抓取某个数据接口下所有页面的数字然后计算总和。听起来就是个普通的分页爬虫但实际动手会发现直接用requests请求接口返回的根本不是JSON而是一段被混淆过的JS脚本。这个现象本身就是反爬机制的一种设计——服务器不直接给你数据而是先给你一段“生成请求参数”的代码你必须在本地执行这段代码拿到正确的签名参数后再带着参数去请求真实数据接口。这种机制在行业内叫“前端参数动态化”核心目的有两个第一让普通爬虫脚本直接失效因为你的请求里缺少经过计算的签名第二提高批量抓取的成本因为攻击者必须分析你的前端逻辑才能模拟出同样的签名过程。从攻防视角看第一题其实是在模拟一个非常真实的反爬场景。整道题涉及的技术点可以拆成下面这张表环节涉及技术作用数据入口requests、浏览器开发者工具观察请求和响应确认返回的不是JSON而是JS响应处理eval混淆、Packer变形隐藏签名生成逻辑防止一眼看穿参数生成时间戳 MD5摘要让请求参数具有时效性防重放身份校验Cookie、User-Agent服务端识别客户端身份校验请求合法性为什么说它适合作为第一题因为它不涉及native层、不涉及so文件、也没有VMP虚拟机和AST抽象语法树还原这些高难度内容。它考察的是最基础的三个能力第一能不能看懂一个被混淆过的JS文件第二能不能从中提炼出签名参数的计算逻辑第三能不能把前端计算逻辑搬到后端的爬虫脚本里。这三个能力恰恰是JS逆向最核心的日常操作。把这道题吃透等于把“抓包→分析→还原→落地”这条主干流程完整地跑了一遍。很多人做这道题时遇到的第一个困惑是我明明按照接口文档请求了为什么返回一段莫名其妙的JS其实这时候不应该慌反而应该意识到——题目真正想让你分析的就是这段返回的JS。服务端把“如何生成签名参数”这件事直接甩给了客户端而你要做的事情就是去阅读、理解、提取这段代码中的关键逻辑。2. 抓包分析从接口返回里发现“猫腻”2.1 第一次请求响应里是一坨压缩后的JS打开浏览器开发者工具切到Network面板找到那个名为/api/match/1的接口刷新页面就能看到请求记录。点进去看一下响应内容会发现它不是常规的JSON而是类似下面这样的一段代码eval(function(p,a,c,k,e,d){efunction(c){return c.toString(36)};if(!.replace(/^/,String)){while(c--){d[c.toString(a)]k[c]||c.toString(a)}k[function(e){return d[e]}];efunction(){return\\w};c1};while(c--){if(k[c]){pp.replace(new RegExp(\\be(c)\\b,g),k[c])}}return p}(...,36,36,....split(|),0,{}))如果你用的是requests直接请求返回的内容是一样的。很多人第一次看到这段代码会误以为接口报错了或者被防火墙拦了。实际上这恰恰是服务器正常的响应。这里有个判断技巧看一下响应头里的Content-Type如果是application/javascript或者text/javascript基本可以确定服务器就是故意返回JS的。我当时的第一反应是先把这段代码保存下来格式化一下看看结构。但格式化之后还是很难直接阅读因为变量名和字符串都被编码过。这就是Packer混淆的特点——它把原始代码压缩成长串的编码字符串然后通过eval动态执行还原。碰到这种混淆方式第一步要做的不是去一行行读代码而是先想办法拿到还原后的“源码”。2.2 还原Packer混淆把eval换成console.logPacker混淆有个非常经典的规律它是一个自执行函数内部把编码字符串解码后交给eval执行。要查看解码后的真实代码最简单的办法就是把eval(改成console.log(让函数把解码结果打印出来而不是执行。具体操作如下// 把这段代码粘贴到浏览器控制台或者用Node.js执行 console.log(function(p,a,c,k,e,d){efunction(c){return c.toString(36)};if(!.replace(/^/,String)){while(c--){d[c.toString(a)]k[c]||c.toString(a)}k[function(e){return d[e]}];efunction(){return\\w};c1};while(c--){if(k[c]){pp.replace(new RegExp(\\be(c)\\b,g),k[c])}}return p}(...,36,36,....split(|),0,{}))运行之后控制台会输出还原后的JavaScript源码。如果输出太长可以在浏览器控制台里右键复制或者用Node.js执行后写入文件node decode.js source.js拿到还原后的源码这道题的难度已经降了三分之一。你不需要去理解Packer的解码算法本身只需要知道它最终会还原出一段“正常”的JS代码。真正的考题就藏在还原出的源码里。2.3 在还原后的代码里找关键变量还原后的代码通常不会太长几十行到一百行左右。我的习惯是先把代码里的函数名和变量名扫一遍重点关注几个东西time、timestamp、md5、sign、m、f这类关键词。猿人学第一题的代码里签名生成逻辑非常清晰核心就是生成一个叫m的参数以及一个跟时间戳有关的参数f。大致逻辑类似于下面的样子var timestamp Date.parse(new Date()) / 1000; var m md5(timestamp.toString());有的版本可能是把时间戳拼上一个固定字符串再做MD5比如md5(timestamp xxxx)。不同版本题面细节有差异这很正常。关键是你要看懂请求参数里有一个基于当前时间计算出来的MD5值。这个值就是服务端校验的核心。看到这一步你已经知道服务端想要什么了。还需要注意一个细节代码里可能会检测当前环境是否存在window对象。如果环境不满足就直接报错或者在控制台输出一堆干扰信息。这就是题目设置的“环境检测”目的是防止你把整段JS复制到Node.js里直接跑。这个问题我会在第四部分专门展开。3. 时间戳签名逻辑复盘核心参数是怎么算出来的3.1 核心代码逐行解读我们把上一步还原出来的核心代码拿出来逐行看一遍。假设你看到的代码是这样function getSign() { var t parseInt(Date.parse(new Date()) / 1000) ; var m md5(t); return {m: m, f: t}; }拆开来看new Date()获取当前时间对象。Date.parse(new Date())把时间转换为时间戳单位是毫秒。比如某个时刻返回1780123456789。Math.round(... / 1000)或parseInt(... / 1000)把毫秒时间戳换算成秒级时间戳因为后端校验时通常使用秒级精度。 数字转字符串因为MD5函数需要字符串输入。md5(t)对时间戳字符串做MD5摘要得到一串32位的十六进制字符串。这里的m就是最终请求时携带的签名参数f是明文时间戳。服务端收到请求后会拿f参数重新计算一次MD5对比是否等于m。如果相等说明这个请求是由“知道签名逻辑的客户端”发出的如果不相等请求就会被拒绝。如果请求里的时间戳跟服务器当前时间相差太大也会被拒绝——这是为了防止有人把签名参数写死实现固定请求。3.2 为什么偏偏用时间戳时间戳是签名算法里最常用的元素因为它有两个天然特点不可预测性以及时效性。同一个MD5值在一秒之后就会失效因为时间戳变化了这就迫使客户端必须在每次请求前动态计算签名。对爬虫来说如果你直接把第一次抓到的m和f写死在代码里下一次请求基本必挂。这里顺带提一个常见的误区很多新手以为拿到了接口返回的JS就等于破解了这道题。实际上你只是看到了生成签名的手段还必须真的去执行它或者用等价逻辑重写它。服务端校验的不只是“你会不会写这段JS”而是“你发送的请求里是否携带了正确签名”。这个区别很关键。3.3 签名校验的完整链路把整个请求过程串起来服务端的校验链路大致是这样客户端访问/api/match/1页面或者直接请求接口地址。服务端返回一段渲染页面所需的JS代码同时通过Set-Cookie种下一个标识身份的Cookie。客户端在本地执行JS代码生成基于当前时间戳的签名参数m和f。客户端带着Cookie、User-Agent、签名参数向/api/match/1发起数据请求。服务端校验Cookie是否有效、UA是否符合预期、签名是否与时间戳匹配、时间戳是否在允许的时间窗口内。校验通过则返回JSON数据否则返回错误信息或空数据。理解了这条链路你就会明白为什么“光看代码”不够必须完整模拟客户端的整个行为。拿到JS后下一步的核心问题就是怎么在非浏览器环境下执行它或者怎么把它的逻辑转换成Python代码。4. 环境检测与浏览器对象补齐让混淆JS乖乖执行4.1 在Node或PyExecJS里执行前端JS时缺了什么前端JS代码在浏览器里能正常运行是因为浏览器提供了一整套运行环境window、document、navigator、location、localStorage等等。但这些对象在Node.js里都不存在。直接把还原出来的JS代码扔进Node.js执行大概率会报错比如ReferenceError: window is not defined或者document is not defined。这就是题目设置的环境检测点。有些题目还会检测navigator.userAgent、document.cookie、window.navigator等更细节的东西甚至会用canvas指纹这种更高级的手段。不过猿人学第一题算比较温和的通常只需要一个简单的window对象就能骗过去。4.2 用Node的vm模块快速补齐环境如果你选择在Node.js里执行JS可以用vm模块创建一个独立的执行上下文在上下文中预置一些浏览器对象。示例代码如下const vm require(vm); const fs require(fs); // 读取还原后的JS文件 const code fs.readFileSync(source.js, utf-8); // 构造一个沙箱环境 const sandbox { window: {}, document: {}, navigator: { userAgent: yuanrenxue.project }, console: console, Date: Date, Math: Math, parseInt: parseInt }; // 把沙箱里的window指向自己模拟浏览器全局对象 sandbox.window sandbox; sandbox.global sandbox; vm.createContext(sandbox); vm.runInContext(code, sandbox); // 调用JS里暴露出来的函数 const m vm.runInContext(getSign(), sandbox); console.log(m);这里有一个技巧把window指回sandbox本身因为前端代码里经常出现window.xxx的写法如果window是独立对象很多通过window挂载的变量就会丢失。另外navigator.userAgent这里需要特别注意因为猿人学第一题的真题明确要求请求头里的User-Agent必须是yuanrenxue.project否则拿不到数据。这个细节我会在后面踩坑部分再强调。4.3 更推荐的思路不执行整段JS直接抠核心函数虽然补齐环境可以执行整段JS但在真实开发中我更推荐另一种思路只提取签名逻辑不执行无关代码。原因很简单执行整段JS依赖太多环境属性一旦题目升级环境检测变复杂你就得不断给沙箱“打补丁”维护成本很高。而提取核心函数只需要看懂签名算法的逻辑然后用JavaScript或Python重新实现即可。对于这道题核心函数就两个变量时间戳和MD5。完全可以在Node里只用crypto模块复现const crypto require(crypto); function getSign() { const t String(Math.round(Date.now() / 1000)); const m crypto.createHash(md5).update(t).digest(hex); return {m, f: t}; } console.log(getSign());甚至不需要还原出来的原始JS光靠观察接口请求参数你也能自己推导出这套逻辑。这正是“逆向”的本质不是照搬别人的代码而是理解其原理后用你自己的方式等价实现。5. 完整落地Node执行与Python纯算双方案5.1 方案一PyExecJS 补齐环境执行JS如果你不想费劲推导算法想直接用现成的JS代码可以选择在Python中调用JS引擎。最常用的库是PyExecJS它底层依赖Node.js或PhantomJS。安装命令如下pip install PyExecJS然后准备一个JS文件sign.js内容是提取出来的签名函数function getSign() { var t parseInt(Date.parse(new Date()) / 1000) ; var m md5(t); return {m: m, f: t}; }注意这里的md5函数需要前置定义。很多前端代码会自带MD5实现直接从还原代码里复制即可。如果精简版JS里没有md5函数可以用Node的crypto模块代替const crypto require(crypto); function md5(str) { return crypto.createHash(md5).update(str).digest(hex); } function getSign() { var t parseInt(Date.parse(new Date()) / 1000) ; var m md5(t); return {m: m, f: t}; }Python端调用import execjs import requests # 编译JS with open(sign.js, r, encodingutf-8) as f: ctx execjs.compile(f.read()) sign ctx.call(getSign) print(sign) # {m: xxx, f: xxx} session requests.Session() session.headers.update({User-Agent: yuanrenxue.project}) # 第一次请求获取Cookie session.get(https://match.yuanrenxue.cn/api/match/1) # 带签名请求数据 resp session.get( https://match.yuanrenxue.cn/api/match/1, params{page: 1, m: sign[m], f: sign[f]} ).json() print(resp)PyExecJS的好处是代码改动小前端JS几乎可以原封不动地运行。缺点是执行效率偏低而且它需要一个可用的JS运行时环境。如果环境里没装Node.js它会尝试用其他运行时兼容性比较看运气。5.2 方案二看懂算法后用Python重写推荐对于猿人学第一题这种简单签名我强烈推荐直接用Python重写因为这条路线更通用也更锻炼分析能力。完整代码如下import time import requests from hashlib import md5 # 必须使用固定的User-Agent session requests.Session() session.headers.update({User-Agent: yuanrenxue.project}) def get_sign(): # 秒级时间戳 ts str(int(time.time())) m md5(ts.encode()).hexdigest() return ts, m # 第一次请求主要是为了拿到Cookie session.get(https://match.yuanrenxue.cn/api/match/1) total 0 for page in range(1, 6): ts, m get_sign() # 每次请求前重新计算签名避免复用导致失效 params { page: page, m: m, f: ts } resp session.get(https://match.yuanrenxue.cn/api/match/1, paramsparams) data resp.json().get(data, []) for item in data: total item.get(value, 0) print(f第{page}页完成当前累计: {total}) print(最终答案:, total)这里有一个核心习惯签名参数必须在每次请求前重新计算。如果你把ts和m放在循环外面只算一次后面几页请求必然失败因为时间戳已经过期。另外time.time()返回的是浮点数必须转成整数再转字符串否则MD5的结果对不上。这个坑我踩过一次花了不少时间才排查出来。5.3 两种方案的对比与选型建议维度PyExecJS方案Python纯算方案代码量需要维护JS文件和Python调用代码只需一个Python文件运行速度每次调用都要启动JS引擎偏慢纯Python计算毫秒级环境依赖依赖Node.js等JS运行时只依赖requests、hashlib可维护性前端代码升级时容易过时逻辑透明好改好调适用场景算法复杂、不想推导时应急算法简单或已分析清楚时首选就这道题而言Python纯算方案明显更优。但我也建议初学者把PyExecJS这条路走一遍因为实际工作中很多签名算法并不只是简单的时间戳加MD5可能还涉及AES、RSA、Base64、字符编码转换等一系列操作。那种情况下保留前端JS通过JS引擎执行往往是成本最低的方案。6. 实际踩坑记录练习题里最容易被忽略的细节6.1 忘记先GET一次导致Cookie缺失我第一次做这道题时直接session.get带签名去请求数据接口结果返回了一段错误提示。排查了半天才发现必须先请求一次接口地址让服务端Set-Cookie到session里。后续的数据请求都要带上这个Cookie否则服务端无法确认你的身份。这个套路在真实反爬场景里非常常见很多网站会把Cookie校验和签名校验叠加在一起。解决办法就是前面代码里写的那样用一个session.get(https://match.yuanrenxue.cn/api/match/1)先把Cookie种好再发起真正的数据请求。如果你用的是requests的裸请求每次都要手动带上Cookie很容易漏。强烈建议全程使用requests.Session()。6.2 User-Agent校验不过导致拿不到数据这道题有一个非常隐蔽的校验点请求头里的User-Agent必须设置为yuanrenxue.project。如果你用默认的python-requests/2.x去请求服务端会直接拒绝数据。这个细节设计其实很巧妙它模拟的是“客户端指纹识别”的思路——真实浏览器和爬虫脚本的User-Agent存在明显差异服务端可以借此拦截一批低水平爬虫。解决方法很简单session.headers.update({User-Agent: yuanrenxue.project})需要注意的是这个UA不仅要设置还要保证它和你在JS环境里伪造的navigator.userAgent保持一致。如果JS代码里有读取navigator.userAgent参与计算的情况两边不一致会导致签名结果错误。6.3 时间戳用秒还是毫秒直接影响MD5结果Date.now()返回的是毫秒时间戳Math.round(Date.now() / 1000)才是秒级时间戳。如果你在Python里用了int(time.time() * 1000)来生成毫秒级时间戳而服务端期望的是秒级时间戳那么MD5值完全对不上。反过来也一样。判断方法是看前端JS代码里有没有除以1000的操作。如果还原后的代码里有Date.parse(new Date()) / 1000说明它用的是秒级。如果只有Date.now()没有除法那就是毫秒级。做题前先把这个细节定死能省去大量调试时间。6.4 调试习惯先在浏览器控制台验证再移植代码最后分享一个我做题时养成的习惯拿到还原后的JS代码先在浏览器控制台里跑一遍确认它能在浏览器环境里生成正确的签名参数然后直接把这个签名参数用到请求里看看是否能拿到数据。这一步做通了再开始做环境补齐或者Python重写。这样做的好处是能把“问题定位”和“代码实现”两个阶段分开。如果控制台里生成的签名能成功请求到数据说明你的分析方向是对的后面只是纯工程问题。如果控制台里生成的签名也被拒绝那说明你对代码逻辑的理解有偏差需要继续抠细节。调试逆向题目最大的忌讳就是一上来就写完整爬虫跑不通之后分不清是签名错了、Cookie错了还是UA错了。这道题做完之后你会积累一套可复用的方法论先抓包看返回类型再解混淆看核心逻辑然后推签名算法最后用Python端重算或直接调JS引擎。这套流程在后面的猿人学题目里几乎每一道都能用上。我个人做了几道题之后的体会是JS逆向的核心反而不是“破解”本身而是分析和复现的耐心。只要能把每次报错都拆解成具体的校验点再逐个击破绝大多数反爬题最终都能落地。