避免竞争状态

“竞争状态”可以定义为“对事件相对节奏意外的临界依靠引发的异常行为”[FOLDOC]。竞争状态一般涉及一个或多个进程访问某个共享资源(如某个文件或变量),而这一多重访问没有被适当地控制。

一般来说,进程不是以原子方式运行的,另一个进程甚至可以在任意两条指令之间中断它。如果一个安全程序的进程对这样的中断没有准备,其它进程就能够干扰安全程序的进程。如果其它进程的任意代码在安全程序进程的任意一对操作之间被执行,操作都不应该失败。

竞争状态的问题可以从概念上分为两类:

次序问题

一般来说,必须检查代码中如果任意代码在某对操作之间被执行就可能会失败的任一对操作。

注意,载入和保存共享变量一般是由不同的操作实现的,而且也不是原子操作。这就意味着一个“增加变量”的操作通常会转变为载入、增加和保存操作,所以如果变量是与其它进程共享的,就可能会干扰增加操作。

安全程序必须确定是否同意某个请求,如果是的话,对请求作出反应。一个不可信用户应该无法在程序反应之前改变此决定中用到的任何信息。这种竞争状态有时被称为“检查时间/使用时间”(TOCTOU)竞争状态。

这个问题在文件系统中经常出现。程序一般应该避免使用access(2)来确定是否同意某个请求,并随后使用open(2),因为用户可以在这两个调用之间移动文件,可能建立他们自己用来替代的符号连接或文件。安全程序应该设置自己的有效ID或文件系统ID,然后直接进行open调用。安全地使用access(2)也是可能的,但只有在用户无法影响文件或文件系统根目录下文件所在路径上的任一目录时才是安全的。

例如,在对文件维护信息执行一系列操作(比如改变其所有者、复制文件或改变其许可比特)时,先打开文件,然后对打开的文件执行操作。这就意味着应该使用fchown( )、fstat( )或fchmod( )系统调用,而不要使用chown()、chgrp()和chmod()一类的需获取文件名的函数。这样做可以防止在程序运行过程中文件被替换(一个可能的竞争状态)。例如,如果关闭某个文件,然后使用chmod()改变其许可,攻击者就可以在这两个步骤之间删除文件并建立到另一个文件(如/etc/passwd)的符号连接。其它有趣的文件包括/dev/zero,它可以向程序提供无限长的数据流输入。另外,应该避免使用access( )函数来确定访问文件的能力:使用跟随open( )的access( )函数会出现竞争状态,而且差不多总是个Bug。如果不可信进程可以修改其父进程的相关目录,这样做才是迫不得已的。

特别对于所有用户共享的/tmp和/var/tmp目录会出现这个问题。如果可能应避免使用这些目录及其子目录。特别是想象一下如果用户在任意时刻在打算使用的目录里创建文件(包括符号连接)会出现什么情况(例如,在确定文件名的时刻和试图打开文件的时刻之间)。甚至无法检查给定的文件是否为符号连接;如果某个不可信用户拥有该文件,此用户就可以在检查后改变它。

锁定

经常出现某个程序需要确定对某些资源(例如某个文件、某个设备或某个特别的服务器进程的存在)的独占权。任一锁定资源的的系统都必须处理锁定的标准问题,即死锁(“抱死”)、活锁以及在程序无法清除自己的锁时释放“受困”的锁。如果程序受困于互相等待对方释放资源时,死锁就发生了。例如,如果进程1锁定了资源A并等待资源B,而进程2锁定了资源B并等待资源A,死锁就发生了。简单地要求所有进程按相同顺序锁定资源就可以防止许多死锁(例如必须按字母顺序锁定资源)。

在类Unix系统上,传统上通过创建一个标识锁的文件来实现资源锁定,因为这样移植性很好。这也使“修正”受困锁定变得简单,因为系统管理员可以查看文件系统来发现被设置的锁。受困锁定可以因为程序没有在结束后清除(例如崩溃或发生功能障碍)而出现,也可以因为整个系统崩溃而出现。注意,这些是“报告”(而非“强制”)锁定 -- 所有需要资源的进程都必须合作使用这些锁。

尽管如此,还是需要避免几个陷阱。首先,即使在创建文件时把它设置为独占(O_EXCL)模式(O_EXCL模式在文件已存在的情况下通常会失败),具有root权限的程序还是可以打开该文件。所以,如果想用一个文件来表示锁定,不要使用open(2)和独占模式,在进行这一操作时需要root权限。简单的做法是换用link(2)来创建同一目录下某些文件的硬连接; 如果某个硬连接已经存在的话,即使是root也不能创建它。

