Race Condition¶
约 2078 个字 7 张图片 预计阅读时间 10 分钟
Introduction¶
竞态条件(Race Condition)或竞态危害(Race Hazard)是指电子、软件或其他系统中,系统的实质性行为依赖于其他不可控事件的顺序或时间,从而导致意外或不一致结果的情况。当其中一种或多种可能的行为不符合预期时,就会成为一个漏洞—— From Wikipedia
在计算机系统当中,竞态条件通常发生在多线程或多进程环境中,多个线程或进程试图同时访问共享资源(如内存、文件等),而这些资源的状态可能会因为并发访问而变得不一致。这可能出现在应用代码(Application Code)、操作系统代码(OS/Kernel Code,典型攻击为 Dirty COW 攻击)甚至是 CPU (典型攻击为 Meltdown & Spectre 攻击)中。
Race Condition Vulnerability¶
我们有如下一个拥有者为 Root 的 SetUID 程序:
这个程序有一个 access 函数来查看对于 /tmp/X
文件是否可写,同时里面的 open 函数也会查看 /tmp/X
文件是否可写。但是 access 函数看的是 Real User ID 而 open 函数看的是 Effective User ID,假设所要攻击的机器运行非常慢,我们可以先将 /tmp/X
文件指向一个我们可以写的文件,这样就能通过 access 函数的检查,然后迅速将 /tmp/X
文件指向一个我们无法写的文件,这样就能通过 open 函数打开一个我们无法写的文件并写入内容。
How to Attack¶
在现实当中,我们要攻击的机器运行当然没有那么慢,但是只要我们让这个程序反复运行,同时反复执行 /tmp/X
文件的指向文件改动,如下图所示:
在成功的情况下,正确的攻击执行顺序应该为 A -> 1 -> B -> 2
Another Example
我们也有很多其他的例子,例如下面的程序:
同理,有一个 check_file_existence
函数,只要我们让 /tmp/X
指向一个不存在的文件(或者干脆不指向)那么就能进入 if 分支当中触发 open 函数,此时再将 /tmp/X
指向一个我们无法写的文件即可。
这样类似的竞态条件是很多竞态条件的一种,称为 Time of Check to Time of Use(TOCTTOU),意思就是利用 Check 到 Use 之间的时间将 Check 的状态改变,使得 Check 没有用处。
How to Gain Root Privilege¶
我们可以通过上面的竞态条件漏洞来写入很多文件,例如 /etc/passwd
文件,那么我们如何可以获得 Root 权限呢?
我们可以写入一个新的条目,添加一个新的用户 test,但是为了不再去写 /etc/shadow
文件,我们可以直接将第二项变为加密过后的密码,并把 uid 设为 0 (Root 的用户 ID)即可
Improved Attack¶
事实上,如上面的描述并不一定能总是成功,这是因为实际上更改 /tmp/X
文件的指向是有两条指令的,分别为 unlink(解除所有指向)以及 symlink(指定指向),而在这两条指令之间,如果程序已经执行了 access 函数并建立了一个叫做 /tmp/X
的文件,那么就会导致这个文件的 owner 为 root(因为我们程序是 SetUID 程序),从而导致后续的 unlink 和 symlink 函数均会失败。(换句话来说,我们的攻击程序也出现了竞态条件漏洞)
因此,我们需要将更改文件指向的两条指令变为一个原子指令,我们会使用到一个 rename
函数来实现这个功能:
如上图,syscall 语句的含义是将 /tmp/XYZ
和 /tmp/ABC
的指向交换,这样就实现了 /tmp/XYZ
在指向 /dev/null
和 /etc/passwd
之间来回更换。
Countermeasures¶
为了防止竞态条件漏洞,一种很直白的想法是文件锁,当我们需要 access 某个文件时就将这个文件上锁,没有任何人能够更改这个文件的任何东西。但是在很多操作系统中并不支持这样的文件锁,原因也和安全有关,这是因为这可以导致所有人都可以锁上任意文件,那么攻击者只要将所有文件锁上就可以导致服务瘫痪。
那么,操作系统采取了另一种方式,同样还是锁,但是是一种软锁(Soft Lock),意味着这个锁在一定条件下是可以强行打开的,例如在 unix 系统中文件锁被称为 advisory,这意味着相互协作的进程可能会使用锁来协调彼此对文件的访问,但不协作的进程可以自由地忽略锁,以自己选择的任何方式访问文件,这样锁的方法就失效了。但是这样的锁方式在 Kernel 当中非常常用,因为 Kernel 是受信任的。
但是,我们的想法和锁是一样的,我们都希望能将有竞态条件漏洞的两个操作变得原子化,因此我们有了上面改进的攻击,让一些 Kernel 内原子化的操作来执行我们想要的操作,那么同理放到文件的保护上,我们同样也可以类似,例如,当我们想要更改文件的指向时,我们使用改进攻击中的方法从而避免竞态条件漏洞;当我们想要检查文件的存在并建立文件时,open 函数同样提供了 O_EXCL 的标志来避免:
这样的描述就告诉了操作系统我只是想要当文件不存在时建立文件,交由 Kernel 去实现文件存在性检查和建立两个操作,这样的附加描述也提供了一些限制,当文件真的存在时打开操作就会失败,同时如果文件只是一个指向其他文件的 symbolic link 也会导致打开失败。
对于 access 和 open 的情况,确实没有一个原子操作能够同时执行两个操作,但是我们也有一些办法让攻击成功率变低,我们让这一套语句执行多次,并获取状态,如果每次状态都是一致的那么就可以写入文件:
- 这样的方法利用了在假设一次攻击成功率为 \(p\) 时通过多次使得成功率变为 \(p^n\)(更加小)的方法。
Another Solution
在 Ubuntu 当中有一种解决方法称为 Sticky Link Protection。因为大多数竞态漏洞会利用到 /tmp
这个目录,所以在某些条件下这样的防护措施会切断文件的指向。
Least-Privilege Principle¶
事实上,对于 access 和 open 的例子,我们可以发现,当 access 函数执行时会查看 real user id,而到 open 函数执行的时候却查看 effective user id,而因为是 SetUID 程序所以导致 open 函数能打开一些我们正常情况下无法打开的文件,那如果我们在 access 函数执行过后放弃 SetUID 带来的 Root 权限,这样就不能去写一些我们无法打开的文件了。形象一点来说,就是用最少但够用的权限办事。
如上图,我们就在 open 函数两端分别用 seteuid
函数更改 effective user id 使得 open 函数的权限仅限于 real user id 的权限当中,这样就能避免一些重要文件因为竞态漏洞被恶意写,在这样危险的操作过后可以将权限归还。
Question
那么就有人会说了,这样的话我们同理只要在所有可能出现危险操作的指令前后套上这样的壳不就可以了吗?但是很多情况也是这样的方法无法解决的,例如 Buffer Overflow。
对于 Buffer Overflow 攻击,理论上按照最少权限原则应该在 strcpy
函数两端套上壳,在 buffer overflow 和恶意代码执行的期间降低权限,但是谁又能保证恶意代码不能将权限升回去呢(例如加一条 setuid 指令)?所以最少权限原则在这里失效了。