From: http://linux.ccidnet.com/art/741/20090701/1815783_1.html
【赛迪网-IT技术报道】了解什么是竞争条件,以及它们为什么会引发安全问题。本文向您展示了如何在类 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 平台上都获得了支持(它得到了 POSIX.1 的授权),它可以锁定文件的一部分(而不是只会锁定整个文件),而且它可以区别处理读锁和写锁的不同之处。此外,如果一个进程死掉,那么它的 POSIX 记录锁就会自动被删除。
只有所有程序都共同合作的时候,使用单独的文件或者 fcntl(2) 任意锁才能生效。如果您不喜欢该思想,那么可以转而使用 System V 风格的强制锁。强制锁允许您锁定一个文件(或者它的一部分),使每一次 read(2) 和 write(2) 都检查锁,任何没有持有该锁的操作都将被挂起,直到该锁被释放为止。这样做可能稍微方便一些,但也有其缺点;拥有 root 特权的进程也可能被强制锁挂起,这样通常容易造成拒绝服务(denial-of-service)攻击。实际上,拒绝服务问题是非常严重的,因此通常要避免使用强制锁。强制锁是可用范围很广,但它不是通用的;Linux 和基于 System V 的系统支持这种锁,但其他的类 Unix 系统不支持它。在 Linux 上,为了启用强制文件锁,必须用特定的方式装配文件系统,因此很多配置在默认情况下不支持强制文件锁。
在一个进程内部,线程可能也同样需要锁;有很多书都非常详细地讨论了这些问题。在这里,我们要讨论的主要问题是确保您小心地涵盖了所有情况;很容易忘记某个特定情形,或者没有正确处理。事实上,正确使用锁是很难的,攻击者可能利用这些锁处理中的错误。如果您需要在一个进程内部对线程使用很多锁,那么可以考虑使用自动完成锁的维护的语言或者语言结构。有很多语言,比如 Java 和 Ada95,都有内置的可以自动处理锁维护(并使结果有可能更正确)的语言结构。
只要有可能,在开发程序时最好根本不使用锁。一个单独的服务器进程每次只接受一个客户机请求,然后处理该请求,直到完成该请求为止,而后再获得下一个请求,从某种意义上讲,进程内部的所有对象是被自动锁定的;这种简单的设计可以避免很多危险的加锁问题。如果您需要一个锁,那么保持其简单性(比如为几乎所有内容都使用惟一的锁)是有好处的。这并不总是实用的,因为这样设计有时会损害性能。具体地说,单服务器系统需要确保无论哪个操作都无法占用过长的时间。但是这个建议是值得考虑的;使用很多锁的系统会更可能有缺陷,而且维护这些锁也会影响性能。
处理文件系统
安全程序的编写必须确保攻击者无法以导致问题的方式利用共享的资源,有时这并不像看起来那样容易办到。文件系统是最常见的共享资源之一。所有的程序都可以共享文件系统,所以,有时需要额外的努力,以确保攻击者不能以引发问题的方式利用文件系统。
有很多确定为安全的程序都存在称为“time of check - time of use”(TOCTOU)的竞争条件缺陷。这只说明了程序检查某种情形是否可行,然后稍后使用那一信息,但是攻击者可能会在这两个步骤之间改变该情形。对文件系统来说,以下问题尤为突出;在这两个步骤之间,攻击者通常可以创建一个普通的文件或者一个符号链接。例如,如果某个已授予特权的程序检查是否不存在给定名称的文件,然后打开该文件写入信息,那么在那两个步骤之间,攻击者可以创建一个使用该名称的符号链接文件... 比如 /etc/passwd 或者其他一些敏感文件。
遵守一些简单的规则,可以避免这些问题:
•不要使用 access(2) 来判定您是否可以做某件事情;通常攻击者会在调用 access(2) 后改变该情形,所以,通过调用 access(2) 获得的任何数据都可能不再是可信任的。换一种方式,将您的程序的特权设置得恰好是您想要的特权(例如,设置它的有效 id、文件系统 id 或者有效 gid,并通过 setgroups 来清除所有不需要的组);然后调用 open(2) 直接打开或创建您需要的文件。在类 Unix 系统上, open(2) 调用是原子的(与以前的 NFS 系统版本 1 和版本 2 不同)。
•当创建一个新文件时,使用 O_CREAT | O_EXCL 模式打开它(确保只有在创建一个新文件时调用 O_EXCL 才会成功)。最初只授与非常有限的权限;至少禁止任意的用户修改它!通常,这表示您需要使用 umask 和/或打开参数,将初始的访问权限局限于用户,也可以局限于用户所在的组。不要尝试在创建完文件后再去减少权限,因为这样做会导致竞争条件。在大部分类 Unix 系统上,只在打开文件时才检查权限,所以,攻击者可以在权限位(permission bit)允许时打开文件,并使该文件一直处于打开状态,不管权限如何改变。如果您愿意,还可以在以后将权限修改得更为开放。您还需要为打开失败做好准备。如果您绝对需要能打开某个新文件,那么应该创建一个循环:(1)创建一个“随机”的文件名,(2)使用 O_CREAT | O_EXCL 选项打开文件,(3)成功打开文件后停止循环。
•当对文件的元信息进行操作时(比如修改它的所有者、对文件进行统计,或者修改它的权限位),首先要打开该文件,然后对打开的文件进行操作。只要有可能,应尽量避免使用获取文件名的操作,而是使用获取文件描述符的操作。这意味着要使用 fchown( )、 fstat( ) 或 fchmod( ) 系统调用,而不使用取得文件名的函数,比如 chown()、 chgrp() 和 chmod()。这样做将避免文件在您的程序运行时被替换(一种可能的竞争条件)。例如,如果您关闭一个文件,然后使用 chmod() 来修改其权限,那么攻击者很可能在这两个步骤之间移动或删除该文件,并创建指向另一个文件(比如 /etc/passwd)的符号链接。
•如果您的程序需要遍历文件系统(递归地遍历子目录),那么要提防攻击者可能会利用您正在遍历的目录结构。这种情形的一个常见的例子是,运行您的程序的管理员、系统程序或者有特权的服务器正在遍历的是由普通用户控制的文件系统部分。GNU 文件实用程序(fileutils)可以完成递归目录删除和目录移动,但是在版本 4.1 之前,当遍历目录结构时,它只是简单的遵循“..”这个特殊条目。当文件被删除时,攻击者可以将一个低层级的目录移动到更高的层级;fileutils 将会遵循“..”目录向上到更高层级,可能会一直到文件系统的根。通过在适当的时间删除目录,攻击者可以删除计算机中的任何文件。您不应该信任“..”或“.”,如果它们是由攻击者控制的。
如果可以,不要将文件放置在可以由不信任用户共享的目录中。如果不是那样,那么应该尽量不使用在用户间共享的目录。不要介意创建只能由受信任的特定进程访问的目录。
考虑避免使用传统的共享目录 /tmp 和 /var/tmp。如果您可以只使用一个管道,将数据从一个位置发送到另一个位置,那么您就可以简化程序,并排除潜在的安全问题。如果您确实需要创建一个临时文件,那么可以考虑将临时文件存储到其他地方。如果您不是在编写一个有特权的程序,那么这点尤其需要考虑;如果您的程序没有特权,那么将临时文件放置在用户目录内部会更安全一些(处理 root 用户时要当心,它以“/”作为其主目录)。这样,即使您没有“正确地”创建临时文件,攻击者通常也无法引发问题(因为攻击者不能利用用户主目录的内容)。
但是,无法总是能够避免使用共享目录,所以我们需要理解如何处理 /tmp 等共享目录。这一点非常复杂,所以它应该自己占用一节!
共享目录(比如 /tmp)
共享目录基本概念
如果您可信任的程序将要与潜在的非信任用户共享一个目录,那么要特别小心。在类 Unix 系统中,最常见的共享目录是 /tmp 和 /var/tmp,对这些目录的错误使用滋生了很多安全缺陷。最初创建 /tmp 目录,是将它作为一个创建临时文件的方便位置,通常不应该与任何其他人共享临时文件。不过,该目录很快它就有了第二个用途 —— 创建用户间共享对象的标准位置。由于这些标准目录有多种用途,使得操作系统难以加强访问控制来防止攻击;因此,您必须正确地使用它们,以避免受到攻击。
当您使用共享目录时,确保目录和文件有适当的权限。显然,您需要限制哪些人可以对共享目录中创建的文件进行读写操作。但是,在类 Unix 系统中,如果多个用户都可以向同一目录添加文件,而且您计划通过一个有特权的程序向该目录添加文件,那么要确保为该目录设置“sticky”位。在一个普通的目录中(没有 sticky 位),任何人对它都有写权限 —— 包括攻击者 —— 可以删除或者重命名文件,导致各种各样的问题。例如,一个可信任的程序可以在这样一个目录下创建一个文件,而一个不受信任的用户可以删除或者重命名它。在类 Unix 系统上,需要设置共享目录的 “sticky”位;在 sticky 目录中,文件只能由 root 或者文件的所有者解除链接或者重新命名。/tmp 和 /var/tmp 目录通常实现为“sticky”目录,以排除一些问题。
程序有时会留下一些没用的临时文件,所以,大部分类 Unix 系统会自动删除特定目录 /tmp 和 /var/tmp 下的原有临时文件(“tmpwatch”程序可以完成这项任务),一些程序会“自动”删除它们所使用的特定临时目录下的文件。这听起来很方便...只可惜攻击者可能会让系统特别繁忙,使 活动文件成为旧文件。结果:系统可能会自动删除正被使用的文件名称。然后会发生什么?攻击者可能会尝试创建他们自己的相同名称的文件,或者至少让系统创建另一个进程,并重新使用相同的文件名称。结果:混乱。这类问题叫做“tmpwatch”问题。解决这种问题的方法是,一旦自动创建了一个临时文件,就必须始终使用打开该文件时得到的文件描述符或文件流。永远不要重新打开文件或者使用任何以文件为参数的操作 —— 始终使用文件描述符或者相关的流,否则,tmpwatch 竞争将引发一些问题。您甚至无法先创建文件、然后关闭它、然后再重新打开它,即使权限已经限制了谁可以打开该文件。
攻击 sticky 目录以及您创建的文件的受限权限只是第一步。在运行安全程序期间,攻击者可能会尝试进行插入操作。常见的一种攻击是,当您的程序正在运行时,在共享目录中创建和反创建指向其他一些文件的符号链接 —— /etc/passwd 或者 /dev/zero 是常见的目标。攻击者的目标是,创造这样一种情形,即让安全程序判定某个给定的文件名并不存在,然后,攻击者就可以创建指向另一个文件的符号链接,而后安全程序继续执行某些操作(但是现在,它打开的实际上是一个意料之外的文件)。重要的文件经常会被这样破坏或更改。这种做法的另一个变种是,创建和反创建攻击者可以进行写操作的普通文件,这样,攻击者有时就可以控制有特权的程序创建的“内部”文件。
在这些共享目录中创建文件时,常遇见的一个问题是,您必须确保您计划使用的文件名在创建时并不存在,然后自动创建该文件。在创建该文件“之前”进行检查没有用,因为在已经进行了检查但还没有创建该文件之前,另一个进程可以使用该文件名创建出这个文件。使用“不可预知的”或者“惟一的”文件名也没有用,因为攻击者可以反复猜测该文件名,直到成功为止。所以,您需要执行一个或者创建一个新文件或者失败的操作 —— 不做其他任何事情。类 Unix 系统可以这样做,但是您需要知道如何要求系统去做。
共享目录的解决方案
不幸的是,有很多并不是解决方案。有一些程序只是直接调用 mktemp(3) 或 tmpnam(3) 来创建临时文件,然后基于这样做会成功的假定去简单地打开它。错误的计划!实际上,线程使用 tmpnam(3) 并不可靠,也不能可靠地处理循环,所以永远不要使用它。1997 年的“Single Unix Specification”推荐使用 tmpfile(3),但不幸的是,在一些老系统上实现它很不安全。
在 C 中,为了在共享(sticky)目录中安全地创建临时文件,通常的解决方案是将 umask() 值设置为一个非常受限制的值,然后反复进行以下操作:(1)创建一个“随机的”文件名,(2)使用 O_CREAT | O_EXCL 选项 open(2) 它(这将自动创建文件,如果没有创建该文件,则操作失败),(3)当成功打开文件时停止重复步骤。
C 程序员实际上不需要直接这样去做;只需要调用库函数 mkstemp(3),就可以打开文件。 mkstemp(3) 的一些实现并没有将 umask(2) 设置为一个受限的值,所以聪明的做法是先调用 umask(2) 来强制将文件设置为受限的值。有一个小麻烦, mkstemp(3) 不直接支持 TMP 或 TMPDIR 环境变量,所以,如果这对您来说是重要的,那么您必须做更多的工作。
当为了安全地打开临时文件而在共享(临时)目录中创建文件系统对象时,GNOME 编程向导推荐您使用下面的 C 代码:
清单 3. 推荐使用的创建临时文件的 C 代码
char *filename; int fd; do { filename = tempnam (NULL, "foo"); fd = open (filename, O_CREAT | O_EXCL | O_TRUNC | O_RDWR, 0600); free (filename); } while (fd == -1); |
注意,尽管使用了不安全的 tempnam(3) 函数,但是它被包装在循环中,并使用了 O_CREAT 和 O_EXCL 选项,从而抵消了它的安全弱点,所以这样做是可以的。一个附带好处是, tempnam(3) 通常使用 TMPDIR,这使得用户可以重定向其临时文件,如果需要的话。注意,您需要 free() 文件名称。完成使用之后,您应该 close() 并 unlink() 文件。这种方法有一个小的缺点,由于可能无法安全使用 tempnam,所以各种编译器和安全扫描器可能都会向您发出使用不合逻辑的警告。使用 mkstemp(3) 就不存在这个问题。
整个这个打开文件的方法展示出了标准的 C IO 库的一个奇特之处:没有标准的方法来指定 O_EXCL 选项使用 fopen(),所以您不能以“普通的” C 方式来打开文件,并安全地创建临时文件。如果您想使用标准 C IO 库,然后使用 open(),那么您可以使用指定“w+b” 模式的 fdopen(),将文件描述符转换为一个 FILE *。
Perl 程序员应该使用 File::Temp,它尝试提供一个安全创建临时文件的跨平台方法。不过,首先要仔细阅读如何正确使用它的文档;它同样有不安全的函数接口。我建议您显式地将 safe_level 设置为 HIGH;这样就会调用附加的安全检查。对大部分编程库来说都是如此;大部分库都既有安全接口,又有不安全接口,所以您需要查阅文档,并确保选择了安全的版本。
注意,在旧版的 NFS(版本 1 或者版本 2)的目录上使用 O_EXCL 是没有用的,因为 NFS 的这些旧版本没有正确地支持 O_EXCL。如果您自己使用了 O_EXCL,而且共享目录是使用这些旧版 NFS 实现的,那么您的程序将是不安全的。实际上,关于旧版 NFS 中 link(2) 和 stat(2) 的使用有一个复杂的解决方法;如果您的程序一定要在这样的环境中工作,那么您可以在 Linux 的 open(2)手册页或者其他地方阅读关于该方法的内容。在这里我不准备对它进行讨论,因为即使 您的程序可以与旧版的 NFS 一起使用,您所使用的很多其他程序也不会使用该解决方法。无论如何您都不可能获得使用 NFS 版本 1 或者版本 2 的临时目录的安全系统,因为其他程序没有使用该解决方法,所以,如果使用远程挂载的临时目录,更明智的做法是要求使用 NFS 版本 3 或更高版本。
您可以尝试使用 mkdtemp(3),但这通常不是一个好主意,因为临时文件清除器(temp cleaners)可能会决定清除它们。
如果您正在编写 shell 脚本,那么可以使用管道,或者在用户的主目录存入临时文件。根本不要使用 /tmp 或 /var/tmp 目录;普通的 shell 通常无法支持文件描述符,所以临时文件清除器(tmpfile cleaners)最终将使它们失败。如果没有临时文件清除器,而且您只是必须在 /tmp 中创建临时文件,那么至少要使用 mktemp(1) 来防止更明显的攻击,因为 mktemp(1)(不是 mktemp(3))将使用 O_EXCL 来防止典型的竞争条件攻击。您可能做的最糟糕事情通常也是最令人不安的事:假定“$$”没有被攻击者猜出来,并且只是将信息重定向到这类文件;那么在创建时就不会按要求使用 O_EXCL 模式。攻击者可以简单地预创建类似的文件,或者反复创建和删除它们,最终接管程序。这样,类似的 shell 脚本几乎肯定有一个严重的缺陷:
清单 4. 有缺陷的 shell 脚本
echo "This is a test" > /tmp/test$$ # DON'T DO THIS. |
不要再次使用临时文件名称(即不要删除和重新创建文件),不管您最初是如何获得“安全的”临时文件名称。攻击者都有可能观察到原始的文件名称,并在您第二次重新创建它时非法控制它。当然,要始终使用合适的文件权限。
做好自己的清理工作,或者通过使用退出处理器,或者利用 UNIX 文件系统语义,在创建后立即 unlink() 该文件,以便在清除目录条目的同时仍然可以访问该文件,直到指向它的最后一个文件描述符被关闭。于是,您就可以在自己的程序中通过传送文件描述符来访问该文件。对代码维护来说,解除文件的链接有很多好处:不管您的程序是怎样崩溃的,文件都会被自动删除。它还降低了维护者不安全地使用文件名的可能性(改为使用文件描述符)。立即解除链接也有一个小问题,即这使得管理员查看磁盘空间使用情况的难度稍有增加。
使这些对策成为操作系统的一部分已经取得了一些成功,尽管它们当前还没有被广为使用。要获得更多资料,请参阅参考资料列表中关于来自 OpenWall 项目的 Solar Designer 的 Linux 内核补丁、RaceGuard,以及 Eugene Tsyrklevich 和 Bennet Yee 的工作链接。
信号处理
在信号中也会发生竞争条件。程序可以注册处理各种类型的信号,但是信号可能会在最不合适的时候出现,包括您正在处理另一个信号的时候。在一个信号处理器内部,您通常应该做的一件事是,设置一个将在以后处理的全局标记。还有几个操作可以在信号中安全地完成,但不是很多,而且在处理信号之前,您必须对它有深入的理解。那是因为只有一些系统调用可以安全地调用内部信号:只有可重入的(re-entrant)或者不被信号中断的调用才可以被安全地调用。您可以调用库函数,但是只有极少数函数是被安全调用的;在一个信号处理器中调用大部分函数是出问题的主要原因,比如 free() 或者 syslog()。要获得更多资料,请阅读 Michal Zalewski 的名为 “Delivering Signals for Fun and Profit”的一篇文章。但是您最好只在一个信号处理器中设置标记(别的什么都不做),这胜过尝试创建复杂的处理器。
结束语
本文讨论了什么是竞争条件,以及它为什么会导致安全问题。我们已经分析了如何正确地创建锁文件及其替代者。还研究了如何处理文件系统,并重点讨论了如何处理共享目录,以完成在 /tmp 目录下创建临时文件等一些常见任务。我们还简要地研究了信号处理,至少充分了解了使用它们的一种安全方法。
当然,重要的是保护您的程序不受竞争条件的破坏。但是,当前的大部分程序都不能自己完成所有的事情;它们必须向其他程序库和程序(比如命令解释程序和 SQL 服务器)发出请求。攻击程序使用的最常见方式之一就是利用这些程序向其他程序发出请求的方式。所以,接下来我们将分析如何在不暴露缺陷的同时去调用其他程序。