猿人学第 18 题「jsvmp 洞察先机」逆向通关实录

猿人学第 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 字节码」,而是找准运行时观察点:

  1. 字符串表弱加密 → 一眼看出是 CryptoJS 的 AES-CBC + PKCS7
  2. VM 把加密函数挂到 window.myenc → 直接钩它,不必还原整个 VM
  3. 签名明文 = <页码>|<token窗口>,key = hex(t)×2 → 规律简单,一次采样就能复现
  4. 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 头(用于提交接口)"
}
相关推荐
用户47027840040519 分钟前
C语言编译与链接
后端·开源
你三大爷22 分钟前
从 Hessian 迁移到 protostuff:深嵌套模型反序列化耗时与高并发类加载锁的根治实战
后端·架构
IT枫斗者枫哥23 分钟前
子线程断言失败,JUnit 为什么还显示通过?
java·后端
stark张宇2 小时前
Vibe Coding 入门实战:用 Claude Code 跑通 AI 产品开发流程(上)
后端·claude·vibecoding
考虑考虑3 小时前
JDK27中的Set.ofLazy
java·后端·java ee
FungLeo3 小时前
成为全栈·Vue 管理后台篇·分类树与标签管理:层级数据如何落到管理界面
前端·javascript·vue.js·分类树·naive ui·标签管理·前端数据结构
总要学点什么3 小时前
第108篇:三维特效-图片轨迹线效果——把贴图滚成一串箭头,想用它的本色反而整条消失
开发语言·javascript·ecmascript·gis·webgl·材质·cesium
乌暮4 小时前
Spring AOP 与 Spring 事务:用大白话彻底理解原理与实践
java·数据库·spring boot·后端·sql·学习·spring
我不会起名字3225 小时前
未接入最终读上限的参数:`websocketMaxMsgLen` 与 12.5 倍差距
后端·websocket·go·即时通讯·im·故障排查