mobile wallpaper 1mobile wallpaper 2mobile wallpaper 3mobile wallpaper 4
5236 字
14 分钟
某音 a_bogus 签名链路与纯算分析思路(bdms 1.0.1.20)
2026-08-15
2026-08-17

某音 a_bogus 签名链路与纯算分析思路(bdms 1.0.1.20)#

背景#

a_bogus 由某音 bdms SDK 生成,核心逻辑被封装在 JSVMP 虚拟机中。面对这类体积大、控制流复杂、环境依赖明显的签名代码,直接阅读字节码通常效率很低,更合适的做法是先确定输入输出边界,再用差分实验和运行时插桩逐层还原数据流。

本文围绕 bdms 1.0.1.20 的分析过程,记录几个关键发现:

  • URL 与 Body 会经过带盐的 SM3 二次哈希;
  • Payload 经位掩码交错扩展后,再与固定密钥流异或;
  • 随机字段来自可追踪的内部 PRNG 调用;
  • fixed48 中六个旧“常量”实际来自运行时环境,不能跨版本照搬;
  • f[12] 来自 query 摘要,而不是环境字段;
  • 签名正确之外,重风控接口还会校验 TLS 与 HTTP/2 指纹;
  • 关键结论需要通过逐字节对比、真实浏览器解码和服务端响应交叉验证。

分析中收集了 82 组 seed 样本用于逐字节回归,真实浏览器样本的 checksum 关系也能稳定成立。给定相同 query、Body、时间和环境输入后,纯算 fixed48 与浏览器解码结果达到 48/48 字节一致;最终使用纯算 SDK 配合 Firefox 网络指纹,连续 3 次请求巨量百应达人端 pack_detail 均返回 code=0

1、先看整体签名链路#

先把签名过程压缩成一条数据流,有助于避免一开始陷入 VM 指令细节:

URL 参数 ──┐
├─→ 带盐 SM3 二次哈希 ──→ 固定字段
Body ──────┘
固定字段 + 环境字段 + 指纹 + 时间 + checksum ──→ Payload
位掩码交错扩展并注入随机字节
随机头 + 固定密钥流 XOR
前缀 + 自定义 Base64
a_bogus

这条链路可以拆成三层:

  1. 输入摘要层:处理 URL 参数和 Body;
  2. 结构组装层:拼接时间、指纹、环境信号和校验字段;
  3. 编码混淆层:位掩码扩展、异或和自定义 Base64。

分析时应按这三层分别验证。只看最终字符串是否“像签名”,很难判断偏差究竟来自输入格式、环境字段还是编码阶段。

2、如何定位关键数据流#

2.1 从已知输入做差分#

固定时间、环境和随机序列,每次只修改一个变量,例如 URL 中的一个字符。观察解码后哪些字节发生变化,可以快速区分:

  • URL 或 Body 的摘要字段;
  • 时间相关字段;
  • 环境与设备指纹字段;
  • 纯随机或校验字段。

这种单变量实验比直接猜测字段含义可靠。字段位置确定后,再回到 VM 调用点追踪来源,范围会小很多。

2.2 从出口向前回溯#

最终输出使用自定义 Base64,因此先还原字符表,再去掉前缀并逆向异或层,可以看到更稳定的内部结构。外层变换被逐层剥离后,Payload 中的固定区、可变区和随机区会明显分开。

2.3 在随机函数调用点插桩#

Math.random.apply 的调用点记录调用序号、上下文和后续指令,可以把随机值与随机头、前缀及位掩码扩展中的注入字节对应起来。

重点不是反编译整个 JSVMP,而是找到少量稳定的输入输出边界:摘要入口、随机数入口、Payload 组装点、校验值写入点和最终编码出口。

3、几个关键发现#

3.1 URL 与 Body 使用 SM3 二次哈希#

SDK 使用 SM3,而不是常见的 MD5 或 CRC32。一个明显特征是代码中出现 SM3 初始向量 0x7380166f。输入会先拼接盐值 dhzx,再连续计算两次 SM3,后续只抽取部分摘要字节写入固定字段。

h1 = SM3(SM3(query + "dhzx"))
h2 = SM3(SM3(body + "dhzx"))

