JS逆向实战:瑞数412会话还原
声明本文仅记录授权测试环境下的调试过程和工程化分析思路文中的域名、接口路径、Cookie 名称和敏感常量均已做脱敏处理。代码片段只用于学习 JS 逆向、协议分析和问题排查不提供针对真实站点的完整可运行客户端。一、案例介绍本文介绍一个典型的 Web 端瑞数挑战还原案例裸请求访问目标站数据查询页面时返回HTTP 412响应体不是业务 JSON而是一段挑战 HTML在会话状态恢复后业务接口还需要额外的timestamp sign请求签名。这个案例可以拆成两层瑞数挑战层执行挑战页 JS得到当前 UA 绑定的会话 Cookie。业务签名层按前端规则生成timestamp和sign再调用数据接口。最终目标不是用浏览器自动化长期跑业务而是在 Node.js 中完成最小协议复现挑战脚本由可控 DOM 环境执行后续页面、配置和 API 请求全部走undici tough-cookie。二、环境准备本节案例使用 Node.js核心依赖如下{type:module,dependencies:{cheerio:1.2.0,sdenv:1.1.3,tough-cookie:6.0.1,undici:8.3.0}}undici负责纯协议请求tough-cookie负责维护 CookieJarcheerio用来解析真实页面里的标题和脚本列表。sdenv用来在 Node.js 环境里执行挑战页脚本把脚本写入的 Cookie 保存下来。常用验证命令可以拆成三类# 步骤一观察裸请求基线pnpmbaseline# 步骤二验证挑战 Cookie 生成后能否进入真实页面pnpmtest:session# 步骤三验证签名 API 是否返回业务数据pnpmtest:search ----repeat2这三个命令对应三个验收点先确认问题确实是挑战页再确认会话恢复最后确认签名后的接口可用。排查时不要一上来就写签名函数否则很容易把412挑战层和业务参数层混在一起。三、抓包与 412 特征观察第一步先做裸请求基线只保留最基础的请求头// 步骤一裸请求基线观察是否进入挑战页import{request}fromundici;consttargets[https://example.invalid/data/home.html,https://example.invalid/data/search.html,];for(consttargetoftargets){constresponseawaitrequest(target,{headers:{user-agent:curl/8.0.0,accept:*/*,},});constbodyawaitresponse.body.text();console.log({statusCode:response.statusCode,contentType:response.headers[content-type],hasChallenge:body.includes($_ts)||body.includes($_ts.cd),});}这里重点看三个字段状态码、响应类型、响应体特征。若状态码是412content-type是text/html并且响应体存在$_ts一类挑战变量就说明当前拿到的是挑战页不是业务页面。基线输出可以整理成下面这样{statusCode:412,contentType:text/html; charsetutf-8,hasChallenge:true}至此可以确定直接改业务参数、分页参数或Accept头没有意义。当前第一目标是还原“首包挑战 HTML - JS 执行 - Cookie 写入 - 二次请求 200”的会话链路。四、瑞数挑战链路拆解瑞数这类挑战不是一个简单的md5()签名函数。它更像一个浏览器状态生成过程脚本会读取navigator、location、document.cookie、UA 等环境信息然后写入会话 Cookie。可以把链路抽象成第一次请求页面 - 服务端返回 412 挑战 HTML - 挑战脚本在 DOM 环境中执行 - 脚本写入 acw_tc / RS_COOKIE_* 等状态 - 同一 UA CookieJar 再请求真实页面 - 进入业务页面和接口层项目里的会话类可以简化成下面的结构// 步骤二使用 sdenv 执行挑战页并导入 Cookieimport{createRequire}fromnode:module;import{CookieJar}fromtough-cookie;constrequirecreateRequire(import.meta.url);exportclassRuishuSession{constructor(options{}){this.originhttps://example.invalid;this.entryPath/data/search.html;this.userAgentoptions.userAgent;this.cookieJarnewCookieJar();this.domnull;}asyncinit(){constsdenvrequire(sdenv);this.domawaitsdenv.jsdomFromUrl(this.originthis.entryPath,{userAgent:this.userAgent,referrer:this.origin/data/home.html,});awaitthis.waitForChallenge();this.importCookies();this.dom.window.close();}}这段代码的关键不是“打开页面”而是让挑战页在 Node.js 的 DOM 环境中完成执行。执行完成后不从浏览器手动复制 Cookie而是从sdenv的 cookieJar 导入到自己的tough-cookieCookieJar。等待挑战完成时不建议依赖混淆函数名因为函数名经常变化。更稳的做法是监听执行边界// 步骤三监听挑战脚本完成边界waitForChallenge(){returnnewPromise((resolve){letdonefalse;constfinish(){if(done)return;donetrue;setTimeout(resolve,1500);};this.dom.window.addEventListener(sdenv:exit,finish);this.dom.window.addEventListener(sdenv:location.replace,finish);this.dom.window.addEventListener(sdenv:location.assign,finish);setTimeout(finish,12000);});}瑞数脚本执行结束后常见动作是退出、跳转或替换location。监听这些稳定边界比在大段混淆代码里追动态函数名更适合工程落地。Cookie 导入和校验可以这样写// 步骤四导入挑战脚本写入的 Cookie并做名称校验importCookies(){constcookiesthis.dom.cookieJar.getCookiesSync(this.origin);for(constcookieofcookies){this.cookieJar.setCookieSync(cookie.toString(),this.origin);}}assertReady(){constnamesthis.cookieJar.getCookiesSync(this.origin).map((item)item.key);if(!names.includes(acw_tc)||!names.some((name)/^RS_COOKIE_/.test(name))){thrownewError(challenge cookies missing:${names.join(,)});}}这里要注意只看到acw_tc不一定够。实际项目里还会有一组挑战状态 Cookie校验时至少要确认主 Cookie 和挑战状态 Cookie 都存在避免出现“脚本执行了但会话状态没成功”的假阳性。五、签名参数定位会话层解决的是412。进入真实页面后数据 API 还有业务签名。定位时可以优先搜索这些锚点timestamp sign md5 headers searchValue pageNum项目中签名规则可以概括为过滤空值。按keyvalue形式拼接参数。对拼接结果按字符串排序。使用连接。末尾追加脱敏后的业务常量。整体做 URL 编码并修正()!~。对编码后的字符串计算 MD5。对应代码片段如下// 步骤五还原业务接口 sign 生成逻辑importcryptofromnode:crypto;constAPP_SECRETsecret;functionisPresent(value){returnvalue!value!undefinedvalue!null;}exportfunctiongetSignString(params){returnObject.keys(params).filter((key)isPresent(params[key])).map((key)${key}${params[key]}).sort().join();}exportfunctionsignParams(params,secretAPP_SECRET){constraw${getSignString(params)}${secret};constencodedencodeURIComponent(raw).replace(/\(/g,%28).replace(/\)/g,%29).replace(/!/g,%21).replace(/~/g,%7E);returncrypto.createHash(md5).update(encoded).digest(hex);}这里最容易错的是编码顺序。不是分别编码每个字段也不是简单md5(query secret)而是拼接完成后整体编码再做额外字符修正。六、Node.js 协议请求实现业务 API 请求时timestamp既要放在查询参数中也要放在请求头中并且两处必须一致// 步骤六构造带 timestamp 和 sign 的 API 请求asyncfunctionsignedApiRequest(session,path,params){consttimestampDate.now();constsignedParamscleanParams({...params,timestamp});constsignsignParams(signedParams);returnsession.request(path,{retryOn412:false,params:signedParams,headers:{accept:application/json, text/plain, */*,referer:https://example.invalid/data/search.html,token:false,timestamp:String(timestamp),sign,},});}这里的session.request()需要统一复用挑战阶段生成的 UA 和 CookieJar。若生成 Cookie 时使用一个 UA业务请求时换成另一个 UA很容易重新落回412。搜索流程可以拆成两步先用统计接口判断哪些栏目有结果再调用列表接口获取分页数据。// 步骤七先统计再查询列表asyncfunctionsearch(client,keyword,page1,pageSize10){constcountsawaitclient.apiGet(/api/count,{itemIds:item_a,item_b,item_c,searchValue:keyword,isSenior:N,});constselectedcounts.data.find((item)Number(item.nums)0)?.itemId;returnclient.apiGet(/api/search,{itemId:selected,isSenior:N,searchValue:keyword,pageNum:page,pageSize,});}这样做的好处是避免盲目请求所有栏目。实际工程里还可以加载前端配置 JSON把返回的f0/f1/f2字段映射成可读字段名。七、412 恢复与重试策略会话 Cookie 可能过期或者某一次请求中途重新进入挑战页。因此请求层需要识别412并刷新会话// 步骤八底层请求只负责刷新一次asyncrequest(pathOrUrl,options{}){if(!this.ready){awaitthis.init();}constresponseawaitthis.requestOnce(pathOrUrl,options);if(response.statusCode412options.retryOn412!false){awaitthis.refresh();returnthis.requestOnce(pathOrUrl,options);}returnresponse;}底层 session 只做一次刷新避免无限递归。业务 API 层可以再控制最大尝试次数、退避时间和是否重载配置这样挑战层和业务层的职责更清楚。八、验证与测试第一组验证是会话验证重点确认412是否变成200以及真实业务脚本是否加载{ok:true,statusCode:200,cookieNames:[acw_tc,RS_COOKIE_O,RS_COOKIE_S,RS_COOKIE_P],title:某数据查询,scripts:[js/ajax.js,js/api.js,js/index/search-result.js]}看到statusCode: 200只能说明挑战层通过了还不能说明业务接口签名正确。还需要继续验证列表接口是否返回数据。第二组验证是签名 API 查询{ok:true,repeat:2,itemName:样例栏目,total:552,first:{fields:{字段一:样例值一,字段二:样例值二,字段三:样例值三}}}连续运行两次都返回非空列表说明会话 Cookie、UA、timestamp、sign和分页参数之间的关系基本正确。若这里失败要分别检查响应是否重新变成412还是业务 JSON 返回了签名错误。九、踩坑记录UA 必须一致挑战 Cookie 和 UA 有绑定关系。用sdenv生成 Cookie 时是一套 UA后续 API 请求必须继续使用同一套 UA。页面 200 不等于 API 可用真实页面能打开只说明瑞数挑战过了。业务接口如果缺少timestamp、sign或签名编码细节不一致仍然会失败。不要只盯着 md5 搜索瑞数挑战层不是普通签名函数直接搜索md5、sha往往定位不到挑战逻辑。挑战层更适合看首包状态、Cookie 写入和二次请求结果。Cookie 校验要检查一组状态只检查acw_tc容易误判。建议同时检查RS_COOKIE_*这类挑战状态 Cookie并在缺失时直接抛错。编码细节会影响 sign签名中()!~的编码修正很容易漏掉。如果关键词或参数里出现特殊字符默认encodeURIComponent与前端实现可能不完全一致。