How can you not be romantic about UNIX domain sockets?

How can you not be romantic about UNIX domain sockets?

你怎能不对 UNIX 域套接字感到浪漫?

Yuval Hanoch Hirschenbein Sadde | Sep 5, 2026 Yuval Hanoch Hirschenbein Sadde | 2026年9月5日

Earlier this summer, I gave a technical iOS talk at the DEFCON 34 convention (search up “Rage Against the Sandbox”) and my demo crashed on stage right at startup. I played it off cool and just re-launched the demo and it all worked pretty nicely. Honestly, I was so nervous about a 1-day LPE exploit (DarkSword) with 36% success rate I was about to show, that the crash didn’t even bother me. But it was Vegas, so I figured “what the hell” and beat the house with a success on first try on stage (not counting the initial crash as it’s not related to the exploit). 今年夏天早些时候,我在 DEFCON 34 大会上做了一场关于 iOS 的技术演讲(搜索“Rage Against the Sandbox”即可找到),我的演示程序在舞台上刚启动时就崩溃了。我表现得很冷静,重新启动了演示,一切运行得相当顺利。老实说,我当时正为即将展示的一个成功率仅为 36% 的 1-day 本地提权(LPE)漏洞(DarkSword)感到紧张,以至于崩溃本身并没有困扰我。但毕竟是在拉斯维加斯,我想“管他呢”,结果在舞台上第一次尝试就成功了,赢过了庄家(不计入最初的崩溃,因为它与漏洞无关)。

Coming back home, I began finalizing the project before releasing its code publicly and it made me want to dig deeper into the crash issue. The investigation I noticed this crash always occurs only after the iOS device is freshly booted, never on the second time onwards. That’s weird because it means it’s not affected by randomness such as ASLR or multi-thread race conditions. Let’s get a little familiar with the details. 回到家后,我开始在公开发布代码前完善项目,这让我想要深入挖掘崩溃的原因。调查中我注意到,这个崩溃总是只发生在 iOS 设备刚重启之后,之后再运行就不会出现。这很奇怪,因为这意味着它不受 ASLR 或多线程竞争条件等随机因素的影响。让我们先了解一下细节。

My project is a VM running an SSH server inside an iOS application. The premise is that iOS does not permit applications to create child processes. The VM implements multi-processing semantics by overriding process-creation functions. Instead of creating processes, it creates threads in the same process with duplicated resources and memory. In addition, the VM implements a logical code signing bypass and the demo showed it all coming into play by running an unsigned 1-day exploit over an SSH connection that inherently requires multi-processing for job control and TTY. 我的项目是一个在 iOS 应用程序内运行 SSH 服务器的虚拟机(VM)。前提是 iOS 不允许应用程序创建子进程。该虚拟机通过重写进程创建函数来实现多进程语义。它不创建进程,而是在同一个进程中创建具有重复资源和内存的线程。此外,该虚拟机实现了一个逻辑代码签名绕过,演示展示了这一切是如何运作的:通过 SSH 连接运行一个未签名的 1-day 漏洞,而 SSH 连接本身就需要多进程来进行作业控制和 TTY(终端)。

Since the TTY spec defines only 1 controlling terminal per process we cannot rely on the iOS kernel’s implementation because we won’t be able to have multiple SSH sessions through the same iOS application process. The VM implements TTY in userspace by creating a pair of UNIX domain sockets connected to each other and performing pre-processing on the data sent into each side (when master sends Ctrl+C, slave gets a SIGINT, etc). The crash happens somewhere along the initialization of the SSH connection, which sets up the master/slave ends of the TTY for that session. 由于 TTY 规范定义每个进程只能有一个控制终端,我们不能依赖 iOS 内核的实现,因为这样我们就无法通过同一个 iOS 应用程序进程拥有多个 SSH 会话。该虚拟机在用户空间实现了 TTY,方法是创建一对相互连接的 UNIX 域套接字,并对发送到每一端的数据进行预处理(当主端发送 Ctrl+C 时,从端会收到 SIGINT 等)。崩溃发生在 SSH 连接初始化的过程中,该过程会为该会话设置 TTY 的主/从端。

// 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;
}

Since file-descriptors can be dup()ed around, I’m keeping the original inode numbers of the UNIX domain socket ends and I distinguish the TTY mode of the file by it (master/slave). The crash is at VERIFY(tt->t_sfd_ino == st.st_ino). That sanity check fails and the VM panics. It seemed to me that there’s some kind of memory corruption out there, because it would make no sense for a file-descriptor to change its associated inode. 由于文件描述符可以通过 dup() 复制,我保留了 UNIX 域套接字两端的原始 inode 编号,并以此区分文件的 TTY 模式(主/从)。崩溃发生在 VERIFY(tt->t_sfd_ino == st.st_ino) 处。该完整性检查失败,导致虚拟机恐慌(panic)。在我看来,这似乎是某种内存损坏,因为文件描述符改变其关联的 inode 是毫无道理的。

The crash itself happens at SSH connection start, when the SSH server modifies some terminal properties of the TTY through its master end, reaching the userspace TTY implementation. The SSH server is dropbear, a popular open-source embedded SSH implementation, and my code hooks operating system functions to divert execution flow into the VM. I assume the problem is in my implementation, and not in any codebase with years of mileage. 崩溃本身发生在 SSH 连接启动时,当 SSH 服务器通过其主端修改 TTY 的某些终端属性并触及用户空间 TTY 实现时。SSH 服务器使用的是 dropbear,这是一个流行的开源嵌入式 SSH 实现,而我的代码通过挂钩操作系统函数将执行流重定向到虚拟机中。我假设问题出在我的实现中,而不是在任何经过多年验证的代码库中。

After debugging the returned inode numbers from fstat() inside the VM, something seemed very weird. It didn’t look like a corruption anymore. It appears like an fstat() call on one end of the socket yielded a different inode on the second call to fstat() on the same end of that socket (second call is in the code snippet shown above). 在虚拟机内部调试 fstat() 返回的 inode 编号后,事情变得非常奇怪。这看起来不再像是内存损坏了。似乎是对套接字一端进行 fstat() 调用时,在对同一端进行第二次 fstat() 调用时(第二次调用见上述代码片段)产生了不同的 inode。

The bug: After debugging a little more trying to convince myself something is wrong in my code (perhaps the socket I fstat() changed??), I came to the point where nothing made sense to me. Code seemed good, crash was deterministic on the first run of the demo after reboot, it has to be something else. So, I did the obvious thing I’d been avoiding: Just open the kernel code. And so, I did: 漏洞:在进一步调试并试图说服自己是我的代码有问题(也许我 fstat() 的套接字变了??)之后,我发现一切都无法解释。代码看起来没问题,崩溃在重启后第一次运行演示时是确定性的,那一定是别的原因。于是,我做了我一直避免做的显而易见的事:直接打开内核代码。我确实这么做了:

// /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;
}

That snippet of uipc_sense() implements the logic which pulls out the socket’s inode into the st_ino field of struct stat. You can see the implementation lazily assigns an inode on the first call to stat on the target socket (unp->unp_ino == 0) from some global variable named unp_ino. Can you spot the bug? If you still want to find it yourself, you better stop reading because the next sentence will reveal the answer. 这段 uipc_sense() 代码实现了将套接字的 inode 提取到 struct statst_ino 字段中的逻辑。你可以看到,该实现是在第一次对目标套接字调用 stat 时(unp->unp_ino == 0),从一个名为 unp_ino 的全局变量中延迟分配 inode。你能发现这个漏洞吗?如果你还想自己寻找,最好停止阅读,因为下一句将揭晓答案。

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++. The unp_ino global variable is initialized to zero (as most global data should be), and the check (unp->unp_ino == 0) assumes 0 means an uninitialized inode field on the socket. But the first ever call to uipc_sense() on the system will cause unp_ino++ to yield 0 (where ++unp_ino will yield 1). This causes the first ever socket to have fstat() called on it (which receives inode 0), to change its… 这个漏洞与全局变量的竞争条件无关(事实证明它是确定性的),而是与全局变量的使用方式有关——应该是 ++unp_ino 而不是 unp_ino++。全局变量 unp_ino 被初始化为 0(正如大多数全局数据应该做的那样),而检查 (unp->unp_ino == 0) 假设 0 意味着套接字上的 inode 字段未初始化。但是,系统上第一次调用 uipc_sense() 会导致 unp_ino++ 返回 0(而 ++unp_ino 会返回 1)。这导致第一个被调用 fstat() 的套接字(接收到 inode 0)改变了它的……