这里最容易出错的是输入边界:

  • URL 只取 ? 之后的查询参数,不包含域名和路径;
  • 查询串遵循 URLSearchParams.toString() 的格式,空格编码为 +
  • POST 请求还会处理 Body,GET 请求对应位置通常为空值;
  • UA 不直接进入这组摘要,而是通过环境字段产生影响。

如果把整条 URL 送入哈希,即使后续结构全部正确,最终结果也会从摘要字段开始偏离。

真实浏览器最终 query 的闭环验证还修正了一个早期误判:f[12] 不是环境字段,而是 h1[3]。当前已确认的摘要映射包括:

f[9] = h1[18]
f[12] = h1[3]
f[15] = h1[9]
f[33] = h2[10]
f[38] = h2[19]

因此环境字段索引集合中必须移除 12。否则即使大部分结构正确,query 变化后仍会在 fixed48 中产生稳定偏差。

3.2 最终输出使用自定义 Base64#

bdms 内置多套编码表,最终输出使用其中一套 URL-safe 变体。它的分组方式与标准 Base64 一致,区别只在字符映射表。

因此分析时不必重新推导 Base64 算法,只需建立自定义字符表与标准字符表之间的双向映射。完成这一步后,最终字符串就能还原为前缀和内部密文。

3.3 外层是固定密钥流 XOR#

外层并不是复杂的分组密码,而是数据与一段静态密钥流逐字节异或。这个结论可以通过选明文差分确认:固定输入和随机状态,多次比较密文变化;再对已知明文与密文做异或,即可观察到稳定重复的密钥流。

XOR 具有自逆性,同一变换既能用于加密,也能用于解密。分析上的价值在于:一旦剥离这一层,内部结构便可以直接按字节观察,不必继续在密文上猜字段。

3.4 位掩码交错扩展#

Payload 会按每 3 字节一组进行扩展,并注入 1 个随机字节,最终得到 4 字节。三组互补掩码分别覆盖输入字节与随机字节,最后一个输出字节保存前三个输入字节被拆出的剩余位。

这类结构看起来像单向混淆,实际是完全可逆的:输入字节可以从互补掩码重新拼回,注入的随机字节也能从前三个输出字节恢复。

另一个细节是余数处理。当 Payload 长度不能被 3 整除时,末尾 1 至 2 字节会直接追加,不参与扩展。若忽略这个分支,签名长度和末尾内容都会出现偏差。

3.5 随机部分来自内部 PRNG#

运行时记录显示,随机头、前缀和位掩码扩展使用的是同一组有序随机调用。调用序号稳定后,可以根据上下文把它们分成几类,而不必为每个随机字节单独猜公式。

底层状态推进呈现 glibc LCG 特征,但复刻时有一个容易忽略的问题:原始计算遵循 JavaScript float64 语义。乘法结果超过 2^53 后会发生舍入,若在其他语言中直接使用任意精度整数计算,第二轮开始就可能与真实序列分叉。

这也是逐字节回归的重要性:结构正确并不代表随机序列一致,首个偏差位置通常能直接暴露语言语义差异。

3.6 六个旧固定值实际来自运行时环境#

早期纯算实现把 fixed48 的六个位置写成全局常量:

f[14] = 3
f[25] = 0
f[27] = 3
f[30] = 0
f[34] = 0
f[45] = 3

对真实 bdms.js 的签名主入口和 Payload 组装入口插桩后,可以把 env 块输入逐值映射到 fixed48。结果表明,这六项全部是运行时环境检测值,而不是算法常量:

索引Buyin 1.0.1.20 Firefox 样本变化特征
143慢变运行时值,样本中可出现 3 与 4
2540页面载入或 Canvas 环境派生,跨页载漂移明显
2713浏览器环境检测值,与 UA 字符串无直接对应关系
300环境或能力检测值,与 34 成对变化
340环境或能力检测值,与 30 成对变化
4513浏览器环境检测值

另一个重要修正是:真实 Chrome 1.0.1.20 样本的 [27][45] 同样为 13,并不是 此前根据旧 trace 推测的“Chrome 为 3、Firefox 为 13”。旧值 3 来自不同 bdms 版本, 不能跨版本直接复用。

