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那么庞大但该有的工具链其实都已经成熟了。我最先试的是WABTWebAssembly 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可以作为交叉验证工具但主线分析尽量以WABTwasm2c为主。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 bknight # 内层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那就做好打硬仗的准备吧。祝各位调试顺利内存不越界对拍一遍过。