☰
WebAssembly逆向实战:从APK到风控签名算法的完整拆解
2026/9/29 15:54:41 网站建设 项目流程

1. 为什么航司会把风控逻辑藏进wasm:一件让我折腾整晚的事

这周在分析某航司APP的接口签名时,我顺着调用栈一路追下去,最终在内存里撞见了一堆0x0061736d开头的字节——这是WebAssembly的魔数。当时的第一反应是:"他们居然把核心算法编译成wasm了?"第二反应才是:"有意思,这活儿值得拆一拆。"

做过APP逆向的朋友应该都有体会:以前我们面对的加固方案无非是So库、Java反射、Dex混淆三板斧。但随着移动端安全对抗升级,wasm作为一个既能在Android/iOS上稳定运行、又具备极强反调试潜力的执行中间层,开始被越来越多的大厂选作风控算法的容器。它的优势很直白:不是汇编但比汇编更难读,是字节码但比Dalvik字节码更冷门,绝大部分逆向工程师平时的知识栈里根本没有这一环。等到真正需要面对它的时候,光是把工具链跑通就能劝退不少人。

这篇东西就是把我这次从零开始的wasm逆向过程完整复盘出来。我尽量不预设读者已经有WebAssembly基础,但默认你至少熟悉常规的APK逆向流程,比如会用jadx看Java层逻辑、会用IDA/ghidra分析so。如果你连这些还没摸过,建议先补基础再回来看本文,否则有些步骤会显得跳跃。

先说清楚边界:本次分析的目的是技术研究与学习验证,走的是合法合规的样本分析路线,重点在于梳理一套可复用的wasm模块逆向方法论。涉及绕过高强度安全防护的完整具体操作就不展开讲了,这既是对规则的尊重,也是对自己职业生涯的保护。

2. 起手式:先确认目标到底是不是wasm,以及它藏在哪里

2.1 从APK包体里直接定位wasm模块的三种方式

拿到一个APK,第一步不是急着反编译,而是先做一次"全文搜索"。wasm模块在Android应用里的存在形式主要有这么几种:

  • 打包在assets目录下,常见扩展名是.wasm、.dat、.bin,这类最好找,直接解压看就完事;
  • 伪装成其它扩展名,比如放在assets下面但叫xxx.png或xxx.json,这类需要靠魔数识别;
  • 运行时从网络拉取或由so库在内存中释放,这类最难搞,需要hook文件读取或网络层才能截获。

我这次遇到的属于第二类。用jadx打开APK后,Java层代码里能看到一个可疑的loadData调用,参数是一个"图片资源"的文件名,但点进去看资源本身,文件头根本不是PNG签名。用十六进制编辑器打开后,前4个字节就是0061736d,也就是wasm的魔数,后面的版本号01000000也完全匹配Wasm v1格式。到这里基本实锤了:目标确实是一份wasm模块,而且大概率承载了核心签名逻辑。

2.2 没有魔数的"生肉"如何识别:熵值分析兜底

但有些场景下,开发方会把wasm二进制做二次封装——比如先异或、再压缩、甚至直接拆成几段分片存储。这时候光靠魔数搜索就会失灵。我的习惯是做一个全文件熵值扫描:正常文本文件熵值在4~5左右,压缩加密后的数据熵值会逼近8。wasm虽然是二进制,但内部字符串常量多的时候熵值反而没那么高,在6~7之间。如果发现某个文件熵值异常、体积又明显超过了图片应有的范围,那大概率就是被加工过的wasm。

我们这次的目标没到这个强度,但对于准备长期搞这一块的朋友,熵值分析是很值得提前练的基本功。工具方面,binwalk的熵值子命令、或者CyberChef里的Entropy模块都能直接出数值。

2.3 动态分析方法:用Frida在运行时捕获释放路径

静态搜索覆盖了大部分场景,但碰到"加载器+远程下发"这种组合拳,静态是找不到完整文件的。这种时候就得动态上了。Android端的常规操作是Frida hook文件读取类,比如java.io.FileInputStream的read方法,把读到的内容当十六进制dump下来,再看能不能匹配到wasm魔数。

