猿人学第10题「js混淆 · 重放攻击对抗」协议通关全记录
猿人学第10题「js混淆 · 重放攻击对抗」协议通关全记录
目标:
https://match.yuanrenxue.cn/match/10
题目要求:使用协议爬虫脚本请求本页全部 5 页数据(每页 10 个数字),加和后提交答案。
难度:BOSS 关,难度系数 75。
0. 结论先行
- 本题早已不是早期版本里「时间戳
m签名重放」的简单玩法,而是升级成了 瑞数信息(Ruishu)5 代动态安全防护(VMP)。 - 每个请求都会由页面内混淆 JS 自动注入一个 281 字符的
m=签名。 - 该
m令牌是 一次性(one-time)+ 强时效 的:同一 token 重放会直接返回{"error":"token failed"}。所谓「重放攻击对抗」,对抗的就是拿旧 token 反复打。 - 通过 Node.js + jsdom 补环境 执行瑞数 VMP、在
Function.prototype.apply层截获签名,再用 Python requests 纯协议请求 拿数据,可稳定通关。 - 最终 50 个数加和答案:
24258927
| 页码 | 每页求和 |
|---|---|
| 1 | 4239428 |
| 2 | 5700903 |
| 3 | 4195751 |
| 4 | 5241901 |
| 5 | 4880944 |
1. 抓包与页面结构分析
打开页面,几个关键特征立刻暴露了它是瑞数:
<!-- 特征1: meta 加密指纹 (以 qqq 开头) -->
<meta content="{qqqhDDexFaTvMa0kihgqql4096qqqt1075314760...}">
<!-- 特征2: 瑞数核心引擎 rs.js -->
<script type="text/javascript" charset="iso-8859-1" src="/static/new_match/question/10/rs.js" r='m'>
<!-- 特征3: 动态加密 VM 数据 -->
<script type="text/javascript" charset="iso-8859-1" src="/api2/10" r='y'>
<!-- 特征4: 时间偏移/解码参数 -->
<script type="text/javascript" src="/api/10/offset">
<!-- 特征5: 73KB 内联 VMP 状态机 -->
<script> ... var yuanrenxue_59=192; ... </script>
1.1 四个关键端点
| 端点 | 作用 | 内容 |
|---|---|---|
GET /match/10 |
页面 HTML | meta 指纹 + 内联 VMP(73KB) |
GET /static/.../rs.js |
瑞数核心引擎 | 初始化 $_ts,写入字符串字典(118KB) |
GET /api2/10 |
加密 VM 数据 | 被编码的 $_ts 扩展 + VMP 逻辑(627KB) |
GET /api/10/offset |
解码参数 | window.Cbdc = 192(动态变量名+值) |
1.2 真实数据接口
GET /api/question/10?page=1&pageSize=10&kw=&m=<281字符签名>
响应结构:
{
"data": [182460, 616484, 199740, ...],
"k": {"k": "Cbdc|843"}
}
注意响应里的 k 字段:页面 JS 会执行
window[res.k.k.split('|')[0]] = parseInt(res.k.k.split('|')[1]),
即 window.Cbdc = 843。这是服务端回传的下一状态,用于反重放。
1.3 两道硬性约束
题目明文提示:
- 发送第 5 页时,
User-Agent必须改成yuanrenxue,否则最后一页返回的是提示文字而不是数字。 - 请求必须携带
sessionid,每个已登录用户的数字答案不同。
2. 瑞数 5 代防护原理拆解
瑞数(Botgate / 动态安全)的核心理念不是「加密」而是 环境绑定 + 动态令牌。
2.1 脚本加载与执行顺序
1. rs.js → 初始化全局 $_ts 对象,写入字符串字典 dfe1675
2. api2/10 → 向 $_ts 追加加密数据 dfe1683
3. 内联 VMP → 73KB 状态机,负责:
├─ 解码 api2 数据
├─ eval 出 238KB 保护代码
├─ hook XMLHttpRequest.open(注入 m= 签名)
└─ 写 cookie(m=pua 等)
4. offset → 设置 window.XXXX = N(解码参数)
2.2 数据解码层
api2/10 返回的内容是一串被编码的 Unicode 字符:
$_ts['dfe1683'] = '|yme±ip©§¨wtt¸³~...' // 627KB
还原公式(本文实测 + 相关资料印证):
output[i] = chr(ord(input[i]) - (i % N) - 0x32)
其中 N 来自 /api/10/offset 的返回值。关键点:只有正确的 N 才能让整串字符还原成合法 base64,而 VMP 内联代码里硬编码的 yuanrenxue_59 值与 offset 返回值在同一次页面加载中完全一致(本例都是 192)。
2.3 请求签名层(m= 注入)
VMP 通过 hook XMLHttpRequest.prototype.open,给每个 API 请求的 URL 自动追加签名:
XMLHttpRequest.prototype.open = function _yrx2LR() {
_yrxXdb(); // 环境检查
arguments[1] = _yrxyA$(arguments[1]); // URL 签名!
return _yrxxCJ[_yrxQ9C[32]](this, arguments);
};
浏览器里抓到的真实 URL:
/api/question/10?page=1&pageSize=10&kw=&m=4UrkgIwjsrchfAKOC2qRltb4jx0ieHyhXvZ...
m 是 281 字符签名,由指纹 + 时间戳 + nonce 共同计算,用过即失效。
2.4 环境检测层
| 检测项 | 目的 | 补环境方案 |
|---|---|---|
navigator.webdriver |
检测自动化 | mock 为 undefined |
window.chrome |
检测真实 Chrome | { runtime:{}, app:{} } |
| IndexedDB | 存储/读取 nonce 状态 | mock 空数据库 |
| Canvas/WebGL | 浏览器指纹 | mock 空白画布 |
outerWidth/innerWidth |
检测 DevTools | mock 正常值 |
debugger |
反调试陷阱 | 无 DevTools 时为 no-op |
2.5 控制流混淆层
VMP 使用 控制流平坦化:原始逻辑被拆成 260+ 个 if-else 分支的状态机,通过 yuanrenxue_31(nextState) 递归串联。字符串加密使用 RC4 + base64。单看任何一个片段都无法理解整体逻辑,所以静态还原极难,只能动态执行。
3. 逆向与调试全过程
3.1 第一直觉验证:token 能否重放?
题目叫「重放攻击」,我第一反应就是:抓一次 m,反复用。结果:
curl "https://match.yuanrenxue.cn/api/question/10?page=1&m=<旧token>"
# → {"error": "token failed"}
即使带全 cookie(sessionid、m=155、HM4hUBT0dDOnenable=true)也照样 token failed。结论:重放这条路被堵死了,必须每次生成新 token。
3.2 定位签名来源
用 Playwright 观察页面,发现请求参数是 m 而非页面源码里的 token:
// 页面自己的 JS 写的是 token
var d = {page: p, token: window.token};
$.ajax({url: API, data: d, ...});
但实际请求是 m=...。这说明有东西在 $.ajax 之前改了参数——正是 VMP hook 的 XMLHttpRequest.open。
3.3 补环境,让 VMP 在 Node.js 里跑起来
我选择 jsdom(而不是 Playwright)来跑 VMP,因为题目要求「协议通关、不允许浏览器自动化」,且 jsdom 更轻量可控。
执行顺序照搬浏览器:
window.eval(rsJs); // 瑞数核心
window.eval(api2Js); // 加密 VM 数据
window.eval(offsetJs); // window.Cbdc = 192
window.eval(vmpJs); // 内联 VMP
第一次跑直接崩:
TypeError: Cannot read properties of undefined (reading 'length')
at _yrx1dz ...
3.4 关键调试:dump 出解码后的 VMP
我在 window.eval 上挂了钩子,把 VMP 解码出来的 238KB 代码 dump 下来:
const origEval = window.eval;
window.eval = function (src) {
if (typeof src === 'string' && src.length > 100000) {
fs.writeFileSync('decoded_vm.js', src); // 捕获!
}
return origEval.call(this, src);
};
顺着调用栈 _yrx3kb → _yrxanj(3) → _yrxlIn → _yrx16k → _yrx1dz 往上追,定位到源头是 _yrxWFt(3) 返回了 undefined。而 _yrxWFt 读的是 _yrxI6a 字符串表,这张表来自 meta 指纹的解码。
最终定位到读取 meta 的函数:
function _yrxnhf() {
var r = document.getElementsByTagName('meta');
var last = r[r.length - 1]; // ← 取【最后一个】meta
var content = last.content;
last.parentNode.removeChild(last);
return content;
}
3.5 真正的坑:meta 必须插在末尾
我最初是这么注入 meta 的:
document.head.insertBefore(meta, document.head.firstChild); // ❌ 插到最前
而 jsdom 自带的 <meta charset="UTF-8"> 排在最后。结果 _yrxnhf 读到的最后一个 meta 是空 content 的 charset 标签 → 字符串表解码失败 → undefined.length 崩掉。
改成追加到末尾后,VMP 顺利跑通:
document.head.appendChild(meta); // ✅ 保证是最后一个 meta
4. 最终方案
4.1 整体架构
┌───────────────────────────────────────────────┐
│ 每页请求循环 │
│ │
│ ① Python requests 刷新资源(同一 session) │
│ GET /match/10 → HTML + 内联VMP + meta │
│ GET rs.js → 瑞数核心 │
│ GET /api2/10 → 加密VM数据 │
│ GET /api/10/offset → 解码参数 N │
│ ↓ │
│ ② Node.js (jsdom) 补环境执行 VMP │
│ 加载 rs.js → api2 → offset → vmp │
│ VMP hook XHR.open → 注入 m= 签名 │
│ hook Function.apply → 截获签名 │
│ ↓ │
│ ③ Python requests 纯协议请求 │
│ GET /api/question/10?page=N&m=<token> │
│ → 返回数据 │
└───────────────────────────────────────────────┘
4.2 核心代码一:Node.js 补环境生成 token
文件:work/token_gen.js
const fs = require('fs');
const { JSDOM, VirtualConsole } = require('jsdom');
function generateToken(pageNum, dir = 'work') {
return new Promise((resolve, reject) => {
const rsJs = fs.readFileSync(`${dir}/fresh_rs.js`, 'utf8');
const api2Js = fs.readFileSync(`${dir}/fresh_api2.js`, 'utf8');
const vmpJs = fs.readFileSync(`${dir}/fresh_vmp.js`, 'utf8');
const offsetJs = fs.readFileSync(`${dir}/fresh_offset.js`, 'utf8');
const metaContent = fs.readFileSync(`${dir}/fresh_meta.txt`, 'utf8');
const dom = new JSDOM(
`<!DOCTYPE html><html><head><meta charset="UTF-8"></head><body></body></html>`,
{ url: 'https://match.yuanrenxue.cn/match/10',
runScripts: 'outside-only', pretendToBeVisual: true,
virtualConsole: new VirtualConsole() }
);
const { window } = dom;
const document = window.document;
// ---- 补环境 ----
// 1. Cookie 读写(VMP 会写入 m= 等标记 cookie)
let cookies = `sessionid=${process.env.SESSIONID || ''}`;
Object.defineProperty(document, 'cookie', {
get: () => cookies,
set: (v) => {
const first = String(v).split(';')[0];
const eq = first.indexOf('=');
if (eq > 0) {
const name = first.slice(0, eq).trim();
const value = first.slice(eq + 1).trim();
const re = new RegExp(`(?:^|;\\s*)${name}=[^;]*`);
if (cookies.match(re)) cookies = cookies.replace(re, `${name}=${value}`);
else cookies += `; ${name}=${value}`;
}
},
configurable: true,
});
// 2. Navigator(反自动化检测)
Object.defineProperty(window.navigator, 'webdriver', { get: () => undefined, configurable: true });
Object.defineProperty(window.navigator, 'platform', { get: () => 'Win32', configurable: true });
Object.defineProperty(window.navigator, 'hardwareConcurrency', { get: () => 8, configurable: true });
// 3. Screen / window 尺寸
Object.defineProperty(window.screen, 'width', { get: () => 1920, configurable: true });
Object.defineProperty(window.screen, 'height', { get: () => 1080, configurable: true });
Object.defineProperty(window, 'outerWidth', { get: () => 1920, configurable: true });
Object.defineProperty(window, 'outerHeight', { get: () => 1040, configurable: true });
// 4. Chrome 对象
window.chrome = { runtime: {}, app: { isInstalled: false } };
// 5. localStorage / sessionStorage
const makeStorage = () => {
let store = {};
return {
getItem: (k) => (k in store ? store[k] : null),
setItem: (k, v) => { store[k] = String(v); },
removeItem: (k) => { delete store[k]; },
clear: () => { store = {}; },
key: (i) => Object.keys(store)[i] || null,
get length() { return Object.keys(store).length; },
};
};
Object.defineProperty(window, 'localStorage', { get: () => makeStorage(), configurable: true });
Object.defineProperty(window, 'sessionStorage', { get: () => makeStorage(), configurable: true });
// 6. Canvas mock(指纹用)
const canvasCtx2d = {
fillText: () => {}, fillRect: () => {}, clearRect: () => {},
getImageData: () => ({ data: new Uint8Array(4) }),
measureText: () => ({ width: 10 }),
save: () => {}, restore: () => {}, beginPath: () => {},
moveTo: () => {}, lineTo: () => {}, stroke: () => {},
fill: () => {}, arc: () => {}, closePath: () => {},
};
const origCreateElement = document.createElement.bind(document);
document.createElement = function (tag) {
const el = origCreateElement(tag);
if (String(tag).toLowerCase() === 'canvas') {
el.getContext = function () { return canvasCtx2d; };
el.toDataURL = function () { return 'data:image/png;base64,iVBORw0KGgo='; };
}
return el;
};
// 7. meta 注入 —— 追加到末尾,保证是最后一个 meta
const meta = document.createElement('meta');
meta.setAttribute('content', metaContent);
document.head.appendChild(meta);
// ---- 截获签名 ----
let capturedToken = null;
const nativeOpen = window.XMLHttpRequest.prototype.open;
// 关键 hook:VMP 的 XHR.open 最终通过 .apply() 调原生 open,
// 此时 URL 已被签上 m=,在最底层拦 apply 即可拿到 token
const origApply = Function.prototype.apply;
Function.prototype.apply = function (thisArg, args) {
if (this === nativeOpen && args && args[1] && String(args[1]).includes('m=')) {
const m = String(args[1]).match(/[&?]m=([^&]+)/);
if (m) capturedToken = m[1];
}
return origApply.call(this, thisArg, args);
};
// ---- 执行脚本 ----
window.eval(rsJs);
window.eval(api2Js);
window.eval(offsetJs);
window.eval(vmpJs);
// ---- 触发 XHR 让 VMP 签名,再截获 ----
setTimeout(() => {
window.eval(`
var __xhr = new XMLHttpRequest();
__xhr.open('GET', '/api/question/10?page=${pageNum}&pageSize=10&kw=');
__xhr.send();
`);
setTimeout(() => {
capturedToken
? resolve({ m: capturedToken })
: reject(new Error('no token captured'));
}, 1500);
}, 2500);
});
}
if (require.main === module) {
generateToken(parseInt(process.argv[2] || '1', 10))
.then(r => { console.log(JSON.stringify(r)); process.exit(0); })
.catch(e => { console.error('ERROR:', e.message); process.exit(1); });
}
module.exports = { generateToken };
4.3 核心代码二:Python 协议请求
文件:solve.py
import requests, subprocess, json, re, sys, time, os
BASE = "https://match.yuanrenxue.cn"
UA_PC = ("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/120.0 Safari/537.36")
WORK = "work"
def new_session():
s = requests.Session()
s.headers.update({"User-Agent": UA_PC, "Referer": f"{BASE}/match/10"})
return s
def refresh_resources(s):
"""同一 session 内刷新全部资源"""
html = s.get(f"{BASE}/match/10", timeout=20).text
open(f"{WORK}/fresh_page.html", "w", encoding="utf-8").write(html)
vmp = None
for m in re.finditer(r'<script[^>]*>(.*?)</script>', html, re.DOTALL):
c = m.group(1)
if 'yuanrenxue_59' in c:
open(f"{WORK}/fresh_vmp.js", "w", encoding="utf-8").write(c)
vmp = c
break
if vmp is None:
return False
meta = re.search(r'<meta content="(\{qqq[^"]+)"', html)
if not meta:
return False
open(f"{WORK}/fresh_meta.txt", "w", encoding="utf-8").write(meta.group(1))
rs = s.get(f"{BASE}/static/new_match/question/10/rs.js", timeout=20).content
open(f"{WORK}/fresh_rs.js", "wb").write(rs)
api2 = s.get(f"{BASE}/api2/10", timeout=30).content
open(f"{WORK}/fresh_api2.js", "wb").write(api2)
off = s.get(f"{BASE}/api/10/offset", timeout=20).text
open(f"{WORK}/fresh_offset.js", "w", encoding="utf-8").write(off)
return True
def gen_token(page, sid):
env = dict(os.environ, SESSIONID=sid)
r = subprocess.run(["node", f"{WORK}/token_gen.js", str(page)],
capture_output=True, text=True, timeout=40, env=env)
if r.returncode != 0:
raise RuntimeError(f"node failed: {r.stderr[:300]}")
return json.loads(r.stdout)["m"]
def fetch_page(s, page, m):
ua = "yuanrenxue" if page == 5 else UA_PC
return s.get(f"{BASE}/api/question/10",
params={"page": page, "pageSize": 10, "kw": "", "m": m},
headers={"User-Agent": ua, "Referer": f"{BASE}/match/10",
"X-Requested-With": "XMLHttpRequest"},
timeout=15)
def main():
s = new_session()
s.get(f"{BASE}/match/10", timeout=20)
sid = s.cookies.get("sessionid", domain="match.yuanrenxue.cn") \
or s.cookies.get("sessionid")
print(f"sessionid = {sid}")
total = 0
all_nums = []
for page in range(1, 6):
for attempt in range(3):
try:
if not refresh_resources(s):
raise RuntimeError("资源提取失败")
m = gen_token(page, sid)
resp = fetch_page(s, page, m)
if resp.status_code == 200:
nums = [int(x) for x in resp.json()["data"]]
total += sum(nums)
all_nums += nums
print(f"page {page}: {nums} (sum={sum(nums)})")
break
else:
print(f"page {page} attempt {attempt+1}: "
f"status={resp.status_code} body={resp.text[:100]}")
except Exception as e:
print(f"page {page} attempt {attempt+1}: err={e}")
time.sleep(1)
time.sleep(1)
print(f"\n=== 共 {len(all_nums)} 个数, 总和 = {total} ===")
if __name__ == "__main__":
main()
4.4 运行
# 1. 安装依赖
npm install jsdom
# 2. 一键跑 5 页并求和
python solve.py
输出:
sessionid = 8rdltbeo3czydr31k0vlaap1jqkojmxv
page 1: [182460, ...] (sum=4239428)
page 2: [565849, ...] (sum=5700903)
page 3: [631989, ...] (sum=4195751)
page 4: [518538, ...] (sum=5241901)
page 5: [142536, ...] (sum=4880944)
=== 共 50 个数, 总和 = 24258927 ===
5. 踩坑记录
| # | 问题 | 原因 | 解决 |
|---|---|---|---|
| 1 | Cannot read properties of undefined (reading 'length') |
meta 注入到了 <head> 最前面,而 VMP 取的是最后一个 meta,读到 jsdom 自带的空 charset 标签 |
appendChild 追加到 head 末尾 |
| 2 | token 重放返回 token failed |
m 是一次性令牌,服务端校验唯一性+时效性 |
每页重新生成 token |
| 3 | 复用旧资源生成 token 全部失败 | 瑞数资源会随 session/时间轮换,旧 VMP/offset 已失效 | 每页刷新全部资源 |
| 4 | 第 5 页返回提示文字 | UA 未改成 yuanrenxue |
第 5 页单独设 UA |
| 5 | s.cookies.get('sessionid') 抛 CookieConflictError |
预置 cookie 与服务器 Set-Cookie 的 domain 不同,jar 里出现同名 cookie | 不预置 cookie,用 domain= 参数读取 |
| 6 | jsdom 报 HTMLCanvasElement getContext not implemented |
jsdom 无原生 canvas | mock document.createElement 返回带 getContext 的假 canvas |
| 7 | 请求太频繁被断连 | 服务端风控 | 页间 sleep + 每页 3 次重试 |
6. 总结
这道题的「重放攻击对抗」本质是瑞数 5 代的一次性动态令牌:
- token 不能重放 —— 服务端校验唯一性和时效性,旧 token 必失败;
- token 不能脱离环境 —— 依赖 Canvas/IndexedDB/navigator 等浏览器指纹;
- token 不能跨 session ——
api2加密数据与offset解码参数 N 严格配对; - token 不能复用生成器 —— VMP 用 IndexedDB 维护 nonce 状态,单会话只能产出一个有效 token。
应对策略就是 补环境 + 每页重新初始化:在 Node.js 里用 jsdom 模拟浏览器环境跑 VMP,在 Function.prototype.apply 最底层截获签名,每次刷新资源生成全新的「第一个 token」,再用纯 requests 协议请求拿数据。
真正卡人的不是某个加密算法,而是「环境绑定」这一层——补环境的每个 mock 都有它存在的理由,漏一个就可能崩在 238KB 状态机的某个分支里。
免责声明:本文仅用于逆向技术学习与 CTF/爬虫对抗练习,目标是猿人学官方公开的练习平台。请勿将相关技术用于任何未经授权的目标。

浙公网安备 33010602011771号