其次,如果锁文件位于某个安装成NFS的文件系统上,那么可能会遇到NFS版本2不完全支持普通的文件语法的问题。即使假定对于客户程序是“本地”的工作,这也可能成为一个问题,因为某些客户程序没有本地磁盘,而且可能所有的文件都是通过NFS远程安装的。open(2)手册解释了在这种情况下该如何处理(也可以用来处理root程序):

"......依赖于[open(2)的O_CREAT和O_EXCL标志]来执行锁定任务的程序会包含竞争状态。使用锁文件进行原子文件锁定的解决方案是在同一个文件系统(如包含主机名和pid)上创建一个独特的文件,使用link(2)建立一个到锁文件的连接并用stat(2)检查那个独特文件的连接数是否增加到2。请不要使用link(2)调用的返回值。"

很明显,该解决方案只有在所有操作锁的程序都合作,而且所有非合作的程序都不允许介入的情况下才起作用。特别是用来进行文件锁定的目录对于创建和移动文件必须没有宽松的文件许可。

NFS版本3增加了对open(2)中O_EXCL模式的支持;参见IETF RFC 1813,特别是“CREATE”的“mode”参数的“EXCLUSIVE”值。可惜的是,在写作本文时不是所有人都切换到了NFS版本3,所以对于可移植程序不能依赖这一点。

如果要在本地机器上锁定某个设备或某个已有进程,请尽量使用标准约定。我推荐使用文件系统分级结构标准(FHS);Linux系统已经广泛地参考了该标准,但它还试图结合其它类Unix系统的想法。FHS描述了这些锁定文件的标准约定,包括这些文件的命名、存放和标准内容[FHS 1997]。如果只是想保证在某个给定机器上服务器程序不会执行一次以上,那么通常应该创建一个内容为pid的/var/run/NAME.pid作为进程标识符。与此相同,应该把设备锁文件一类的锁文件放在/var/lock里。这种方案有一个小的不足,就是在程序突然暂停时会使文件挂起,但这种情况是个标准的实际情况,而且很容易用其它系统工具来处理这个问题。

重要的是合作使用文件来代表锁的程序应该使用“相同”目录,而不仅仅是相同的目录名。 这是与网络系统有关的问题:FHS明确地提到/var/run和/var/lock是不可共享的,而/var/mail是可共享的。因此,如果希望一个在单机上工作的锁不与其它机器相互干扰,就应该使用/var/run之类不可共享的目录(例如希望允许每台机器运行自己的服务器程序)。尽管如此,如果希望一个网络上所有共享文件的机器都受锁的控制,就需要使用一个共享的目录;/var/mail就是这样的一个地方。参见FHS第二章以了解有关该主题的更多信息。

当然,没必要一定使用文件来代表锁。 网络服务器通常无需担心这个问题;纯粹的绑定就可以作为一种锁,因为如果某个现存的服务器程序绑定了一个给定端口,其它的服务器程序就不能绑定该端口。

另一个锁定的方案是使用POSIX的记录锁,通过fcntl(2)作为一个“可任意使用的锁”来实现。这些锁是可任意使用的,也就是说,使用时要求需要锁定的程序间的合作(就象用文件代表锁定的方案所做的那样)。 有许多理由推荐使用POSIX记录锁:几乎所有类Unix平台都支持POSIX记录锁定(这是POSIX.1所要求),它可以锁定文件的一部分(而不是整个文件),而且可以处理读锁定和写锁定之间的区别。更有用的是,如果一个进程死掉了,它的锁自动被删除,就像通常所期望的那样。

还可以使用基于System V强制锁定框架的强制锁定。这只适用于锁定文件的setgid比特位被设置而群组执行比特位没有被设置的文件。同样,另外,文件系统安装时还必须允许强制文件锁。在这种情况下,每一次read(2)和write(2)都要检查锁定,这比报告性的锁更彻底,同时也更慢。此外,强制锁无法广泛移植到其它类Unix系统上(可用于 Linux和基于System V的系统,但在其它系统上不是必需的)。注意,具有root权限的进程也可以被强制锁停止,这使得它可以被用作拒绝服务攻击的基础。