了解什么是竞争条件,以及它们为什么会引发安全问题。本文向您展示了如何在类 UNIX® (Unix-like)系统中处理常见的竞争条件,包括如何正确地创建锁文件、锁文件的替代者,如何处理文件系统,以及如何处理共享目录(特别是如何在 /tmp 目录下正确创建临时目录)。需要您对信号处理稍有了解。
通过一个偷窃而来的口令,Mallory 成功地登录到一台运行 Linux 的重要服务器。其帐号是一个非常受限的帐号,但是 Mallory 知道如何使用它来制造麻烦。Mallory 安装并运行了一个行为非常奇怪的小程序,该程序使用多个进程在 /tmp 目录下快速地创建和删除很多不同的符号链接文件。(符号链接文件也称为 symlink,是一种简单的文件,当被访问时,它会将请求重定向到另一个文件。)Mallory 的程序不停地创建和删除很多指向同一特殊文件(/etc/passwd,口令文件)的不同符号链接文件。
这台重要的服务器的安全措施之一是,它每天都运行 Tripwire ―― 具体地说,是较老的 2.3.0 版本。Tripwire 是一个检测重要文件是否被篡改的安全程序。与很多程序一样,Tripwire 启动时会尝试着创建一个临时文件。Tripwire 会查看并断定不存在名为“/tmp/twtempa19212”的文件,所以看起来这是一个合适的临时文件名称。但是在 Tripwire 完成检查后,Mallory 的程序就会使用该名称创建一个符号链接文件。这不是偶然的;Mallory 程序的设计目标就是创建最有可能为 Tripwire 所使用的文件名。然后 Tripwire 就会打开该文件,开始写入临时信息,但不用创建新的空文件,Tripwire 现在正在重写口令文件!从那时起,任何人 ―― 甚至是管理员 ―― 都不能登录到该系统,因为口令文件已经被破坏了。更糟的是,Mallory 的攻击完全可以覆盖所有文件,包括服务器上存储的重要数据。
竞争条件简介
这是个假想的故事;“Mallory”是攻击者的一个惯用名。但是这类攻击,以及它所利用的缺陷,都极其常见。问题是很多程序都容易受到名为“竞争条件”的安全问题的影响。
当由于事件次序异常而造成对同一资源的竞争,从而导致程序无法正常运行时,就会出现“竞争条件”。注意,竞争条件无需介入同一程序的两个部分之间的竞争;如果一个外部的攻击者可以通过意想不到的方式干扰程序,那么就会出现很多安全问题。例如,如果 Tripwire 2.3.0 确定某个文件不存在,它就会尝试着创建该文件,而不去考虑在进行这两个步骤期间,该文件是否已经被攻击者创建。几十年前,竞争条件还不是什么问题;那时,计算机系统通常在同一时刻只能运行一个单独的程序,什么都不能打断它或者与它竞争。但是,当今的计算机通常需要同时运行大量的进程和线程,经常还会有多个处理器确实在同时运行不同的程序。这样做更灵活,但是有一个危险:如果这些进程和线程共享了所有的资源,那么它们都可能互相影响。实际上,竞争条件缺陷是软件的更常见缺陷之一,此外,在类 Unix 系统上,/tmp 和 /var/tmp 目录经常会被错误地使用,从而导致竞争条件。
不过,我们首先需要了解一些术语。所有 类-Unix 系统都支持用户进程;每个进程都有自己的内存空间(其他进程通常无法访问)。底层的内核会尽量使进程看起来像是在同时运行;在多处理器的系统中,它们确实可以同时运行。从理论上讲,一个进程可以拥有一个或多个线程;这些线程可以共享内存。线程也可以同时运行。由于线程可以共享内存,所以,相对于进程,线程之间更有可能产生竞争条件;正是由于这个原因,多线程程序的调试要困难得多。Linux 内核有一个非常好的基本设计:只有线程,并且一些线程可以与其他线程共享内存(这样实现了传统的线程),而另外一些线程则不能(这样就实现了独立进程)。
为了理解竞争条件,让我们首先来看一个非常普通的 C 声明:
清单 1. 普通的 C 声明
b = b + 1;
看起来非常简单,不是吗?但是,让我们假定有两个线程在运行这一行代码,在这里,“b”是一个由两个线程共享的变量,“b”的初始值为“5”。以下是一个似是而非的执行次序:
清单 2. 使用共享的“b”的可能执行次序
(thread1) load b into some register in thread 1.
(thread2) load b into some register in thread 2.
(thread1) add 1 to thread 1's register, computing 6.
(thread2) add 1 to thread 2's register, computing 6.
(thread1) store the register value (6) to b.
(thread2) store the register value (6) to b.
初始值为 5,然后两个线程分别加 1,但是最终的结果是 6... 而不是应该得到的 7。问题在于,两个线程互相干扰,从而导致产生错误的最终答案。
通常,线程不是以原子的方式执行的;另一个线程可以在任何两个指令期间打断它,而且还可以使用一些共享的资源。如果一个安全程序的线程没有预防这些中断,那么另一个线程就可以干扰该安全程序的线程。在安全程序中,不管在任何一对指令中间运行了多少其他线程的代码,程序都必须正确地运行。关键是,当您的程序访问任意资源时,要确定其他某个线程是否可能因为使用该资源对您的程序造成干扰。
解决竞争条件
竞争条件的典型解决方案是,确保程序在使用某个资源(比如文件、设备、对象或者变量)时,拥有自己的专有权。获得某个资源的专有权的过程称为加锁。锁不太容易处理。死锁(“抱死,deadly embrace”)是常见的问题,在这种情形下,程序会因等待对方释放被加锁的资源而无法继续运行。要求所有线程都必须按照相同的顺序(比如,按字母排序,或者从“largest grain”到“smallest grain”的顺序)获得锁,这样可以避免大部分死锁。另一个常见问题是活锁(livelock),在这种情况下,程序至少成功地获得和释放了一个锁,但是以这种方式无法将程序再继续运行下去。如果一个锁被挂起,顺利地释放它会很难。简言之,编译在任何情况下都可以按需要正确地加锁和释放的程序通常很困难。
有时,可以一次执行一个单独操作来完成一些特殊的操作,从而使您不需要显式地对某个资源进行加锁而后再解锁。这类操作称为“原子”操作,只要能够使用这类操作,它们通常是最好的解决方案。
有一些错误是如此常见,所以,为了避免犯这些错误,您需要了解它们。一个问题是,以不总是锁定某资源的方式创建锁文件;您应该学习如何正确创建它们,或者转而采取不同的加锁机制。您还需要正确地处理文件系统中的竞争,其中包括如何处理永远危险的共享目录 /tmp 和 /var/tmp,以及如何安全地使用信号。下一章中将描述如何安全使用它们。
锁文件
通常,类 Unix 系统是通过创建表示一个锁的文件来实现不同进程间共享的锁。使用单独的文件来表示锁,是“劝告式(advisory)”锁而不是“强制(mandatory)”锁的一个例子。换句话说,操作系统不会强制您通过锁来共享资源,所以,所有需要该资源的进程都必须协同使用该锁。这看起来好像很简单,但并不是所有简单的主意都不是好主意;创建单独的文件,就可以方便地获得系统的状态,其中包括哪些资源被加锁了。如果您使用这种方法,有一些标准的技巧可以简化这些锁的清除,具体地说,是删除那些挂起的锁。例如,一个父进程可以设置一个锁,然后调用一个子进程来执行工作(确保父进程可以有效地调用子进程),当子进程返回时,父进程释放该锁。或者,可以使用 cron 作业来查看那些锁(其中包括进程的 id);如果进程没有处于活动状态,那么该作业就会清除那些锁,并重新启动相应的进程。最后,锁文件的清除可以作为系统启动的一部分(从而使您的锁在系统突然崩溃之后不再处于挂起状态)。
如果您正在创建单独的文件来表示锁,那么要注意一个常见的错误:对 creat() 或者与之相当的 open() 的调用(模式为 O_WRONLY | O_CREAT | O_TRUNC)。问题是,root 总是 可以这样创建文件,即便锁文件已经存在,这意味着该锁不能为 root 正常工作。简单的解决方案是在使用 open() 时指定标记 O_WRONLY | O_CREAT | O_EXCL(将权限设置为 0,使同一用户的其他进程无法获得该锁)。注意 O_EXCL 的使用,这是创建“专用”文件的正式途径;甚至在本地文件系统上,root 也可以这样做。这个简单的方法对 NFS 版本 1 或者版本 2 不适用;如果必须在使用这些老的 NFS 版本连接的远程系统上使用锁文件,那么可以使用 Linux 文档中给出的方案:“在相同的文件系统上创建一个惟一的文件(例如,结合主机名和 pid),使用 link(2) 来创建一个指向锁文件的链接,使用 stat(2) 来检查该惟一文件的链接计数器是否增加到了 2。不要使用 link(2) 调用的返回值。”
如果您使用文件来表示锁,那么要确保这些锁文件放置在攻击者无法利用(例如,不能删除它们或者添加干扰它们的文件)的位置。典型的解决方案是使用一个目录,使该目录的权限根本不允许未经授权的程序添加或者删除文件。确保只有您可以信任的程序才能添加或者删除锁文件!
文件系统层次结构标准(Filesystem Hierarchy Standard,FHS)得到了 Linux 系统的广泛使用,同时还引入了这类锁文件的标准约定。如果您只是希望确保您的服务器在一台给定的机器上运行不超过一次,那么您通常应该创建一个名为 /var/run/NAME.pid 的进程标识符,以进程 id 作为文件内容。根据同样的思路,您应该将设备锁文件之类的锁文件放置在 /var/lock 中。
锁文件的代替者
使用单独的文件来表示锁是一个非常古老的方法。另一个方法是使用 POSIX 记录锁(record locks),它通过 fcntl(2) 实现为一个任意的锁。采用 POSIX 记录锁的理由有很多:POSIX 记录锁在几乎所有的类 Unix 平台上都获得了支持(