关于WASM的基础知识学习。
前一段时间都在实习和做实验室安排的工作,好久没打CTF了。这次参加idekCTF也是感觉力不从心。下学期还有实习,希望能抽出时间看看题目
WASM pwn
wasm是google开发的一款浏览器中使用的汇编语言,全程为web assembly。设计的初衷是使用c原生binary加速jiavascript的计算行为。wasm编译形成的binary类似一种基于栈的虚拟机,有自己的编译器和指令集。WASM官网。
那么对于一个PWN手而言,这当然是非常惊喜的。毕竟为了效率在浏览器中使用低级语言,虽然在虚拟机中,依然可能为我们增加攻击的机会。WASM曾在2018年上过BlackHat的议题
一个非常详细的WASM逆向指南可以参考网站
部署开发环境
首先我们可以跟着网站布置一个简单的WASM开发环境。需要一些前置条件例如llvm,git,python等。
安装emsdk
注意,和中文版官网的说法不同,需要按照README中提到的方法进行安装。
1 2 3 4 5 6
| git clone https://github.com/juj/emsdk.git cd emsdk ./emsdk install latest ./emsdk activate latest # 配置环境变量 source ./emsdk_env.sh
|
创建第一个wasm程序
我们编写一个hello.c文件,并用以下语句编译
1 2 3 4 5
| #include<stdio.h>
int main(){ printf("hello world.\n"); }
|
1 2
| # 注意:-s WASM如果不指定,将只会生成asm.js文件 emcc hello.c -s WASM=1 -o hello.html
|
之后在当前目录下产生一个简单的server
1
| emrun --no_browser --port 8080 .
|
之后访问127.0.0.1:8080/hello.html
可以看到hello world
的消息。以及emsc控制台的界面。
WASM文件格式
和一般的binary文件不同,WASM文件一般只包含四种段。
- memory space: 可以通过load/store访问到的线性内存,可读可写
- table space: 包含一系列函数指针,用来非直接的调用函数,只读
- global space: 包含只读或者只写的变量
- function space: 包含所有外部函数以及他们的函数体。只读
上述是对于静态环境而言的。如果对于执行中的wasm文件,还有两种新的space。
- local and parameter variable space: 保存临时变量以及函数参数(回忆一下,WASM是一种基于栈的虚拟机,没有寄存器)
- operand stack: 用于加减乘除指令push操作数。
WASM指令集
WASM的所有opcode,都可以用0x0~0xff这256中数字表示(一字节)。主要包含以下集中operator
- control flow operator: 包含了标签的定义,条件/无条件跳转,类似switch的表示,branch等
- 本地变量相关operator:包括read/write等
- 内存访问相关operator: load/store等
- 常量operator:在栈上push硬编码的整形或者浮点型
- 比较operator:相等/不相等
- 数学计算operator:add/mul/div等
WASM的逆向
对于binary类型的WASM,一般的IDA是无法解析的。我们可以通过wasm2wat来生成一种所谓的S表达式。S表达式和WASM bitcode的关系就类似于c语言bytecode和汇编的关系。不过S表达式更容易看明白。这里的S含义就是symbol,因为S表达式实际上是一种符号化表示(symbolic representation)。类似下图。符号化表示能自动帮助我们把相同变量用一些固定的符号代替。同时由于WASM没有寄存器操作,因此不会有寄存器。
工具
现成的工具可以从网站找到。直接clone并安装即可。
同时,github上面由对应的IDA插件可以下载。不过似乎也只能做到类似wasm2wat的反编译级别,也就是还是无法输出伪代码。
目前针对WASM格式的反编译软件只有JEB一家。看雪上有相关资源。
简单举例
我们来看一段简单的指令。指令右侧部分指代了operand stack中operand的个数。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| Function f: (i32,i32)i32 0 get_local $0 [1] 0 get_local $1 [2] 0 i32.le_s [1] 0 set_local $2 [0] 0 get_local $2 [1] 0 if $I1(i32) [0] 1 i32.const 3 [1] 1 else [0] 1 i32.const 4 [1] 1 end [1] 0 set_local $3 [0] 0 get_local $3 [1] 0 return [0] 0 end [0]
|
首先,在函数签名中可以看到函数参数以及函数返回值。$0, $1指代两个函数参数。注意get_local并不是从栈中取出一个变量,而是将一个东西push到operand栈上。因此两次get_local之后,我们将得到一个大小为2的operand stack。
le_s
是less or equal, signed
的缩写。他将会从operand stack上pop出来两个值,然后对他们进行比较。这里就是比较$0<=$1。并将结果写回到栈上。
之后一个set_local和get_local没有任何意义,跳过。就是把内容储存到栈上。
注意if这一句,$I1(i32)表示查看当前operand stack里面,最上面的一个是否是1,如果是,就进入,否则进入else部分。同时这个I(英文i)也指代了返回值是一个Int。
后来的内容就比较好理解了。通过const将一个立即数放入operand stack上,并返回。总的来说,上面代码的含义是
1 2
| int func(int $1, int $2) return $0 <= $1 ? 3:4
|
函数跳转
我们来看一下官网给出的第二个例子。这是一个通过函数指针进行跳转的例子。
1 2 3 4 5 6 7 8 9
| int x0(int val) {return val + 8800;} int x1(int val) {return val - 8811;} int x2(int val) {return val * 8822;} int x3(int val) {return val / 8833;}typedef int (* PFUNC)(int); PFUNC pfuncs[] = {x0, x1, x0, x2, x2};int z(int val, int index) { PFUNC f = pfuncs[index + 2]; return f(val); }
|
将被编译成如下内容。注意看S表达式,其实每一个缩进都是由原因的。例如下面的set_local表示设置一个寄存器,而后面跟着的get_local则表示从栈上取出的内容,一开始是由什么产生的。此外,例如add也是如此,add后面跟着的两个操作数会表示出来隐式的栈操作。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64
| (module ... (func $_z (export "_z") (type $t4) (param $p0 i32) (param $p1 i32) (result i32) ... # 将函数参数复制到本地变量 (set_local $l2 (get_local $p0)) (set_local $l3 (get_local $p1)) (set_local $l5 (get_local $l3)) # 构造一个$p1+2 (set_local $l6 (i32.add (get_local $l5) (i32.const 2))) # 从memorybase中取出对应函数的地址。其中对$16做了左移操作,左移两位。这是因为一个地址大小为4, 需要将index左移两位。 (set_local $l7 (i32.add (i32.add (get_global $env.memoryBase) (i32.const 0)) (i32.shl (get_local $l6) (i32.const 2)))) # 获取上面计算的函数指针位置 (set_local $l8 (i32.load (get_local $l7))) (set_local $l4 (get_local $l8)) (set_local $l9 (get_local $l4)) (set_local $l0 (get_local $l2)) # 调用此函数指针,并布置参数 (set_local $l1 (call_indirect (type $t0) (get_local $l0) (get_local $l9))) (set_global $g10 (get_local $l11)) (return (get_local $l1))) ... (func $runPostSets (export "runPostSets") (type $t5) (local $l0 i32) (i32.store (i32.add (get_global $env.memoryBase) (i32.const 0)) (i32.add (get_global $env.tableBase) (i32.const 1))) (i32.store (i32.add (get_global $env.memoryBase) (i32.const 4)) (i32.add (get_global $env.tableBase) (i32.const 2))) ... (elem (get_global 1) $f14 $_x0 $_x1 $_x2 $_x3 $_z $f14 $f14))
|
因此,反编译之后的伪代码可以是
1 2
| int FUNC_INDEX = *(i32*)(memoryBase + 0 + (2 + index) << 2); return CALL_INDIRECT(val, FUNC_INDEX);
|
栈操作
对于栈而言,主要是通过一对寄存器储存了栈的顶端和底端的地址。并通过这两个地址检查栈操作是否产生越界。如下是一个例子
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
| (func $_fbu (type $t0) (param $p0 i32) (result i32) (local $l0 i32) ... (local $l14 i32) (set_local $l14 <--- SP($l14) (get_global $g10)) # 设置栈顶位置。下一行官网写的是set_global $g10,但是我认为应该是$g11,用来指示栈顶 (set_global $g11 (i32.add (get_global $g10) (i32.const 1040))) # 判断是否发生越界 (if $I0 (i32.ge_s (get_global $g10) (get_global $g11)) (then (call $env.abortStackOverflow (i32.const 1040)))) ... routine body ... # 设置SP指针为原先指针位置,即返回到上一个函数的栈 (set_global $g10 (get_local $l14)) (return ...
|
能够看懂上面两个例子,那么这边也变得非常好理解了。如下是反汇编得到的内容。
1 2 3 4 5 6
| SP = STACKTOP; STACKTOP += 1040; if(SP >= SP_MAX) { abortStackOverflow(1040); } ... STACKTOP = SP; return ... ;
|
WASM的调试
可以参考这篇文章。所需的环境是google chrome。由于题目给了源码我们可以加上-g的flag编译。再chrome中找到如下内容。点击源码就可以在源码上下断点调试。首先我们需要设置再进入js时就暂停
接下来再目标位置下断点
注意需要把标准库也放在我们启动server的根目录下。可以看到这里我一开始没有放,出现了一个报错。报错也告诉了我们google使用的是musl libc来进行堆块分配的。
而对于堆块的分配,可以看到使用的是dlmalloc.c
例题
picoctf2021 some assembly required 4
先从一个比较容易入手的地方开始。题目链接
这其实是一个逆向题,程序直接给出了一个wasm的反汇编结果。其实这就是看起来比较麻烦。本质上就是一个简单的flag checker和输入。下面是用JEB反编译的结果。
首先根据while循环猜测下标应该被存放在(v0+12)里面,并且每组是加上一个int大小,说明迭代器可能是int类型。字符串应该被放在v1+1072位置上。在循环内部首先根据下标v0取出这个值,然后把+1072位置亦或上0x14,也就是str[i]^0x14。
接下来判断这个下标如果大于0,就把下标对应的内容与这个字符串上一个内容亦或,也就是str[i] = str[i] ^ str[i-1]
然后判断如果下标大于2,就str[i] = str[i] ^ str[i-3]。这一步和上一步不矛盾,可以同时发生。
接下来str[i] = (i%10)^str[i]
在接下来根据下标为偶数或者奇数分别亦或不同的数字。其实到这里已经很清晰了。我们写一个check_flag()
的代码。首先找到0x400地方的加密数据(这里我不太熟悉JEB数据提取,所以一个一个手动输入的)
那么我们不难写出伪代码。下面的change_arget对应将答案数组中元素两两交换的结果。或者我们直接在这里得到的结果中交换也是可以的。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61
| str = [] change_target = [] target = [0x18,0x6a,0x7c,0x61,0x11,0x38,0x69,0x37,0x5b,0x48,0x7e,0x4a,0x68,0x5e,0x48,0x6f,0x1f,0x5d,0x5c,0x77,0x34,0x6b,0x50,0x15,0x70,0x4f,0x3f,0x5c,0x45,0x6f,0x14,0x6,0x5,0x7d,0x3e,0x3d,0x4,0x16,0x2e,0x12,0x4c]
def checkflag(): i = 0; str[i] = str[i] ^ 0x14 while(i < len(target)): if(i>0): str[i] = str[i - 1]^ str[i] if(i>2): str[i] = str[i - 3]^str[i] str[i] = (i%10) ^ str[i] if((i%2) == 0): str[i] = str[i] ^ 0x9 else: str[i] = str[i] ^ 0x8 if(i%3 == 0): str[i] = str[i] ^ 0x7 elif(i%3 == 1): str[i] = str[i] ^ 0x6 else: str[i] = str[i] ^ 0x5 i = i + 1 j = 0 while(j<i): if((j%2) == 0 and (j+1 < i)): tmp = str[j] str[j] = str[j+1] str[j+1] = tmp else: j = j + 1
already = r"picoCTF{"
def check_chr(i,ch): ch = ord(ch)^0x14 if(i>0): ch = ord(already[i - 1])^ch if(i>2): ch = ord(already[i - 3])^ch ch = ((i % 10) ^ ch) if((i%2) == 0): ch = ch ^ 0x9 else: ch = ch ^ 0x8 if(i % 3 == 0): ch = ch ^ 0x7 elif(ch % 3 == 1): ch = ch ^ 0x6 else: ch = ch ^ 0x5 if(ch == change_target[i]): return True else: return False
|
由于每个字符可以单独尝试,每个字符只依赖于以前的字符,因此可以逐个爆破。有意我们知道这个比赛flag开头部分为picoctf{
因此可以很方便的得到结果。或者使用z3也可以。
idekCTF2023 weep
这道题给了源码,不用我们自己去逆向了。远程访问内容是一个带有输入框的页面。附件
下载之后使用docker下载环境。需要注意一般wasm都是statically linked的,也就是说往往我们需要手动去逆向一下题目所给的文件使用的库函数。当然在这方面出题也是可以的。比如使用一些自己写的动态库,然后找到存在的问题。
1
| docker build ./Dockerfile .
|
得到的网页内容
来分析一下给的源码。是一个比较明显的UAF漏洞。首先是数据结构。表示了一个name的长度和内容。
1 2 3 4
| struct Title { char* name; int len; };
|
接下来的操作分别对应于上面勾选框的操作。其中在delete时没有删去titles[idx]对应的index。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29
| void add(int idx, char* name) { if(idx < 0 || idx >= MAX_TITLES) return; titles[idx].len = strlen(name); titles[idx].name = strdup(name); }
void delete(int idx) { if(idx < 0 || idx >= MAX_TITLES) return; free(titles[idx].name); }
void edit(int idx, char* name) { if(idx < 0 || idx >= MAX_TITLES) return; strncpy(titles[idx].name, name, titles[idx].len); }
void greet(int idx) { if(idx < 0 || idx >= MAX_TITLES) return; if(numCalls > 0) return; numCalls++; title_fp(titles[idx].name); }
void setTitle(int val){ if(val) title_fp = mrTitle; else title_fp = mrsTitle; if((long long)val == 0x1337133713371337) title_fp = emscripten_run_script; }
|
注意title_fp()是一个函数指针。在初始化的时候,指向以下内容。其中有一个函数emscripten_run_script(jscode)
可以参考此网站。简单来说,就是接受js字符串并执行的,类似于system()。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| void mrTitle(const char* name){ char jsCode[100]; sprintf(jsCode, "alert(\"Mr.%.10s\")", name); emscripten_run_script(jsCode); }
void mrsTitle(const char* name){ char jsCode[100]; sprintf(jsCode, "alert(\"Mrs.%.10s\")", name); emscripten_run_script(jsCode); }
struct Title titles[MAX_TITLES];
int numCalls = 0; void (*title_fp)(const char*) = mrTitle;
|
注意到setTitle()里面,如果val等于一个特殊数字,似乎就能直接修改掉函数指针,执行我们名字对应的内容。难道直接发送0x1337133713371337
就可以有类似system()权限吗?应该是,但是还需要控制对应的js代码。但是我连js获取信息的payload也不太清楚。
分析
在delete
中,可以找到free()
函数,查看反编译,是一种类似dlmalloc的free操作。在上面已经介绍过如何调试了。
关于如何expolit,需要先了解chrome pwn相关内容