猿人学第 18 题「jsvmp 洞察先机」逆向通关实录
题目:JSVMP 洞察先机 目标:抓取第 1~5 页共 50 个数字,求和提交 结果:✅ 通关(
{"result":"success","created":true,"code":2,"exp":500},总和28787253)
这是一道典型的 JSVMP(JavaScript 虚拟机保护) 逆向题。页面里关键逻辑被塞进了一个自研的字节码虚拟机里执行,直接读源码基本读不出什么。本文记录完整的逆向思路、签名机制的还原过程,以及最终「伪造第 5 页签名」通关的工程实现。
1. 题目现象
数据接口是:
bash
/api/v/question/18data?page=N&t=<秒级时间>&v=<base64签名>
三个参数里,t 看起来是时间,v 是一个 Base64 字符串。直接改 page 去请求,服务端会校验签名,签不过就 4xx / 拿不到数据。
常规思路是看懂页面 JS 怎么算出 v。但这题的 JS 是 JSVMP 保护的:逻辑被编译成一坨字节码,再由一个解释器循环跑。顺着 v = xxx(...) 直接读代码会陷入无穷尽的 switch-case 虚拟机分派。
2. 逆向思路:从字符串表切入
JSVMP 虽然把控制流藏起来了,但字符串表通常还是明文或弱加密地躺在附近。只要把字符串表还原出来,就能看到 VM 内部用了哪些库、哪些关键字段。
实测这题的字符串表用单字节异或 即可还原,还原后能看到 VM 内嵌了 CryptoJS,并且出现了 AES、CBC、PKCS7 等字样。这直接提示我们:
v大概率是一个 AES-CBC + PKCS7 的输出,再 Base64 编码。
辅助脚本(本会话逆向时使用):
q18_dump_strings.js------ dump VM 字符串表q18_hook_charcodes.js------ 单字节异或还原字符串q18_collect_myenc.js/q18_dense_sample.js------ 采样(t, 明文, 密钥, v)对照
3. 定位 window.myenc
继续追 VM 的暴露面,发现它把加密函数直接挂到了全局:
js
window.myenc
这是一个 JSVMP 内部导出的 AES-CBC + PKCS7 函数,签名满足 AES 的分组长度规律:
scss
输入 n 字节 → 输出 16 × ceil((n+1)/16) 字节
于是问题简化成:v 到底等于 myenc(什么明文, 什么密钥)?
4. 钩住 myenc,直接看明文
与其死磕 VM 字节码,不如运行时钩子 。用 CDP 注入一段代码,把 myenc 包一层,记录每次调用的参数和返回值:
js
var orig = window.myenc;
window.__last = null;
window.myenc = function(a, b) {
var r = orig.apply(this, arguments);
window.__last = { plain: String(a), key: String(b) };
return r;
};
然后真实点击第 4 页(page=4 会触发签名),捕获到:
css
[*] 捕获到的明文: 4|611m734,611d734,611u734
| 前面是页码 ,| 后面是token 窗口。
再对照请求里的 v 与 myenc 返回值,完全一致 ------ 说明 v 的生成就是:
ini
v = myenc("<页码>|<token窗口>", key)
5. 还原 key 的生成规则
采样多组 (t, 明文, key, v) 后,发现 key 与 t 的关系非常简单:
ini
key = hex(t) 重复两次
即:
ini
t = 0x6abbf384 → key = "6abbf3846abbf384"
t 是服务端秒级时间 ,页面通过重写后的 Date.now() 去请求 /api/getTime 拿到毫秒时间,再 Math.floor(ms / 1000) 转秒。
实测样本:
erlang
myenc("2|524m734,524d734,524u734", "6abbf3846abbf384")
= d1Wsu5vQVHldSiNrOCy4bImtHdf3ErbrqbK36qeSxUU= ← 即请求里的 v
myenc("3|524d734,524u734,572m734,572d734,572u734", "6abbf3886abbf388")
= vZ9rweRZipmjXuM3uOHgUNy/AXGEb1gTPVhk2M1YaObUlliSgZH4tnwBSGzILkeZ
6. token 窗口的规律
| 后面的 token 窗口来自一个 9 元循环序列的滑动窗口,每次取末尾 5 个(不足 5 个取全部):
524m734, 524d734, 524u734, 572m734, 572d734, 572u734, 620m734, 620d734, 620u734
关键结论:token 窗口与页码无关,只随时间推进。也就是说,钩到任意一页的 token 窗口,就能拿它去伪造其它页码的明文。
7. 三个关键坑
逆向过程踩了不少坑,这里集中记录:
坑 1:必须用真实输入事件点击分页
JSVMP 校验了 event.isTrusted。用 JS 的 element.click() 模拟点击会让签名流程内部报错,请求根本不发出。
必须通过 CDP 的 Input.dispatchMouseEvent 发送真实鼠标事件:
js
await cdp.send('Input.dispatchMouseEvent', { type: 'mousePressed', x, y, button: 'left', clickCount: 1 });
await cdp.send('Input.dispatchMouseEvent', { type: 'mouseReleased', x, y, button: 'left', clickCount: 1 });
坑 2:VM 只给 page=2/3/4 签名
page=5走的是「不签名」分支(作者故意留的)page=1也不需要签名- 其它页码会签,但
t=NaN
所以想拿第 5 页,不能靠页面自己点,得自己伪造 v5。
坑 3:签名绑定页码、一次性、且与 UA 绑定
- 明文里含页码,换页码就要重新签
- 签名一次性,不能重放
- 与 UA 绑定:Node 用别的 TLS 指纹重放会被拒,必须在真实浏览器里请求
因此整条链路必须跑在真实 Chrome 里,借助浏览器的 TLS 指纹。
8. 最终方案
思路一句话:钩 myenc 拿到当前 token 窗口 → 取服务端时间算 key → 用 myenc("5|<tokens>", key) 伪造第 5 页签名 → 在浏览器里请求第 5 页。
完整步骤:
ini
1. 加载前把 UA 覆盖为 yuanrenxue(第 5 页的 UA 要求)
2. 钩住 window.myenc(只记录不改行为)
3. 真实点击第 4 页 → 拿到"当前 token 窗口"(明文 "4|<tokens>" 里 | 之后的部分)
4. 取服务端时间 → t,key = hex(t)×2
5. v5 = window.myenc("5|<tokens>", key) ← 页码换成 5,token 窗口与页码无关
6. 在浏览器里请求 /api/v/question/18data?page=5&t=..&v=..
7. 1~4 页照常由页面自己签名取数,5 页相加提交
9. 核心实现
main.js 的关键片段如下。
9.1 启动 headless Chrome 并接管 CDP
js
const chrome = spawn(CHROME, ['--headless=new', `--remote-debugging-port=${PORT}`,
`--user-data-dir=${dir}`, '--no-first-run', '--disable-gpu', '--no-sandbox',
'--remote-allow-origins=*', '--window-size=1500,900', 'about:blank'],
{ stdio: ['ignore', 'ignore', 'ignore'] });
// 拿到 webSocketDebuggerUrl 后建立 WebSocket,构造 CDP 客户端
9.2 设置 Cookie、UA,进入题目页
js
await cdp.send('Network.setCookie', { name: 'sessionid', value: cfg.sessionid,
domain: 'match.yuanrenxue.cn', path: '/', secure: true });
await cdp.send('Network.setUserAgentOverride', { userAgent: 'yuanrenxue' });
await cdp.send('Page.navigate', { url: `${HOST}/match/${Q}` });
await sleep(8000);
9.3 钩住 myenc 记录明文与密钥
js
await ev(`(function(){
var orig = window.myenc;
window.__last = null;
window.myenc = function(a, b){
var r = orig.apply(this, arguments);
try { window.__last = { plain: String(a), key: String(b) }; } catch(e){}
return r;
};
return 'hooked';
})()`);
9.4 真实点击第 4 页,捕获 token 窗口
js
const click = async (p) => {
const box = JSON.parse(await ev(`(function(){
var el = document.querySelector('#pgxPages .pgx-page[data-page="${p}"]');
var r = el.getBoundingClientRect();
return JSON.stringify({ x: r.x + r.width/2, y: r.y + r.height/2 });
})()`));
await cdp.send('Input.dispatchMouseEvent', { type: 'mouseMoved', x: box.x, y: box.y });
await cdp.send('Input.dispatchMouseEvent', { type: 'mousePressed', x: box.x, y: box.y, button: 'left', clickCount: 1 });
await cdp.send('Input.dispatchMouseEvent', { type: 'mouseReleased', x: box.x, y: box.y, button: 'left', clickCount: 1 });
};
await click(4);
// 等 window.__last 出现明文
const cap = JSON.parse((await ev('JSON.stringify(window.__last)')) || 'null');
const tokens = cap.plain.slice(cap.plain.indexOf('|') + 1); // "611m734,611d734,611u734"
9.5 取服务端时间 → 算 key → 伪造第 5 页明文
js
const nowMs = Number(String(await ev(`(async function(){
var r = await fetch('/api/getTime'); return await r.text();
})()`)).trim());
const t = Math.floor(nowMs / 1000);
const key = t.toString(16).padStart(8, '0').repeat(2); // hex(t) × 2
const plain5 = `5|${tokens}`; // 页码换成 5
9.6 用 myenc 生成 v5,在浏览器里请求第 5 页
js
const v = await ev(`String(window.myenc(${JSON.stringify(plain5)}, ${JSON.stringify(key)}))`);
const url = `/api/v/question/${Q}data?page=5&t=${t}&v=${encodeURIComponent(v)}`;
const body = await ev(`(async function(){
var r = await fetch(${JSON.stringify(url)}, { headers: { 'x-requested-with': 'XMLHttpRequest' } });
return r.status + ' ' + (await r.text());
})()`);
9.7 1~4 页正常取数,5 页求和提交
js
const pages = [];
for (const p of [1, 2, 3, 4]) {
await click(p);
// 等页面渲染,读出 #pgxList 里的 10 个数字
pages.push(nums);
}
pages.push(data); // 第 5 页数据
const total = pages.flat().reduce((a, b) => a + b, 0);
await fetch(`${HOST}/a/${Q}`, {
method: 'POST',
headers: { 'content-type': 'application/x-www-form-urlencoded; charset=UTF-8', ... },
body: new URLSearchParams({ answer: String(total) }).toString(),
});
10. 运行结果
text
PS D:\...\18jsvmp洞察先机> node main.js
[*] 捕获到的明文: 4|611m734,611d734,611u734
[*] 服务端时间 ms=1794303794203 → t=1794303794,key=6af2e7326af2e732
[*] 伪造明文: 5|611m734,611d734,611u734
[*] 伪造 v = LGI6nl+RlLOLY/uG3vMoAWUhhah1FCfUSQWpKxNnEIk=
[*] 响应: 200 {"data": [252228, 299606, 600758, 941667, 444820, 407400, 715801, 917414, 545942, 737129]}
✅ 成功拿到第 5 页数据: [252228,299606,600758,941667,444820,407400,715801,917414,545942,737129]
[*] 第1页(页面渲染) [495316,881277,184975,612348,230793,252595,279445,478201,830155,760028]
[*] 第2页(页面渲染) [924017,250707,450212,645733,648704,266812,262107,825437,540298,863546]
[*] 第3页(页面渲染) [184036,654456,687488,834995,426536,850730,234154,944813,978349,594317]
[*] 第4页(页面渲染) [647307,247249,697545,492512,725180,864487,544325,178242,914627,540434]
[*] 共 50 个数,总和 = 28787253
[*] 提交 → HTTP 200 {"result": "success", "created": true, "code": 2, "exp": 500}
[*] ✅ 通关
11. 总结
这道题的考点不是「解密 JSVMP 字节码」,而是找准运行时观察点:
- 字符串表弱加密 → 一眼看出是 CryptoJS 的 AES-CBC + PKCS7
- VM 把加密函数挂到
window.myenc→ 直接钩它,不必还原整个 VM - 签名明文 =
<页码>|<token窗口>,key =hex(t)×2→ 规律简单,一次采样就能复现 - token 窗口与页码无关 → 钩第 4 页的窗口,伪造第 5 页的签名
剩下的事情就交给 CDP:真实点击、浏览器内发请求、借 TLS 指纹,最后 5 页相加提交。
JSVMP 看似高大上,但只要从运行时数据流入手,把「难以读懂的字节码」降维成「可以钩的函数调用」,很多题就迎刃而解了。
附:文件清单
| 文件 | 说明 |
|---|---|
main.js |
通关主脚本(钩子 + 伪造签名 + 取值 + 提交) |
config/session.json |
会话 Cookie(sessionid / cookie) |
docs/page5.json |
伪造签名拿到的第 5 页数据 |
result.json |
最终 5 页数据与提交响应 |
附:完整可运行源码(main.js)
直接 node main.js 即可取值 + 提交;node main.js --no-submit 只取值不提交。
js
/**
* Q18 通关尝试:伪造第 5 页签名
*
* 已知(本会话逆向所得):
* v = myenc("<页码>|<token窗口>", key),key = hex(t) 重复两次(t = 服务端秒级时间)
* token 窗口与页码无关,只随时间推进;myenc 是 VM 暴露出来的 AES-CBC+PKCS7 函数
* 做法:
* 1. 加载前把 UA 设为 yuanrenxue(第 5 页的 UA 要求)
* 2. 钩住 myenc,真实点击第 4 页,拿到"当前 token 窗口"
* 3. 取服务端时间 → t,算出 key = hex(t)×2
* 4. 用 myenc("5|<token窗口>", key) 生成 page=5 的 v
* 5. 在浏览器里请求 /api/v/question/18data?page=5&t=..&v=..(借浏览器 TLS 指纹)
*
* 用法:node main.js [--no-submit]
*/
const { spawn } = require('child_process');
const fs = require('fs');
const os = require('os');
const path = require('path');
const CHROME = process.env.CHROME_BIN
|| 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe';
const PORT = Number(process.env.CDP_PORT || 9556);
const HOST = 'https://match.yuanrenxue.cn';
const Q = 18;
const cfg = JSON.parse(fs.readFileSync(path.join(__dirname, 'config', 'session.json'), 'utf8'));
const log = (...a) => process.stdout.write(a.join(' ') + '\n');
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
class CDP {
constructor(ws) {
this.ws = ws; this.id = 0; this.pending = new Map(); this.handlers = [];
ws.addEventListener('message', (ev) => {
const m = JSON.parse(ev.data);
if (m.id && this.pending.has(m.id)) { const { resolve, reject } = this.pending.get(m.id); this.pending.delete(m.id); m.error ? reject(new Error(JSON.stringify(m.error))) : resolve(m.result); }
else if (m.method) for (const h of this.handlers) h(m);
});
}
on(f) { this.handlers.push(f); }
send(method, params = {}) {
const id = ++this.id;
return new Promise((resolve, reject) => {
this.pending.set(id, { resolve, reject });
this.ws.send(JSON.stringify({ id, method, params }));
setTimeout(() => { if (this.pending.has(id)) { this.pending.delete(id); reject(new Error('timeout ' + method)); } }, 30000);
});
}
}
(async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'yrx18forge-'));
const chrome = spawn(CHROME, ['--headless=new', `--remote-debugging-port=${PORT}`, `--user-data-dir=${dir}`,
'--no-first-run', '--disable-gpu', '--no-sandbox', '--remote-allow-origins=*', '--window-size=1500,900', 'about:blank'],
{ stdio: ['ignore', 'ignore', 'ignore'] });
let ws;
try {
for (let i = 0; i < 80; i++) { try { await (await fetch(`http://127.0.0.1:${PORT}/json/version`)).json(); break; } catch { await sleep(300); } }
const target = await (await fetch(`http://127.0.0.1:${PORT}/json/new?about:blank`, { method: 'PUT' })).json();
ws = new WebSocket(target.webSocketDebuggerUrl);
await new Promise((r) => ws.addEventListener('open', r));
const cdp = new CDP(ws);
await cdp.send('Page.enable');
await cdp.send('Network.enable');
await cdp.send('Network.setCookie', { name: 'sessionid', value: cfg.sessionid, domain: 'match.yuanrenxue.cn', path: '/', secure: true });
await cdp.send('Network.setUserAgentOverride', { userAgent: 'yuanrenxue' });
await cdp.send('Page.navigate', { url: `${HOST}/match/${Q}` });
await sleep(8000);
const ev = async (expr) => {
const r = await cdp.send('Runtime.evaluate', { expression: expr, returnByValue: true, awaitPromise: true });
if (r.exceptionDetails) return 'EXC ' + ((r.exceptionDetails.exception || {}).description || '').slice(0, 160);
return r.result.value;
};
for (let i = 0; i < 20; i++) { if (await ev('typeof window.myenc') === 'function') break; await sleep(500); }
await ev(`(function(){
var orig = window.myenc;
window.__last = null;
window.myenc = function(a, b){ var r = orig.apply(this, arguments); try { window.__last = { plain: String(a), key: String(b) }; } catch(e){} return r; };
return 'hooked';
})()`);
const click = async (p) => {
const box = JSON.parse(await ev(`(function(){var el=document.querySelector('#pgxPages .pgx-page[data-page="${p}"]');var r=el.getBoundingClientRect();return JSON.stringify({x:r.x+r.width/2,y:r.y+r.height/2});})()`));
await cdp.send('Input.dispatchMouseEvent', { type: 'mouseMoved', x: box.x, y: box.y });
await cdp.send('Input.dispatchMouseEvent', { type: 'mousePressed', x: box.x, y: box.y, button: 'left', clickCount: 1 });
await cdp.send('Input.dispatchMouseEvent', { type: 'mouseReleased', x: box.x, y: box.y, button: 'left', clickCount: 1 });
};
await click(4);
for (let i = 0; i < 20 && !(await ev('window.__last && window.__last.plain')); i++) await sleep(400);
const cap = JSON.parse((await ev('JSON.stringify(window.__last)')) || 'null');
if (!cap) throw new Error('没能从 myenc 拿到明文样本');
log('[*] 捕获到的明文:', cap.plain);
const tokens = cap.plain.slice(cap.plain.indexOf('|') + 1);
const nowMs = Number(String(await ev(`(async function(){var r=await fetch('/api/getTime');return await r.text();})()`)).trim());
const t = Math.floor(nowMs / 1000);
const key = t.toString(16).padStart(8, '0').repeat(2);
const plain5 = `5|${tokens}`;
log(`[*] 服务端时间 ms=${nowMs} → t=${t},key=${key}`);
log('[*] 伪造明文:', plain5);
const v = await ev(`String(window.myenc(${JSON.stringify(plain5)}, ${JSON.stringify(key)}))`);
log('[*] 伪造 v =', v);
const url = `/api/v/question/${Q}data?page=5&t=${t}&v=${encodeURIComponent(v)}`;
const body = await ev(`(async function(){
var r = await fetch(${JSON.stringify(url)}, { headers: { 'x-requested-with': 'XMLHttpRequest' } });
return r.status + ' ' + (await r.text());
})()`);
log('[*] 响应:', String(body).slice(0, 300));
let data = null;
const bodyTxt = String(body).replace(/^\d+\s/, '');
try { const j = JSON.parse(bodyTxt); if (Array.isArray(j.data) && typeof j.data[0] === 'number') data = j.data; } catch { /* ignore */ }
if (!data) { log('[-] 伪造签名未被接受'); return; }
log('\n✅ 成功拿到第 5 页数据:', JSON.stringify(data));
fs.writeFileSync(path.join(__dirname, 'docs', 'page5.json'), JSON.stringify(data));
if (process.argv.includes('--no-submit')) return;
// 依次取 1~4 页(页面自己会签名),读出渲染结果
const pages = [];
for (const p of [1, 2, 3, 4]) {
await click(p);
let nums = [];
for (let i = 0; i < 20; i++) {
await sleep(1000);
const txt = await ev(`(document.querySelector('#pgxList')||{}).innerText||''`);
nums = (String(txt).match(/\d+/g) || []).map(Number).slice(0, 10);
if (nums.length === 10) break;
}
pages.push(nums);
log(`[*] 第${p}页(页面渲染)`, JSON.stringify(nums));
}
pages.push(data);
const all = pages.flat();
const total = all.reduce((a, b) => a + b, 0);
log(`\n[*] 共 ${all.length} 个数,总和 = ${total}`);
const res = await fetch(`${HOST}/a/${Q}`, {
method: 'POST',
headers: {
'content-type': 'application/x-www-form-urlencoded; charset=UTF-8', accept: 'application/json, text/javascript, */*; q=0.01',
cookie: cfg.cookie, referer: `${HOST}/match/${Q}`, origin: HOST, 'user-agent': 'yuanrenxue', 'x-requested-with': 'XMLHttpRequest',
},
body: new URLSearchParams({ answer: String(total) }).toString(),
});
const text = await res.text();
log(`[*] 提交 → HTTP ${res.status} ${text}`);
try { fs.writeFileSync(path.join(__dirname, 'result.json'), JSON.stringify({ pages, total, count: all.length, response: JSON.parse(text) }, null, 2)); } catch { /* ignore */ }
if (JSON.parse(text).code === 2) log('[*] ✅ 通关');
} finally {
if (ws) ws.close();
chrome.kill();
await sleep(200);
}
})().catch((e) => { console.error('[-]', e.message); process.exit(1); });
运行方式
bash
node main.js # 取值 + 提交
node main.js --no-submit # 只取值,不提交
依赖说明
- Node.js 版本需支持全局
fetch/WebSocket(Node 18+ 内置) - 本机需安装 Chrome,默认路径
C:\Program Files\Google\Chrome\Application\chrome.exe,可用环境变量CHROME_BIN覆盖 config/session.json里需提供两个字段:
json
{
"sessionid": "登录后的 sessionid",
"cookie": "完整 Cookie 头(用于提交接口)"
}