用 Rust 给某音 a_bogus 补环境:从「吐环境」到一个 1.8MB 单二进制纯算签名(无需 Node、无需浏览器)

上一篇我把「反检测浏览器 + 验证码 OCR + 滑块 + 高并发」收进了一个 Rust 库 drission。这一篇聊一个更偏 JS 逆向 的硬核能力:吐环境(补环境),并把它推到一个我自己都觉得很爽的终点------

把某站点签名参数(这里以某音的 a_bogus 为例)所依赖的浏览器环境整个「吐」出来,生成可在 无浏览器 的 JS 引擎里复现的补环境 env.js;再把它和一个内嵌的 JS 引擎一起编译成单个二进制 。最终效果:拿到一个 1.8MB 的可执行文件,双击就能跑,不装 Node、不开浏览器、不 npm i,就地复现浏览器环境、纯算签名。

全文代码都对应仓库里真实可跑的能力。

一、先说清楚:什么是「补环境」,它解决什么

做 JS 逆向时,签名参数(a_bogus / X-Bogus / _signature / msToken...)往往不是纯函数算出来的,而是重度依赖浏览器环境 :navigator.userAgentscreen.widthcanvas 指纹、webgl 渲染器、audio 指纹、document.cookie......你想脱离浏览器、用 Node 「纯算」出签名,就得先把这些环境搬过去------这就是「补环境」。

传统补环境的痛点很现实:

传统手工补环境 drission dump_env 的回应
手动 console.log 抠 navigator/screen,漏一个就报错 一键吐全真实种子:navigator/screen/document/storage 全量采集
canvas/webgl/audio 指纹根本不知道怎么搬 指纹回放:录制真实指纹,在 Node 侧用同一配方回放
不知道签名脚本是哪个文件 签名 sink 通用化定位:从调用栈自动定位签名脚本
补完不知道对不对,只能反复试签名 同构双跑自验证:浏览器真实环境 vs 补环境逐字段对比
补环境是一坨 JS,换台机器还得装 Node 编译成单二进制:内嵌 JS 引擎,开箱即用、零安装

二、三件事一次说清:指定 / 吐全 / 只吐关键

dump_env 的设计就围绕三个动作:

  • 指定 :告诉它「要针对哪个签名参数」。用 EnvTarget 定位参数(query / header / cookie),用 match_url 锁定是哪个请求。探针会顺手把这个参数的真实上线值 和它的 writer 调用栈 抓出来,方便核对。
  • 吐全 :安全模式下采集全量真实种子 seed(navigator/screen/document/storage + canvas/webgl/audio 指纹),作为 env.js 的值来源。
  • 只吐关键 :可选地用 Proxy 追踪签名算法实际读取 了哪些环境字段(access),据此把种子裁剪 成精简补环境 env.accessed.js

注意:对某音这类强检测 站点,替换 navigator 做 Proxy 追踪会被识破、导致它干脆不发签名请求。所以实践是:安全模式先跑一遍拿值,再单独开 Proxy 跑一遍拿访问集。强检测下默认就走安全模式。

三、针对 a_bogus 吐环境

核心 API 是链式的,探针必须在导航前注入 (用了 add_init_script):

rust 复制代码
use drission::prelude::*;

let browser = Browser::launch(
    BrowserOptions::new().headless(true).locale("zh-CN").timezone("Asia/Shanghai"),
).await?;
let tab = browser.latest_tab().await?;

// 通用吐环境:目标 = query 参数 a_bogus,只针对 detail 请求;强检测站点默认关 proxy(安全模式)。
let mut probe = tab
    .dump_env()
    .target_query("a_bogus")                       // 指定:要针对的签名参数
    .match_url("aweme/v1/web/aweme/detail")         // 指定:只针对这个请求取值/记 writer
    .proxy(false)                                   // 安全模式(不替换 navigator)
    .start()                                        // 导航前注入探针
    .await?;

// 业务监听:detail(每个视频各自签名)+ related(取下一批 id),边滑边攒种子。
tab.listen_xhr(&["aweme/v1/web/aweme/detail", "aweme/v1/web/aweme/related"]).await?;
tab.get(START_URL).await?;                          // 某音视频页(脱敏)

页面跑起来后,多次导航就反复 collect() 累积(页面里的探针每次导航会重置,所以要逐轮取走):

rust 复制代码
let dump = probe.collect().await?;   // 累积 seed / access / sinks / targets

// 1) 落盘全部产物到 ./dump-env/
dump.write_to("./dump-env")?;

// 2) 一键导出「可直接 node 运行的补环境工程」(npm 包结构,零依赖)
dump.export_project("./moyin-env", EnvScope::Full)?;

// 3) 同构双跑自验证:补出的 env.js 是否忠实还原浏览器
let report = dump.verify(&tab, "./dump-env", EnvScope::Full).await?;

跑完,产物目录长这样(每个文件都是后续纯算的弹药):

