安全编程:避免竞争条件资源争用可能对您不利DavidA.Wheeler专职研究员,InstituteforDefenseAnalyses简介:了解什么是竞争条件,以及它们为什么会引发安全问题。本文向您展示了如何在类UNIX®(Unix-like)系统中处理常见的竞争条件,包括如何正确地创建锁文件、锁文件的替代者,如何处理文件系统,以及如何处理共享目录(特别是如何在/tmp目录下正确创建临时目录)。需要您对信号处理稍有了解。标记本文!发布日期:2004年10月25日级别:初级访问情况1385次浏览建议:0(添加评论)平均分(共0个评分)通过一个偷窃而来的口令,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”是攻击者的一个惯用名。但是这类攻击,以及它所利用的缺陷,都极其常见。问题是很多程序都容易受到名为“竞争条件”的安全问题的影响。当由于事件次序异常而造成对同一资源的竞争,从而导致程序无法正常运行时,就会出现“竞争条件”。注意,竞争条件无需介入同一程序的两个部分之间的竞争;如果一个外部的攻击者可以通过意想不到的方式干扰程序,那么就会出现很多安全问题。例如,如果Tripwire2.3.0确定某个文件不存在,它就会尝试着创建该文件,而不去考虑在进行这两个步骤期间,该文件是否已经被攻击者创建。几十年前,竞争条件还不是什么问题;那时,计算机系统通常在同一时刻只能运行一个单独的程序,什么都不能打断它或者与它竞争。但是,当今的计算机通常需要同时运行大量的进程和线程,经常还会有多个处理器确实在同时运行不同的程序。这样做更灵活,但是有一个危险:如果这些进程和线程共享了所有的资源,那么它们都可能互相影响。实际上,竞争条件缺陷是软件的更常见缺陷之一,此外,在类Unix系统上,/tmp和/var/tmp目录经常会被错误地使用,从而导致竞争条件。不过,我们首先需要了解一些术语。所有类-Unix系统都支持用户进程;每个进程都有自己的内存空间(其他进程通常无法访问)。底层的内核会尽量使进程看起来像是在同时运行;在多处理器的系统中,它们确实可以同时运行。从理论上讲,一个进程可以拥有一个或多个线程;这些线程可以共享内存。线程也可以同时运行。由于线程可以共享内存,所以,相对于进程,线程之间更有可能产生竞争条件;正是由于这个原因,多线程程序的调试要困难得多。Linux内核有一个非常好的基本设计:只有线程,并且一些线程可以与其他线程共享内存(这样实现了传统的线程),而另外一些线程则不能(这样就实现了独立进程)。为了理解竞争条件,让我们首先来看一个非常普通的C声明:清单1.普通的C声明b=b+1;看起来非常简单,不是吗?但是,让我们假定有两个线程在运行这一行代码,在这里,“b”是一个由两个线程共享的变量,“b”的初始值为“5”。以下是一个似是而非的执行次序:清单2.使用共享的“b”的可能执行次序(thread1)loadbintosomeregisterinthread1.(thread2)loadbintosomeregisterinthread2.(thread1)add1tothread1'sregister,computing6.(thread2)add1tothread2'sregister,computing6.(thread1)storetheregistervalue(6)tob.(thread2)storetheregistervalue(6)tob.初始值为5,然后两个线程分别加1,但是最终的结果是6...而不是应该得到的7。问题在于,两个线程互相干扰,从而导致产生错误的最终答案。通常,线程不是以原子的方式执行的;另一个线程可以在任何两个指令期间打断它,而且还可以使用一些共享的资源。如果一个安全程序的线程没有预防这些中断,那么另一个线程就可以干扰该安全程序的线程。在安全程序中,不管在任何一对指令中间运行了多少其他线程的代码,程序都必须正确地运行。关键是,当您的程序访问任意资源时,要确定其他某个线程是否可能因为使用该资源对您的程序造成干扰。解决竞争条件竞争条件的典型解决方案是,确保程序在使用某个资源(比如文件、设备、对象或者变量)时,拥有自己的专有权。获得某个资源的专有权的过程称为加锁。锁不太容易处理。死锁(“抱死,deadlyembrace”)是常见的问题,在这种情形下,程序会因等待对方释放被加锁的资源而无法继续运行。要求所有线程都必须按照相同的顺序(比如,按字母排序,或者从“largestgrain”到“smallestgrain”的顺序)获得锁,这样可以避免大部分死锁。另一个常见问题是活锁(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)调用的返回值。”如果您使用文件来表示锁,那么要确保这些锁文件放置在攻击者无法利用(例如,不能删除它们或者添加干扰它们的文件)的位置。典型的解决方案是使用一个目录,使该目录的权限根本不允许未经授权的程序添加或者删除文件。确保只有您可以信任的程序才能添加或者删除锁文件!文件系统层次结构标准(FilesystemHierarchyStandard,FHS)得到了Linux系统的广泛使用,同时还引入了这类锁文件的标准约定。如果您只是希望确保您的服务器在一台给定的机器上运行不超过一次,那么您通常应该创建一个名为/var/run/NAME.pid的进程标识符,以进程id作为文件内容。根据同样的思路,您应该将设备锁文件之类的锁文件放置在/var/lock中。回页首锁文件的代替者使用单独的文件来表示锁是一个非常古老的方法。另一个方法是使用POSIX记录锁(recordlocks),它通过fcntl(2)实现为一个任意的锁。采用POSIX记录锁的理由有很多:POSIX记录锁在几乎所有的类Unix平台上都获得了支持(它得到了POSIX.1的授权),它可以锁定文件的一部分(而不是只会锁定整个文件),而且它可以区别处理读锁和写锁的不同之处。此外,如果一个进程死掉,那么它的POSIX记录锁就会自动被删除。只有所有程序都共同合作的时候,使用单独的文件或者fcntl(2)任意锁才能生效。如果您不喜欢该思想,那么可以转而使用SystemV风格的强制锁。强制锁允许您锁定一个文件(或者它的一部分),使每一次read(2)和write(2)都检查锁,任何没有持有该锁的操作都将被挂起,直到该锁被释放为止。这样做可能稍微方便一些,但也有其缺点;拥有root特权的进程也可能被强制锁挂起,这样通常容易造成拒绝服务(denial-of-service)攻击。实际上,拒绝服务问题是非常严重的,因此通常要避免使用强制锁。强制锁是可用范围很广,但它不是通用的;Linux和基于SystemV的系统支持这种锁,但其他的类Unix系统不支持它。在Linux上,为了启用强制文件锁,必须用特定的方式装配文件系统,因此很多配置在默认情况下不支持强制文件锁。在一个进程内部,线程可能也同样需要锁;有很多书都非常详细地讨论了这些问题。在这里,我们要讨论的主要问题是确保您小心地涵盖了所有情况;很容易忘记某个特定情形,或者没有正确处理。事实上,正确使用锁是很难的,攻击者可能利用这些锁处理中的错误。如果您需要在一个进程内部对线程使用很多锁,那么可以考虑使用自动完成锁的维护的语言或者语言结构。有很多语言,比如Java和Ada95,都有内置的可以自动处理锁维护(并使结果有可能更正确)的语言结构。只要有可能,在开发程序时最好根本不使用锁。一个单独的服务器进程每次只接受一个客户机请求,然后处理该请求,直到完成该请求为止,而后再获得下一个请求,从某种意义上讲,进程内部的所有对象是被自动锁定的;这种简单的设计可以避免很多