在我们上一篇关于 WebAssembly (Wasm) 的博客中,我们初步了解了一个未知的 Wasm 二进制文件,并对其进行了一些行为分析。今天我们将继续研究相同的 Wasm 样本,但会更深入。我们将通过查看 Wasm 文本格式来手动分析它。
为了能够手动分析 Wasm 文本格式,我们需要先学习更多理论。我们之前的博客文章描述了如何处理内存和数据。在此基础上,我们将介绍一些对逆向工程 Wasm 有用的附加概念,然后应用新获得的知识来分析 Wasm 样本。
注意:这篇文章是一个系列的一部分。该系列的最后一篇文章介绍了 Wasm 内存处理,因此如果您错过了该文章,您可能需要在继续之前阅读它。
Wasm 指令集
正如我们在今年早些时候所讨论的,Wasm 本身无法与外界联系。所有与外部环境的通信都需要通过 JavaScript API 调用。考虑到这一点,现在让我们专注于对在Wasm中实际计算有用的指令,而不是调用 JavaScript。
与 x86 或 x64 的指令集相比,Wasm 的指令集非常小。我们有几组不同的函数:
- 算术指令
- 控制流指令
- 内存访问指令
- 比较说明
- 转换说明
下面是一些常见的 Wasm 指令示例。如需更全面的说明列表,请参阅参考手册。
操作说明 | 描述 |
---|---|
get_local 《variable》 | 获取本地存储中变量的值,并通过将其压入堆栈使其可用于后续指令。 |
set_local 《variable》 | 通过从堆栈中弹出值并将弹出的值分配给相关的局部变量来设置本地存储中变量的值。 |
get_global 《variable》 | 获取全局变量的值。 |
set_global 《variable》 | 设置全局变量的值。 |
i32.add | 从堆栈中弹出两个数字,将它们相加并将结果压入堆栈。 |
call 《func nbr》 | 直接调用指定的函数号。 |
call_indirect 《var》 | 调用函数,其编号在运行时解析。 |
if/else/end | 条件分支。 |
br | 无条件分支。 |
br_if | 条件分支。 |
loop | 定义要循环的代码块。 |
block | 定义代码块。 |
return | 从函数返回。 |
了解文本格式
上面给出的示例是 WebAssembly 文本格式的说明。由于 Wasm 是二进制格式,因此这些文本指令将在编译文件中表示为字节码。
我们来分析一个简单的函数:
(func $max (; 0 ;) (param $0 i32) (param $1 i32) (result i32)
(select
(get_local $0)
(get_local $1)
(i32.gt_s
(get_local $0)
(get_local $1)
)
)
)
第一行意味着我们有一个名为“max”的函数,它接受两个整数作为参数,$0 和 $1,并返回一个整数,结果 i32。
’ select ’ 指令采用三个参数:第一个操作数 (get_local $0)、第二个操作数 (get_local $1) 和一个条件参数(在本例中为 i32.gt_s 指令及其相关操作数)。如果条件操作数非零,则“Select”返回第一个操作数,否则返回第二个。
在 select 中,我们有指令“ i32.gt_s ”,它检查第一个参数 (get_local $0) 是否大于第二个参数 (get_local $1)。此检查的结果将是“选择”运算符的条件操作数。
因此,如果第一个参数大于第二个参数,则返回第一个参数,否则返回第二个参数。
请注意,不同的工具可能表示文本 Wasm 格式略有不同(就像不同的反汇编程序一样)。例如,上面的也可以这样表示:
(func (;0;) (type 0) (param i32 i32) (result i32)
get_local 0
get_local 1
get_local 0
get_local 1
i32.gt_s
select)
无论我们使用什么文本表示,它都对应于以下高级代码:
int max(int a, int b) {
return a > b ? a : b;
}
有关 Wasm 文本格式的更多信息,请参见此处。
使用 wasm2wat 进行静态代码分析
现在我们已经了解了栈机、局部变量、全局内存、数据存储(在我们之前的文章中介绍过)、指令集和 Wasm 文本格式的概念,让我们利用所获得的知识来更深入地分析它们Wasm 示例与我们之前的博客文章中的一样。
从上次中断的地方继续:最初的浅层分析和行为分析都表明我们正在处理排序算法。对于有问题的样本,此时您可能已经完成了,这取决于您可以在一个样本上牺牲多少时间。
如果我们正在处理一个功能不那么明显的样本怎么办?您可能经常需要查看源代码,所以让我们展示一下我们如何去做。
为了处理代码,我们将再次使用我们在之前的博客文章中使用的 wasm2wat 工具。我们已经发现 sort 函数是函数号 1。下面是该函数在 Wasm 文本表示中的第一部分:
$ ./wasm2wat quicksort.wasm
[snip]
(func (;1;) (type 1) (param i32 i32) (result i32)
(local i32)
它从函数定义开始,显示该函数接受两个整数并返回一个整数。然后我们有一个局部变量定义,local i32。用高级伪代码表示,我们有:
func sort(int param1, int param2) {
int var1;
The Wasm code continues:
get_local 0
get_local 1
i32.ge_s
if ;; label = @1
get_local 1
return
end
前两条指令 get_local 0/1 将分别获取第一个和第二个函数参数的值,并将它们压入堆栈。然后第三条指令i32.ge_s将对堆栈上的这两个值进行操作,隐式弹出它们,然后测试第一个值是否大于或等于第二个值。比较的结果将被压入堆栈。如果堆栈顶部的值是非零值,则后续 if 语句将为真。换句话说,if 语句的分支将取决于前面的三个指令。
如前所述,相同的代码可以用不同的方式表示。如果上面的 if 语句感觉难以理解,这里有另一种表示:
(if
(i32.ge_s
(get_local $var$0)
(get_local $var$1)
)
到目前为止,我们已经反转的内容可以用高级伪代码表示,如下所示:
func sort(int param1, int param2) {
int var1;
if (param1 >= param2)
return param2;
继续查看 Wasm 文本格式,使用 wasm2wat,我们拥有的排序功能:
get_local 0
get_local 1
i32.add
i32.const 4
i32.div_s
i32.const 2
i32.div_s
i32.const 4
i32.mul
这段代码进行数学计算。再一次,get_local 0/1 指令获取传递给函数的两个参数值,随后的“ i32.add ”指令将对这两个值进行操作,将它们加在一起并将结果数字放入堆栈。
之后我们有指令’ i32.const 4’,它将值4压入堆栈。随后的指令i32.div_s将堆栈顶部旁边的值与最顶部的值相除。换句话说,之前相加的值,param1 + param2,将被 4 除。紧接着我们再次有相同的模式,但这次除以 2。类似地,以下两条指令涉及指令i32.mul操作关于常数值 4。
最终结果是到目前为止获得的值乘以 4。更简洁地表达,代码执行以下计算: (param1 + param2) / 4 / 2 * 4
让我们看一下随后的 Wasm 代码,在右侧添加了我们的注释:
操作说明 | 描述 |
---|---|
set_local 2 | 局部变量 var1 = 栈顶的任何东西,我们知道是:(param1 + param2) / 4 / 2 * 4 |
get_local 0 | 通过将 param1 放入堆栈来准备函数调用。 |
get_local 1 | 通过将 param2 放入堆栈来准备函数调用。 |
get_local 2 | 通过将 var1 放入堆栈来准备函数调用。 |
i32.load | 弹出堆栈顶部的值并将其用作指向全局内存的指针,然后获取它指向的数据并将该数据推送到堆栈顶部。 |
call 0 | 使用刚刚设置的参数调用函数 0(名为“分区”)。 |
set_local 2 | 局部变量 var1 = 函数调用的返回值。 |
再一次,让我们用高级伪代码来表达:
var1 = (param1 + param2) / 4 / 2 * 4;
var1 = call partition(*var1,param2,param1);
如果这感觉难以掌握,请尝试在 Chrome 中运行示例,单步执行并在调试器(即 DevTools)中观察堆栈和局部变量如何更改值,以及全局内存是什么样的。关于 Wasm 分析的早期博客文章描述了如何做到这一点。当我们在“call 0”指令处时,在它执行之前,局部变量和堆栈将如下所示:
图 1:在 Chrome 开发者工具中调试
Wasm 代码继续:
操作说明 | 描述 |
---|---|
get_local 0 | 将 param1 放入堆栈。 |
get_local 2 | 将 var1 放入堆栈。 |
i32.const 4 | 将 4 放入堆栈。 |
i32.sub | 从 var1 中减去 4(仅在堆栈上,不在局部变量内存中)。 |
call 1 | 调用排序。 |
drop | 丢弃排序调用的返回值。 |
get_local 2 | 将 var1 放入堆栈。 |
i32.const 4 | 将 4 放入堆栈。 |
i32.add | 将 4 添加到 var1(仅在堆栈上,不在局部变量内存中)。 |
get_local 1 | 将 param2 放入堆栈。 |
call 1 | 调用排序。 |
drop | 丢弃排序调用的返回值。 |
get_local 2) | 返回 var1(堆栈中最后剩余的项目将是返回值)。 |
在高级伪代码中,这对应于:
call sort(var1 - 4, param1);
call sort(param2, var1 + 4)
return var1;
总之,我们的最终结果是:
func sort(int param1, int param2) {
int var1;
if (param1 >= param2)
return param2;
var1 = (param1 + param2) / 4 / 2 * 4;
var1 = call partition(*var1,param2,param1);
call sort(var1 - 4, param1);
call sort(param2, var1 + 4)
return var1;
}
我们发现这确实是一个 QuickSort 实现。如果您愿意,您可以通过将上述伪代码与您在 Internet 上找到的一些现有实现进行比较来进一步验证这一点。分区函数的反转留给读者作为练习。
结论
我们现在已经成功地逆向设计了一个完整的 Wasm 函数。首先,我们使用 wasm2wat 实用程序将 Wasm 二进制格式转换为文本格式,通过分析文本格式表示,我们能够创建算法的高级伪代码。
存在用于进行自动反编译的工具,这是一种比我们今天做的更有效的逆向工程方法。虽然自动反编译可以节省时间,但它通常是不完美的,了解手动分析可以让我们解决这些缺陷。
参考
- https://developer.mozilla.org/en-US/docs/WebAssembly/Understanding_the_text_format
- https://github.com/sunfishcode/wasm-reference-manual/blob/master/WebAssembly.md#instructions
- https://www.pnfsoftware.com/reversing-wasm.pdf
- https://sophos.files.wordpress.com/2018/08/sophos-understanding-web-assembly.pdf