text 复制代码
dump-env/
├── seed.json        # 环境种子(吐全:navigator/screen/document/storage + canvas/webgl/audio 指纹)
├── env.js           # Node 补环境(含指纹回放;require 即挂全局或 vm 沙箱 setup)
├── targets.json     # 命中目标参数 a_bogus 的真实上线值 + 调用栈
├── sinks.json       # 签名请求 writer(URL + 调用栈)
├── signers.json     # 从调用栈通用化定位出的「签名脚本」(频次降序)
└── access.json      # (开 proxy 才有)算法实际读取的环境路径

四、难点一:canvas / webgl / audio 指纹回放

补环境里最容易翻车的就是指纹。Node 里没有真正的 canvas/webgl,硬算肯定和浏览器对不上。drission 的思路是 「录制 + 回放」,而且录制和回放共用同一份配方------这是它能逐字段对齐的前提。

录制时(浏览器里)按固定配方画一张 canvas、读一遍 webgl 参数、用 OfflineAudioContext 渲染一段音频,把结果存进 seed:

js 复制代码
// 录制配方(浏览器里跑真实实现,Node 补环境里跑回放实现,二者返回值一致)
function __fpCanvas() {
  var c = document.createElement("canvas"); c.width = 280; c.height = 60;
  var ctx = c.getContext("2d");
  ctx.fillStyle = "#f60"; ctx.fillRect(125, 1, 62, 20);
  ctx.fillStyle = "#069"; ctx.fillText("drission,补环境🦊", 2, 15);
  return { supported: true, dataURL: c.toDataURL() };   // 录下这串 dataURL
}

回放时(生成的 env.js 里),document.createElement('canvas').toDataURL() 直接返回录制下来的那串 dataURL;webglgetParameter / getExtension / getSupportedExtensions 回放录制的参数表;OfflineAudioContext(1,5000,44100) 这套经典配方则把录制的音频切片塞回 [4500,5000) 区间。于是签名脚本在 Node 里读到的指纹,和它在浏览器里读到的一模一样

五、难点二:签名脚本在哪?------调用栈通用化定位 + 反 hook

补完环境,你还得知道签名算法在哪个 JS 文件 。drission 的探针 hook 了 fetch / XMLHttpRequest:凡是 URL 里带签名关键词(a_bogus 等)的请求,就把它的调用栈 记下来(sink);再从每条栈里取最靠上的 http(s) 脚本帧,按文件聚合、频次降序,就得到 signers.json。这套不依赖站点名,任意站点通用。

实测某音那一跑,定位结果直接命中它的 web 安全 SDK 运行时:

json 复制代码
[
  { "url": "https://lf-security.********/obj/security-secsdk/runtime_bundler_34.js",
    "line": 15, "count": 39,
    "sample_request": "https://mcs.********/webid?aid=****&..." }
]

这里有个细节很关键:很多风控脚本会用 ('' + fetch) / fetch.toString() 检测 fetch/XHR 有没有被改写。所以探针顺手 hook 了 Function.prototype.toString ,让被它包过的 fetch/XHR 自报 [native code]------这样既能记录调用栈,又不会被反 hook 检测发现。

六、它补得对不对?------同构双跑自验证

补环境最怕「看起来补好了,一签名就错」。drission 用 同构双跑 来证明忠实还原:基于种子动态生成同一份「快照脚本」,在 ① 浏览器真实环境② Node 的 vm 沙箱 + env.js各跑一次,逐字段对比 navigator/screen/location 以及 canvas/webgl/audio 指纹。

我这次的实测结果:

text 复制代码
==== 环境验证(浏览器真实环境 vs 吐的 env.js): 39/39 字段一致 ====
  ✅ 全部一致------吐的环境忠实还原了浏览器

导出的工程里还自带一个纯 Node、零依赖 的回放自检 verify.js(env.js 回放 vs seed.json 录制):

bash 复制代码
$ node verify.js
==== env.js 回放自检: 33/33 字段与 seed.json 一致 ====
  ✅ 全部一致 ------ 补环境忠实回放了录制环境。

到这里,「你补的环境对不对」就有了可重复、可量化的答案。

七、亮点:编译成一个 1.8MB 单二进制(无需 Node、无需浏览器、零安装)

前面都还需要 Node。最后这一步是我最想要的体验:把补环境做成开箱即用的单文件

思路是:在 Rust 里内嵌一个 JS 引擎 (我用了 rquickjs,即 QuickJS,编译期由 C 源码静态编进二进制),再用 include_str! 把吐出来的 env.jsseed.json 编进可执行文件 。于是签名脚本要用的浏览器环境,在一个进程里就地就绪------不依赖系统装没装 Node

它做两件事:① 离线自检补环境;② 加载站点签名脚本纯算签名。核心就这么点(节选):

rust 复制代码
use rquickjs::{CatchResultExt, Context, Runtime};

const ENV_JS: &str  = include_str!("../moyin-env/env.js");   // 补环境编进二进制
const SEED_JSON: &str = include_str!("../moyin-env/seed.json"); // 录制基准编进二进制

let rt = Runtime::new()?;
let ctx = Context::full(&rt)?;

// 1) 装载补环境:eval env.js -> setup(globalThis) 把浏览器环境注入沙箱
ctx.with(|ctx| ctx.eval::<(), _>(format!("{ENV_JS}\n;void 0;")).catch(&ctx))?;

