wasm 是 WebAssembly 的二进制文件,包含二进制字节码。写网站基本上是用 JavaScript, 当 JavaScript 速度不够快时,某个模块就可以用 C/C++ 编写,再编译成 wasm,供 JS 调用。
WebAssembly 有两种格式,一种是二进制格式 wasm,一种是文本格式 wat,可以用工具 wasm2wat 和 wat2wasm 相互转换。wat 使用 S 表达式,可以手写,也容易阅读。wasm 类似于机器码,wat 类似于文本汇编。wasm 载入后,跑在浏览器定义的栈机器上。
JS 和 wasm 如何交互有很多资料,不用详细说。我们的问题是,已经拿到了别人编译出来的 wasm 文件,怎么转换回 C 代码,重新用 C/C++ 调用执行。
为什么要这样做呢?有时我们需要逆向看别人的算法,假如可以用 C/C++ 将其执行起来,有个方便调试的环境,就可以设立断点,修改输入影响输出。通常程序员都习惯看 C 代码,而不是 wat 的那种栈指令代码。
待会我们就去一步一步去实践,将 C++ 编译成 wasm(正向),然后将 wasm 转换回 C 代码(逆向),再接着分析转换出来的 C 代码然后调用。我使用 macOS,其它平台应该差不多。
先将结论提到前面。
- 将 C/C++ 编译成 wasm,需要用 Emscripten 编译器。
- 将 wasm 转换 C 代码,使用 wabt 工具集中的 wasm2c。
本文测试代码放在 GitHub 中。
安装配置 emscripten
使用 brew install emscripten 安装编译器。假如不配置,直接执行 emcc main.cpp 会出现类似这样的错误:
WARNING:root:LLVM version appears incorrect (seeing "9.1", expected "6.0")
CRITICAL:root:fastcomp in use, but LLVM has not been built with the JavaScript backend as a target xxxfastcomp 是 emscripten 修改过 LLVM 分支。需要在 ~/.emscripten 中配置 LLVM_ROOT,例如在我机器上,配置为
LLVM_ROOT = '/usr/local/Cellar/emscripten/1.38.11/libexec/llvm/bin'这样就可以使用了 emcc 命令编译了。
将 C++ 编译成 wasm
wasm2c 中用来测试的 C 代码太简单了,不能说明问题。我们弄个复杂点的 C++ 代码。
// color.cpp
#include <ctype.h>
#include <string.h>
#include <emscripten.h>
static int hex_to_bin(char ch) {
if ((ch >= '0') && (ch <= '9')) {
return ch - '0';
}
ch = tolower(ch);
if ((ch >= 'a') && (ch <= 'f')) {
return ch - 'a' + 10;
}
return -1;
}
EMSCRIPTEN_KEEPALIVE extern "C" int (*hex_to_bin_ptr)(char ch) = hex_to_bin;
static int hex_to_bin2(char c0, char c1) {
return (*hex_to_bin_ptr)(c0) * 16 + (*hex_to_bin_ptr)(c1);
}
EMSCRIPTEN_KEEPALIVE extern "C" void colorFromHexString(const char *hex, float *result) {
if (*hex == '#') {
hex++;
}
result[0] = result[1] = result[2] = result[3] = 1.0;
if (strlen(hex) == 6) {
result[0] = hex_to_bin2(hex[0], hex[1]) / 255.0;
result[1] = hex_to_bin2(hex[2], hex[3]) / 255.0;
result[2] = hex_to_bin2(hex[4], hex[5]) / 255.0;
}
}EMSCRIPTEN_KEEPALIVE 用于告诉编译器,这个符号需要导出。
这个例子代码中,我们使用了指针,指针有两种。一种是 const char* hex 这种数据指针,一种是 hex_to_bin_ptr 这种函数指针。使用数据指针,是让 wasm 生成 Memory;使用函数指针,是让 wasm 生成 Table。Memory 和 Table 的含义后面的小节会讲述。
现实中的工程通常会用 malloc 分配堆内存,为了模拟这种情况,我们再导出 malloc 和 free 这两个函数。使用下面命令编译
emcc -O3 color.cpp -o color.js -s EXPORTED_FUNCTIONS="['_malloc', '_free']"就产生了 color.js、color.wasm 这两个文件。其中 color.wasm 包含了具体的二进制指令,color.js 用于创建 js 和 wasm 交互的环境。具体的业务 js 可以通过 color.js 去调用二进制指令,不用太关心里面的如何交互的。
将 wasm 转换成 C 代码
下载 wabt 工具集源码,使用 make 编译,就会得到一系列工具。使用
wasm2c color.wasm -o wasm_color.c这样我们就得到 wasm_color.h wasm_color.c 两个文件。
这时我们将 wasm_color.h wasm_color.c 跟,将 wasm2c 得到的几个文件一起放到测试工程中编译。
wasm_color.h
wasm_color.c
wasm-rt.h
wasm-rt-impl.h
wasm-rt-impl.c你会发现,工程可以编译通过,但是链接不过,缺少很多符号。
/* import: 'env' 'memory' */
extern wasm_rt_memory_t (*Z_envZ_memory);
/* import: 'env' 'table' */
extern wasm_rt_table_t (*Z_envZ_table);
/* import: 'env' 'tableBase' */
extern u32 (*Z_envZ_tableBaseZ_i);
/* import: 'env' 'DYNAMICTOP_PTR' */
extern u32 (*Z_envZ_DYNAMICTOP_PTRZ_i);
/* import: 'env' 'STACKTOP' */
extern u32 (*Z_envZ_STACKTOPZ_i);
/* import: 'env' 'abort' */
extern void (*Z_envZ_abortZ_vi)(u32);
/* import: 'env' 'enlargeMemory' */
extern u32 (*Z_envZ_enlargeMemoryZ_iv)(void);
/* import: 'env' 'getTotalMemory' */
extern u32 (*Z_envZ_getTotalMemoryZ_iv)(void);
/* import: 'env' 'abortOnCannotGrowMemory' */
extern u32 (*Z_envZ_abortOnCannotGrowMemoryZ_iv)(void);
/* import: 'env' '___setErrNo' */
extern void (*Z_envZ____setErrNoZ_vi)(u32);符号分析
wasm_color.h 中的符号,分为导入 (import) 符号,和导出(export)符号。js 和 wasm 交互时,import 符号是 js 提供给 wasm 使用的。而 export 符号是 wasm 提供给 js 使用的。上文说过
emcc -O3 color.cpp -o color.js -s EXPORTED_FUNCTIONS="['_malloc', '_free']"编译出的 color.js 用于创建 js 和 wasm 交互的环境。打开 color.js,格式化一下。可以找到这些代码
Module.asmLibraryArg = {
"abort": abort,
"enlargeMemory": enlargeMemory,
"getTotalMemory": getTotalMemory,
"abortOnCannotGrowMemory": abortOnCannotGrowMemory,
"___setErrNo": ___setErrNo,
"DYNAMICTOP_PTR": DYNAMICTOP_PTR,
"STACKTOP": STACKTOP
};这里就是用 js 实现了这些函数,传递给 wasm。js 使用 WebAssembly.instantiate 函数去创建 wasm 实例时,通过第二个参数传递进去。如
info["global"] = { "NaN": NaN, "Infinity": Infinity };
info["global.Math"] = Math;
info["env"] = env;
xxxxx
return WebAssembly.instantiate(binary, info)wasm_color.h 也导出(export)了一些符号。如下
/* export: '___errno_location' */
extern u32 (*WASM_RT_ADD_PREFIX(Z____errno_locationZ_iv))(void);
/* export: '_colorFromHexString' */
extern void (*WASM_RT_ADD_PREFIX(Z__colorFromHexStringZ_vii))(u32, u32);
/* export: '_free' */
extern void (*WASM_RT_ADD_PREFIX(Z__freeZ_vi))(u32);
/* export: '_malloc' */
extern u32 (*WASM_RT_ADD_PREFIX(Z__mallocZ_ii))(u32);原始的 C++ 代码中,我们使用了 EMSCRIPTEN_KEEPALIVE 导出了 _colorFromHexString 函数,使用 EXPORTED_FUNCTIONS="['_malloc', '_free']" 编译参数导出了 _malloc 和 _free 函数。而 ___errno_location函数是 C 标准库的符号。
上面说的 import 和 export 符号都是些函数指针,运行时可以动态设置。js 和 wasm 不可以直接调用对方的函数,需要通过一个间接层。wasm 的导出符号,在 color.js 中如此调用。
var _colorFromHexString = Module["_colorFromHexString"] = (function() {
return Module["asm"]["_colorFromHexString"].apply(null, arguments)
});
var _free = Module["_free"] = (function() {
return Module["asm"]["_free"].apply(null, arguments)
});分析到这里,我们可能会疑惑。
- 为什么原始函数
colorFromHexString的名字变成这个样子Z__colorFromHexStringZ_vii。 - 导出符号
Z_envZ_memory、Z_envZ_table、Z_envZ_tableBaseZ_i并非函数,如何跟 js 对应。这里 memroy 和 table 是什么?
Name Mangling
Z__colorFromHexStringZ_vii 这种名字是叫 Name Mangling(名字映射),通常用在 C++ 中。跟 C 不一样,C++ 支持重载,只要参数不一样,函数可以有相同的名字。比如下面函数名字一样,在 C++ 中可以共存。
void add(float a, float b);
void add(int a, int b);Name Mangling 就是将函数名字和参数类型一起考虑,共同生成一个名字。参数不同,就算函数名字相同,映射出来的名字也不一样。不同的编译器和工具有不同的 Mangling 方式。
假如是 C++ 的名字映射,可以用 c++filter 工具还原出来。而 wasm2c 这个工具的名字映射应该是自己的格式。wasm2c 工具的映射方式见 c-writer.cc 下列函数的实现。
static char MangleType(Type);
static std::string MangleTypes(const TypeVector&);
static std::string MangleName(string_view);
static std::string MangleFuncName(string_view, const TypeVector& param_types, const TypeVector& result_types);假如不想 wasm2c 工具进行 Name Mangling,可以修改其源码。
Memory 和 Table
Z_envZ_memory、Z_envZ_table、Z_envZ_tableBaseZ_i 看它的名字,猜测对应成 env["memory"] 和 env["table"]。查找 color.js 代码,我们可以看到。
env["memory"] 其实被设置为
Module["wasmMemory"] = new WebAssembly.Memory({
"initial": TOTAL_MEMORY / WASM_PAGE_SIZE,
"maximum": TOTAL_MEMORY / WASM_PAGE_SIZE
});
env["memory"] = Module["wasmMemory"]env["table"] 被设置为这样
env["table"] = new WebAssembly.Table({
"initial": TABLE_SIZE,
"maximum": MAX_TABLE_SIZE,
"element": "anyfunc"
})WebAssembly.Memory 和 WebAssembly.Table 的作用在 WebAssembly 可以找到描述。
Memory
wasm 只有 i32, i64,f32, f64 四种类型。当 js 和 wasm 传输并非这 4 种基础类型的数据时(比如字符串,图片),就需要用其它方式来实现。WebAssembly 使用的方式,是创建了一个 Memory 作为 js 和 wasm 传递大型数据的通道。在 js 端,从 Memory 获取到 ArrayBuffer。在 wasm 端,这个 Memory 就是一块内存块。
当 js 向 wasm 传输大型数据(比如图片)时,先将图片数据填充到 buffer 中。之后就可以用数据偏移值和数据长度作为参数,调用 wasm 的函数。wasm 端就通过 Memory 的偏移和长度获取这块数据。同理 wasm 向 js 传输数据,也可以写到 Memory 中,js 也可以从自己的 buffer 中读取。
C/C++ 编译成 wasm,js 和 wasm 交互。C/C++ 很多代码会操作内存,会涉及指针操作。编译后 wasm 不可以使用指针,指针都会被编译成这个 Memory 的偏移值。比如最原始的 C++ 代码,函数签名为
extern "C" void colorFromHexString(const char *hex, float *result)编译成 wasm 后,再转换回 C 代码,对应的函数签名就变为
static void _colorFromHexString(u32 p0, u32 p1)测试代码中,我也故意让代码包含指针,使得转换后的 C 代码包含 wasm_rt_memory_t (*Z_envZ_memory); 符号。这样更接近实际工程代码。
Table
Memory 解决了 C/C++ 代码的数据指针问题,数据指针都被编译成偏移值。但 C/C++ 中还有函数指针。数据指针跟函数指针有点不同,函数指针对应于一段代码,可以被间接调用。
wasm 没有没有函数指针,那 C/C++ 中的函数指针怎么在 wasm 中表示呢?也就引入了我们的 Table。
Table 可以看成函数指针的数组,数组的每一项存放着函数的签名和函数的地址。这个数组可以放任何不同类型的函数指针,有了函数签名这信息,在函数调用的时候就可动态检查参数是否对应。
wasm 可以往 Table 中放函数,js 也可以往 Table 中放函数。放置好函数之后,就可以通过 Table 的索引去间接调用。于是 wasm 和 js,只需要知道 Table 的函数索引,没有必要使用函数指针。
C++ 这种函数指针代码
int (*hex_to_bin_ptr)(char ch) = hex_to_bin;
*hex_to_bin_ptr)(c0)使用 Table,可以写成下面伪码
table[0] = { proto<int(char)>, &hex_to_bin }
proto<int(char)>(table[0].addr)(c0);实际上,C/C++ 的函数指针调用,会被编译成 call_indirect 指令。在 js 中写成
tbl.get(0)(c0);于是 Memory 和 Table 就解决了 C/C++ 的指针问题。Memory 解决了数据指针的问题,Table 解决了函数指针的问题,指针都被对应成偏移值。
wasm-rt 的代码
上述的 Memory 和 Table 其实是 WebAssembly 的基础知识,有了这个基础。我们再回头看 wasm-rt.h 头文件,这时就很清晰了。
wasm_rt_memory_t相当于 js 中的 WebAssembly.Memorywasm_rt_table_t相当于 js 中的 WebAssembly.Table
wasm_rt_allocate_memory、wasm_rt_allocate_table 这些函数的作用就很明显了。而 wasm_rt_register_func_type 函数的作用是注册函数原型。函数原型的作用是在间接调用 Table 中的函数时,动态检查参数是否对应。
在 C 代码中,函数原型还不明显。假如我们用 wasm2wat 工具,将 wasm 转换成 wat,就可以看得更清楚了。wat 的 S 表达式如下。
(type (;0;) (func (param i32) (result i32)))
(type (;1;) (func (param i32)))
(type (;2;) (func (result i32)))
(type (;3;) (func (param i32 i32)))转换出来的 C 代码
static u32 func_types[4];
static void init_func_types(void) {
func_types[0] = wasm_rt_register_func_type(1, 1, WASM_RT_I32, WASM_RT_I32);
func_types[1] = wasm_rt_register_func_type(1, 0, WASM_RT_I32);
func_types[2] = wasm_rt_register_func_type(0, 1, WASM_RT_I32);
func_types[3] = wasm_rt_register_func_type(2, 0, WASM_RT_I32, WASM_RT_I32);
}在 C/C++ 中创建出 wasm 执行环境
C/C++ 编译成 wasm 时,对应的 color.js 创建了 js 和 wasm 交互的环境,于是 js 可以调用起 wasm。
wasm 是运行在一个虚拟栈机器上的,wasm2c 工具已经帮我们从 wasm 的栈指令,转换到 C 代码形式(虽然那些转换后的 C 代码有很多栈指令的影子)。我们要执行这份 C 代码,也需要在 C/C++ 中创建出执行环境。在 C/C++ 的语境下,这个执行环境,就是要我们将缺少的符号都补充完毕。
很多符号,我们其实不关心的,直接设置为空就行。也可以写个空函数再用赋值,比如
static u32 doNothing() {
return 0;
}
u32 (*Z_envZ_abortOnCannotGrowMemoryZ_iv)(void) = &doNothing;
or
u32 (*Z_envZ_abortOnCannotGrowMemoryZ_iv)(void) = nullptr;内存相关的的函数不能省略,需要明确赋值。
static u32 getTotalMemory(void) {
assert(Z_envZ_memory);
return Z_envZ_memory->size;
}
static u32 enlargeMemory(void) {
assert(Z_envZ_memory);
wasm_rt_grow_memory(Z_envZ_memory, Z_envZ_memory->pages);
return Z_envZ_memory->size;
}
/* import: 'env' 'enlargeMemory' */
extern u32 (*Z_envZ_enlargeMemoryZ_iv)(void) = &enlargeMemory;
/* import: 'env' 'getTotalMemory' */
extern u32 (*Z_envZ_getTotalMemoryZ_iv)(void) = &getTotalMemory;那怎么判断那个函数指针需要赋值,那个可以为空呢?其实很简单,先都不赋值,都设置为 nullptr, 之后运行这份代码,假如崩溃了,就可以看到崩溃堆栈,相应的函数指针就是需要赋值的。
函数的符号都弄好了,接下来处理 memory 和 table。最关键就是那个 memory。
内存分为全局(global)区域,静态(static)区域,栈(stack)区域,堆(heap)区域。堆也可以称为动态区域。其中全局和静态区域平时我们并不关心,通常编译器也帮我们弄好了。我们关心的主要是栈和堆。
memory 实际就是模拟程序运行时候的内存环境,也需要将各个区域设置好。特别是栈和堆,对应于这两个符号:
/* import: 'env' 'DYNAMICTOP_PTR' */
extern u32 (*Z_envZ_DYNAMICTOP_PTRZ_i);
/* import: 'env' 'STACKTOP' */
extern u32 (*Z_envZ_STACKTOPZ_i);那我们怎么知道这两个符号要用什么值呢?至于具体数值,可以从 color.js 找。要使用 wasm,在 js 就一定有个初始化过程。js 的代码就可以直接查看调试。那个 color.js 创建 js 和 wasm 的交互环境的,我们可以用 Chrome 来调试那个 color.js 代码。它怎么初始化,我们就怎么初始化。假如弄清楚了 emcc 编译出来的代码,也不用调试,打开那个 js 文件,搜索一下就行了。针对这个例子,我们的初始化如下:
const size_t TOTAL_STACK = 1 * 1024 * 1024; // 1M
const size_t TOTAL_MEMORY = 8 * 1024 * 1024; // 8M
const size_t TOTAL_STATIC = 1536;
const size_t kWasmTableSize = 2;
const size_t WASM_PAGE_SIZE = 65536;
const size_t GLOBAL_BASE = 1024;
const size_t STACK_ALIGN = 16;
const size_t STATIC_BASE = GLOBAL_BASE;
xxxxx
WasmRuntime::WasmRuntime() {
size_t STATICTOP = STATIC_BASE + TOTAL_STATIC;
const size_t DYNAMICTOP_PTR = staticAlloc(STATICTOP, 4);
const size_t STACK_BASE = alignMemory(STATICTOP);
const size_t STACKTOP = STACK_BASE;
const size_t STACK_MAX = STACK_BASE + TOTAL_STACK;
DYNAMIC_BASE = alignMemory(STACK_MAX);
const int initPages = TOTAL_MEMORY / WASM_PAGE_SIZE;
wasm_rt_allocate_memory(&memory, initPages, initPages * 8);
wasm_rt_allocate_table(&table, kWasmTableSize, kWasmTableSize);
tableBase = 0;
stacktop = (u32)STACKTOP;
dynamictop_ptr = (u32)DYNAMICTOP_PTR;
nan = NAN;
infinity = INFINITY;
resetWasmEnv();
}
void WasmRuntime::switchWasmEnv() {
Z_envZ_memory = &memory;
Z_envZ_table = &table;
Z_envZ_tableBaseZ_i = &tableBase;
Z_envZ_STACKTOPZ_i = &stacktop;
Z_envZ_DYNAMICTOP_PTRZ_i = &dynamictop_ptr;
(*WASM_RT_ADD_PREFIX(init_globals_))();
}其实主要最上面的 4 个参数,初始化时的总内存,栈总内存,静态区域内存,和 table 项的个数。剩下的参数对于其他的 wasm 基本不变,都是从那 4 个参数计算得出的。栈大小和总内存大小可以根据实际情况调整,堆大小就是总内存减去其它区域。
例子源码中,为了产生这个 Z_envZ_DYNAMICTOP_PTRZ_i 参数,我编译时故意导出了 malloc 和 free 函数。现实中的 wasm,malloc 和 free 基本上也是导出的。这两个内存函数会触发下列函数指针,因而需要明确赋值。
/* import: 'env' 'enlargeMemory' */
extern u32 (*Z_envZ_enlargeMemoryZ_iv)(void) = &enlargeMemory;
/* import: 'env' 'getTotalMemory' */
extern u32 (*Z_envZ_getTotalMemoryZ_iv)(void) = &getTotalMemory;调用函数
环境已经模拟好了,我们可以将 wasm 转换的 C 代码封装起来。
void WasmRuntime::colorFromHexString(const char *hex, float *result) {
std::lock_guard<std::mutex> lock(g_wasmMutex);
switchWasmEnv();
const u32 byteSize = (u32)strlen(hex) + 1;
u32 ptr0 = (*Z__mallocZ_ii)(byteSize);
u32 ptr1 = (*Z__mallocZ_ii)(sizeof(float) * 4);
memcpy(wasm_ptr(ptr0), hex, byteSize);
memcpy(wasm_ptr(ptr1), result, sizeof(float) * 4);
(*Z__colorFromHexStringZ_vii)(ptr0, ptr1);
memcpy(result, wasm_ptr(ptr1), sizeof(float) * 4);
(*Z__freeZ_vi)(ptr0);
(*Z__freeZ_vi)(ptr1);
}这里是需要将原始的 C++ 指针内存,复制到 memory 中,再传递偏移值。之后再从 memory 中复制出结果。封装后,调用起来就方便了。
wasm::WasmRuntime runtime;
float result[4];
runtime.colorFromHexString("#ff00ff", result);就调用了 wasm 转换的 C 代码。取得结果 result = { 1, 0, 1, 1 };
大家可能会问,你的测试源码是自己写的,你封装时自然知道源码的接口。但假如 wasm 是别人写的,怎么知道接口呢?其实 wasm 写的函数是供 js 调用的,你去参考调试 js 是如何使用的,如何传递参数的,自然可以猜测到每个参数的含义,就可以对应到 C/C++ 接口,
转换后的 C 代码和 wat 对比
理解了上面的内容,转换后的 wasm_color.c 代码中,那些 import 符号,export 符号。init_func_types、init_globals 之类的代码就很容易看懂了,都只是在创建执行环境。
但我们的目的是去逆向别人的算法,更关心具体的实现代码,而不是那些 wasm 环境。为了逆向这份 C 代码,我们应该对工具转换的风格要有所了解。
wasm 指令执行在一个虚拟的栈机器上,每条指令的操作数都从栈中获取,指令的执行结果也放到栈中。而 C 代码有所不同,虽然也有栈,但它的指令更多是在操作变量,映射到机器码就是操作内存和寄存器。wasm2c 工具,需要将 wasm 操作栈中数据,转换成 C 的操作变量。
其实习惯了的话,转回后的 C 代码,并不比 wat 的 S 表达式更好懂。但通常更熟悉 C 那种变量方式,最起码调试起来会方便些。那个 C 代码其实还残留很多栈指令的影子,我们可以将 C 和 wat 对比着看。
我们先看 _colorFromHexString 这个函数。在 wat 中查找这个符号,看到
(export "_colorFromHexString" (func 7))于是就知道在 wat 中对应 func (;7;)。对比两个函数签名
(func (;7;) (type 3) (param i32 i32)
static void _colorFromHexString(u32 p0, u32 p1)wat 操作栈中数据,并不需要变量,只需要使用 get_local 0, set_local 1 之类的指令。在 C 中对应成变量,于是我们就知道 p0, p1 的 p 其实是 param 的意思。
另外一点主要的是,wat 的变量类型为 i32,但 C 中类型为 u32。我们知道 WebAssembly 中,只有 i32, i64, f32, f64 这四种类型。wasm2c 这个工具,都将有符号数转换成符号数了,因而将 i32 转换成 u32。可以参见 c-writer.cc 中代码。
void CWriter::Write(Type type) {
switch (type) {
case Type::I32: Write("u32"); break;
case Type::I64: Write("u64"); break;
case Type::F32: Write("f32"); break;
case Type::F64: Write("f64"); break;
default:
WABT_UNREACHABLE;
}
}将 i32 转换成 u32 平时没有什么大问题。但对于负数就需要注意,比如这里的 C 代码,出现一个超大的数字。
l0 = i0;
i0 = p0;
i1 = 4294967248u;
i0 += i1;而对比 wat 的指令
set_local 1
get_local 0
i32.const -48
i32.add会发觉 4294967248u 其实是就是负数 -48, 被从 i32 强转成 u32 了。这个问题其实可以通过修改 wasm2c 源码来解决。接来下 C 中有 4 个变量
u32 l0 = 0, l1 = 0, l2 = 0, l3 = 0;其实对应于 wat 的
(local i32 i32 i32 i32)而 C 中还有些额外变量
u32 i0, i1, i2, i3;
f32 f1;
f64 d1, d2;这些都是些临时变量。wat 中因为是操作栈中元素,是不需要变量名字。转换到 C 代码,就需要为栈中元素取个名字。WebAssembly 中的 i32, i64, f32, f64 四种类型,分别对应于 i, j, f, d 四个前缀。可以从源码中找到确认,
char CWriter::MangleType(Type type) {
switch (type) {
case Type::I32: return 'i';
case Type::I64: return 'j';
case Type::F32: return 'f';
case Type::F64: return 'd';
default: WABT_UNREACHABLE;
}
}我们总结一下,为 C 中的变量前缀列个表。
- p,表示函数签名中的外部传参。
- g,表示全局变量。对应于 WebAssembly 的
set_global、get_global。 - l,表示局部变量。对应于 WebAssembly 的
set_local、get_local - i,表示临时变量,对应于 WebAssembly 的栈中元素,i32 类型。
- j,表示临时变量,对应于 WebAssembly 的栈中元素,i64 类型。
- f,表示临时变量,对应于 WebAssembly 的栈中元素,f32 类型。
- d,表示临时变量,对应于 WebAssembly 的栈中元素,f64 类型。
上表正确准确了,但有个细节需要补充,WebAssembly 中其实是不区分外部传参和局部变量的,他们两者都可以用 set_local 和 get_local 来操作。对比 C 代码和 wat 代码
static void _colorFromHexString(u32 p0, u32 p1) {
u32 l0 = 0, l1 = 0, l2 = 0, l3 = 0;
}
(func (;7;) (type 3) (param i32 i32)
(local i32 i32 i32 i32)
get_local 0wat 的 local 0 其实是 C 中的 p0 变量。local 3 才是 C 中的 l0 变量。了解上述内容,就很容易知道 wasm2c 工具的转换规则了。wat 代码
(func (;7;) (type 3) (param i32 i32)
(local i32 i32 i32 i32)
get_local 0
i32.const 1
i32.add
set_local 2
get_local 0
i32.load8_s
i32.const 35
i32.ne
if ;; label = @1
get_local 0
set_local 2
end只是很生硬,很规则地转换为:
static void _colorFromHexString(u32 p0, u32 p1) {
u32 l0 = 0, l1 = 0, l2 = 0, l3 = 0;
FUNC_PROLOGUE;
u32 i0, i1, i2, i3;
f32 f1;
f64 d1, d2;
i0 = p0;
i1 = 1u;
i0 += i1;
l0 = i0;
i0 = p0;
i0 = i32_load8_s(Z_envZ_memory, (u64)(i0));
i1 = 35u;
i0 = i0 != i1;
if (i0) {
i0 = p0;
l0 = i0;
}我上面说过,转换后的 C 代码,并不比 wat 的 S 表达式更好懂。但逆向 C 代码有个好处,假如看懂变量含义,也可以随手改个名字。也可将 C 代码随手做简化,删除多余临时变量。只要够耐心,就可慢慢就可将啰嗦的 C 代码简化。比如上述代码就可简化为
i0 = i32_load8_s(Z_envZ_memory, (u64)(hex));
if (i0 != 35u) {
l0 = hex + 1u;
}已经比较接近最初源码了。
if (*hex == '#') {
hex++;
}实际上转换出来的 C 源码,临时变量是很多的,删除多余临时变量的过程也很机械的。假如要大规模逆向 wasm, 可以修改 wasm2c 工具,或者去修改个 C 编译器,自动删除临时变量并重新输出整理好的 C 代码。这些功能做起来花时间,但是可行的,机械规律的东西,就可以写程序来辅助。只是没有太大的需求和物质推动,恐怕没有那样多精力去折腾了。
其它补充
wasm 的转换代码中包含一些全局变量,假如在多线程环境,每次调用 wasm 的函数,都需要上锁。
在多线程环境,每次切换 Wasm 环境时(switchWasmEnv),需要将全局变量重新赋值,于是我添加了这个函数
void WASM_RT_ADD_PREFIX(init_globals_)(void) {
init_globals();
}那个 wasm_rt_register_func_type 的函数用于注册函数原型,间接调用 table 中的函数指针时,比较函数原形,会安全些。只是 C/C++ 中性能很重要的。代码写得正确,而每次去判断函数原形就会浪费。函数原型的相关检查可以去掉。另外注意 wasm_rt_register_func_type 的实现中用了些全局变量,要进行清除。
wasm2c 反编译出来的代码,包含多余的安全性检查,比如 DEFINE_LOAD 中的 MEMCHECK。这些安全性检查会拖慢速度,都可以删除的。
wasm2c 反编译出来的代码包含一些静态和全局变量,这个就是个祸患,要特别注意。可以修改 wasm2c 的源码,去掉所有全局变量,放在某个环境当中。
别人的原始代码可能有 bug, 导致包装代码不稳定,这时隔一段时间,就调用一下 resetWasmEnv,将环境重新初始化恢复原状,会更稳定些。