猿人学第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 两道硬性约束

题目明文提示:

  1. 发送第 5 页时,User-Agent 必须改成 yuanrenxue,否则最后一页返回的是提示文字而不是数字。
  2. 请求必须携带 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 代的一次性动态令牌:

  1. token 不能重放 —— 服务端校验唯一性和时效性,旧 token 必失败;
  2. token 不能脱离环境 —— 依赖 Canvas/IndexedDB/navigator 等浏览器指纹;
  3. token 不能跨 session —— api2 加密数据与 offset 解码参数 N 严格配对;
  4. token 不能复用生成器 —— VMP 用 IndexedDB 维护 nonce 状态,单会话只能产出一个有效 token。

应对策略就是 补环境 + 每页重新初始化:在 Node.js 里用 jsdom 模拟浏览器环境跑 VMP,在 Function.prototype.apply 最底层截获签名,每次刷新资源生成全新的「第一个 token」,再用纯 requests 协议请求拿数据。

真正卡人的不是某个加密算法,而是「环境绑定」这一层——补环境的每个 mock 都有它存在的理由,漏一个就可能崩在 238KB 状态机的某个分支里。


免责声明:本文仅用于逆向技术学习与 CTF/爬虫对抗练习,目标是猿人学官方公开的练习平台。请勿将相关技术用于任何未经授权的目标。

posted @ 2026-11-08 00:23  Johnjincaaa  阅读(4)  评论(0)    收藏  举报