我们最近发表了一篇关于WebAssembly (Wasm)的安全问题和基本概念的博文。作为后续,这篇文章将介绍 Wasm 应用程序的逆向工程。考虑一下您遇到未知 Wasm 应用程序的场景,您需要弄清楚它的作用。你将如何分析它?目前几乎没有任何关于该主题的有用文档,因此我们决定部分填补这一空白。
Wasm 应用程序可以通过不同的方式进行分析。今天我们将通过一个非常简单的应用程序来介绍 Chrome 内置的 Wasm 调试功能。随着我们的进行,一些理论将被介绍。
想要直接了解更多技术内容的不耐烦的读者可以从附录部分获取 HTML 文件 test.html,然后直接跳转到“调试我们的示例应用程序”部分。
为什么我们首先对分析 Wasm 应用程序感兴趣?在深入研究动手技术之前,让我们回答这个问题。
在 Forcepoint,我们对恶意行为者如何利用新兴技术和技术感兴趣。每当出现新的威胁时,例如新的勒索软件系列、物联网蠕虫或更不寻常的东西,安全研究人员都希望分析该恶意代码的功能。当我们知道恶意软件是如何工作的,并且我们知道它的属性时,我们可以编写签名来获得保护。
存在许多用于分析传统恶意软件的工具,无论是混淆的 JavaScript、恶意 Flash 对象、可移植可执行文件 (PE) 还是其他东西。存在一种行之有效的方法来分析这些类型的威胁。
正如我们在本系列的第一篇文章中提到的那样,Wasm 的情况有所不同。几乎没有关于如何分析 Wasm 应用程序的文档,而且大多数常见的逆向工程工具还不了解 Wasm。这篇博文试图阐明对 Wasm 二进制文件的逆向工程。
让我们从创建一个简单的 Wasm 应用程序开始,稍后我们将对其进行分析。我们将在浏览器中运行应用程序并使用 Chrome 的开发者工具对其进行分析。
要在浏览器中运行 Wasm 应用程序,我们需要一个 HTML 文件来加载和执行 Wasm 二进制文件。让我们来看看创建这个 HTML 文件的过程。(如前所述,我们最终将获得本文末尾附录中列出的文件。)
从以下骨架开始(我们将进一步修改)并将其保存为名为 test.html 的文件:
为了便于设置并避免安装任何工具,让我们使用一个名为WasmFiddler的在线 Web 应用程序来生成我们的 Wasm。在 WasmFiddler 中,键入以下简单程序:
void hello() {
printf("Hello World\n");
}
然后单击“构建”,如下面的屏幕截图所示:
图 1:使用 WasmFiddler 编译 Wasm 应用程序。
在上面屏幕截图的右侧,我们看到了一个名为 utf8ToString() 的函数。将该函数复制并粘贴到我们 HTML 页面的 JavaScript 部分,将其放在 test() 函数上方。
仍然看屏幕截图的右侧,我们可以看到函数 utf8ToString() 之后的几行 JavaScript:
let m = new WebAssembly.Instance(new WebAssembly.Module(buffer));
let h = new Uint8Array(m.exports.memory.buffer);
let p = m.exports.hello();
复制这些行并将它们粘贴到 test() 函数中。这些行将从定义在名为“buffer”的数组中的代码中实例化我们的 Wasm,然后执行我们的 hello() 函数。
那么我们如何真正定义这个缓冲区的内容(Wasm 代码)呢?在 WasmFiddler 中,单击源代码下方的下拉菜单(图 1 中显示“文本格式”的菜单),然后选择“代码缓冲区”。
图 2:查看 WasmFiddler 中的代码缓冲区。
WasmFiddler 现在将生成二进制 Wasm 代码并将其放入 JavaScript 缓冲区。你应该得到这个(为了简洁而缩短):
var wasmCode = new Uint8Array([0,97,115,109,1,0,0,0,...,108,100,0]);
注意:如果你只是得到一个空数组(“var wasmCode = new Uint8Array([null]);”),那么你忘了先编译源代码。在这种情况下,单击构建并重试。
复制此缓冲区并将其粘贴到我们的 test() 函数的开头。将数组从“wasmCode”重命名为“buffer”以匹配由 WasmFiddler 生成的其他代码的命名。
如果您还记得我们在本系列的第一篇博文中,Wasm 应用程序无法自行将文本打印到屏幕上。我们需要定义一个 JavaScript 函数,我们的 Wasm 代码中的 printf() 调用可以使用该函数。在 WasmFiddler 中,在下拉菜单中选择 Text Format 以查看我们编译的 Wasm 应用程序的文本表示:
图 4: puts() 函数的导入模板。
复制上面看到的“wasmImports”定义并将其粘贴到我们的 JavaScript test() 函数的开头。然后我们需要将此导入定义提供给 Wasm 的实例化。通过使用以下实例化来做到这一点:
var m = new WebAssembly.Instance(new WebAssembly.Module(buffer),wasmImports);
最后,让我们定义 puts() 函数在调用时应该做什么。将其更改为以下内容:
puts: function puts (index) {
alert(utf8ToString(h, index));
}
现在我们已经完成了构建演示程序的所有必要步骤。在 Chrome 中加载我们的文件 test.html 会给我们一个警告:
图 5:Chrome 中的警报。
我们可以看到 Wasm 代码成功调用了我们的外部函数。
注意:如果您没有看到弹出窗口,那么问题可能是您的浏览器不支持 Wasm。在这种情况下,请尝试使用更新的浏览器,因为所有主流浏览器的最新版本都应该支持 Wasm。
现在我们终于可以使用 Chrome 开发者工具进行调试了。
在 Chrome 中打开 test.html 文件后,启动 Chrome 开发者工具(按 F12)并选择顶部的 Sources 选项卡。然后按 Ctrl+R 重新加载页面。现在出现一个带有文本“wasm”的小云图标。展开它以及它下面的项目。选择 wasm 子树下的叶子条目。您现在应该看到如下内容:
图 6:Chrome 开发者工具
让我们单步执行这个函数,以便更好地理解它的作用。单击以“i32”开头的行的左侧以设置断点。一个蓝色条将变为可见,表示已设置断点。接下来,按 Ctrl+R 再次重新加载页面。执行现在将在断点处停止。此时 Wasm 堆栈为空。现在按下调试器中的 Step Over 按钮(F10 或带有弯曲箭头的图标)以执行指令“i32.const 16”,这会将 16 的值放入堆栈:
图 7:值 16 放入堆栈。
Wasm 中的所有函数都有编号,函数编号 0 对应于 Wasm 从 JavaScript 导入的函数“puts”(函数编号 1 是“hello”函数)。因此,下一条指令 call 0 对应调用 printf/puts 函数,栈上的值 ‘16’ 就是参数。
值“16”如何对应字符串“Hello World”?这个值实际上是一个指向 Wasm 应用程序内存空间中地址的指针。Chrome 的调试器让我们通过展开全局树来查看 Wasm 应用程序的内存:
图 8:查看 Wasm 应用程序的内存。
让我们看看内存中的第 16 位:
图 9:Wasm 应用程序内存中的“Hello World”。
正在运行的 Wasm 应用程序的内存空间实际上是作为 JavaScript 数组实现的。该数组在负责加载 Wasm 应用程序的 HTML 文件中声明。在上面的示例中,以下行声明了变量“h”,其中包含应用程序的内存空间:
让 h = new Uint8Array(m.exports.memory.buffer);
现在再次按下 Step Over 按钮以执行呼叫。这最终会给我们 JavaScript 警报。
我们现在已经成功地逆向设计了我们的第一个简单的 Wasm 程序。这个例子非常简单,但我们必须从基础开始。
在逆向过程中,我们了解了 Wasm 如何通过调用在 JavaScript 中声明的导入函数与外部环境进行交互。此外,我们还了解了 JavaScript 和 Wasm 之间如何共享内存。
Forcepoint 安全实验室将继续监控 WASM 的发展,并酌情提供更新。
WasmFiddle,在线编译 Wasm:https /wasdk.github.io/WasmFiddle/?wvzhb
有关如何在浏览器的调试器中调试 Wasm 的视频:https /www.youtube.com/watch?v=R1WtBkMeGds
在 JavaScript 和 Wasm 之间传递值:https /hacks.mozilla.org/2017/07/memory-in-webassembly-and-why-its-safer-than-you-think/
为了便于参考,这里是我们创建并分析的整个 test.html 文件: