core-jmp core-jmpdeath of core jump

How Can You Not Be Romantic About UNIX Domain Sockets: A 40-Year Kernel Bug From the DEF CON Stage

Yuval Hanoch Hirschenbein Sadde traced a DEF CON 34 demo crash to one character in XNU: unp_ino++ assigns inode 0 to the first UNIX domain socket statted after boot, so the second fstat() invents a new identity. The same line is in 4.3BSD, NeXTSTEP, Mac OS X 10.0, and every iPhone.

oxfemale September 9, 2026 18 min read 88 reads
Export PDF
How Can You Not Be Romantic About UNIX Domain Sockets: A 40-Year Kernel Bug From the DEF CON Stage
Original text: "How can you not be romantic about UNIX domain sockets?"Yuval Hanoch Hirschenbein Sadde, yuvalino.com (5 September 2026). Code, tables and figures below are reproduced verbatim with attribution captions.

If You Have Never Cared About a Socket Inode

Most people meet sockets as internet plumbing: an IP address, a port, a browser talking to a server. A UNIX domain socket is the same idea with the network ripped out. Two programs on one machine ask the kernel for a private string between rooms. There is no Ethernet, no TCP handshake, no packet that can leave the box. The kernel just copies bytes from one process into another. macOS, iOS, Android, Linux, and every BSD still use this every second — for SSH, for launchd, for the window server, for the thing that makes sudo ask your password.

When a program asks “what file is this descriptor?” the kernel answers with an inode number, a coat-check ticket. For a real file on disk that ticket is stable for the life of the file. For a UNIX domain socket on Darwin the ticket is invented lazily, the first time anyone calls fstat(). The inventor of that ticket, in 1985, used the number zero to mean two different things: “I have not handed out a ticket yet” and “here is your first ticket.” Forty-one years later that collision crashed a live demo on the DEF CON 34 main stage, on a freshly booted iPhone, in front of a room full of people who came to watch an iOS sandbox talk.

Three pictures for sockets, inodes, and sentinel zero
Three everyday pictures for the three moving parts. Diagram: core-jmp.org technical analysis.

Executive Summary

On 5 September 2026 researcher Yuval Hanoch Hirschenbein Sadde published a short post tracing a crash in his DEF CON 34 talk Rage Against the Sandbox to a one-character mistake in XNU. The talk is a userspace thread-VM (TVM) that gives an iOS app fork-like semantics and a logical code-signing bypass, then runs Dropbear SSH inside the app. Because iOS will not let an app create child processes, TVM implements TTY in userspace with a pair of connected UNIX domain sockets. A sanity check compared the inode of each end. After a fresh boot, the second fstat() on the same descriptor returned a different number, and VERIFY() panicked.

The kernel function is uipc_sense() in bsd/kern/uipc_usrreq.c. A global counter unp_ino starts at zero. A per-socket field unp->unp_ino == 0 is treated as “not yet assigned.” The assignment is unp->unp_ino = unp_ino++, which yields zero on the first call after boot. The next fstat() sees zero, assumes the field is still empty, and hands out a new identity. The author walked the same line back through XNU 123.5 (Mac OS X 10.0, 2001), 4.3BSD-Tahoe, and the CSRG history repo to a 20 December 1985 change that tried to make socket inodes stable — and to a 28 May 1985 commit message that called the whole feature “fake up inode numbers and dev for the naive.” The bug is not a privilege escalation. It is CWE-193-adjacent sentinel collision, it breaks userspace that caches st_ino on AF_UNIX, and it has been in every Mac and iPhone kernel since there has been a Mac.

Four steps from a fresh boot to the TTY sanity-check crash
Why the crash is deterministic on the first SSH session after reboot and never on the second. Diagram: core-jmp.org technical analysis.

The Stage, the Talk, the Crash

DEF CON 34, Las Vegas, summer 2026. Hirschenbein Sadde is about to show a 1-day local privilege escalation named DarkSword with a 36 percent success rate. The demo around it is more ambitious than the exploit: an SSH server running inside a normal iOS application, talking to unsigned code, with job control and a TTY, on a device that is not jailbroken. The first launch crashes at startup. He relaunches. The house-beat exploit lands on the first real try. He plays it cool. Later, at home, while preparing the TVM sources for public release, the crash starts to look like a bug worth chasing.

