前言
最近看到了一些以 at
结尾的Linux系统调用,在维基百科上面说这可以防御一些特定的TOCTTOU攻击,而在TOCTTOU对应页面中并没有中文版的介绍,而且百度的结果也比较少,于是决定抽空写一个关于 TOCTTOU攻击的简介,其中部分参考了英文版的维基百科。
什么是竞争条件与TOCTTOU?
在电路设计、软件开发、系统构建中,如果一个模块的输出与多个不可控事件发生的先后时间相关,则称这种现象为“竞争条件”(Race condition),又称“竞争冒险”(race hazard)。
在大多数情况下,我们希望模块的行为/输出是确定可靠的,这时竞争条件就会成为bug。例如,假设有两个线程都希望将某个为0全局整型变量加1,理想情况下,我们期望的操作会是这样:
线程 1 | 线程 2 | 全局变量值 |
---|---|---|
0 | ||
读入值到寄存器 | 0 | |
对读入的值加1 | 0 | |
将寄存器中的值回写 | 1 | |
读入值到寄存器 | 1 | |
对读入的值加1 | 1 | |
将寄存器中的值回写 | 2 |
最终结果为2. 但是,如果这两个线程的原子操作交替进行(没有进行同步),输出的结果就可能出错:
线程 1 | 线程 2 | 全局变量值 |
---|---|---|
0 | ||
读入值到寄存器 | 0 | |
读入值到寄存器 | 0 | |
对读入的值加1 | 0 | |
对读入的值加1 | 0 | |
将寄存器中的值回写 | 1 | |
将寄存器中的值回写 | 1 |
(在少数情况下,竞争条件也是有益的,例如它在硬件随机数产生器或物理不可克隆函数中的应用)
从计算机安全考虑,许多竞争条件都会产生漏洞——攻击者通过访问/竞争共享资源,从而使利用该资源的其他参与者出现故障,导致包括拒绝服务和违法权限提升等后果。例如有名的Dirty_Cow漏洞就是由竞争条件引起的。
而一种常见的起因就是代码先检查某个前置条件(例如认证),然后基于这个前置条件进行某项操作,但是在检查和操作的时间间隔内条件却可能被改变,如果代码的操作与安全相关,那么就很可能产生漏洞。这种安全问题也被称做TOCTTOU(time of check and the time of use)。
TOCTTOU通常出现在类Unix系统对文件系统的操作上,但是也可能在别的环境下发生,例如对本地sockets或数据库事务的使用。例如,下面这个setuid程序就有TOCTTOU bug:
// NOTE: This program has setuid access rights flag
if (access("filePathName", W_OK))
{
exit(EXIT_FAILURE);
}
fd = open("filePathName", O_WRONLY);
write(fd, buffer, sizeof(buffer));
在这个程序中,先用 access()
检查当前程序/进程的真实用户是否对制定的文件具有写权限,如果有,则对其写入相应的内容,否则异常退出。
要注意的是,
access()
检查的是进程的real user id (ruid),而非effective user id (euid)。而> 对于set-user-ID程序来说,其进程的real user id 会是真正发起者的uid,以便检查某一些操作是否合法,而effective user id会被设置为0(root),以便进行某些特权操作,例如普通用户只能使用passwd
程序修改自己的密码。也就是说,access()
解决的问题不是“本进程是否能访问该文件”,而是“发起本进程的用户是否能访问该文件”。而到了
open()
这类真正的访问操作时,其检查的是effective user id (此处为root)。更多有关于User identifier的内容可以参考:
- User identifier
- Difference between Real User ID, Effective User ID and Saved User ID
- effective-user-id-and-group-id-vs-real-user-id-and-group-id
个人感觉这和早期操作系统分段保护中内核对CPL、RPL和DPL位的设置检查相似——普通进程调用内核IO操作时,内核负责把进程提供的数据段选择子RPL位设置为用户的CPL,而内核访问数据段时的CPL为内核本身的代码段RPL,处理器最终会检查访问时CPL、RPL是否都满足和数据段DPL的关系,从而即为普通进程提供了硬件操作,又可以防止违法访问。
在上面的程序中,access()
这个检查和open()
这个实际访问操作中可能会有其他(恶意)程序对文件系统进行更改,从而导致恶意访问发生,以下代码按照从上到下的时间执行:
// --------------process of setuid program-------
//
if (access("filePathName", W_OK))
{
exit(EXIT_FAILURE);
}
// ----------------------------------------------
// --------------process of attacer--------------
//
// After the access check
unlink("filePathName");
symlink("/etc/passwd", "filePathName");
// Before the open, "file" points to the password database
// ----------------------------------------------
// --------------process of setuid program-------
//
fd = open("filePathName", O_WRONLY);
// Actually writing over /etc/passwd
write(fd, buffer, sizeof(buffer));
// ----------------------------------------------
可以看到,攻击者在 access
和 open
之间的时间片中将 setuid
程序的写入点改变为了 /etc/passwd
,而open
的检查可以顺利通过( euid
为0),从而向敏感文件写入数据,最终达到提权等目的。
这里要注意的是,由于access会在文件不存在的时候返回ENOENT错误,而symlink会在链接文件名存在的时候返回EEXIST错误,所以attacker进程应该在symlink之前删除"filePathName"(此处使用的是unlink)。
维基百科此处(last edited on 16 April 2018, at 17:23 (UTC) )没有删除操作,似乎有些问题。
虽然这种对竞争条件的攻击需要对CPU时间分片有一定的判断,但通常攻击者不会遇到很多麻烦(后文中会就如何把握攻击时间进行分析)。总之,应用程序不能假设在系统调用之间操作系统管理的某个状态不发生变换(在上例中是文件系统中的文件/文件名)。
接下来我们就一个典型(而古老)的TOCTTOU漏洞进行分析。
4.3BSD /bin/mail中的TOCTTOU漏洞
漏洞代码及工作原理
在4.3BSD部分实现及其之前的版本中,/bin/mail
基本实现流程如下,详细的代码可参见2.9BSD :
// ...... omitted
char lettmp[] = "/tmp/maXXXXX";
char maildir[] = "/usr/spool/mail/";
char mailfile[] = "/usr/spool/mail/xxxxxxxxxxxxxxxxxxxxxxx";
// ...... omitted
main(argc, argv)
char **argv;
{
// ...... omitted
setbuf(stdout, sobuf);
mktemp(lettmp);
unlink(lettmp);
// ...... omitted
tmpf = fopen(lettmp, "w");
if (tmpf == NULL) {
fprintf(stderr, "mail: cannot open %s for writing
", lettmp);
done();
}
if (argv[0][0] != 'r' && /* no favors for rmail*/
(argc == 1 || argv[1][0] == '-'))
printmail(argc, argv);
else
sendmail(argc, argv);
done();
}
// ...... omitted
注:在一二行将参数类型声明在外的写法是“old-style K&R C ”的风格,现在的C标准也允许这样写。
但是要注意这样写只是函数声明,不会提供函数原型,即编译器无法对传参类型进行检查,在gcc开启“-Wstrict-prototypes”选项后,会输出“warning: function declaration isn’t a prototype”的警告。
关于函数声明和函数原型的区别可以参考declaration-and-prototype-difference
另外,在现在的类unix中,
/usr/spool
一般都变为了(或指向)/var/spool
,代表一些待处理文件。
上述代码的基本工作原理为首先调用mktemp得到临时文件名,然后使用unlink删除重名的文件以避免冲突,而后判断是发现还是读信。
若是发信,则从标准输入读入信件正文,写入一个临时文件 /tmp/ma*****
,而后转发到 /usr/spool/mail/账号
中。若是读信,则从文件 /usr/spool/mail/账号
中读,写入临时文件,然后将信件内容打印在终端上,等待用户输入命令。
漏洞成因
在部分4.xBSD中,mktemp
的实现仅仅会将提供的 xxxxx
序列替换为当前进程的PID,并将最后一个字符替换为一个字母,所以最多只有26个名字会被返回。这导致了攻击者非常容易猜测mktemp
的返回值。
另一方面,虽然编程者使用 unlink
避免了冲突,但是在 unlink
和 fopen
之间的时间中可能会和别的进程产生竞争,例如攻击者可以在 /tmp
下创建一个猜测的链接文件指向敏感文件,从而使程序在 fopen
的时候实际打开敏感文件。
最后,/bin/mail
是一个setuid程序,所以其effective user id为0(root).
漏洞利用
1991年,浙江大学的周尚德先生(首先?)发表了这个漏洞并给出了相应的Poc:
我们可以建立两个线程,一个运行 mail
程序,另一个暴力猜测和尝试占用CPU分片时间,从而改写/读出敏感文件(例如写入 /etc/passwd
来提权)。这里只给出简化后的核心代码:
int try(void)
{
switch (pid = fork())
{
case 0:
execl("/bin/mail", "mail", NULL);
case -1:
exit(EXIT_FAILURE);
default:
{
tmp_file_name = guess_name(pid);
for(char c = 'a'; c <= 'z'; ++c)
{
tmp_file_name[7] = c;
symlink(sys_file, tmp_file_name);
}
wait(NULL);
}
}
}
题外话:在周先生的代码中,出现了几次return_t function()这样的声明,以表示该函数不需要参数,这是一个不好的编程习惯,还是应该使用
void
关键词明确。关于两者的区别可以参考function declaration isn't a prototype
漏洞修复
解决问题的本质方法是消除/阻止竞争,例如采取不使用临时文件,或者对临时文件产生的目录进行权限限制等。
当然也可以采取监视的措施,在竞争发生的时候立即失败(fail fast)。例如,mktemp
后在调用 open
的时候使用 O_EXCL
(C库中对应 fopen
的 x
标志,内核能够保证创建操作是原子的),以确保该文件确实是由应用程序创建的。不过C库中已经有封装好的函数了——mkstemp .
下面是4.3BSD修复这个漏洞后的大致写法,可以看到它使用了 mkstemp
而非 mktemp
:
// ...... omitted
i = mkstemp(lettmp);
tmpf = fdopen(i, "r+w");
if (i < 0 || tmpf == NULL)
panic("mail: %s: cannot open for writing", lettmp);
/*
* This protects against others reading mail from temp file and
* TOCTTOU attack. And if we exit, the file will be deleted already.
*/
unlink(lettmp);
if (argv[0][0] == 'r')
rmail++;
if (argv[0][0] != 'r' && /* no favors for rmail*/
(argc == 1 || argv[1][0] == '-' && !any(argv[1][1], "rhd")))
printmail(argc, argv);
else
bulkmail(argc, argv);
done();
}
TOCTTOU攻击之single-stepping
可以看到,TOCTTOU攻击要求攻击者能够准确的把握时间(CPU分片),例如在上节的例子中,攻击者需要让 symlink
在应用程序 unlink
和 fopen
中执行——我们采取的是暴力的尝试,但这也仅需要几十秒(参见周先生的论文)。这种对程序间的操作/调用进行先后安排的行为被称为“single-stepping”。
single-stepping的技术包括“文件系统迷惑(file system mazes)”和”算法复杂度攻击(algorithmic complexity attacks)“等。其中文件系统迷惑是强制应用程序读取一个不再操作系统缓冲中的目录入口,以此让操作系统将应用程序置于睡眠状态(随后从磁盘中读取)。而算法复杂度攻击是强制应用程序浪费掉操作系统为它分配的CPU资源,例如攻击者可以创建一大堆hash值相同的文件,从而导致内核hash表的链非常长,然后让用户去索引相关的文件,耗费其资源(在拒绝服务的漏洞中也有这种攻击)。
如何防范TOCTTOU漏洞
尽管概念上很简单,但是TOCTTOU通常很难被避免。一种广泛使用的策略是遵循EAFP "It is easier to ask for forgiveness than permission"(不去检查而是引起并捕获异常)而不是 LBYL"look before you leap"(判断后再决策)。在上面的例子中,我们使用 mkstemp
就是一种”ask for forgiveness“的行为,只不过C没有专门的异常处理机制罢了。
对于文件系统中的TOCTTOU,最迫切的挑战是如何确保两个系统调用之间文件系统不发生改变——但在2004年的一篇会议报告显示,并不可能存在可移植的技术能完全避免TOCTTOU在unix上的存在。
不过,IBM的研究员在随后开发出了一些能够追踪并检查 文件描述符库,他们能够一定程度上避免TOCTTOU。
另一种解决方案是在文件系统或/内核中引入事务机制(transaction),以此为操作系统提供 并发控制 的抽象层。不过目前这种方案没有得到广泛的使用(微软在NTFS中引入了这种机制,但并不推荐客户使用,并且可能在未来的版本中去除掉)。
文件锁 是另一种广泛使用的防止竞争的机制。但是它不会对元数据或者在文件系统的名称空间中起作用(也不能对网络文件进行操作)。
对于 setuid
程序,另一种可能的解决方案是在 open()
前使用 seteuid()
修改用户的effective user id,但是这会带来一些兼容性上的问题。
回到开头的 at
后缀
那么,"前言"中提到的带 at
后缀的系统调用是防止哪一类的TOCTTOU攻击的呢?又是怎么防止的呢?
简单点说,它们是为了防止在用相对路径使用访问文件时,不同进程/线程对文件夹访问的竞争。
我们就拿 openat()
来解释:
int open(const char *path, int oflag, ...);
int openat(int fd, const char *path, int oflag, ...);
可以看到其就比 open()
多一个文件描述符,而这个文件描述符是一个文件夹的句柄,以作为 path
的相对路径。POSIX 定义的openat()
工作原理如下,这里就不翻译了:
The
openat()
function shall be equivalent to theopen()
function except in the case wherepath
specifies a relative path. In this case the file to be opened is determined relative to the directory associated with the file descriptorfd
instead of the current working directory. If the file descriptor was opened withoutO_SEARCH
, the function shall check whether directory searches are permitted using the current permissions of the directory underlying the file descriptor. If the file descriptor was opened withO_SEARCH
, the function shall not perform the check.The
oflag
parameter and the optional fourth parameter correspond exactly to the parameters ofopen()
.If
openat()
is passed the special valueAT_FDCWD
in thefd
parameter, the current working directory shall be used and the behavior shall be identical to a call toopen()
.要注意的是,O_SEARCH在Linux内核中没有实现。
当我们运行一个进程时,内核会为其维护一个链表结构的数据(图片来自维基百科):
其中描述符是进程可以拿来访问资源的句柄(handle),File table中记录了进程的权限和相关资料,而Inode table记录了文件在磁盘中的地址。我们知道,在unix文件系统中,很少进行“真正”的删除,而是像 unlink()
这样删除文件夹中的链接;对于移动和重命名也是一样,也是改变文件夹中的链接和内容,数据没有移动。所以当我们打开一个文件并获得描述符后,如果这个文件被另一个进程“删除”或者“重命名”或者“移动”,我们依然可以通过上面数据结构的索引访问到打开时对应的数据。也就是说,在文件描述符被修改(例如dup)和关闭前,这个句柄是不可能指向其他位置的数据/inode的。
现在考虑这样一种攻击,“setuid受害者”进程会在 open()
中使用了相对路径 ./foo.txt
打开一个文件,并随后进行操作。攻击者可以将“受害者”进程的工作目录修改指向为敏感目录,从而修改不同文件。但如果我们能够保证有一个确定合法的文件夹句柄(文件描述符),然后使用 openat()
访问相对的文件,那么即使攻击者修改了“那个”目录,我们在进程中依然可以确保访问文件夹/文件是合法的。
另外,在 Advanced Programming in the UNIX 3rd中,作者也提到 openat
可以让不同的线程对不同文件夹中文件的操作变得方便,因为 open
只能相对于当前工作目录,而所有线程的工作目录是一样的。
参考: