在我们上一篇关于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 (32-bit integer)类型的整数。
'select'指令有三个参数:第一个操作数(get_local $0),第二个操作数(get_local $1)和一个条件参数(在这种情况下是i32.gt_s指令及其相关的操作数)。如果条件操作数不为零,则'Select'返回第一个操作数,否则返回第二个操作数。
在select中,指令'i32.gt_s'检查第一个参数(get_local $0)是否大于第二个参数(get_local $1)。此检查的结果将保存在“select”运算符的条件操作数中。
因此,如果第一个参数大于第二个参数,则返回第一个参数,否则返回第二个参数。
请注意,不同的工具表示的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;
Wasm代码:
get_local 0
get_local 1
i32.ge_s
if ;; label = @1
get_local 1
return
end
前两个指令get_local0/1分别获取第一个和第二个函数参数的值,并将它们压入堆栈。然后第三条指令i32.ge_s将对堆栈上的这两个值进行操作,通过隐式弹出它们然后判断第一个值是否大于或等于第二个值。比较的结果将被压入堆栈。如果堆栈顶部的值为非零值,则后续if语句将为true。换句话说,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;
继续使用wasm2wat查看Wasm文本格式,发现sort函数:
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(名为“partition”) |
set_local 2 |
局部变量var1=函数调用的返回值 |
再一次,用高级伪代码表达:
var1 = (param1 + param2) / 4 / 2 * 4;
var1 = call partition(*var1,param2,param1);
如果这很难掌握,请尝试在Chrome中运行示例,单步执行并在调试器中查看(即DevTools)堆栈和局部变量值的变化,以及全局内存的构造。一篇关于Wasm分析的早期博文介绍了做法。当我们位于'call 0'指令时,在执行之前,局部变量和堆栈将如下所示:
继续查看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 |
var1加4(仅在堆栈上,而不是在局部变量内存中) |
get_local 1 |
将param2放入堆栈 |
call 1 |
调用sort函数 |
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上找到的某些现有实现进行比较验证。逆向 partition函数留给读者练习。
总结
我们现在成功地逆向了一个完整的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
翻译:看雪翻译小组 欢歌笑语
校对:看雪翻译小组 Nxe
原文链接:https://www.forcepoint.com/blog/security-labs/manual-reverse-engineering-webassembly-static-code-analysis
[培训]《安卓高级研修班(网课)》月薪三万计划,掌握调试、分析还原ollvm、vmp的方法,定制art虚拟机自动化脱壳的方法