你怎能不为UNIX域套接字而心动?
摘要
一项技术调查,针对一个使用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 错误
一篇技术博客文章,通过使用 strace 和 tcpdump 来调试同一台机器上两个服务之间 TCP 套接字通信中出现的 ECONNRESET 错误,并定位其原因。
一个存在了32年的漏洞惊现Telnet服务器
在GNU inetutils Telnetd中发现了一个预认证远程代码执行漏洞(CVE-2026-32746),由于其遗留代码库,影响了多个操作系统。
@jedisct1: epoll UAF
对 Linux 内核 epoll 子系统中的一个释放后使用(UAF)漏洞的详细分析,该漏洞通过切换到 RCU 修复,以及作者在现代设备上尝试利用该漏洞失败的经过。
抱歉,号码错误:调试 Wine 下的崩溃(2022)
本文详细介绍了调试在 Wine 下运行的程序崩溃的过程,使用 Valgrind 和 rr 等工具来识别 libpng 中一个损坏的 CALL 指令。
Unix中的古怪注释和奇怪行为
丹尼斯·里奇讲述了早期Unix源代码中一些古怪的错误信息和注释,包括著名的“values of β will give rise to dom!”以及那句不可思议的注释“You are not expected to understand this.”