更精准一点的方法是hookSystem.load和AssetManager.open,因为无论文件藏多深,最终要被执行就必须经过这些入口。跑一轮正常业务流量触发签名计算,大概率就能在日志里看到wasm文件落地的完整路径。还有个比较巧的思路:直接hookWebAssembly.instantiate这个JS接口。如果APP内部恰好用了WebView跑JS壳,那么这个调用点一旦出现,wasm的传参就以Base64字符串的形式呈现在我们面前,省掉前面所有找文件的功夫。

3. 工具链选型:WABT、wasm2c还是IDA插件,实测下来哪个靠谱

3.1 初筛反汇编利器WABT:从字节码到可读文本

WebAssembly的生态虽然没有x86/ARM那么庞大,但该有的工具链其实都已经成熟了。我最先试的是WABT(WebAssembly Binary Toolkit)里的wasm2wat,它的作用是把二进制wasm转换成人类可读的.wat文本格式(Wasm Text Format)。

执行命令非常简单:

wasm2wat target.wasm -o output.wat

生成的wat文件是S表达式风格,读起来类似Lisp,函数边界非常清晰。我这次的目标模块不算大,生成出来的wat大约不到3000行,但包含了大大小小约80个函数。最吸引注意力的是(export "computeSignature" (func $computeSignature))这类导出声明——导出函数名直接告诉我们这个模块的对外入口是什么。

WABT的另一个优势在于wasm-objdump,可以快速查看模块的导入导出表、函数段、记忆段等元信息。wasm-objdump -x target.wasm一下,所有内部结构一览无余。这一步的意义在于:你不用先读懂每个函数在干嘛,就能先判断出哪些函数值得重点看。

3.2 进阶还原利器wasm2c:把字节码翻译成可读C伪代码

WABT只能到wat为止,但wat看起来还是累。真正让我效率起飞的是一个叫wasm2c的工具,它会把wasm字节码翻译成C源码目录,每个wasm函数对应一个C函数。命令也很简单:

wasm2c target.wasm -o target.c

生成出来的C代码本质上是"虚拟机解释器+C源码"的组合产物:一方面有一个wasm_rt_*开头的runtime头文件,模拟了栈和线性内存;另一方面,每个wasm函数都会被翻译成一个可读的C函数。虽然翻译结果不是源语义级别的还原(比如变量名还是a、b、c),但控制流、算术运算、内存读写这些核心逻辑已经非常接近直接读C的程度。

拿这次的分析来说,wasm2c生成的代码里,签名函数的骨架一眼就能看懂:读取入参、拼接时间戳和某个盐值、循环做若干次SHA之类的杂凑运算、最后把结果字节序翻转作为签名。有了这个级别的内容,后面的分析基本上就是顺着思路走的问题了。

3.3 图形化路线:Ghidra对wasm的支持现状

如果你更喜欢在图形界面里看反汇编,可以试Ghidra的WebAssembly插件。这个插件的成熟度要比IDA的官方支持好一些(IDA对wasm的支持直到较新版本才开始逐步完善)。我在某个小功能模块上用Ghidra的wasm插件做过对照验证,它能比较准确地恢复函数调用关系,甚至在伪代码视图里也能给出比较合理的翻译。

但我的实际体验是:Ghidra的wasm插件处理小模块尚可,一旦函数超过200个、控制流复杂起来,反编译结果的可靠性下降明显。尤其在处理wasm的stack machine指令模式时,伪代码里会出现大量中间临时变量,阅读体验远不如wasm2c直出。所以我的建议是:Ghidra可以作为交叉验证工具,但主线分析尽量以WABT+wasm2c为主。

4. 定位核心逻辑:从导出表一路追到加密算法实现

4.1 导出表就是寻宝地图

wasm模块不同于Android的so库,它没有动态符号表那种丰富的信息,但它的导出表已经透露了足够多的内容。用wasm-objdump -x查看导出段,目标模块导出了三个函数:

  • computeSignature(params_ptr, params_len, sign_ptr)——签名主函数
  • getVersion()——返回一个整型版本号
  • setSeed(seed_ptr, seed_len)——设置种子数据