The important clue is the shape of the failure. It happens only after a fresh boot. Never on the second run. That rules out the usual suspects of a live demo: ASLR sliding a pointer into a bad place, a race that loses on a loaded system, a use-after-free that needs luck. Something about the first moments of a Darwin boot is different from every moment after.

The project that crashed is TVM, described in the February 2026 paper Rage Against the Sandbox: Bypassing Apple’s iOS Security to Run Unsigned Code via SSH (PDF, 17 pages). iOS sandboxing refuses fork(). Shells and SSH need processes, job control, and a controlling terminal. TVM cheats: process creation becomes a thread with duplicated resources; unsigned code runs through a logical signing bypass; TTY is implemented in userspace because the kernel allows only one controlling terminal per process and TVM is, from the kernel’s point of view, a single process hosting many SSH sessions.

A userspace TTY made of sockets

A real TTY is a kernel object with two ends. The master is what SSH talks to. The slave is what the shell thinks is a terminal. When the master writes Ctrl+C, the slave is supposed to receive SIGINT. TVM cannot ask the kernel for a second controlling terminal, so it builds the pair itself: two UNIX domain sockets connected to each other, with userspace pre-processing in the middle. File descriptors get dup()ed. To tell master from slave later, TVM stores the original inode numbers of each end and compares them on the way in.

// tvm.c
/**
 * for a given VM-managed file-descriptor,
 *   pull out the associated TTY object.
 */
static struct tty *
tty_for_file_locked(struct file *file, int *out_ttymode) {
    // ...

    struct tty *tt = (struct tty *)file->f_data;

    // ...

    struct stat st;
    if (-1 == fstat(file->f_rfd, &st)) {
        // ...
        return NULL;
    }

    if (tt->t_mfd_ino == st.st_ino) {
        *out_ttymode = TTM_MASTER;
        return tt;
    }

    VERIFY(tt->t_sfd_ino == st.st_ino); // sanity only XXX: CRASH HERE!
    *out_ttymode = TTM_SLAVE;
    return tt;
}

tvm.c — the sanity check that panicked on stage. Source: original article.

The crash is the VERIFY on the slave inode. Hirschenbein Sadde first assumed memory corruption. A file descriptor does not change its inode. Then he printed the numbers. The same descriptor, two fstat() calls, two different st_ino values. Not corruption. The kernel was lying, consistently, and only the first time after boot.

The Kernel Line

UNIX domain sockets are not files. They have no disk inode. BSD still wants fstat() to return something that looks like a file, because userspace is full of code that treats every descriptor as if it were one. The Darwin implementation lives in uipc_sense(). On a connected stream socket it even folds the peer’s receive buffer into st_blksize. The identity comes from a global counter.

// /bsd/kern/uipc_usrreq.c
static int
uipc_sense(struct socket *so, void *ub, int isstat64)
{
    struct unpcb *unp = sotounpcb(so);
    struct socket *so2;
    blksize_t blksize;

    if (unp == 0) {
        return EINVAL;
    }

    blksize = so->so_snd.sb_hiwat;
    if (so->so_type == SOCK_STREAM && unp->unp_conn != 0) {
        so2 = unp->unp_conn->unp_socket;
        blksize += so2->so_rcv.sb_cc;
    }
    if (unp->unp_ino == 0) {
        unp->unp_ino = unp_ino++;
    }

    if (isstat64 != 0) {
        struct stat64  *sb64;

        sb64 = (struct stat64 *)ub;
        sb64->st_blksize = blksize;
        sb64->st_dev = NODEV;
        sb64->st_ino = (ino64_t)unp->unp_ino;
    }

    // ...

    return 0;
}

bsd/kern/uipc_usrreq.c — modern XNU uipc_sense(). Source: original article.

Read the assignment slowly. unp_ino is a BSS global, so it starts at 0. unp->unp_ino == 0 is the “not yet assigned” test. The value that gets stored is the old value of the counter, because this is post-increment. First call after boot: store 0, then bump the counter to 1. The socket now holds the number that still means empty. Second call: the test fires again, store 1, bump to 2. Same socket, new name.

Post-increment collides with the zero sentinel
The one-character bug and the one-character fix. Diagram: core-jmp.org technical analysis.

The bug has nothing to do with race conditions over the global variable as evident by the fact it is deterministic but rather the global variable usage itself – it should be ++unp_ino rather than unp_ino++.

Yuval Hanoch Hirschenbein Sadde

