vm_pwn入门
- 打算新生赛出一题
vm pwn
,不知道来不来得及出。趁着出题给自己也入门一下vm pwn
- 接下来介绍一下
vm pwn
,这里需要纠正一个误区,vm pwn
不是虚拟机逃逸之类的pwn
,虚拟机的pwn
可能还距离我有点远。
介绍
vmpwn
一般指代在程序中实现运算指令(汇编指令)来模拟程序的运行或者在程序中自定义运算指令的程序(编译类型),这种类型的难点可能在于逆向
。- 对于汇编类题型,一般的题目就像如下这样:
- 解题的关键点就是逆向出
伪汇编指令
,漏洞一般就是越界读写。 - 这种类型题目的程序,一般都会让用户输入
OPCODE
,之后就会将用户输入的OPCODE
转换为一个伪汇编指令,通过这样来模拟程序在底层中的运行模式。
- 解题的关键点就是逆向出
- 对于
vmpwn
的题目,并没有出现什么知识点,主要就是逆向;如果硬要掰扯,那么涉及到的可能就是计算机组成原理
对指令设计这个理论知识点的实际应用,操作码的话如果是扩展操作码
就比较难逆向,而非扩展操作码
会更容易逆向。所以我们在做这题的时候就要逆向出一下几点:- 模拟的寄存器的初始值和功能,比如
SP
指针,PC
指针 - 模拟的内存,模拟的栈,模拟的缓冲区
- 模拟的指令,例如
mov r1,r2,r3
,三寄存器运算指令,二寄存器运算指令,一寄存器运算指令,固定操作码,可扩展操作码。 - 模拟的指令规划。
- 模拟的寄存器的初始值和功能,比如
VM_PWN_level_1
- 这里由于时间原因我就哪
level_2
的题目做了一个简单的修改,并且调试会变得更简单,所以这里就给出源码。
1 |
|
level_1分析1
- 按照流程我们先来
check
一下保护机制。发现并没有开启PIE
保护
-
现在我们来反编译这个程序,查看一下这个程序的具体运行逻辑
-
我们先来查看
main
函数,我们按顺序分析这个程序- 首先会
funcptr
是一个函数指针,它指向了my_print
这个函数,并且使用init
对输入输出进行初始化 - 然后程序会让用户输入
SP
和IP
,并且将用户输入的值放入sp
和ip
寄存器中。并检查用户输入的初始化值是否合法 - 之后程序会让用户输入程序要执行的指令数。然后进入循环,执行两个函数
- 最后调用
funcptr
这个函数指针指向的函数
- 首先会
- 接下来我们查看一下
fetch()
这个函数,发现就是一个取memory[ip]
的值,并且将ip
自增,然后返回取出来的值
- 接下来查看一下
execute()
这个函数,这个函数会将前面取出来的memory[ip]
指令作为参数传递 - 这里我们一开始并不知道
HIBYTE(a1)
的值,此时我们就要查看汇编理解一下,我们先看到v5
存储在rbp-8
这个栈地址中 - 通过汇编我们可以看到
v5
存储的是a1
的最高8
位,之后通过伪c代码就可以看到v2
存储的值是a1
的第17-20
位v3
存储的值是a1
的第9-12
位v4
存储的值是a1
的第1-4
位
- 我们接下去查看,我们会发现当
v5
即(a1
的最高8
位为特定的值时,会执行特定的类似于汇编指令)就像图中v5=0x50
,则会执行reg[v2]=reg[v3]-reg[v4]
,也就是执行sub
指令v5=0x70
,则会执行reg[v2]=reg[v3]>>reg[v4]
,也就执行shr
指令- 这时我们就可以知道,变量
v2
、v3
、v4
就代表着寄存器的编号。
- 这时我们通过逆向,可以归纳出剩下的指令,而该函数模拟的指令如下,这时我们还注意到
reg
这个数组是int
类型,而不是unsigned
类型
1 | 0x10 reg[v2] = imm; mov imm |
- 我们在函数这块还注意到有一个
后门函数
- 我们现在来查看一下
.bss
段的全局变量,这时我们发现funcptr
就在memory
相邻低地址处
- 我们还注意到有
reg
这个数组
- 还注意到
stack
level_1分析2
-
这时我们可以确定漏洞点,就是利用
memory[reg[v2]]
这个指令进行负索引,从而修改funcptr
这个指针为backdoor()
这个函数的地址。 -
接下来我们就来构造一个负索引,我们先初始化
sp=0
、ip=0x1000
-
首先我们需要构造寄存器的值为负值。一开始我们的各个寄存器都为
0
,我们先通过mov imm
操作,将这个寄存器0、1、2
赋值为8、4、20
1 | reg[0]=8 |
- 之后我们通过
0x80
左移操作,将寄存器r1
设置为0x400000
,即:r1=r1 << r2
(r1 = 4 << 20
) - 然后通过
0x10
这个操作将0x877
赋值给r3
- 最后通过
0x40
这个操作(add
)将r1
的值变为0x400877
,这就是backdoor
的地址,这一步操作就是为越界读写修改函数指针做准备
1 | reg[0]=8 |
-
之后我们要构造负索引,这时我们就用
0x50
,sub
指令,使r4-r0
,这时我们就得到了负值。 -
最后我们再通过
0x90
存指令,直接就可以实现越界读写,使得函数指针指向backdoor
-
至于负索引要索引到多少,就需要动调去计算偏移了。
level_1_exp
- exp如下:
1 | from pwn import * |
VM_PWN_level_2
- 由于没有什么新知识点,那么就直接来看题,动手做一题就知道
vm_pwn
的具体是怎么做的了,并且也不用害怕不理解新知识。 - 接下来就以:buuctf上的
[OGeek2019 Final]OVM
作为例题来入门。
level_2分析1
- 我们拿到附件后就先来
check
一下程序的保护机制。发现程序开启了如下保护机制。
- 然后我们使用
IDA
对这个程序进行反汇编并且逆向出这个程序的运行逻辑。先查看main
函数的执行流程,同时先运行一下这个程序,由于main
函数比较长,我们根据输入点,分段逆向。- 首先程序会先申请一个堆块,然后输入输出初始化,之后会出现一个
signal
函数。signal
函数这里就先不管他 - 之后就会输出
WELCOME TO OVM PWN\n PC
这个字符串 - 输出之后就会让用户输入一个
short int
类型的数据。之后会处理用户输入的\n
- 此时我们还不知道PC是什么作用
- 首先程序会先申请一个堆块,然后输入输出初始化,之后会出现一个
- 按照要求输入之后就会执行如下程序:
- 程序会输出
SP:
,然后要求用户再次输入一个short int
类型的数据,并处理用户输入的\n
- 之后会将我们之前输入的
PC
值给reg[15]
,会将SP
的值给reg[13]
,(这里reg
是一个拥有16个元素的数组,每个元素的数组大小为4
字节) - 这下我们知道
PC
的值就是程序执行的地址,而SP
的值是栈顶指针。 - 这两个输入的作用就是让用户指定PC和SP的值
- 所以我们就在
IDA
中加上注释,防止我们后续忘记。
- 程序会输出
- 继续接下去看代码:
- 程序先会输出
CODE SIZE
,然后再让用户输出一个short int
类型的数据,并处理用户输入的\n
- 之后程序会检查我们之前输入的
SP
指针的值和CODE SIZE
两者的和是否会大于0x1000
并且检查CODE SIZE
是否为0
- 如果大于
0x1000
或者CODE SIZE
为0
,程序就会退出。
- 程序先会输出
- 接下去逆向:
- 之后程序会输出
CODE
字符串,并且设置running
这个全局变量为1
。 - 之后会让用户循环输入
int
类型的数据,循环的次数为我们之前输入的CODE SIZE
。 - 所以
CODE SIZE
的值就是我们之后要输入的操作数个数。 - 这里
memory
也是一个全局变量,它是一个65536
长度的数组,数组里面每个元素的长度为4字节。 - 并且我们
memory
中的什么位置的索引开始,这与我们之前输入的PC
有关。并且用户每次输入程序都会处理程序输入的\n
- 注意:在用户输入后还会对用户输入的值做一个检查,即用户输入的int类型不能以0xff开头
- 之后程序会输出
- 查看最后剩下的代码:
- 程序会通过
while
循环,先调用fetch()
函数,将返回值给v7
,之后调用execute()
这个函数,并且把v7
这个参数传入到execute()
函数中。 - 之后用户会向
comment
写入0x8c
长度的内容,程序会调用sendcomment()
函数,并且将comment
作为参数传递进去 - 最后程序会输出
Bye\n
然后结束运行。
- 程序会通过
-
分析完
main
函数,我们接下来就分析其他自定义的函数,主要还有fetch()
、execute()
、sendcomment()
这三个函数。 -
接下来我们分析
fetch()
函数:fetch()
函数很短,就是将执行PC=PC+1
这个操作- 并且返回的是
CODE
的值,即下一个要执行的操作码
- 再来分析
execute()
这个函数,这个函数的内容就非常多,所以还是需要一步一步来进行逆向,这边有很多个if
分支,我们先来逐个分析:- 程序定义了
v2
、v3
、v4
三个unsigned char
类型的变量和i
这一个int类型的变量 - 而
v4
存储的是a1
即CODE
的17-20
位 v3
存储的是CODE
的9-12
位v2
存储的是CODE
的最低4
位,- 而
HIBYTE(a1)
就是取a1
的最高8
位(这个需要从汇编来理解)。存入rax
中的最低8位,并且将rax
的高位填充为0
,所以指令被存储在最高a1
的最高8位中 - 当
a1
的25-32
位的值为0x70
时,就会执行if语句,也就是将执行add
操作,add r1,r2,r3 <=> r1=r2+r3
,并返回。 - 并且我们可以确定在三寄存器运算中
v4
代表目的寄存器的编号
,v2
、v3
都表示源寄存器的编号
- 程序定义了
- 继续分析,接下来的
if
语句稍微有点长,还是需要逐步分析,这个if
语句是从第19
行到第76
行。- 我们先从上到下进行分析,当
a1
的9-16
位的值为0xB0
时,执行的是寄存器的异或操作 - 当
a1
的25-32
位的值为0xD0
时,执行的是寄存器的值位移的操作 - 当
a1
的25-32
位的值为224
即(0xE0
时),执行的是就会设置running=0
也就是将要退出执行,如果reg[13]
即sp
寄存器的值为0
还会输出退出操作。 - 当
a1
的25-32
位大于0xD0
并且不是0xFF
就会执行nop
操作 - 当
a1
的25-32
位大于0xD0
,且不等于0xFF
和0xE0
程序就会输出0-15
这16个寄存器的值,并且会输出HALT
。其实这个地方的执行逻辑并不是这样的,而是任意一个寄存器的值最高位有0xff
都会输出这些寄存器的值,并退出执行。这是在后面动态调试的时候才得到的运行逻辑
- 我们先从上到下进行分析,当
- 接下来继续分析
- 当
a1
的25-32
位为0xC0
的时候就会执行左移操作
- 当
- 之后就是如下操作
- 当
a1
的25-32
位为0x90
时,就会进行与
操作 - 当
a1
的25-32
位为0xA0
时,就会进行或
操作 - 当
a1
的25-32
位为0x80
时,就会进行减
操作
- 当
- 逆向到这里我们会注意到上面的代码就都是对寄存器的操作,而接下去的代码就是会有对内存(即全局变量
memory
)进行操作。- 看到下面代码我们发现并没有使用到变量
v3
,这边都是二寄存器操作或者一寄存器操作 - 从上到下依次执行的操作为当
a1
的9-16
位为0x30
时会进行直接寻址
操作,即寄存器的值被当做地址去取相应内存中的值。 - 当
a1
的25-32
位为0x50
时就会先将reg[13]
即SP
指针的值给eax
,之后将SP=SP+1
,然后将reg[v4]
的值放入stack[eax]
,这个操作类似于Push
,其中stack
也是一个全局数组,这个数组有16个元素,并且长4
字节。 - 当
a1
的25-32
位为0x60
时就是类似于Pop
的操作,Pop reg[v4]
- 当
a1
的25-32
位为0x40
时就是类似于read
操作,将寄存器的值读入内存中。 - 当
a1
的25-32
位为0x10
时,将CODE
的值给指定寄存器reg[v4]
- 当
a1
的25-32
位为0x20
时,也就是相当于一个将寄存器置0
的操作。 - 到此为止
execute()
函数就逆向完成了
- 看到下面代码我们发现并没有使用到变量
- 这时我们就开始逆向
sendcomment()
这个函数,这个函数其实没什么好逆向的,就是一个free
操作,但是没有将指针置0
- 还有一个这个函数
signal_handler()
,这个函数就是一个退出操作,锁得比较死,没什么利用空间
level_2分析2
- 接下来我们就继续对指令集进行一个总结。以便我们后续进行操作。首先我们可以确定,这个操作码
CODE
并不是可扩展操作码,所以这就会更好理解。 - 在上面的指令中,一般情况都是这样的形式,而且这个指令并没有进行指令扩展操作。
- 实际上,指令也就使用了高
4
位,而寄存器编号也就使用了各自的低4
位。 - 其中目的寄存器是变量
v4
存储,9-16
位的源寄存器是v3
存储,1-8
位的源寄存器是v2
存储,且二元运算和一元运算都没有使用v3
- 然后接下来我们对汇编指令做一个汇总,并且给出初始寄存器的值:
1 | 0x10 reg[v4] = code |
- 这里我们还知道了几个全局变量,即定义在
.bss
段上的变量comment
存储堆块地址memory
模拟内存reg
模拟寄存器stack
模拟栈running
执行标志
- 这边我们注意到指向堆块的指针
comment
是存储在.bss
段中的,而我们模拟的寄存器是32
位的寄存器,所以我们很可能就可以通过负索引,造成越界读写,从而修改comment
的值对使其指向其他地方。 - 之后我们可以向
comment
写入数据,从题目中可以得知got
表不可写,所以我们这时可能就需要劫持free_hook
,这里有两种劫持方式- 第一种是将
comment
修改为free_hook
的地址劫持free_hook
,为ong_gadget
- 第二种是将
comment
修改为free_hook-0x8
的地址处先写入/bin/sh
,再写入system_addr
,从而getshll
- 不论是第一种还是第二种,我们都需要泄露libc地址,即将某个函数的地址打印出来,这就要使用到我们的
0xFF
这个功能,而我们不能在写入code
的时候写入指令0xFF
,我们需要在模拟运行的时候对指令进行修改才能使得code
最高一位是0xFF
- 第一种是将
- 接下来我们就边写脚本边进行动态调试,对整个程序进行分析。
- 我们先编写如下脚本,形成模版,接下来我们就着重于对
code
进行编写了。
1 | from pwn import * |
- 首先我们要先进行越界读,使得我们的模拟指令能读取到内存中的
got
表地址从而泄露libc的地址,我们先来查看一下memory
地址与got
表地址的偏移。这里我们选取泄露write
的地址,首先我们计算偏移,我们发现偏移为0x1036
- 所以要进行越界读写,我们就首先要构造
-0x1036
,转换为32
位的补码为:
- 但是由于大部分寄存器的值都为
0
,所以我们先要想办法使得某个寄存器的值为FFFF EFCA
,我们先通过:0x10000000
:即reg[v4] = code
,这样就可以进行操作,使得reg[0]=0x10000000
0x50000000
:即push reg[0]
0x10000001
:即reg[0] = 0x10000001
0x60010000
:即pop reg[1]
0x80000001
:即sub reg[0]=reg[0]-reg[1]
,从而得到数据1。
- 由动态调试会发现寄存器的值如下:
1 | reg[0]=1 |
- 这时我们使用
add
命令,将reg[1]
寄存器也变成1
,再使用add
命令将reg[1]
变成2
,之后使用左移
命令,将reg[2]
变成4
,使用add
命令使得reg[3]
变成8
,再使用add
命令让reg[4]
变成16
。 - 此时继续动态调试得到,寄存器的布局如下:
1 | reg[0]=1 |
- 这时我们可以使用
<<
命令,先将使得reg[5] = reg[0] << reg[3]
,再使得reg[5] = reg[5] << reg[2]
,这样就可以构造出数据0x1000
,然后使用add
命令,加上三次0x10
、一次0x2
、一次0x4
即可得到0x1036
1 | reg[0]=1 |
- 这时我们使用
sub
命令就可以构造出负值,从而得到负
索引,但是在得到负
索引之后,发现一个问题,程序会直接输出所有寄存器的值,然后让用户输入内容,这并不是我们所期望的。
- 所以我们不能使用
sub
去构造负值
,我们现在来试试一下使用位移和加法操作是否可以构造出负值。我们现在就利用sub
命令使得reg[6]=reg[4]-reg[0]
,然后再使用4
次位移操作,得到最高位F
和第二高位F
,然后再通过add
命令,看看是可以不触发输出寄存器的值。 - 到目前为止,我们的代码如下:
1 | from pwn import * |
- 寄存器的值如下:
1 | reg[0]=1 |
- 这时我们再使用
add
命令,构造出最高位的0xff
,但是还是会触发输出寄存器的值,从而退出执行。所以之前逆向的逻辑就有错误。
level_2_exp
VM_PWN出题
-
如果我们要出一道
VMpwn
题,即制作一个简单的VM
,我们的程序至少要有如下东西:- 需要准备
寄存器
和栈内存
,用于数值的传递 - 如果要输出
字符串
还需要有一个缓冲区BUFFER
- 需要准备
-
VMpwn
题中常见的设计如下:- 初始化分配模拟寄存器空间(即分配内存空间,将这个内存空间当做寄存器,可以是变量或者是其他可读可写的内存空间)
- 初始化分配模拟栈空间(STACK)
- 初始化分配模拟数据存储空间(BUFFER)
- 初始化分配存储OPCODE(机器指令)空间
本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议。转载请注明来自 iyheart的博客!