好家伙,光看这个导出签名,模块的用途已经猜了个七七八八。这是一套典型的"种子+参数=签名"模式,大概率用于请求签名或者设备指纹计算。

导出函数的参数全是指针,这一点很关键。wasm运行在沙箱线性内存里,函数参数没法直接传复杂结构体,只能传"内存地址+长度"。所以我们要分析的重点之一,就是这些指针指向的线性内存区域到底是什么布局。

4.2 引入函数看"外部依赖"

除了导出表,导入表同样重要。wasm本身不能直接操作系统API,所有系统能力都要通过宿主环境注入。wasm-objdump -x输出里的Import段会显示类似env.floor、env.log这类导入,意味着模块使用了数学函数或日志能力。

我这次看到的导入列表非常干净,只有两个:一个env.memory(线性内存基址),一个env.abort(异常中止回调)。这说明整个模块不依赖任何外部复杂能力,纯粹在内部做计算。这种设计也印证了我的判断:这就是一个纯算法模块,去掉一切干扰项,只负责把输入变成输出。

4.3 结合wasm2c的输出定位核心函数

导出了computeSignature后,在wasm2c生成的C代码里直接搜索这个函数名,找到对应实现。跟着它的调用关系走:第一步读取入参指针指向的一段数据,第二步根据长度计算要读取的字节数,第三步调用了一个内部函数sign_core,最后把结果写入输出指针。

sign_core就是真正的算法本体。它的内部结构一眼看过去有很强的循环形态:先初始化一个状态数组,再对输入数据做分块处理,过程中反复使用异或、移位、加法运算。从这些运算特征上判断,极大概率是某种自定义哈希或者类HMAC结构。

我当时在笔记本上画了半天状态流转图,最后对照常见哈希算法的常量表——一开始怀疑MD5,后来怀疑SHA256,最后通过搜索wasm文件内的字符串常量,找到了几个非常不显眼但能锁定算法的标志:初始化常量6a09e667、bb67ae85等。这是SHA-256家族的标志性初始值,没有任何悬念。于是整个签名算法的性质就确认了:基于SHA-256的加盐签名。

4.4 字节序陷阱——wasm里最常见的坑

确认了算法本身还不够,签名计算中的一个反直觉细节让我在联调时卡了很久:wasm的线性内存是小端序存储,但函数内部对状态变量的存取很多地方是按大端序语义处理的。翻译到C代码后,会出现大量的字节序反转操作,如果不注意这个细节,直接照着C逻辑写复现脚本,签名结果铁定对不上。

比如,wasm2c生成的代码里,某个步骤写成sign = (sign << 8) | (byte & 0xFF),看起来是普通的左移拼接,实际语义就是把这些字节按大端序压入状态。解决的办法很简单:先把所有内存操作统一成"读字节流",再做一次字节序归一化,最后再按小端序输出,就完全一致了。

5. 手写复现脚本:把自己的算法翻译成另一门语言

5.1 为什么我一定建议"复现"而不是"调用"

很多人在逆向出算法后,第一选择是直接在自己的代码里加载原wasm模块,通过宿主环境调用。这种方案在功能上没问题,但有几个隐患:

  • 原模块一旦被加载,就可能触发内置的反调试、环境检测甚至自我完整性校验;
  • 宿主环境的JS或C嵌入接口需要绑定WebAssembly runtime,部署复杂度高;
  • 如果甲方分析的是比赛/漏洞研究场景,很多时候你没法保证目标模块在目标机器上能稳定运行。

所以我更倾向于在理解透逻辑后,用自己熟悉的语言(比如Python)把算法完整复现一遍。复现的过程本身就是对分析结果最好的验证——如果跑出来的签名和原模块一致,说明理解没有偏差。

5.2 Python复现SHA-256签名过程的骨架

这里给出一个简化版骨架示例,展示的是我在复现时的核心控制流,具体逻辑因目标的实现细节而异:

import hashlib import hmac import struct def compute_signature(seed: bytes, payload: bytes) -> bytes: # 种子字串拼接规则:这是逆向分析得到的 material = seed + b"#" + payload + b"@knight" # 内层SHA-256:对应wasm里的sign_core inner = hashlib.sha256(material).digest() # 外层再套一层:注意字节序方向 outer = hmac.new(inner, payload, hashlib.sha256).digest() # 最后有一个固定的字节翻转步骤 return outer[::-1]

这只是一个示意。实际分析中我遇到的算法拼接顺序、盐值位置、翻转轮数都比这个复杂很多,但方法论是完全一样的:先读wasm2c的C代码画出数据流图,再翻译成目标语言,翻译完用样本数据直接对拍,对不上就回头检查字节序和初始常量。

5.3 验证对拍的正确姿势

复现完成后,验证别只用一条数据。我自己的习惯是准备至少20条不同长度、不同字符集的输入样本,包括:

  • 空字符串;
  • 纯数字字符串;
  • 含中文、emoji的UTF-8字符串;
  • 超长字符串(比如1MB级别);
  • 含\x00、\xff等不可见字符的二进制串。

每一条都跑原模块和复现函数,比对签名结果。如果中途某一条对不上,优先检查:

  • 字符串编码是否一致(尤其UTF-8 vs UTF-16);
  • 长度字段的字节宽度(wasm里是32位还是64位长度);
  • 是否有额外的终止符或填充逻辑。

这个对拍过程不只是为了"跑通",更重要的是能帮我们发现最初静态分析时漏掉的细节。我当时就是在测到含中文的样本时,发现原模块会先把字符串做一次UTF-8编码后再参与签名,而我的复现脚本直接用Python字符串参与哈希,导致结果不一致。改完编码逻辑,20条样本全绿。

6. 实战中容易踩的五个坑:从工具报错到逻辑误解

6.1 坑一:wasm2c崩溃,问题出在未初始化的内存区域

wasm规范里,线性内存的初始值不保证是0,底层可能是宿主环境留下的残留数据。而wasm2c生成的C代码里,内存数组默认是用calloc分配的,理论上是清零的。但如果你对某些特定工具链,比如较老版本的WABT,处理带有memory.grow指令的模块时,新增长度的内存区域可能没有正确初始化,导致翻译后的运行结果和原模块不一致。

排查方法是在wasm2c生成的代码里搜索wasm_rt_grow_memory相关调用,检查新增长度是否被平滑清零。我当时是被一条诡异的签名输出搞懵了两小时,最后发现某个内部缓冲区的第64~128字节在两次运行中给出不同的随机值。这其实是内存未定义行为被带进了翻译结果。

6.2 坑二:Frida hook输出时指针被释放

动态分析中,当你hook一个wasm函数入参时,指针指向的内存可能是临时分配且随后被宿主释放的。如果你在回调里只记录数值而不拷贝内容,等真正去读的时候,数据已经被篡改或回收了。正确做法是在回调里立刻用Memory.readByteArray(ptr, len)拷贝一份,或者直接转成十六进制字符串存下来。

这个细节我在第一次搞wasm hook的时候就栽过。日志里明明把参数长度打出来了,但数据内容中有一半是乱码,排查了半天才发现是异步读取导致的悬垂指针问题。从那以后,凡是hook涉及指针型入参,我的第一行代码永远是拷贝数据,绝不延迟处理。

6.3 坑三:把wat里的i64.add直接当成64位整数运算

wasm的i64类型在语义上确实是64位整数,但很多宿主环境在实现时是用"两个32位寄存器拼接"来模拟的。如果你用C语言的long long去对拍wasm2c生成的代码,在一些边界值(溢出、除法)上会出现不一致。

最稳妥的做法是:当看到i64运算时,先确定是否真的有大于2^53的整数值参与计算。如果有,必须用支持高精度的数据结构(比如Python int天然无上限就无所谓;但C语言需要多留意符号和无符号截断)。多数签名算法里不会用到超大整数,但也有特例存在,比如一些服务端自定义的时间戳混淆逻辑会故意用64位溢出制造效果。