A race on the global would have been non-deterministic and would have shown up on the second run too. This is colder: a sentinel value colliding with the first generated identifier. C programmers meet the pattern as CWE-193 (off-by-one) and as every API that uses 0 or -1 both as a valid ID and as “none.” Handle tables, PID 0 on some systems, inode 0 on a UNIX socket. The fix in the kernel is ++unp_ino, or starting the counter at 1, or using a different empty marker. The userspace workaround, which TVM needs anyway because old iPhones will not get the kernel fix, is: if fstat() returns st_ino == 0 on Darwin, call it again.

Is this a security bug?

Hirschenbein Sadde is honest: it is not very interesting from a security standpoint. Nobody gets SYSTEM. Nobody jumps a sandbox. The confused identity is an inode on a socket that never hits disk. The reason it still belongs in a security magazine is what people do with st_ino.

  • Access-control code that keys a cache on (st_dev, st_ino) will treat the second fstat() as a different object. On Darwin, st_dev for these sockets is NODEV, so the tuple is even thinner.
  • A userspace TTY, a connection tracker, or a sandbox helper that distinguishes two ends of a socketpair() by inode will mis-label master and slave after a fresh boot. That is this crash.
  • Anything that records inode 0 as a sentinel of its own (“we have not seen this fd yet”) now collides with a real kernel-issued identity.
  • The first UNIX-socket fstat() after boot is a global event. If a privileged daemon wins that race, it is the one holding the unstable identity. The author asks the right question: is some always-on iPhone service already sitting on inode 0 without knowing it?

The crash also answers a quieter forensic question. If your iOS or macOS tooling assumes “same fd, same inode, always,” that assumption is false for AF_UNIX until someone ships the pre-increment. Detection rules that whitelist a socket by inode will flap once per boot.

How Far Back Does a Plus-Plus Travel?

The obvious next step is the earliest public XNU. Version 123.5 shipped with Mac OS X 10.0 on 24 March 2001. The function is shorter. The bug is identical.

// /bsd/kern/uipc_usrreq.c
static int
uipc_sense(struct socket *so, struct stat *sb)
{
	struct unpcb *unp = sotounpcb(so);
	struct socket *so2;

	if (unp == 0)
		return EINVAL;
	sb->st_blksize = so->so_snd.sb_hiwat;
	if (so->so_type == SOCK_STREAM && unp->unp_conn != 0) {
		so2 = unp->unp_conn->unp_socket;
		sb->st_blksize += so2->so_rcv.sb_cc;
	}
	sb->st_dev = NODEV;
	if (unp->unp_ino == 0)
		unp->unp_ino = unp_ino++;
	sb->st_ino = unp->unp_ino;
	return (0);
}

XNU 123.5, Mac OS X 10.0 (24 March 2001). Source: original article.

That is already twenty-five years of every Mac, and later every iPhone, carrying the collision. XNU is a hybrid: Mach underneath, a BSD server on top. The BSD piece did not start at Apple. It came from NeXTSTEP after the 1997 acquisition, and NeXTSTEP (1989) sat on Mach plus 4.3BSD. The closed NeXT sources are painful to audit, so the author reads 4.3BSD-Tahoe, the public tree of that era. uipc_sense() is not even a function yet. It is a PRU_SENSE arm in a giant uipc_usrreq switch. The line is the same.

// /sys/sys/uipc_usrreq.c
uipc_usrreq(so, req, m, nam, rights)
    struct socket *so;
    int req;
    struct mbuf *m, *nam, *rights;
{
    // ...

    switch (req) {
        // ...

    case PRU_SENSE:
        ((struct stat *) m)->st_blksize = so->so_snd.sb_hiwat;
        if (so->so_type == SOCK_STREAM && unp->unp_conn != 0) {
            so2 = unp->unp_conn->unp_socket;
            ((struct stat *) m)->st_blksize += so2->so_rcv.sb_cc;
        }
        ((struct stat *) m)->st_dev = NODEV;
        if (unp->unp_ino == 0)
            unp->unp_ino = unp_ino++;
        ((struct stat *) m)->st_ino = unp->unp_ino;
        return (0);

        // ...
    }
}

4.3BSD-Tahoe /sys/sys/uipc_usrreq.c. Source: original article.

Forty years of the same post-increment
Berkeley, 1985, to an iPhone on the DEF CON main stage. Diagram: core-jmp.org technical analysis.
Diagram of key Unix and Unix-like operating systems
The path the line travelled: 4.3BSD to NeXTSTEP to Mac OS X to iOS. Source: Wikimedia Commons, Unix history-simple.svg, CC BY-SA 3.0 / GFDL, authors Eraserhead1, Infinity0, Sav_vas.