工程实现应把这些值放入带站点、版本和浏览器范围的 profile,而不是继续留在 build_fixed48() 中:

fixed_field_overrides = {
14: 3,
25: 40,
27: 13,
30: 0,
34: 0,
45: 13,
}

差分请求显示,服务端能够接受这些字段在真实环境中出现的多个取值,因此它们更接近软 信号。profile 能保证当前已验证环境可用,但若目标是与任意时刻的浏览器逐字节一致,仍需 从运行时动态采集。

4、Payload 的结构判断#

Payload 可以概括为三部分:

  • 固定区:版本、时间片段、URL/Body 摘要、环境信号和 magic 值;
  • 可变区:设备指纹、时间字符串及其长度信息;
  • 校验区:随机头与 Payload 前部字段的异或关系。

设备指纹由窗口尺寸、屏幕尺寸、可用区域和平台等信息拼接。它不是孤立字段,必须与 UA 和环境字段形成一致的浏览器画像。

checksum 的定位不适合靠枚举猜测。更可靠的方式是在 VM 内部找到校验值写入点,记录参与计算的寄存器和字节区间,再用多组样本验证。当前版本中,校验值可归纳为随机头与 Payload 前部字段的异或组合。

最终组装顺序如下:

Payload
→ 位掩码交错扩展
→ 前置随机头
→ 固定密钥流 XOR
→ 前置短前缀
→ 自定义 Base64

4.1 一条真实签名的脱敏解码#

为了让结构不只停留在公式上,我对一条真实 bdms 1.0.1.20 签名做了完整逆向解码。 文章中仅保留长度、结构、固定域字节和校验结果;随机前缀、随机头、指纹原文、时间码原文、 query、Body 与会话信息全部隐藏。

样本 D64nDH6L…hhqoE==(中间已省略)
编码长度 192 chars
自定义 Base64 后 142 bytes = 4B 随机前缀 + 138B codec 密文
codec 解密后 138 bytes = 8B 随机头 + 130B packed
逆 garble 后 98 bytes Payload
Payload 布局 fixed48 + meta2 + 43B fp + 3B time + ',' + checksum
fp <已脱敏:43 bytes,9 个字段,均为可打印字符>
time <已脱敏:3 个十进制字符>
随机序列 从 packed 可逆恢复 32 个 rnd 字节
codec 往返 通过
checksum 实际 129,计算 129,通过

这里的 98 字节余数为 2,因此前 96 字节按每 3 字节扩展为 4 字节,最后的逗号与 checksum 原样追加:96 / 3 × 4 + 2 = 130,与 packed 长度完全吻合。

校验值也能独立闭环:

checksum = XOR(header[0:8]) ^ XOR(payload[0:50])
= 129

这条样本不能反推出 query 或 Body 原文。固定域中只保存了双 SM3 摘要的少数字节,表格 展示的是这些派生字节,而不是请求明文。

4.2 fixed48 每个位置的真实值与来源#

索引样本值来源或作用
01格式/版本固定值
10运行时环境字段 env[1]
2145运行时环境字段 env[2]
392时间派生:((ts + 3) >> 8) & 0xff
40保留位
5115时间派生:ts & 0xff
60保留位
70保留位
81运行时环境字段 env[8]
939query 双 SM3 摘要字节 h1[18]
101运行时环境字段 env[10],可随环境变化
113格式固定值
12198query 双 SM3 摘要字节 h1[3],不是环境字段
1388运行时环境字段 env[13]
143慢变运行时环境值,属于六个 profile 覆盖位置之一
15194query 双 SM3 摘要字节 h1[9]
16160格式固定值
170保留位
1892时间派生,与索引 3 同源
1971运行时环境字段 env[19]
2053格式固定值
2144运行时环境字段 env[21]
22120运行时环境字段 env[22],样本间可漂移
230保留位
24251运行时环境字段 env[24]
2539页面载入/Canvas 等环境派生,慢漂移位置
26120运行时环境字段 env[26],通常与 22 同步变化
2714运行时环境检测值,不能从 UA 字符串直接推导
286格式固定值
2910运行时环境字段 env[29]
302环境/能力检测值,通常与 34 成对变化
310保留位
320运行时环境字段 env[32]
3382Body 双 SM3 摘要字节 h2[10]
342环境/能力检测值,通常与 30 成对变化
350保留位
36160格式固定值
37212运行时环境字段 env[37]
38177Body 双 SM3 摘要字节 h2[19]
391上游表达式尚未完全归因;当前样本为 1,旧构造默认值曾为 0
401格式固定值
410保留位
420保留位
4341格式固定值
44114时间派生:(ts - 1) & 0xff
4514运行时环境检测值,属于六个 profile 覆盖位置之一
4643后续设备指纹原文的字节长度
470保留位

这份表还有一个值得注意的新证据:六个慢变位置在该样本中为:

{14: 3, 25: 39, 27: 14, 30: 2, 34: 2, 45: 14}

它与前一页载捕获到的 {14: 3, 25: 40, 27: 13, 30: 0, 34: 0, 45: 13} 不同, 但结构、codec 往返和 checksum 都完全成立。这进一步说明六项是运行时软信号,不能把某一 次抓包值升级为跨页面、跨浏览器或跨版本的算法常量。

5、环境字段与蜜罐#

签名中存在一组环境信号:部分字段在相同环境下稳定,另一些会随 Canvas 随机化等因素变化。在线差分请求证明服务端能够接受来自真实环境的多个值,因此它们不是要求逐字节恒定的强校验常量。不过,这不等于可以随意拼接环境;UA、设备指纹、签名 profile 和网络客户端仍应来自同一类浏览器画像。

分析过程中还会遇到故意拼错的属性,例如 navigator.pemrissions。这类字段可能是环境探测的一部分,不应因为“看起来像拼写错误”就主动修正。不存在的 API 探测也同样如此:返回值本身就是环境特征。

因此,环境处理应遵循两个原则:

  1. 只记录真实执行中确实被读取并进入签名的数据;
  2. 使用同一浏览器环境下自洽的 UA、指纹和环境字段样本。

仅让签名结构合法并不足以证明环境正确。若环境字段来自不同浏览器或由默认值拼凑,算法即使逐层无误,也可能触发服务端校验。

6、用 pack_detail 完成在线闭环#

离线逐字节一致之后,我选择巨量百应达人端的选品决策详情接口作为在线验收样本:

POST https://buyin.jinritemai.com/pc/selection/decision/pack_detail

签名前的 query 由当前会话的 verifyFpfpmsToken 组成,Body 则必须与页面实际 发送的紧凑 JSON 字节完全一致。例如推广表现 Tab 的 90 天请求为:

{
"scene_info": { "request_page": 2 },
"other_params": { "colonel_activity_id": "" },
"biz_id": "<页面使用的业务 ID>",
"biz_id_type": 2,
"enter_from": "pc.selection_square.recommend_main",
"data_module": "dynamic",
"dynamic_params": {
"param_type": 9,
"promotion_data_params": { "time_range": "90" },
"content_data_params": { "time_range": "90" }
},
"extra": {}
}

这里的 biz_id 不能简单理解为响应中的 product_id。在本次推广商品样本中,Body 的 biz_id 与响应 promotion_id 一致,而 product_id 是另一个值。最稳妥的方式是从详情 页 URL 或上游列表响应继承页面实际使用的业务 ID。字段写成 product_id、动态参数放错层级, 都可能在签名已通过的情况下返回 -1025

6.1 签名正确不代表网络环境正确#

这个接口还暴露了一个非常容易误判的问题:服务端不只看 a_bogus,还会检查 TLS/JA3 与 HTTP/2 指纹。

客户端签名来源结果
Python requests浏览器生成的合法签名10001010A 当前环境存在风险
Python requests纯算签名同样被拒绝
primp Firefox 模拟纯算签名code=0

浏览器原始签名经 requests 重放仍被拒,说明失败点位于网络指纹层,而不是签名算法。 最终使用与 UA/profile 一致的 Firefox TLS 模拟:

import primp
client = primp.Client(
impersonate="firefox",
impersonate_os="windows",
http2_only=True,
)

