☆ 背景介绍
现在js与wasm混合编程在WEB前端较常见,前端逆向工程时可能遭遇wasm,本文面向有二进制逆向能力但从未接触过前端逆向的技术人员做一次wasm科普。
讨论wasm涉及两种层面,一种是wasm本身,另一种是解释、优化、执行wasm的引擎,后者包括对wasm的JIT等。对于挖掘浏览器0day的安全人员,需要研究的是后者。对于前端逆向,需要研究的是前者,也即本文范畴。
关于WebAssembly,参看:
https://webassembly.org/WebAssembly Core Specification
https://webassembly.github.io/spec/core/
☆ Hello World
本文演示一个虽然简单但很有代表性的例子。js提供puts函数,接收来自wasm的线性地址,在Console中输出位于wasm中的字符串常量。js并不直接调用puts函数,而是调用wasm的导出函数,通过后者间接调用puts函数。
1) hello.wat
(module
(import "env" "puts" (func $env_puts (param i32) (result))) (memory $memory 2 4)
(export "memory" (memory $memory))
(data (i32.const 4) "Hello World\00")
(func $hello (export "hello") (param) (result)
i32.const 4
call $env_puts
)
)
wat相当于汇编编程,有x86汇编经验的,上述代码瞎猜都能猜明白。
可从wat生成wasm:
wat2wasm -o hello.wasm_from_wat hello.wat
关于wat,参看:
WABT: The WebAssembly Binary Toolkit
https://github.com/WebAssembly/wabtwat2wasm
https://webassembly.github.io/wabt/doc/wat2wasm.1.html
wasm2wat
https://webassembly.github.io/wabt/doc/wasm2wat.1.html
wasm-objdump
https://webassembly.github.io/wabt/doc/wasm-objdump.1.html
wasm-decompile
https://webassembly.github.io/wabt/doc/wasm-decompile.1.html
wasm2c
https://webassembly.github.io/wabt/doc/wasm2c.1.html
Raw WebAssembly - Surma [2019-05-17]
https://dassur.ma/things/raw-wasm/
2) hello.js
'use strict';const PrivateDecode = (src) => {
let i = 0;
let ret = '';
while ( src[i] !== 0 ) {
ret += String.fromCharCode(src[i++]);
}
return ret;
};
async function RunWasm ( filename ) {
function js_puts ( off ) {
let mem = new Uint8Array( memory.buffer, off );
console.log( PrivateDecode( mem ) );
}
let fs = require('fs').promises;
let path = require('path');
let filepath = path.resolve( __dirname, filename );
let buf = await fs.readFile( filepath );
let importObject = {
env: {
puts: js_puts,
},
};
let {
instance
} = await WebAssembly.instantiate(
buf,
importObject
);
let memory = instance.exports.memory;
instance.exports.hello();
}
RunWasm( 'hello.wasm' ).catch( console.error );
在nodejs中测试:
cp hello.wasm_from_wat hello.wasm
node hello.js
3) hello.html
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
</head>
<body>
<script type="module"> const PrivateDecode = (src) => {
let i = 0;
let ret = '';
while ( src[i] !== 0 ) {
ret += String.fromCharCode(src[i++]);
}
return ret;
};
(async() => {
function js_puts ( off ) {
let mem = new Uint8Array( memory.buffer, off );
console.log( PrivateDecode( mem ) );
}
let response = await fetch( 'hello.wasm' );
let buf = await response.arrayBuffer();
let importObject = {
env: {
puts: js_puts,
},
};
let {
instance
} = await WebAssembly.instantiate(
buf,
importObject
);
let memory = instance.exports.memory;
instance.exports.hello();
})();
</script>
</body>
</html>
启动测试用HTTP服务端:
python3 -m http.server -b 192.168.x.x 8080
在Chrome中访问:
http://192.168.x.x:8080/hello.html
在F12 Console中查看输出
4) hello.c
用wat编程很不友好,临时Patch尚可,写框架代码费劲儿。就hello.wat而言,可用C语言实现同样功能。
__attribute__((
__import_module__("env"),
__import_name__("puts"),
))
extern void env_puts ( int );__attribute__((export_name("hello")))
void hello ( void )
{
char *s = "Hello World";
env_puts( (int)s );
}
正常使用wasm的程序员,主要用Emscripten编译。逆向工程人员可能不喜欢这种远离地基的东西,本文直接用clang、llvm编译:
wasi-sdk-22.0/bin/clang \
-Wall -Wextra -Wpedantic \
--target=wasm32 \
-nostdlib \
-nostartfiles \
-Wl,--no-entry \
-Wl,--export-memory \
-Wl,--global-base=4 \
-Wl,--initial-memory=$[2*64*1024],--max-memory=$[4*64*1024] \
-O3 -s \
-o hello.wasm_from_c \
hello.c
编译命令中许多参数非必要,指定它们仅为向hello.wasm_from_wat靠拢
测试:
cp hello.wasm_from_c hello.wasm
node hello.js
关于wasi-sdk,参看:
WASI-enabled WebAssembly C/C++ toolchain
https://github.com/WebAssembly/wasi-sdkWebAssembly lld port
https://lld.llvm.org/WebAssembly.html
Compiling C to WebAssembly without Emscripten - Surma [2019-05-28]
https://dassur.ma/things/c-to-webassembly/
若你的clang版本够高,不用wasi-sdk中的clang亦可。
hello.wasm_from_wat、hello.wasm_from_c并不完全等价,比较如下命令输出:
wasm2wat hello.wasm_from_wat
wasm2wat hello.wasm_from_c
后者多了如下内容:
(table (;0;) 1 1 funcref)
(global (;0;) (mut i32) (i32.const 65552))
hello.wat将常量字符串置于线性内存偏移4处,hello.c干了同样的事。若常量字符串在偏移0处,hello.c无论如何也做不到。未能找到办法让hello.c中常量字符串出现在任意偏移,从wasm生成wat,编辑wat,再从wat生成wasm,这种不算。用memcpy初始化指定偏移处的内存,这种不算。wasm-ld不支持「链接器脚本」。总之,能试的都试了,想找attribute方案,未果。非真实需求,仅为技术探索。
5) hello_inline.js
hello.js是从文件系统读取hello.wasm,wasm可直接嵌在js中,无需访问文件系统。下例将hello.wasm_from_wat的内容直接写在js中。
'use strict';const PrivateDecode = (src) => {
let i = 0;
let ret = '';
while ( src[i] !== 0 ) {
ret += String.fromCharCode(src[i++]);
}
return ret;
};
async function RunWasm () {
function js_puts ( off ) {
let mem = new Uint8Array( memory.buffer, off );
console.log( PrivateDecode( mem ) );
}
let buf = new Uint8Array([
0,97,115,109,1,0,0,0,1,8,2,96,1,127,0,96,
0,0,2,12,1,3,101,110,118,4,112,117,116,115,0,0,
3,2,1,1,5,4,1,1,2,4,7,18,2,6,109,101,
109,111,114,121,2,0,5,104,101,108,108,111,0,1,10,8,
1,6,0,65,4,16,0,11,11,18,1,0,65,4,11,12,
72,101,108,108,111,32,87,111,114,108,100,0,
]);
let importObject = {
env: {
puts: js_puts,
},
};
let {
instance
} = await WebAssembly.instantiate(
buf,
importObject
);
let memory = instance.exports.memory;
instance.exports.hello();
}
RunWasm().catch( console.error );
6) hello_inline.html
下例将hello.wasm_from_wat的内容直接写在html中。
(见TXT)
☆ F12调试wasm
假设用Chrome访问
http://192.168.x.x:8080/hello_inline.html
F12 Sources面板有wasm目录,其中会有buf对应的wasm代码,以wat格式展示。可对具体汇编指令设断、单步调试。假设断在"call $env.puts",Scope中可查看wasm汇编级栈区;单步会跟入js_puts,调用栈回溯中混杂有js、wasm函数,无缝衔接。
☆ 反汇编wasm
1) wasm2wat
wasm2wat some.wasm | less
wasm2wat -o some.wat some.wasm
2) wasm-objdump
wasm-objdump -x -d some.wasm | less
3) IDA插件idawasm
wasm2wat、wasm-objdump可以反汇编wasm,但无法显示CFG。fireeye当年有个IDAPython插件用于反汇编wasm,后来未再更新,有人对之简单更新过,参看:
https://github.com/mandiant/idawasm
https://github.com/huangxiangyao/idawasm
小改后,在IDA 7.6.1/8.4.1中测试,能用,可识别函数块、产生字符串交叉引用等。如遇未被支持的指令,需自行增强,比如多字节操作码。
项目中另有wasm_emu.py,与前述插件不是一回事,是个单独运行的脚本。在IDA中选中一个block,Alt-F7执行脚本,在Output窗口查看结果。比如选中这段代码,输出如下:
get_global global_0
i32.const 0x10
i32.sub
tee_local $local0
set_global global_0
get_local $local0
i32.const 0x425 ;; "scz is here"
i32.store 0, align:2
i32.const 0x43B ;; "(%s)"
get_local $local0
globals:
global_0: (global_0 - 0x10)
locals:
$local0: (global_0 - 0x10)
stack:
0: (global_0 - 0x10)
1: 0x43B
memory:
(global_0 - 0x10): 0x25
((global_0 - 0x10) + 0x1): 0x4
((global_0 - 0x10) + 0x2): 0x0
((global_0 - 0x10) + 0x3): 0x0
☆ 反编译wasm
1) wasm-decompile
wasm-decompile some.wasm | less
wasm-decompile的反编译结果虽然是伪码,但可读性还可以
2) wasm2c+IDA
wasm2c -o some.c some.wasm
wasm2c从some.wasm生成some.c、some.h。查看some.h,可能有
#include "wasm-rt.h"
部分编译some.c时,需用-I指定"wasm-rt.h"所在目录:
gcc -pipe -O0 -g3 -c \
-I/<path>/wabt-1.0.34/include \
-o some.o \
some.c
用IDA反编译some.o。此法不如Ghidra插件,比如字符串全是地址,也不能双击地址跳过去查看字符串。
3) Ghidra插件
参看
Ghidra Wasm plugin with disassembly and decompilation support
https://github.com/nneonneo/ghidra-wasm-plugin
出现"Active Project"界面,拖放some.wasm到其中,右键"Open in Default Tool"打开CodeBrowser,在"Symbol Tree"中查看Exports,点选具体的导出函数,自动显示反汇编、反编译结果。有些字符串已经显示出来,有些只显示了0x43b这样的地址。双击地址跳过去,右键"Data->string",相当于IDA的A键。
☆ 后记
hello示例未涉及WASI,参看:
WebAssembly System Interface (WASI)
https://github.com/WebAssembly/WASI
wasm涉及WASI时,想在Chrome中执行,需要其他奇技淫巧,本文未演示。
假设读者是有二进制逆向能力但未接触过wasm的技术人员,省略了大量wasm基础科普,直接在实践中科普wasm,建议初次接触者仔细阅读前述所有参考链接。