December 1985: make the ticket stable, keep the operator

The CSRG history (unix-history-repo commit 18a9fea, 20 December 1985) is the last time the increment itself moved. Before that change, every fstat() invented a brand-new number. Same socket, infinite identities.

    case PRU_SENSE:
        ((struct stat *) m)->st_blksize = so->so_snd.sb_hiwat;
        if (so->so_type == SOCK_STREAM && unp->unp_conn != 0) {
            so2 = unp->unp_conn->unp_socket;
            ((struct stat *) m)->st_blksize += so2->so_rcv.sb_cc;
        }
        ((struct stat *) m)->st_dev = NODEV;
        ((struct stat *) m)->st_ino = unp_ino++;

        return (0);

Immediately before the 20 December 1985 change: a new inode on every fstat(). Source: original article.

Someone in userspace had noticed that this was nonsense and asked for a cached identity. The cache was added as if (unp->unp_ino == 0) unp->unp_ino = unp_ino++. The operator was not flipped. Zero remained both the empty marker and the first issued ticket. That is the bug that reached Las Vegas.

May 1985: fake it for the naive

One commit earlier (unix-history-repo 628f1f5, 28 May 1985) there are no inodes at all. fstat() on a UNIX socket returned zeroes for st_dev and st_ino.

    case PRU_SENSE:
        ((struct stat *) m)->st_blksize = so->so_snd.sb_hiwat;
        if (so->so_type == SOCK_STREAM && unp->unp_conn != 0) {
            so2 = unp->unp_conn->unp_socket;
            ((struct stat *) m)->st_blksize += so2->so_rcv.sb_cc;
        }

        return (0);

28 May 1985: no st_dev, no st_ino. Source: original article.

fake up inode numbers and dev for the naive

CSRG commit message, 28 May 1985, as quoted by Hirschenbein Sadde

The reconstruction is almost novelistic, and it is also the most likely one. Around May 1985 the first BSD program started calling fstat() on a UNIX socket and caring about the result. A kernel developer added fake numbers. By December the same someone had come back: the numbers must be stable, not a slot machine. The developer who typed the commit message thought that someone was naive. Forty-one years later a userspace TTY on an iPhone is still naive in exactly that way, because it believed the kernel when the kernel said “this is inode 0, and 0 means I already told you who this is.”

The geography is the romance in the title. A two-character operator in a Berkeley lab, carried over the Bay Bridge into Cupertino in a NeXT acquisition, compiled into every iPhone, and finally tripping a demo on a Vegas stage because the first SSH session after boot was the first UNIX-socket fstat() the kernel had seen since BSS was zeroed.

What the Original Takeaway Misses, and What It Gets

Hirschenbein Sadde’s own closing line is a speaker’s joke:

Verify demos better before going on the DEFCON main stage.

Original article, Takeaways

The engineering closing line is sharper. Zero is not a safe empty marker if you also generate identifiers from a counter that starts at zero. Post-increment is not a style choice when the value you store is later tested for emptiness. And a kernel ABI that invents file identity lazily will surprise any userspace that treats st_ino as immutable. TVM was the first program on that iPhone to ask. That is a statement about iOS boot, not about TVM.

Reproducing the Wobble

You do not need TVM to see the collision. After a Darwin boot, the first process to fstat() an AF_UNIX socket twice should observe inode 0 then inode 1 on the same descriptor. On a long-running Mac the first ticket has usually already been given out by launchd or some other daemon, which is why the demo only died after a fresh iPhone boot.

/* Reproduce the Darwin UNIX-socket inode wobble after a fresh boot.
 * First fstat() on the first AF_UNIX socket since boot is assigned 0.
 * Second fstat() on the same fd is assigned a new number.
 * Run this as early as you can after reboot. */
#include <stdio.h>
#include <sys/socket.h>
#include <sys/stat.h>
#include <unistd.h>

int main(void) {
    int sv[2];
    struct stat a, b;

    if (socketpair(AF_UNIX, SOCK_STREAM, 0, sv) == -1)
        return 1;

    if (fstat(sv[0], &a) == -1)
        return 1;
    if (fstat(sv[0], &b) == -1)
        return 1;

    printf("fd=%d first st_ino=%llu second st_ino=%llu%s\n",
           sv[0],
           (unsigned long long)a.st_ino,
           (unsigned long long)b.st_ino,
           a.st_ino == b.st_ino ? "" : "  *** IDENTITY CHANGED ***");

    close(sv[0]);
    close(sv[1]);
    return 0;
}