在自有授权账号环境中,纯算 SDK + primp 连续 3 次请求均返回 code=0,响应包含销量、 浏览量、合作达人数、结算金额和内容数据。这个结果同时证明了签名结构、环境 profile 和 网络发送链路在当前样本上的整体可用性。

6.2 另外两个工程坑#

  • Body 会逐字节进入 h2。签名时使用格式化 JSON、发送时使用紧凑 JSON,即使语义完全 相同也会导致摘要不同。
  • x-ms-token 响应头中的值可能已经包含 %3D%3D。若再经一次 urlencode(),会变成 %253D%253D,表现为 HTTP 200 但响应体为空。构建 URL 前应先 unquote(),再统一编码。

这次在线排查也让我把问题明确拆成三层:

  1. 业务层:Body 字段、biz_id 和动态 Tab 参数是否真实;
  2. 签名层:query、Body、时间与 profile 是否逐字节一致;
  3. 网络层:TLS、HTTP/2、UA、Cookie 和会话凭据是否匹配。

只围绕 a_bogus 调参,很容易把 -1025 或环境风险误判为纯算算法错误。

7、验证方法#

逆向签名最容易出现的问题,是把“能够生成字符串”误当成“算法已经正确”。更稳妥的验证过程分为三层。

7.1 离线逐字节对比#

固定 query、Body、时间、环境和随机状态,对比原环境与纯算结果。发现差异后,按以下顺序定位首个偏差:

原始输入
→ URL/Body 规范化
→ 摘要字节
→ 固定区与可变区
→ 随机头和 checksum
→ 位掩码扩展
→ XOR 密文
→ 最终编码

7.2 真实样本反向验证#

对真实环境生成的签名逐层解码,确认 Payload 结构、随机字节恢复和 checksum 关系都成立。这样可以避免只在自行构造的样本中形成闭环。

7.3 服务端交叉验证#

最后使用多组独立请求确认接口返回正常结果。服务端成功只能证明整体可用,不能替代离线对比;反过来,逐字节一致也需要真实请求验证环境与时效性。

7.4 网络指纹隔离验证#

将同一个浏览器合法签名分别交给普通 HTTP 客户端和浏览器指纹客户端重放,可以把 “签名错误”与“网络环境错误”分离。这个对照实验比反复修改签名字段更快,也能避免把 TLS 风控错误归因到 JSVMP 算法。

8、分析与复核经验#

  • 注意语言语义:LCG 中的 float64 舍入会让看似等价的整数实现快速分叉。
  • 先确认输入边界:查询串、Body、编码方式和时间戳精度比复杂公式更容易造成系统性偏差。
  • 不要猜字段索引:固定输入下逐字节 diff,再回到运行时写入点核对来源。
  • 自动插桩后先做语法检查:保护代码结构复杂,插桩位置稍有偏差就可能破坏脚本执行。
  • 环境数据必须自洽:UA、设备指纹和环境字段应来自同一环境画像。
  • profile 必须声明范围:站点、bdms 版本和浏览器环境不同,旧 trace 常量不能直接复用。
  • 分离业务、签名和网络层-1025、环境风险和空响应分别从对应层级排查。
  • 保留独立证据链:运行时 trace、离线回归和服务端结果分别证明不同环节,不能相互替代。

9、结语#

这类 JSVMP 签名分析的难点,往往不在某个单独算法,而在于如何拆分问题。先从输出逆向剥离编码层,再通过单变量差分识别字段,最后用运行时插桩确认来源,能够把一个庞大的虚拟机黑盒缩小为少量可验证的数据变换。

纯算分析最有价值的部分也不是得到一个最终字符串,而是建立完整证据链:每个输入如何进入 Payload、随机状态如何传播、环境信号如何保持一致,以及每一层如何被独立验证。只要这些边界清晰,后续版本变化时也能快速定位差异,而不必重新从整份混淆代码开始。

分享

如果这篇文章对你有帮助,欢迎分享给更多人!

某音 a_bogus 签名链路与纯算分析思路(bdms 1.0.1.20)
https://blog.sleep0.de/posts/dy-abogus-pure/
作者
Sleep1223
发布于
2026-08-15
许可协议
CC BY-NC-SA 4.0

部分信息可能已经过时

目录