1. 项目概述:一次深入某音核心加密的逆向之旅
最近在逆向分析圈里,某音的a_bogus参数成了一个绕不开的话题。这个版本号为1.0.1.19-fix.01的a_bogus生成逻辑,其核心保护机制已经从传统的混淆、压缩,升级到了JSVMP(JavaScript Virtual Machine Protection)的层面,并且还魔改了国密算法SM3作为其哈希核心。这无疑给爬虫工程师和逆向爱好者们带来了新的挑战。今天,我就来完整复盘一次针对这个目标的逆向过程,从最开始的日志插桩定位关键点,到最终理解其魔改 SM3 的实现细节,希望能为你提供一条清晰的逆向链路。
简单来说,这次逆向的目标是搞清楚a_bogus这个关键参数是如何在浏览器端被计算出来的。它通常出现在请求的 URL 或表单数据中,是服务端验证请求合法性、判断请求是否来自真实浏览器环境的重要依据。逆向成功,意味着我们能够在不启动浏览器的情况下,模拟生成合法的a_bogus,从而构建稳定的数据采集或自动化流程。整个过程涉及对高度混淆的 JSVMP 代码的分析、执行流的跟踪,以及对加密算法变种的识别与还原,是对逆向分析综合能力的一次考验。
2. 逆向环境准备与初步侦查
2.1 工具链选择与配置
工欲善其事,必先利其器。面对 JSVMP,传统的“找到函数下断点”的方式基本失效,因为关键的逻辑被虚拟化成字节码,由一个虚拟机解释执行。因此,我们的工具链需要侧重动态分析和代码追踪。
核心工具如下:
- 浏览器开发者工具:Chrome DevTools 是基石。重点关注Sources面板下的代码调试、Network面板下的请求捕获与重放,以及Console面板用于执行代码片段和日志输出。
- Node.js 环境:用于在本地执行、调试剥离出来的 JavaScript 代码片段。配合
vm2这类沙箱模块可以安全地运行不确定的代码。 - 日志插桩工具:这是本次逆向的“眼睛”。我主要使用自己封装的一个基于
Proxy和Object.defineProperty的插桩脚本,用于监控特定对象属性或函数的访问、调用。市面上也有一些开源工具,但自研的灵活度更高,可以针对性地对疑似虚拟指令操作函数、栈、寄存器进行监控。 - 代码格式化与简化工具:例如
prettier用于格式化混乱的代码,但需谨慎,因为可能破坏某些依赖特定格式的字符串解密逻辑。更重要的是能识别常见混淆模式(如aa,bb,cc变量名)并进行批量重命名的小工具或 IDE 插件。 - 哈希算法识别工具:当怀疑核心是 SM3 时,需要准备标准的 SM3 实现用于对比。同时,熟悉 SM3 的算法流程(填充、消息扩展、压缩函数)至关重要,以便在代码中识别出对应的结构。
环境配置要点:在开始前,建议创建一个干净的浏览器用户配置文件,并安装必要的开发者工具插件。同时,在本地准备好一个可以随时运行 Node.js 脚本的项目目录。将待分析的 JS 文件保存到本地,方便进行多次、反复的静态分析和动态测试。
2.2 目标定位与参数捕获
首先,我们需要在真实的场景下捕获a_bogus。打开目标页面,开启 DevTools 的 Network 面板,并勾选Preserve log。执行一个会触发携带a_bogus请求的操作,比如刷新列表、提交评论等。
在 Network 中找到这个请求,通常是一个 XHR 或 Fetch 请求。在Headers选项卡的Query String Parameters或Form Data部分,就能找到a_bogus参数。记下它的值,同时完整地复制这个请求为 cURL 命令。这个 cURL 命令包含了请求头、Cookie 等所有上下文,是我们后续重放和对比的基准。
关键一步:在 Sources 面板中,使用Ctrl+Shift+F进行全局搜索,关键词可以是a_bogus、abogus或者其值的特征前缀。由于代码被混淆,直接搜索可能无果。更有效的方法是,在 Network 面板中,找到这个请求对应的Initiator调用栈。点击调用栈中的条目,可以跳转到发起这个请求的 JavaScript 代码位置。这里往往是突破的开始,即使代码被混淆,我们也能看到是哪个函数最终负责拼接了包含a_bogus的 URL 或数据体。
3. 核心逆向思路:日志插桩穿透 JSVMP 迷雾
3.1 JSVMP 基本原理与逆向策略
JSVMP 可以理解为开发者用 JavaScript 实现了一个自定义的虚拟机。原本的 JavaScript 业务逻辑被编译(或转换)成一套自定义的字节码指令序列和一个初始状态(包括虚拟栈、寄存器、常量表等)。在运行时,一个用 JavaScript 写的“解释器”(或称调度器)会循环读取这些字节码,根据指令码执行对应的 JavaScript 函数片段,从而模拟出原有逻辑的执行效果。
逆向 JSVMP 的核心不在于理解每一条字节码的含义(那相当于逆向一套新语言),而在于“穿透”虚拟机,定位到关键的原生 JavaScript 操作。这些原生操作通常是:
- 对浏览器环境 API 的调用(如
Date,Math,window对象属性)。 - 明显的算法结构(如循环、位运算、数组操作)。
- 最终输出结果的拼接操作。
我们的策略是:通过日志插桩,大规模监控这些原生操作,从海量的虚拟指令执行日志中,过滤出与加密、哈希、字符串处理相关的真实调用,从而勾勒出关键逻辑的执行路径。
3.2 定制化日志插桩实现
我的插桩脚本主要围绕以下几个核心点进行监控:
关键全局对象监控:对
Math对象的所有方法(random,floor,sin等)、Date.prototype.getTime、Array的相关方法(join,map,reduce)进行包装。每当这些方法被调用时,就打印出调用栈(简化后的)、传入的参数和返回值。// 示例:监控 Math.random const originalMathRandom = Math.random; Math.random = function() { const result = originalMathRandom.apply(this, arguments); console.trace(`[Math.random] called, result: ${result}`); return result; };疑似虚拟机解释器函数监控:在初步静态分析中,通常会找到一个巨大的
switch-case或dispatch table,这就是指令分发器。我会在这个分发器函数入口和每个case分支内部插入日志,记录当前执行的虚拟指令码(opcode)和虚拟机的状态(如栈顶几个值、某个寄存器的值)。这能帮我理解虚拟机的执行流程。字符串操作监控:
a_bogus最终是一个字符串。因此,所有最终参与字符串拼接(+运算符或Array.join)的值都值得关注。我会在代码中寻找最终的return语句或赋值给某个全局变量/参数的语句,并在其之前插桩,记录所有参与拼接的变量值。特定模式监控:SM3 算法内部有固定的常量(如初始向量 IV)和固定的操作(FF1, FF2, GG1, GG2 等函数中的位运算)。我会写一个模式匹配器,在虚拟机执行过程中,如果发现连续的位运算(如
(x & y) ^ ((~x) & z))或与 SM3 常量(如0x79cc4519,0x7a879d8a)相关的操作,就触发高亮日志。
实操心得:插桩会产生巨量的日志,必须要有过滤和搜索策略。我通常会先用“宽松”策略跑一遍流程,捕获a_bogus生成过程的日志。然后,用生成的a_bogus值作为锚点,反向在日志中搜索,看这个值是在哪个日志点之后首次出现或参与计算。这样可以快速缩小关键代码的范围。
4. 算法识别:从魔改哈希到锁定 SM3
4.1 哈希算法特征识别
通过日志插桩,我们可能定位到了一段密集进行位运算、模运算和循环操作的代码区域。这些是哈希算法的典型特征。接下来需要判断它是哪种哈希。
- 输入输出观察:记录下这段代码的输入(通常是一个字符串或字节数组)和输出(一个固定长度的十六进制字符串或字节数组)。
a_bogus的长度和字符集(通常是数字和小写字母)可以提供线索。 - 常量搜索:在代码中搜索魔数(Magic Number)。常见的哈希算法有独特的初始常量。例如,MD5 有
0x67452301,0xefcdab89等;SHA-256 有0x6a09e667,0xbb67ae85等。而SM3 的初始 IV 是0x7380166f,0x4914b2b9,0x172442d7,0xda8a0600,0xa96f30bc,0x163138aa,0xe38dee4d,0xb0fb0e4e。如果在代码中发现了这组常量,那么基本可以确定是 SM3。 - 流程匹配:SM3 算法流程包括消息填充、消息扩展和 64 轮压缩函数。压缩函数中包含了
FFj和GGj等布尔函数以及P0,P1置换函数。通过插桩日志,可以尝试还原出算法的轮数、每轮的操作序列,并与标准 SM3 流程进行比对。
4.2 确认魔改点
“魔改”意味着开发者没有使用标准的 SM3 算法。常见的魔改方式有:
- 修改初始常量(IV):这是最简单的魔改,将标准的 8 个初始常量替换成另一组自定义的值。
- 修改压缩函数中的固定常量:SM3 压缩函数每轮使用两个固定常量
Tj,前 16 轮为0x79cc4519,后 48 轮为0x7a879d8a。修改这些常量会彻底改变哈希结果。 - 修改布尔函数(FFj, GGj):改变这些函数内部的位运算逻辑。
- 修改置换函数(P0, P1):改变
P0和P1函数中的位移和异或操作。 - 改变填充规则:SM3 采用
0x80后接零比特的填充方式,最后 64 位填充消息长度。魔改可能会改变填充字节或长度编码方式(如使用小端序)。 - 多轮哈希或与其他算法组合:例如,先计算一次 SM3,再将结果作为输入进行某种变换,或者与一个固定字符串拼接后再哈希。
排查方法:在日志中定位到疑似 SM3 计算的起点(通常是初始化 IV 或开始处理填充后消息块的位置)。然后,一步一步跟踪计算过程,将每一步中间变量的值(如每一轮压缩后的 A, B, C, D, E, F, G, H 寄存器值)与使用相同输入的标准 SM3 算法的中间值进行对比。第一个出现差异的地方,很可能就是魔改点。有时魔改可能不止一处,需要耐心比对整个流程。
注意:魔改算法通常是为了增加逆向难度和制造独特性。理解其魔改逻辑后,我们既可以用 Python/JavaScript 重新实现这个魔改算法,也可以尝试在本地执行原 JS 代码中的这个函数(通过补环境使其在 Node.js 中运行)。
5. 完整链路还原与代码重构
5.1 提取关键逻辑与补环境
一旦通过日志分析锁定了生成a_bogus的最终 JavaScript 函数(可能是一个被虚拟机包裹的复杂函数),下一步就是尝试将它从庞大的混淆代码中剥离出来,并在独立环境中运行。
代码提取:在 Sources 面板中,找到包含关键函数的文件。使用“Pretty-print”格式化代码。然后,手动分析该函数的依赖关系:它引用了哪些外部变量、函数或全局对象?将这些依赖一并复制到一个新的 JS 文件中。这个过程可能像“抽丝剥茧”,需要反复测试运行,根据错误信息逐步补充缺失的依赖。
环境补全:浏览器环境中拥有大量
window、document、navigator等对象及其属性。当提取的代码在 Node.js 中运行时,会因缺少这些环境而报错。我们需要“补环境”。- 简单补法:直接定义空对象或模拟值。例如
window = global; document = {}; navigator = { userAgent: '...' };。 - 精准补法:使用
Proxy对象进行拦截。对于难以模拟的复杂对象或函数,可以用Proxy设置一个get陷阱,当代码访问某个属性时,打印出属性名,这样我们就知道需要补什么。然后根据打印的结果,去查阅 MDN 文档,实现一个最小功能的版本。 - 针对
a_bogus:特别注意补全与时间、随机数、设备指纹相关的 API,如Date,Math.random,performance.now,screen.width/height等,因为这些信息很可能作为哈希算法的输入。
- 简单补法:直接定义空对象或模拟值。例如
5.2 重构魔改 SM3 算法
如果决定不直接执行原 JS 函数,而是自己重新实现,那么就需要根据之前的分析结果,编写一个确定性的算法。
- 搭建算法框架:以标准 SM3 的 Python/JavaScript 实现为蓝本。
- 植入魔改点:在框架的基础上,修改你之前发现的魔改之处。例如,将初始化 IV 数组替换成你从日志中记录下来的值;修改
T常量数组;或者重写FF,GG,P0,P1函数。 - 验证算法:使用从浏览器中捕获的多个“输入-输出”对(不同的请求,不同的
a_bogus)来测试你重构的算法。确保对于不同的输入,你的算法输出与浏览器生成的a_bogus完全一致。这是最关键的验证步骤。
一个常见的重构结构如下(Python 伪代码):
class ModifiedSM3: def __init__(self): # 魔改的初始 IV self.iv = [0x... , 0x..., ...] # 替换为标准或魔改值 # 魔改的 T 常量 self.T = [0x... for _ in range(64)] # 替换为标准或魔改值 def _ff_j(self, x, y, z, j): # 根据魔改逻辑实现 if j < 16: return x ^ y ^ z # 示例,可能被魔改 else: return (x & y) | (x & z) | (y & z) def _gg_j(self, x, y, z, j): # 根据魔改逻辑实现 if j < 16: return x ^ y ^ z # 示例,可能被魔改 else: return (x & y) | ((~x) & z) def _p0(self, x): # 魔改的 P0 置换 return x ^ self._left_rotate(x, 9) ^ self._left_rotate(x, 17) def _p1(self, x): # 魔改的 P1 置换 return x ^ self._left_rotate(x, 15) ^ self._left_rotate(x, 23) def hash(self, message): # 填充过程(注意填充规则是否魔改) padded_msg = self._padding(message) # 迭代压缩过程 for block in padded_msg: self._cf(block) # 压缩函数,内部会调用上述魔改的函数 # 最终结果拼接并返回十六进制字符串 return self._get_result() # 使用 sm3 = ModifiedSM3() input_data = "需要哈希的字符串,可能包含时间戳、其他参数等" a_bogus = sm3.hash(input_data)5.3 整合与参数生成
最后一步,是将魔改的 SM3 算法整合到完整的a_bogus生成流程中。通过日志分析,我们已经知道a_bogus的输入是什么。它通常不是直接哈希原始数据,而是哈希一个由多个参数拼接、格式化后的字符串。
这个输入字符串的构造规则也需要从 JS 代码中还原。常见的组成部分包括:
- 一个固定的前缀或密钥。
- 时间戳(可能被格式化或取部分)。
- 用户 ID 或设备 ID 的某种编码。
- 请求的路径 (
path) 或查询参数 (query string) 的排序和拼接。 - 一个随机数 (
nonce)。 - 其他从浏览器环境提取的指纹信息。
你需要像拼图一样,将这些部分按照正确的顺序和分隔符拼接起来,形成 SM3 算法的输入。然后,将 SM3 输出的哈希结果,可能还要经过一次十六进制编码、截取或二次变换,才得到最终的a_bogus字符串。
6. 逆向过程中的典型问题与解决策略
6.1 日志淹没与关键信息过滤
问题:即使针对性地插桩,在 JSVMP 执行时也可能产生数万甚至数十万条日志,手动查找如大海捞针。
解决策略:
- 分级日志:设置日志级别,如
DEBUG,INFO,WARN,ERROR。在初步探索时用DEBUG,定位到大致范围后,调高日志级别,只记录最可疑的操作。 - 条件触发:不要无差别记录所有调用。例如,只在函数的第一个参数包含特定字符串(如
“a_bogus”)或返回值长度符合预期时,才打印日志。 - 关联追踪:给每个请求或每个执行上下文分配一个唯一 ID。在插桩时记录这个 ID,这样可以在海量日志中轻松筛选出属于同一次
a_bogus生成过程的所有记录。 - 外部工具:将日志输出到文件,然后用
grep,awk或编写 Python 脚本进行离线分析,搜索模式匹配。
6.2 反调试与代码动态变形
问题:现代前端保护方案会检测开发者工具是否打开,或者检测代码是否被调试。一旦检测到,可能会触发死循环、代码逻辑动态变更甚至直接报错退出,导致分析无法进行。
解决策略:
- 禁用断点检测:有些代码会重写
Function.prototype.toString或debugger关键字来检测断点。可以在 DevTools 的 Settings -> Ignore List 中添加相关脚本,或者使用条件断点来绕过。 - 过反调试:在控制台执行一些代码来覆盖常见的检测函数。例如:
注意:这些操作需要根据具体反调试手段调整,且可能影响正常调试。// 覆盖 console 的一些方法,防止其被用于检测 console.log = function(){}; console.trace = function(){}; // 防止 debugger 语句暂停 Function.prototype.constructor = function() { return function(){}; }; - 时间差攻击:有些代码会测量两个函数执行的时间差,如果时间过长(因为打了断点),就判定为调试。应对方法是尽量使用“日志插桩”而非“断点”进行动态分析,或者找到时间检测代码并绕过它。
- 动态代码加载:如果关键逻辑是在运行时通过
eval、Function构造函数或动态创建<script>标签加载的,你需要监控这些动态执行入口。在 DevTools 的 Sources 面板中,可以给eval或Function调用设置“Event Listener Breakpoints”。
6.3 算法还原中的细节偏差
问题:自己实现的魔改算法,对于大部分测试用例都正确,但偶尔会生成错误的结果,或者与浏览器结果存在固定差异。
解决策略:
- 检查字节序(Endianness):这是最常见的坑。JavaScript 中的位操作通常将数字当作 32 位有符号整数处理,并且是大端序(Big-endian)?不对,这里需要澄清:JavaScript 的位运算符 (
<<,>>,>>>,&,|,^) 操作的是32 位有符号整数,并且是在二进制补码形式下操作,但不直接涉及内存字节序。然而,当算法涉及将字符串或数组缓冲区转换为整数时,字节序就至关重要。SM3 算法标准规定使用大端序(Big-endian)。确保你的实现在处理消息分组(从字节数组到 32 位字)和最终输出(从 32 位字到字节数组)时,都严格遵守了大端序。浏览器中的 JavaScript 实现可能依赖于DataView或Uint8Array的视图,其字节序是可设置的。 - 检查整数溢出与截断:JavaScript 中所有数字都是双精度浮点数,但位运算符会将操作数先转换为 32 位有符号整数,运算后再转回。确保你的算法在模拟加法、循环左移等操作时,正确处理了 32 位溢出(即结果对 2^32 取模)。Python 中整数没有上限,需要显式地进行
& 0xffffffff操作来模拟 32 位截断。 - 对比中间变量:不要只对比最终结果。在浏览器中(通过深度插桩)和你的本地实现中,同时打印出每一轮压缩函数的中间状态(A, B, C, …, H 寄存器)。找到第一个出现差异的轮次和操作,仔细检查该操作对应的魔改函数实现、输入值以及字节序处理。
- 验证输入一致性:确保传递给你本地算法的输入字符串,与浏览器中传递给哈希函数的输入完全一致,包括每一个字符、空格和不可见字符。最好将浏览器中的输入以十六进制形式打印出来进行比对。
6.4 环境依赖的隐蔽调用
问题:提取的代码在独立运行时,报错某个属性undefined,但通过日志看似乎没有访问过这个属性。
解决策略:
- 全面 Proxy 拦截:对于难以捉摸的环境依赖,可以对全局对象
window或this使用一个全局的Proxy进行兜底拦截。
这样,任何未被显式定义的属性访问都会被日志记录,帮助你发现所有隐藏的依赖。const handler = { get: function(target, prop, receiver) { console.log(`[GET] 访问属性: ${String(prop)}`, new Error().stack); // 返回一个模拟值或继续用 Proxy 包装 if (prop === 'someDeepProperty') { return 'mockedValue'; } // 对于不存在的属性,返回一个新的 Proxy,以便链式调用也能被捕获 if (!(prop in target)) { console.warn(`属性 ${String(prop)} 不存在,创建虚拟属性`); return new Proxy({}, handler); } return Reflect.get(target, prop, receiver); }, set: function(target, prop, value) { console.log(`[SET] 设置属性: ${String(prop)} = ${value}`); return Reflect.set(target, prop, value); } }; global.window = new Proxy({}, handler); - 最小化补环境:不要试图补全整个浏览器环境。根据错误日志和拦截日志,只补全那些真正被访问到的属性和方法。保持环境尽可能简单,可以减少干扰和不确定性。
逆向a_bogus这样的参数,是一个系统工程,需要耐心、细致的观察和严谨的推理。从日志插桩切入 JSVMP,到识别并还原魔改算法,每一步都可能遇到意想不到的障碍。但只要你掌握了正确的工具和方法论,沿着“动态分析定位关键点 -> 静态分析理解结构 -> 算法比对识别魔改 -> 补环境/重实现验证”这条链路走下去,最终总能拨开迷雾,见到曙光。记住,每一次成功的逆向,不仅是获得了一个参数算法,更是对前端安全技术和自己分析能力的一次深刻提升。