Minimal Darwin repro. Not from the original article — a laboratory check of the same uipc_sense() assignment.

If the two numbers differ, you are looking at the 1985 line. If they do not, something else already consumed inode 0 after boot, which is the other half of the author’s question: which always-on service on a stock iPhone is holding the unstable identity?

Vulnerability Summary

ItemValue
NameUNIX domain socket inode 0 collision in uipc_sense()
ComponentXNU bsd/kern/uipc_usrreq.c (and 4.3BSD uipc_usrreq)
ClassCWE-193-adjacent sentinel collision; CWE-684 (incorrect provision of specified functionality)
ImpactUserspace that caches st_ino on AF_UNIX sees identity change on the second fstat() after boot
Not impactNo LPE, no sandbox escape, no memory corruption
Introduced20 December 1985 (unix-history-repo 18a9fea); present in 4.3BSD-Tahoe, NeXTSTEP, Mac OS X 10.0, modern iOS
TriggerFirst AF_UNIX fstat() after BSS zero; second fstat() on the same socket
Kernel fixunp->unp_ino = ++unp_ino, or start unp_ino at 1
Userspace fixOn Darwin, if st_ino == 0 for an AF_UNIX fd, call fstat() again
DiscoveredDEF CON 34 main stage, talk “Rage Against the Sandbox,” published 5 September 2026
Compiled from the original article and the public BSD/XNU trees it cites. Source: original article plus public kernel history.

Key Takeaways

  • A UNIX domain socket is a kernel-local pipe with a socket API. Darwin fakes an inode for it on first fstat().
  • Post-increment into a field whose empty value is 0 assigns 0 on the first call. The next call thinks the field is still empty.
  • The crash was deterministic after reboot because that is when unp_ino is zero. ASLR and races were never in play.
  • TVM distinguished TTY master/slave by those inodes. Any program that keys AF_UNIX identity on st_ino has the same landmine.
  • The line is older than Mac OS X. It is older than NeXTSTEP. It is a December 1985 attempt to stop an even worse behaviour (a new inode on every fstat()).
  • The May 1985 commit message “fake up inode numbers and dev for the naive” is the whole ABI contract, written down.
  • This is not an exploit primitive. It is a reminder that identity APIs with overlapping sentinels survive decades of code review because they only fail on the first customer.

Defensive Recommendations

  • On Darwin, never persist AF_UNIX identity from a single fstat(). If st_ino == 0, stat again before caching.
  • Do not use (st_dev, st_ino) as a capability or an allow-list key for UNIX sockets. st_dev is NODEV and st_ino is a lazy counter.
  • Prefer file-descriptor identity (kqueue, NOTE_REVOKE, just keeping the fd) over inode identity for sockets, pipes, and other non-vnode objects.
  • Kernel authors: never generate IDs from a counter that starts at the empty marker. Start at 1, or use a separate “assigned” flag.
  • Boot-time tests: a Darwin userspace self-test that socketpair()s and double-fstat()s in an early daemon will catch a regression of this class.
  • iOS/macOS incident response: an inode that changes on a live UNIX socket is not evidence of fd swapping until you have ruled out this uipc_sense() behaviour after boot.
  • Vendors shipping userspace TTYs, SSH, or connection trackers on iOS should take the double-stat workaround even after Apple fixes the kernel, because old devices remain in the field.
  • When a demo fails only on first boot, believe that clue. It is pointing at BSS, at a global, at a lazy initializer — not at the exploit you were about to show.

Conclusion

The romance in the title is not nostalgia for Berkeley. It is the fact that a post-increment, typed to quiet a naive userspace program in 1985, can still choose the first minutes of an iPhone’s life in 2026, and can still do it on a stage. Hirschenbein Sadde went looking for memory corruption in his own VM and found a kernel that cannot tell “uninitialized” from “first.” The fix is one character. The lesson is older: identity is an API, sentinels are part of that API, and a counter that starts at the sentinel will eventually meet a program that believes the kernel twice.

Original text: “How can you not be romantic about UNIX domain sockets?” by Yuval Hanoch Hirschenbein Sadde at yuvalino.com.

oxfemale Vulnerability research, reverse engineering, and exploit development.
// Discussion