某音 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这条链路可以拆成三层:
- 输入摘要层:处理 URL 参数和 Body;
- 结构组装层:拼接时间、指纹、环境信号和校验字段;
- 编码混淆层:位掩码扩展、异或和自定义 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] = 3f[25] = 0f[27] = 3f[30] = 0f[34] = 0f[45] = 3对真实 bdms.js 的签名主入口和 Payload 组装入口插桩后,可以把 env 块输入逐值映射到
fixed48。结果表明,这六项全部是运行时环境检测值,而不是算法常量:
| 索引 | Buyin 1.0.1.20 Firefox 样本 | 变化特征 |
|---|---|---|
| 14 | 3 | 慢变运行时值,样本中可出现 3 与 4 |
| 25 | 40 | 页面载入或 Canvas 环境派生,跨页载漂移明显 |
| 27 | 13 | 浏览器环境检测值,与 UA 字符串无直接对应关系 |
| 30 | 0 | 环境或能力检测值,与 34 成对变化 |
| 34 | 0 | 环境或能力检测值,与 30 成对变化 |
| 45 | 13 | 浏览器环境检测值 |
另一个重要修正是:真实 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 → 前置短前缀 → 自定义 Base644.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 PayloadPayload 布局 fixed48 + meta2 + 43B fp + 3B time + ',' + checksumfp <已脱敏: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 每个位置的真实值与来源
| 索引 | 样本值 | 来源或作用 |
|---|---|---|
| 0 | 1 | 格式/版本固定值 |
| 1 | 0 | 运行时环境字段 env[1] |
| 2 | 145 | 运行时环境字段 env[2] |
| 3 | 92 | 时间派生:((ts + 3) >> 8) & 0xff |
| 4 | 0 | 保留位 |
| 5 | 115 | 时间派生:ts & 0xff |
| 6 | 0 | 保留位 |
| 7 | 0 | 保留位 |
| 8 | 1 | 运行时环境字段 env[8] |
| 9 | 39 | query 双 SM3 摘要字节 h1[18] |
| 10 | 1 | 运行时环境字段 env[10],可随环境变化 |
| 11 | 3 | 格式固定值 |
| 12 | 198 | query 双 SM3 摘要字节 h1[3],不是环境字段 |
| 13 | 88 | 运行时环境字段 env[13] |
| 14 | 3 | 慢变运行时环境值,属于六个 profile 覆盖位置之一 |
| 15 | 194 | query 双 SM3 摘要字节 h1[9] |
| 16 | 160 | 格式固定值 |
| 17 | 0 | 保留位 |
| 18 | 92 | 时间派生,与索引 3 同源 |
| 19 | 71 | 运行时环境字段 env[19] |
| 20 | 53 | 格式固定值 |
| 21 | 44 | 运行时环境字段 env[21] |
| 22 | 120 | 运行时环境字段 env[22],样本间可漂移 |
| 23 | 0 | 保留位 |
| 24 | 251 | 运行时环境字段 env[24] |
| 25 | 39 | 页面载入/Canvas 等环境派生,慢漂移位置 |
| 26 | 120 | 运行时环境字段 env[26],通常与 22 同步变化 |
| 27 | 14 | 运行时环境检测值,不能从 UA 字符串直接推导 |
| 28 | 6 | 格式固定值 |
| 29 | 10 | 运行时环境字段 env[29] |
| 30 | 2 | 环境/能力检测值,通常与 34 成对变化 |
| 31 | 0 | 保留位 |
| 32 | 0 | 运行时环境字段 env[32] |
| 33 | 82 | Body 双 SM3 摘要字节 h2[10] |
| 34 | 2 | 环境/能力检测值,通常与 30 成对变化 |
| 35 | 0 | 保留位 |
| 36 | 160 | 格式固定值 |
| 37 | 212 | 运行时环境字段 env[37] |
| 38 | 177 | Body 双 SM3 摘要字节 h2[19] |
| 39 | 1 | 上游表达式尚未完全归因;当前样本为 1,旧构造默认值曾为 0 |
| 40 | 1 | 格式固定值 |
| 41 | 0 | 保留位 |
| 42 | 0 | 保留位 |
| 43 | 41 | 格式固定值 |
| 44 | 114 | 时间派生:(ts - 1) & 0xff |
| 45 | 14 | 运行时环境检测值,属于六个 profile 覆盖位置之一 |
| 46 | 43 | 后续设备指纹原文的字节长度 |
| 47 | 0 | 保留位 |
这份表还有一个值得注意的新证据:六个慢变位置在该样本中为:
{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 探测也同样如此:返回值本身就是环境特征。
因此,环境处理应遵循两个原则:
- 只记录真实执行中确实被读取并进入签名的数据;
- 使用同一浏览器环境下自洽的 UA、指纹和环境字段样本。
仅让签名结构合法并不足以证明环境正确。若环境字段来自不同浏览器或由默认值拼凑,算法即使逐层无误,也可能触发服务端校验。
6、用 pack_detail 完成在线闭环
离线逐字节一致之后,我选择巨量百应达人端的选品决策详情接口作为在线验收样本:
POST https://buyin.jinritemai.com/pc/selection/decision/pack_detail签名前的 query 由当前会话的 verifyFp、fp 和 msToken 组成,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(),再统一编码。
这次在线排查也让我把问题明确拆成三层:
- 业务层:Body 字段、
biz_id和动态 Tab 参数是否真实; - 签名层:query、Body、时间与 profile 是否逐字节一致;
- 网络层: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、随机状态如何传播、环境信号如何保持一致,以及每一层如何被独立验证。只要这些边界清晰,后续版本变化时也能快速定位差异,而不必重新从整份混淆代码开始。
如果这篇文章对你有帮助,欢迎分享给更多人!
部分信息可能已经过时