// 2) 逐字段对比 env.js 回放值 vs seed.json 录制值(canvas/webgl/audio 都比)
//    audio 是异步 Promise,跑完微任务队列再取:while rt.is_job_pending() { rt.execute_pending_job(); }

编译就是一行,产出单个二进制:

bash 复制代码
cargo build --release --example env_signer --features signer
# => target/release/examples/env_signer   (1.8 MB,单文件)

它有多「干净」?otool -L 看依赖,只链了 macOS 自带的 libSystem,没有 Node、没有 V8、没有任何外部 JS 运行时:

bash 复制代码
$ otool -L target/release/examples/env_signer
	/usr/lib/libSystem.B.dylib
$ du -h target/release/examples/env_signer
1.8M

直接跑(这台机器可以没有 Node、没有浏览器):

text 复制代码
$ ./env_signer
==== drission 补环境运行器(内嵌 QuickJS · 无 Node · 无浏览器)====

[自检] 补环境回放 vs 录制种子:33/33 字段一致
  ✅ 全部一致 ------ 二进制里补出的环境忠实还原了浏览器(canvas/webgl/audio 指纹均回放正确)。

33/33 ------和前面 node verify.js 完全一致,但这次全程没有 Node、没有浏览器,纯粹在二进制内嵌的 QuickJS 里完成。

把真实的某音安全 SDK 喂进去

光自检还不够过瘾。把第六节定位到的某音真实安全 SDK 运行时脚本 (runtime_bundler_34.js)放进 signer/,让它在这个单二进制的补环境里跑跑看:

text 复制代码
$ ./env_signer ./moyin-env
[自检] 补环境回放 vs 录制种子:33/33 字段一致 ✅

[signer] 加载签名脚本到补环境:
  - runtime_bundler_34.js ✓
  可疑签名全局:["useWebSecsdkApi"]
  SIGN_CALL 结果:{"extra_globals":[
      "SDKRuntime","registToGlobal","registToModule","use",
      "useWebSecsdkApi","SDKNativeWebApi","CryptoJS"], ...}

真实的某音安全 SDK 在我们的单二进制补环境里成功初始化了 ,并把它的运行时(SDKRuntime)、原生 Web API 桥(SDKNativeWebApi)、加密库(CryptoJS)和入口钩子 useWebSecsdkApi 全挂了出来。换句话说:它在浏览器里能跑的初始化路径,在这个 1.8MB 的文件里一样跑通了

这里要补一句实事求是 的工程细节:env.js 只回放数据型 环境(那些有确定值的指纹/字段);而风控脚本初始化时还会摸一堆行为型 API(setTimeout / addEventListener / fetch / XHR / crypto.getRandomValues / TextEncoder...)。这些不参与签名计算本身,所以运行器在加载签名脚本前会注入一层轻量「行为壳」 (no-op / 空响应),让初始化不至于因为缺 addEventListener 这种东西直接崩。第一次跑时它就是卡在一句 addEventListener is not a function------补上壳之后立刻变 这正是补环境的常态:数据型一键吐全,行为型按需补壳。

八、边界:别把它当万能钥匙

老规矩,把边界讲清楚,免得你踩坑:

  • 能稳定回放 :navigator / screen / location / window 度量 / document(cookie + createElement)/ storage,以及 canvas 2D、WebGL、Audio 指纹。同构双跑能把这些逐字段对齐。
  • 可能要按需补全 :getImageData 像素级 canvas 指纹、罕见 WebGL 调用、WebRTC / 字体枚举等高强度点;以及上面说的行为型 API。
  • 签名调用约定仍需对齐 :补环境负责「让签名脚本能在无浏览器下初始化、读到正确环境」;但具体怎么调它的导出函数、传什么参数拿到最终 a_bogus,仍要按目标脚本各自的接口去对(这部分是逆向本身,补环境只是把舞台搭好)。
  • 强检测站点 :Proxy 追踪访问路径会被识破,默认走安全模式;反爬是持续对抗,没有银弹

九、小结

这篇把 drission 的 dump_env 串了一遍:针对签名参数(某音 a_bogus)指定目标 → 吐全真实种子 + 指纹回放 → 调用栈通用化定位签名脚本 → 同构双跑自证补得对(39/39、33/33)→ 最后编译成一个 1.8MB 的单二进制 ,内嵌 QuickJS,开箱即跑、无需 Node、无需浏览器、零安装,并实测把某音真实安全 SDK 在里面跑通了初始化。

如果你在做 JS 逆向 / 补环境 / Rust 爬虫,欢迎试试:

⚠️ 免责声明 :本文及项目仅供学习与合法、非盈利的技术研究。文中目标 App 已脱敏为「某音」,真实 Cookie / 域名 / 业务标识均已打码。请遵守目标站点的 robots 协议与当地法律法规,禁止用于任何违法、侵害他人利益或绕过授权采集受保护数据的行为。使用本库产生的一切后果由使用者自行承担。

觉得有用的话,点个 star、评论区聊聊你在补环境上踩过的坑,我会持续更新。