1. 项目概述:从“金币修改”切入 WebAssembly 逆向世界
最近在技术社区和游戏修改圈子里,WebAssembly(简称 Wasm)逆向分析的热度越来越高。很多朋友,尤其是对游戏安全、客户端逻辑分析感兴趣的开发者,都遇到了一个典型场景:一个网页游戏或者用 Wasm 技术打包的 H5 应用,其核心逻辑(比如角色的金币数量、攻击力数值)被编译成了.wasm二进制文件。面对这一堆“天书”,如何把它变回我们能看懂、甚至能修改的代码,就成了一个非常实际的需求。我最近就花了不少时间研究这个,目标很明确:反编译一个 Wasm 文件,找到存储或计算“金币数量”的逻辑,并尝试修改它。这个过程涉及静态分析、动态调试、代码修改与回编译,是一套完整的逆向工程实践。无论你是想学习 Wasm 底层机制的安全研究员,还是对游戏机制好奇的开发者,甚至是希望加固自己 Wasm 应用的前端工程师,这套方法论都能给你带来不少启发。接下来,我就把自己趟过的路、踩过的坑,以及最终成功的步骤,毫无保留地分享出来。
2. 核心思路与工具选型:为什么是这套组合拳?
面对一个 Wasm 二进制文件,我们的目标是将机器友好的指令转换回人类可理解(至少是程序员可理解)的代码形式,并定位到关键数据或函数。直接阅读 Wasm 字节码是不现实的,因此我们需要借助工具链。经过多次实践对比,我最终确定了以WABT(WebAssembly Binary Toolkit)为核心,结合静态分析工具(如 Ghidra、IDA)和浏览器动态调试的三位一体策略。这里详细解释一下为什么是它们,以及各自扮演的角色。
首先,WABT 是官方维护的工具集,它提供了wasm2wat和wasm2c这两个关键工具。wasm2wat能将二进制.wasm文件转换为文本格式.wat(WebAssembly Text Format)。.wat是一种基于 S-表达式的、人类可读的中间表示,它清晰地展示了模块结构、函数定义、内存布局和指令流。虽然.wat依然接近汇编,但它的可读性比纯二进制强了不止一个数量级,是我们理解程序结构的基石。而wasm2c则更进一步,它能将 Wasm 模块转换为等价的 C 代码。这个 C 代码虽然看起来有些“机械”(因为它是从栈式虚拟机指令翻译过来的),但它提供了完整的函数框架、类型定义和内存访问逻辑,极大地便利了我们在高级语言层面进行逻辑分析。尤其是当我们需要搜索特定的常量(如初始金币数 100)或字符串时,在 C 代码里进行文本搜索的效率远高于在二进制或.wat中摸索。
然而,转换得到的 C 代码并非完美的、可直接编译运行的原生 C 程序。它严重依赖 WABT 运行时库(wasm-rt.h等)来模拟 Wasm 的执行环境,特别是线性内存和函数表。这意味着,如果你想把它编译成一个独立的可执行文件进行调试,会非常麻烦。所以,静态分析工具的介入就至关重要了。Ghidra和IDA Pro这类成熟的逆向工程平台,对 Wasm 的支持日益完善。它们不仅能解析 Wasm 文件格式,还能进行反汇编、控制流图生成、数据类型分析和交叉引用查找。例如,在 Ghidra 中加载由wasm2c生成的.c文件编译出的.o目标文件,或者直接加载.wasm文件(需要相应插件),可以让你以更接近传统二进制逆向的视角来审视代码结构,快速定位到可能进行数值比较、赋值的关键函数。
但静态分析有时会碰到逻辑复杂或混淆过的代码,这时就需要动态调试来验证猜想。现代浏览器(Chrome/Edge/Firefox)的开发者工具内置了强大的 Wasm 调试支持。你可以在 Wasm 指令级别设置断点、单步执行、查看和修改线性内存以及全局变量的值。这对于验证“某个内存地址是否存储着金币数值”或者“某个函数是否在每次消费时被调用”至关重要。动态调试是连接静态分析猜想与实际程序行为的桥梁。
注意:工具链的选择没有银弹。对于简单的、未混淆的 Wasm,
wasm2wat直接阅读可能就够了。对于复杂的、涉及加解密或大量间接调用的,可能需要结合 Ghidra 的反编译能力和浏览器的动态调试。我推荐的这套组合,覆盖了从初级到高级的分析需求。
2.1 工具链的安装与准备
工欲善其事,必先利其器。下面是在 Ubuntu Linux 环境下搭建这套工具链的详细步骤。Windows 和 macOS 用户也可以找到对应的安装包或编译方法。
1. 编译安装 WABT:WABT 是基石,我们需要从源码编译以获取所有工具和必要的头文件。
# 1. 克隆仓库 git clone --recursive https://github.com/WebAssembly/wabt.git cd wabt # 2. 创建构建目录并编译 mkdir build cd build cmake .. make -j$(nproc) # 3. 编译完成后,工具位于 `build` 目录下,如 `./wasm2wat`, `./wasm2c` # 为了方便,可以将其添加到系统 PATH,或者后续使用绝对路径。编译过程可能会需要 CMake 和 C++ 编译器(如 g++),请确保系统已安装。编译成功后,你会得到一系列可执行文件,我们主要用到wasm2wat,wat2wasm,wasm2c,wasm-objdump。
2. 准备静态分析环境:
- Ghidra:从 Ghidra 官网 下载最新版本。它是一个 Java 应用,解压后运行
ghidraRun即可。Ghidra 自带的 Wasm 加载器已经相当不错。 - IDA Pro:如果你有 IDA Pro(建议 7.7 以上版本),需要安装 Wasm 插件,例如
wasm2ida脚本。但根据我的经验,Ghidra 对 Wasm 的支持已经非常友好且免费,对于大多数任务,Ghidra 是首选。
3. 浏览器调试环境:任何基于 Chromium 的浏览器(Chrome, Edge, Brave)或 Firefox 都可以。确保开发者工具(F12)是开启的。后续我们会详细讲解如何在其中调试 Wasm。
3. 静态分析实战:从二进制到可读代码
假设我们有一个名为game.wasm的文件,我们的目标是找到并理解其中管理“金币”的逻辑。
第一步:初步探查与格式转换首先,使用wasm-objdump对文件有个整体认识:
/path/to/wabt/build/wasm-objdump -x game.wasm这个命令会输出 Wasm 模块的段(section)信息,包括导入/导出函数、内存定义、全局变量、数据段等。你需要特别关注:
Export部分:看看有哪些函数是导出给 JavaScript 调用的,比如getGold,setGold,addGold之类的。游戏逻辑通常通过导出函数与 JS 交互。Data部分:这里可能存放着初始化的字符串或数值常量。如果金币的初始值是硬编码的,可能会在这里找到线索。Memory部分:了解 Wasm 模块定义了多少内存(通常至少一个)。金币数值很可能存储在某个固定的内存地址中。
接下来,进行核心的转换。我们将.wasm转换为.wat和.c。
# 转换为文本格式 .wat /path/to/wabt/build/wasm2wat game.wasm -o game.wat # 转换为 C 代码 /path/to/wabt/build/wasm2c game.wasm -o game.c转换完成后,你会得到game.wat和game.c两个文件。先打开game.wat,它的结构类似 Lisp,你会看到很多(func ...)、(i32.const ...)、(i32.store ...)这样的表达式。虽然不直观,但你可以搜索像gold、coin、money这样的导出函数名或全局变量名(如果开发者没有混淆的话)。如果找到了相关的函数名,就可以在文件中定位到该函数的定义,观察其指令逻辑。
第二步:深入分析 C 代码game.c文件才是我们分析的主力。用文本编辑器或 IDE 打开它。这个文件会包含很多以Z_开头的函数,它们对应着 Wasm 模块中的各个函数。文件开头会包含#include "wasm-rt.h",并定义了一个代表整个模块的结构体,比如Z_game_instance。
现在,开始我们的“侦查”工作:
- 搜索常量:在
game.c中搜索你认为的金币初始值,比如100、1000、9999。注意,Wasm 中的整数常量可能会以十六进制形式出现在 C 代码中,比如0x64对应十进制 100。使用编辑器的“在文件中查找”功能。 - 搜索字符串:搜索可能相关的字符串,如
"gold"、"coins"、"currency"。这些字符串可能作为数据段内容,在 C 代码中会以字符数组的形式出现,例如u8 data_segment_data_0[] = {0x67, 0x6f, 0x6c, 0x64, 0x00};(即 "gold\0")。 - 分析函数逻辑:如果你通过导出表或字符串搜索定位到了疑似函数(例如,找到了一个名为
Z_get_gold的函数),就仔细阅读其 C 代码。典型的金币获取函数可能包含从某个固定内存地址加载数据的操作,在wasm2c生成的代码中,这通常表现为调用WASM_RT_LOAD_I32宏或直接访问instance->memory.data数组。例如:
而设置金币的函数则可能使用static u32 Z_get_gold(struct Z_game_instance* instance) { // 假设金币存储在内存地址 0x5000 处 return WASM_RT_LOAD_I32(&instance->memory, 0x5000); }WASM_RT_STORE_I32宏。 - 理解内存布局:在
game.c文件的开头部分或靠近数据段定义的地方,可能会有一个大的u8 data_segment_data_0[]数组,里面存放了初始化数据。你需要结合wasm-objdump输出的数据段信息,确定这些初始化数据被加载到了内存的什么地址。金币的初始值很可能就在这里。
实操心得:
wasm2c生成的代码可读性关键取决于原始 Wasm 的复杂度和优化级别。如果原始代码经过高级编译器(如 Emscripten 的-O2、-O3)优化,生成的 C 代码可能会包含很多内联、循环展开和难以理解的临时变量,分析起来会困难很多。此时,不要纠结于理解每一行 C 代码,而是抓住核心模式:加载(LOAD)、存储(STORE)、运算(加减乘除)、比较(分支)。找到对疑似金币地址的访问模式,就是成功的关键。
第三步:使用 Ghidra 进行辅助分析当 C 代码过于复杂时,将game.c编译成一个对象文件,然后导入 Ghidra,可以利用其强大的反编译引擎得到更易读的伪代码。
# 首先,确保你有 wabt 的 include 目录。假设 wabt 在 /home/user/wabt # 编译 game.c 为 game.o gcc -c game.c -o game.o -I/home/user/wabt/wasm2c -I/home/user/wabt -O2编译时可能会报错找不到wasm-rt.h等头文件,你需要正确指定 WABT 源码中的wasm2c和根目录路径。编译成功后,在 Ghidra 中新建项目,将game.o文件导入。Ghidra 会进行分析。分析完成后,你可以在Symbol Tree中看到所有Z_开头的函数。双击函数名,Ghidra 会在反编译窗口显示伪代码。
Ghidra 的优势在于它能进行数据流分析和类型推断,有时能将混乱的指针访问重构为清晰的数组或结构体访问。你可以利用它的“搜索”功能(快捷键Ctrl+Shift+F)在整个程序中搜索特定的数值常量或字符串,并直接跳转到引用该常量的所有位置,这比在文本中搜索更高效。
4. 动态调试验证:在浏览器中“现场抓包”
静态分析给了我们假设(例如,“金币存储在内存地址 0x5000”)。动态调试则是验证假设的终极手段。我们需要让这个 Wasm 模块在浏览器中运行起来,并能够观察和修改其状态。
第一步:搭建本地调试环境由于浏览器安全限制,直接通过file://协议打开包含 Wasm 的 HTML 文件可能无法进行完整的 Wasm 调试,或者会遇到跨域问题。最可靠的方法是启动一个简单的本地 HTTP 服务器。
# 在包含 game.wasm 和其宿主 HTML/JS 文件的目录下 python3 -m http.server 8080然后,在浏览器中访问http://localhost:8080来打开你的页面。
第二步:使用浏览器开发者工具
- 打开 Chrome 开发者工具(F12),切换到“源代码”(Sources)标签页。
- 在左侧文件导航栏中,你应该能找到你的
.wasm文件。点击它,浏览器会将其反汇编为.wat格式并显示在中央编辑器区域。 - 设置断点:你可以直接在显示的 Wasm 指令行号上点击来设置断点。更有效的方法是,如果你通过静态分析知道了某个关键函数的导出名(例如
get_gold),你可以在“调试器”面板右侧的“作用域”或“事件监听器断点”中,尝试在 Wasm 函数被调用时暂停。但更直接的是在 Wasm 代码中寻找关键操作。 - 定位关键内存访问:根据静态分析,如果我们怀疑
0x5000地址存放金币,那么就在 Wasm 代码中搜索涉及该地址的指令。在 Wasm 中,内存存储指令是i32.store,加载指令是i32.load。你可以在源代码视图里搜索i32.store或(i32.store ...)。找到后,在相应的行设置断点。 - 运行与观察:触发游戏中的相关操作(比如点击获取金币的按钮)。当程序执行到你设置的断点时,执行会暂停。此时,在开发者工具的“内存”(Memory)面板中,你可以查看 Wasm 实例的线性内存。在内存面板中,输入你怀疑的地址(如
0x5000),并选择适当的格式(如“32位有符号整数”或“32位无符号整数”)进行查看。如果这个位置的值正好是你当前的金币数量,并且在你进行游戏操作(花费金币)后,这个值发生了变化,那么恭喜你,找对了! - 修改内存值:在“内存”面板中,你可以直接双击内存值进行修改。将
0x5000处的值从 100 改成 9999,然后继续执行程序。如果游戏界面显示的金币数也随之改变,那么你的修改就成功了。
第三步:高级调试技巧与 JS 辅助有时,Wasm 内存地址不是固定的,或者需要通过基址加偏移来计算。这时,可以在包含 Wasm 的 HTML 页面中注入一些辅助调试的 JavaScript 代码(就像你在资料中看到的那样)。这段 JS 代码可以将 Wasm 实例的导出对象(包括内存)挂载到全局,并提供了方便查看内存的函数(viewDWORD,viewString,search等)。这样,你就能在浏览器控制台(Console)里直接调用这些函数来扫描内存、搜索特定数值或字符串模式,极大地提升了动态分析的效率。
注意事项:动态调试时,浏览器的开发者工具可能会对 Wasm 进行优化或重新编译,导致行号或指令与原始
.wat文件略有不同,这是正常现象。关键在于把握核心指令(load/store)和内存地址。另外,一些游戏可能会对内存中的数值进行加密或校验,直接修改内存可能导致游戏逻辑错误或触发反作弊机制。这属于更高级的对抗,需要结合静态分析理解其加密算法。
5. 修改与回编译:让修改持久化
动态调试修改内存只是临时的,刷新页面就没了。我们的终极目标是通过修改.wasm二进制文件本身,实现永久性的“金币自由”。
方法一:修改 .wat 并回编译如果我们通过静态分析,在game.wat中找到了初始化金币值的位置(比如在一个data段中,或者在某个初始化函数的i32.const指令后跟着i32.store),我们可以直接修改.wat文件。
- 在
game.wat中找到对应位置。例如,数据段初始化:
将其中的(data (i32.const 0x5000) "\64\00\00\00") ;; 0x64 = 100, 小端序\64修改为更大的值的十六进制表示,例如\10\27代表 0x2710 = 10000。注意 Wasm 使用小端序。 - 或者,在函数中找到设置金币的指令:
将(func $init_gold (param i32) i32.const 0x5000 i32.const 100 ;; 这里是十进制表示 i32.store )i32.const 100改为i32.const 9999。 - 保存修改后的
game.wat,使用wat2wasm工具将其编译回.wasm文件。/path/to/wabt/build/wat2wasm modified_game.wat -o modified_game.wasm - 用修改后的
modified_game.wasm替换原来的文件,刷新页面测试。
方法二:直接 Patch .wasm 二进制文件对于简单的数值修改,有时直接编辑二进制文件更快捷。你需要一个十六进制编辑器(如hexedit,010 Editor, 或 VSCode 的 Hex Editor 插件)。
- 使用
wasm-objdump -d game.wasm反汇编,找到对应指令的文件偏移量(file offset)。例如,你发现i32.const 100对应的字节码序列是41 64(0x41是i32.const的操作码,0x64是 LEB128 编码的 100)。 - 在十六进制编辑器中,定位到该偏移量,将代表数值 100 的字节
64修改为对应新值的 LEB128 编码。9999 的 LEB128 编码是CF 9F 01(你可以用 Python 等工具计算:list((9999).to_bytes((9999.bit_length()+7)//8, 'little')))。 - 保存文件。这种方法要求你对 Wasm 二进制格式和 LEB128 编码有一定了解,适合小范围的精确修改。
实操心得:修改前务必备份原文件。回编译或 Patch 后,一定要用
wasm-validate(WABT 工具之一)检查修改后的文件是否有效。./wasm-validate modified_game.wasm。如果验证通过,再替换测试。有时简单的数值修改会导致内存访问越界或其他逻辑错误,如果游戏崩溃或行为异常,需要重新审视你的修改是否影响了其他依赖此值的计算。
6. 常见问题与排查技巧实录
在这一路上,我遇到了不少坑。这里总结几个典型问题和解决方法,希望能帮你节省时间。
问题1:wasm2c转换失败或生成的 C 代码编译报错。
- 可能原因:使用的 WABT 版本与 Wasm 文件使用的某些新特性或尾调用(tail call)等指令不兼容。
- 解决:尝试更新到最新版本的 WABT 源码并重新编译。如果问题依旧,可以尝试先用
wasm2wat转换,再使用wat2wasm重新编译为.wasm(这有时会规范化格式),然后再进行wasm2c。
问题2:在 Ghidra 中分析.o文件时,函数名杂乱,找不到入口。
- 可能原因:
wasm2c生成的 C 代码函数名都是Z_前缀,Ghidra 可能无法识别 Wasm 特有的调用约定和内存模型,导致分析不完整。 - 解决:优先尝试 Ghidra 直接导入原始的
.wasm文件。新版本的 Ghidra 对 Wasm 的支持越来越好,能够自动识别函数、内存和数据段。如果必须分析.o文件,可以在 Ghidra 的Symbol Table中手动查找和重命名你认为重要的函数。
问题3:浏览器开发者工具中看不到 Wasm 文件,或者无法设置断点。
- 可能原因1:Wasm 模块是通过
WebAssembly.instantiate()动态创建或流式编译的,源代码列表中没有独立的.wasm文件。 - 解决:在开发者工具的“源代码”标签页,注意顶部偏右有一个“更多选项”图标(三个点),点击后确保“Wasm 调试”是启用的。同时,在“网络”(Network)标签页过滤 Wasm 请求,找到对应的文件,然后右键选择“以源形式打开”。
- 可能原因2:页面是通过
file://协议打开的,存在安全限制。 - 解决:务必使用本地 HTTP 服务器(如
python -m http.server)来提供页面。
问题4:找到了金币的内存地址,但修改后游戏没有立即更新显示,或者数值很快被改回去了。
- 可能原因1:金币值有多个副本或缓存。游戏逻辑可能在内存中多个位置存储了同一数值(例如,一个用于显示,一个用于计算)。你只修改了一处。
- 解决:在修改内存后,继续单步执行或触发一次金币更新操作(如捡一个金币),观察是否有其他指令将旧值重新写回。或者,搜索所有向该地址进行
i32.store的指令。 - 可能原因2:存在客户端校验或加密。金币值在存储前可能经过了某种变换(如 XOR 一个密钥,或加上一个随机偏移)。直接修改存储值会导致解密后得到错误结果。
- 解决:这需要更深入的逆向分析。通过静态分析,找到负责“读取”和“写入”金币的函数,理解其完整的变换逻辑。修改时可能需要模拟这个逻辑,或者直接修改变换后的值,并确保相关校验也能通过。这通常涉及算法逆向,是更高级的挑战。
问题5:修改.wat后,wat2wasm回编译失败,提示语法错误。
- 可能原因:手动编辑
.wat时引入了语法错误,如括号不匹配、指令格式错误、或使用了错误的数值表示法。 - 解决:
.wat是严格的 S-表达式格式。仔细检查你修改区域的括号是否成对。确保数字常量格式正确(十进制、十六进制0x...)。可以使用文本编辑器的括号高亮功能辅助检查。一个稳妥的方法是,只修改指令中的立即数(如i32.const后面的值),不要改动指令结构和内存布局。
这个过程就像一场数字侦探游戏,需要耐心、细心和对底层原理的理解。从模糊的二进制到清晰的逻辑,每一步破解都带来巨大的成就感。记住,工具只是辅助,最重要的还是你对程序运行逻辑的推理和假设验证能力。