6.4 坑四:模块内部自带反调试特征码比对

这个坑严格说不算wasm特有的,但wasm模块内部实现反调试时,方式比较隐蔽。它不像so库那样直接调用ptrace,而是在计算过程中周期性检查输入数据的某些特征字节,比如检测你是通过Frida注入后再传入的参数还是APP正常业务生成的参数。

我遇到的情况是:模块会对入参的前8个字节做一次CRC校验,如果校验值异常,后续计算路径就会被替换成一条假分支,输出来一个恒定的假签名。这个假签名在格式上完全正常,长度也对,但每次内容都一样,很容易被误判成"算法分析错误"。排查方法是:对同一入参多次运行原模块,如果输出恒定且与Python复现值不同,优先怀疑有特征校验分支。

6.5 坑五:wasm热更新导致代码版本漂移

航司这类大厂APP,核心签名模块经常做灰度升级。你今天分析到的wasm文件,可能明天就被服务端下发的热更新版替换了。如果后续你的自动化脚本一直基于旧模块复现的算法跑,就会在某个时间点突然大量失效。

应对方案有两个:一是对捕获到的每个wasm模块计算哈希并记录版本,建立"版本-算法摘要"映射表;二是在脚本里加一个自适应逻辑,定期检查APP内wasm的哈希值是否变化,如果变了就触发新一轮逆向流程。听起来很重,但在真实自动化场景里这是必备工程能力,不是可选项。

7. 从"逆向"到"防护":理解攻击者的逻辑才能更好地防守

逆向做完,我通常还会反过来思考一个问题:如果我是这个模块的开发者,该怎么让逆向难度再上一个台阶?这次分析中暴露出的几个薄弱点,正好可以作为防护设计的参考。

如果能调整,我觉得下面几个方向是最值得投入的:

  • 不要导出自解释性太强的函数名。computeSignature这种命名等于直接告诉分析者"这里是签名入口",改成一个中立名或直接做成单导出API,分析门槛立刻高一层。
  • 在参数传入前做一次数据混淆。比如把输入按奇偶位拆成两个缓冲区分别拼接,让签名逻辑对输入布局的依赖更隐蔽,分析者就很难通过猜结构的方式快速定位算法。
  • 引入不定长状态表。把SHA-256的固定初始常量改成动态生成,比如根据输入长度、时间戳、设备指纹等生成初始状态,这样即使分析者对拍单条数据成功,也难以推导出全量规律。
  • 把部分计算放到宿主环境里做。以前很多模块是纯wasm计算,如果能把一半的运算留在Java/OC层,另一半放进wasm,两边各做一半变换,分析者就不得不同时还原两层逻辑,工作量成倍增加。

当然,这些思路同样提醒我:从事逆向研究时,永远要默认目标模块比上一代更难啃。逆向和反逆向的博弈没有终点,我们能做的,就是不断更新自己的方法栈,别躺在某一次成功的功劳簿上。

8. 结尾:关于wasm逆向,我想留给你的几点实在建议

整个过程复盘下来,我最想说的一点是:wasm逆向并没有想象中那么玄乎,它本质上只是换了一种"汇编"的逆向工作。只要你掌握了已有的工具链、理解了wasm的线性内存模型和导入导出机制,剩下的就是耐心和细致的数据流追踪。

从技术成长的角度,我强烈建议做移动安全方向的朋友把wasm纳入技能树。微信小程序、短视频APP、金融类应用、航旅类应用……wasm的应用面已经在肉眼可见地增长,而能熟练分析wasm的人目前还是少数。这个技能差,就是机会差。

最后分享一个小技巧作为收尾:拿到任何wasm模块后,先别急着反编译,先用wasm-objdump -x把它的导入导出表完整打出来看一眼。很多时候,光看这几十行元信息,你就能判断出值不值得继续投入精力——如果导出函数名已经把意图暴露得干干净净,后面的分析只是体力活;如果导出名全部被混淆成f1、f2,那就做好打硬仗的准备吧。祝各位调试顺利,内存不越界,对拍一遍过。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询