你怎能不为UNIX域套接字而心动?

Lobsters Hottest 新闻

摘要

一项技术调查,针对一个使用UNIX域套接字进行TTY仿真的iOS虚拟机项目中的崩溃问题,重点关注SSH连接初始化期间的inode号不匹配。

<p><a href="https://lobste.rs/s/uauns9/how_can_you_not_be_romantic_about_unix">评论</a></p>
查看原文
查看缓存全文

缓存时间: 2026/09/14 17:03

# 如何不为UNIX域套接字而心动? 来源:https://yuvalino.com/how-can-you-not-be-romantic-about-unix-domain-sockets 今年夏天早些时候,我在DEFCON 34大会做了一场iOS技术演讲(搜索"Rage Against the Sandbox"),而我的演示程序在启动阶段就当场崩溃。我装作镇定地重新启动演示,结果运行得相当顺利。 说实话,我当时满脑子都是即将展示的、成功率仅36%的1-day本地提权漏洞(DarkSword),根本无暇顾及这次崩溃。但既然身在拉斯维加斯,我索性"管他呢",最终在舞台上首次尝试就成功复现了漏洞(不将初始崩溃计入,因其与漏洞无关)。 回到家后,我开始完善项目准备公开代码,这让我想要深入探究这个崩溃问题。 ## 调查过程 我注意到这个崩溃总是发生在iOS设备刚重启后,第二次及以后运行时从未出现。这很奇怪——这意味着它不受ASLR或多线程竞争条件等随机因素的影响。 让我简单介绍一下项目背景:我的项目是在iOS应用内运行SSH服务器的虚拟机。由于iOS禁止应用创建子进程,该虚拟机通过重写进程创建函数实现了多进程语义——实际上是通过复制资源和内存,在同一进程内创建线程。此外,虚拟机实现了逻辑性的代码签名绕过,演示中通过SSH连接运行未签名的1-day漏洞时,虚拟机展示了这一切如何协同工作(SSH连接本质需要多进程支持作业控制和TTY)。 由于TTY规范要求每个进程只能有一个控制终端,我们无法依赖iOS内核的实现,否则无法通过同一个iOS应用进程建立多个SSH会话。虚拟机通过创建一对相互连接的UNIX域套接字在用户空间实现了TTY,并在数据传输到双方时进行预处理(当主端发送Ctrl+C时,从端会收到SIGINT信号等)。崩溃发生在SSH连接初始化阶段——该过程需要为会话设置TTY的主从端。 ```c // tvm.c /** * 对于虚拟机管理的文件描述符,提取关联的TTY对象 */ 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); // 仅校验:此处崩溃! *out_ttymode = TTM_SLAVE; return tt; } ``` 由于文件描述符可以通过`dup()`复制,我保留了UNIX域套接字两端的原始inode号,并通过它区分TTY模式(主/从端)。崩溃发生在`VERIFY(tt->t_sfd_ino == st.st_ino)`处,该校验失败导致虚拟机恐慌。当时我认为存在某种内存损坏,因为文件描述符变更其关联inode毫无道理。 崩溃本身发生在SSH连接启动时——SSH服务器通过主端修改TTY的终端属性,触发了用户空间TTY实现。SSH服务器采用的是轻量级开源嵌入式实现dropbear,而我的代码通过系统函数钩子将执行流导向虚拟机。我假设问题出在我的实现中,而非那些历经多年打磨的代码库。 在调试虚拟机中`fstat()`返回的inode号时,我发现了一些异常现象:这看起来不像内存损坏,反而像是在对套接字同一端两次调用`fstat()`时,第二次调用返回了不同的inode号(第二次调用即上文代码片段所示)。 ## 漏洞本质 经过更多调试后,我不得不怀疑自己的代码存在错误(难道我调用`fstat()`的套接字被替换了?),但所有线索都自相矛盾。代码逻辑看似正确,崩溃在重启后首次演示时必然出现,肯定另有玄机。于是,我终于做了之前一直回避的事:直接查看内核代码。结果如下: ```c // /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; } ``` 这段`uipc_sense()`代码实现了将套接字inode提取到`struct stat`的`st_ino`字段的逻辑。可以看到实现中采用惰性赋值:首次对目标套接字调用stat时(`unp->unp_ino == 0`),从全局变量`unp_ino`分配inode。 看出问题了吗?如果还想自己寻找答案,建议立即停止阅读——因为下一句话将揭示真相。问题与全局变量的竞争条件无关(证据是其确定性表现),而在于全局变量本身的使用——应该用`++unp_ino`而非`unp_ino++`。全局变量`unp_ino`初始为零(大多数全局数据都应如此),检查`unp->unp_ino == 0`的逻辑假设0表示套接字的inode字段未初始化。但系统首次调用`uipc_sense()`时,`unp_ino++`会返回0(而`++unp_ino`将返回1)。这导致首次调用`fstat()`的套接字获得inode 0,但当假设"0表示未初始化"时,第二次stat调用会返回不同的inode值。 首先,虽然从安全角度看这不是个有趣的漏洞,但在DEFCON舞台上发现一个破坏用户空间的内核逻辑漏洞,仍令我惊讶。当时我不知道这个漏洞在操作系统中存在了多久,但这让我好奇:有多少程序受此影响?还有,我的演示程序怎么会成为iPhone上首个对UNIX域套接字调用`fstat()`的进程?是否所有iPhone的系统服务都可能持有一个错误的UNIX域套接字inode? 其次,内核侧的修复非常简单。但由于我的代码需要支持旧版本,我可能需要增加苹果特定的检查:当获取到inode号为0时重新调用`fstat()`。这引发了我另一个疑问:如果所有修复此漏洞之前的iOS版本都受影响,那么是什么引入了这个漏洞?它又能追溯到多远的历史? ## 历史溯源 确定漏洞历史最直接的方法:调出GitHub上最早的XNU提交记录,查看`uipc_sense()`函数。这是2001年3月24日随Mac OS X 10.0发布的XNU 123.5版本: ```c // /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); } ``` 虽然代码更简洁,但漏洞依然存在。这可以追溯到25年前Mac OS X的首次发布。我们开始理清脉络:这个漏洞自互联网泡沫破裂后Mac OS X首个版本就已存在,这意味着所有现代macOS和iOS软件都受此影响。很好,现在人人受影响,但我仍想追溯漏洞的源头。 2001年发布的XNU内核是Rhapsody内核的延续,后者基于Mach 2.5和4.4BSD内核。Mach始终是核心,BSD层建立其上——因此人们常称XNU为"混合"内核(两个内核合二为一)。但更重要的是,这是对NeXTSTEP内核的彻底改造(苹果1997年收购NeXT时引入)。NeXTSTEP内核最初于1989年发布,基于Mach和4.3BSD。由于代码闭源,我们可以直接查看同时期的4.3BSD源码(4.3BSD-Tahoe版本): ```c // /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); // ... } } ``` 当时`uipc_sense()`并非独立函数,而是处理UNIX域套接字所有用户空间请求的大型switch-case中的一个小分支。有趣的是,XNU 123.5与4.3BSD Tahoe的逻辑几乎完全一致,可以确信该漏洞历经NeXT到苹果的时代得以延续——从1989到2001年,跨越了12年! 通过深挖历史"提交记录"(当时尚未有git),我发现`unp_ino++`这行代码最后修改于1985年12月20日(unix-history-repo提交`18a9fea`),之前的代码是: ```c 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); ``` 似乎在该提交之前,UNIX套接字的inode存在根本性错误:每次`fstat()`调用都会返回不同的inode。而此区域上一次修改发生在1985年5月28日(提交`628f1f5`): ```c 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); ``` 当时完全没有`st_dev`或`st_ino`,对UNIX域套接字的`fstat()`调用只返回零值。吸引我的是1985年5月28日这次变更的提交信息(可能提取自当时的版本控制软件):"fake up inode numbers and dev for the naive"(为天真者伪造inode号和设备号)。虽然无法确知原始开发者何意,但我感觉自己被一条41年前的提交信息嘲讽为"天真"。 我们可以推断的故事是:1985年5月左右,BSD上第一个依赖UNIX域套接字`fstat()`的用户空间程序出现,有人足够重视并要求实现此功能。更关键的是,在1985年12月,有人要求为同一套接字保持inode一致性(正如预期),而非仅仅为每次`fstat()`调用输出递增数字。而且这个人在内核开发者眼中显得很"天真"哈哈。我无法确知40年前伯克利发生的具体情况,但UNIX域套接字的inode看似是个小功能——可能只是实验性原型,从未被妥善复审。很高兴看到这个小问题如何跨越海湾大桥,从伯克利实验室来到库比蒂诺,并在最新版本的iPhone上留下印记。 ## 经验教训 在登上DEFCON主舞台前,请务必充分验证演示程序。

相似文章

偶尔出现的 ECONNRESET 错误

Hacker News Top

一篇技术博客文章,通过使用 strace 和 tcpdump 来调试同一台机器上两个服务之间 TCP 套接字通信中出现的 ECONNRESET 错误,并定位其原因。

@jedisct1: epoll UAF

X AI KOLs Timeline

对 Linux 内核 epoll 子系统中的一个释放后使用(UAF)漏洞的详细分析,该漏洞通过切换到 RCU 修复,以及作者在现代设备上尝试利用该漏洞失败的经过。

Unix中的古怪注释和奇怪行为

Lobsters Hottest

丹尼斯·里奇讲述了早期Unix源代码中一些古怪的错误信息和注释,包括著名的“values of β will give rise to dom!”以及那句不可思议的注释“You are not expected to understand this.”