安全的程序必须最小化特权,以降低 bug 转化为安全缺陷的可能性。本文讨论了如何通过最小化有特权的模块、授与的特权以及特权的有效时间来最小化特权。
文章不仅讨论了一些传统的类 UNIX 特权机制,还讨论了较新的机制,如 FreeBSD 的 jail(),Linux 安全模块(Linux Security Modules,LSM)框架,以及 Security-Enhanced Linux(SELinux)。
2003 年 3 月 3 日,Internet Security Systems 对 Sendmail 中的一个严重的漏洞提出了警告。所有的电子邮件都通过邮件传输代理(mail transfer agent,MTA)来传输,Sendmail 则是最流行的 MTA,所以这个警告影响了世界范围内的很多组织。问题在于,按通常的配置,精心设置了“from”、 “to”或者“cc”域的电子邮件消息可以让发送者完全(root)控制任何一台运行着 Sendmail 的机器。更严重的是,一般的防火墙将 不能保护其内部的机器免受这种攻击。
造成这一漏洞的直接原因是,Sendmail 的一个安全检测是有缺陷的,可以发生缓冲区溢出。不过,一个重要的作用因素是,Sendmail 经常被安装为一个单一的“setuid root”程序,对运行它的系统有完全的控制权限。这样,Sendmail 中的任何缺陷都可以让攻击者直接控制整个系统。
这个设计是必须的吗?不是;Wietse Venema 的 Postfix 是一个常见的可以与之匹敌的 MTA。类似于 Sendmail,Postfix 会去做很多安全检测,不过,为了 最小化特权,Postfix 设计为一组模块。结果,Postfix 通常被认为是比 Sendmail 更安全的程序。本文讨论了如何最小化特权,您可以将同样的思想应用到您的程序中。
最小化特权的基础
实际应用的程序会有缺陷。不是我们希望那样,但是确实是有。复杂的需求、日程的压力和环境的变化使得不太可能得到实用的无缺陷的程序。甚至那些通过复杂而且精确的技术正式地证明是正确的程序,也会有缺陷。为什么?其中一个原因是,验证必须做很多假设,而且通常这些假设并不是完全正确。无论如何,出于种种原因,大部分程序没有得到严格的检验。而且,即使今天没有任何缺陷(不太可能),日后维护的改变或者环境的改变都可能会引入缺陷。所以,要处理实际的问题,我们不得不以某种方式来开发安全的程序, 尽管 我们的程序中有缺陷。
尽管有这些缺陷,对安全编程来说最重要的方法是 最小化特权。特权只是允许去做并不是 每个人 都可以做的事情。在类 UNIX 系统中,拥有“root”用户、其他用户或者一个组的成员的特权是最常见的特权种类。一些系统让您可以授与读或写特定文件的特权。不过,不管怎么样,要最小化特权:
只为程序中需要特权的部分授与特权
只授与部分绝对需要的具体特权
将特权的有效时间或者可以有效的时间限制到绝对最小
这些其实是目标,不是绝对的。您的基础组织(比如您的操作系统或者虚拟机)可能使得严格完成这些并不容易,或者严格完成这些可能会很复杂,而导致在尝试严格完成时引入更多缺陷。但是,你距离这些目标越近,缺陷导致安全问题的可能性就越低。即使缺陷导致了安全问题,它导致的安全问题的严重性可能会更低。而且,如果您可以确保只有小部分程序拥有特定的特权,您就可以用大量额外的时间来确保 那 一部分能抵御攻击。这个思想并不新; Saltzer 和 Schroeder 的优秀的 1975 论文讨论了安全的原理,明确地将最小化特权作为一个原则(查看 参考资料)。有一些思想是永恒的,比如最小化特权。
接下来的三节将依次讨论这些目标,包括如何在类 UNIX 系统中实现他们。然后,我们将讨论 FreeBSD 和 Linux 中可用的一些特别的机制,包括对 NSA 的 Security-Enhanced Linux(SELinux)的讨论。
最小化有特权的模块
如前所述,只有需要特权的部分程序才应用拥有特权。这就是说,当您设计您的程序时,尽量将程序分解为独立的部分,以使得只有小而独立的部分需要特定的特权。
如果不同的部分必须同时运行,那么在类 UNIX 系统中使用进程(不是线程)。线程共享它们的安全特权,有问题的线程可能会干扰进程中所有其他线程。编写有特权的部分时,就当作其它的程序正在攻击它:某一天会可能!确保有特权的部分只去做尽可能少的事情;受限的功能意味着更不易被利用。
一个通常的方法是,创建功能极度受限的拥有特定特权(比如是 setuid 或者 setgid)命令行工具。UNIX 的 passwd 命令就是一个例子;它是一个具有特定特权的命令行工具,用于修改密码(setuid root),但是它所能做的只是修改密码。于是各种 GUI 工具可以要求 passwd 来做实际的更改。如果有可能,尽量完全避免创建 setuid 或 setgid 程序,因为很难确保您正在真正保护所有输入。不过,有时您需要创建 setuid/setgid 程序,所以,当需要时,尽可能使程序最小且最受限制。
有很多其他的方法。例如,您可以有一个具有特定特权的小的“服务器(server)”进程;那个服务器只允许特定的请求,而且只是在确认请求者被允许发出请求之后。另一个常见的方法是,使用特权启动一个程序,这个程序然后派生放弃所有特权的第二个进程,而由这个进程来做大部分工作。
要当心这些模块彼此之间如何通信。在很多类 UNIX 系统上,命令行值和环境可以被其他用户看到,所以不是在进程间保密地发送数据的好办法。管道可以胜任,但是要细心地避免死锁(一个两端都可以刷新的简单的请求/响应协议就可以胜任)。
最小化授与的特权
确保您只授与特权给确实需要的程序——到此为止。UNIX 进行获得特权的主要途径是它们以哪个用户或组的身份运行。通常,进程以使用它们的用户和组身份运行,不过,“setuid” 或 “setgid” 的程序会获得拥有这个程序的用户或组的特权。
悲哀的是,还是有一些不自主地给予程序“setuid root”特权的类 UNIX 系统上的开发者。这些开发者认为他们使得事情对自己来说变得“容易”,因为他们不必再去深入考虑他们的程序确切需要什么特权。问题是,由于这些程序程序可以在大部分类 UNIX 系统上做差不多所有的事情,所以任何一个缺陷都可以很快成为一个安全灾难。
不要只是因为您需要完成一个简单的任务就给出所有可能的特权。而应该只给予程序它们所需要的特权。如果您可以,以 setgid 来运行它们,而不要用 setuid——setgid 给予的特权更少。创建特定的用户和组(不要使用 root),并根据您的需要使用它们。确保 root 所拥有的那些可执行程序只能由 root 来写,这样其他人就不能修改它们。设置非常严格的文件权限——如果不是绝对需要,不要让所有人都可以读或写文件,并且使用那些特定的用户和组。能说明所有这些的一个例子可能是游戏“top ten”分数的标准惯例。很多程序都是“setgid games”,以使得只有游戏程序可以修改“top ten”分数,而且存储这些分数的文件的主人是 games 组(而且只有这个组可以写)。即使攻击者攻击并进入了一个游戏程序,所有他能做的事情将是修改分数文件。无论如何,游戏开发者还是需要编写他们的程序来防止恶意的分数文件。
chroot() 系统调用是一个实用的工具——不幸的是有一些难用。当进程查看文件系统的 “root”时,这个系统调用会修改进程所看到的内容。如果您计划用它——而且它可能是实用的——要准备好花一些时间来用好它。必须精心准备 “new root”,这是复杂的,因为确切的应用程序依赖于平台和应用程序的特性。您 必须 以 root 身份来进行 chroot() 调用,而且您 应该快速地 改变为非 root 身份(root 用户可以脱离 chroot 环境,所以如果它要生效,您需要解除那个特权)。而且 chroot 不会改变网络访问。这可以是一个实用的系统调用,所有有时候需要考虑它,但是要做好付出努力的准备。
限制资源是一个经常被遗忘的工具,这既包括存储的资源也包括进程的资源。这些限制拒绝服务攻击尤其有用:
对存储来说,您可以为每个用户或组设置每个挂载的文件系统的存储量或文件数的配额(限定)。在 GNU/Linux 系统中查看 quota(1)、quotactl(2) 和 quotaon(8) 来深入了解这一功能,不过,尽管它们不是哪里都能用,大部分类 UNIX 系统都包含了 quota 系统。在 GNU/Linux 和很多其他系统中,您可以设置“硬”界限(永远不能超出)和“软”界限(可以临时超出)。
对进程来说,您可以设置很多限定,比如打开文件的数目、进程的数目,等等。这种能力实际上是标准的一部分(比如单一 UNIX 规范(Single UNIX Specification)),所有它们在类 UNIX 系统上几乎普遍存在;要深入了解,请查看 getrlimit(2)、 setrlimit(2) 以及 getrusage(2)、sysconf(3) 和 ulimit(1)。进程永远不能超出“当前界限”,但是它们可以将当前界限一路上升到“上限”。不幸的是,这里有一个不合常理的术语问题可能会使您迷惑。“当前界限”也被称为“软”界限,上限也称为 “硬”界限。这样,您就会处在一个异乎寻常的情形,进程 永远 不能超出进程界限的软(当前)界限——而对于 quota 来说您 可以 超出软界限。我建议为进程界限使用术语“当前界限”和“上限”(永远不要使用术语“软”和“硬”),那样就没有任何迷惑了。
最小化特权的时间
只是当需要的时候才给予特权——片刻也不要多给。
只要可能,使用无论什么您立即需要的特权,然后 永久地 放弃它们。一旦它们被永久放弃,后来的攻击者就不能以其他方式利用那些特权。例如,需要个别的 root 特权的程序可能以 root 身份启动(比如说,通过成为 setuid root)然后切换到以较少特权用户身份运行。这是很多 Internet 服务器(包括 Apache Web 服务器)所采用的方法。类 UNIX 系统不允许任何程序打开 0 到 1024 TCP/IP 端口;您必须拥有 root 特权。但是大部分服务器只是在启动的时候需要打开端口,以后就再也不需要特权了。一个方法是以 root 身份运行,尽可能快地打开需要特权的端口,然后永久去除 root 特权(包括进程所属的任何有特权的组)。也要尝试去除所有其他继承而来的特权;例如,尽快关闭需要特定的特权才能打开的文件。
如果您不能永久地放弃特权,那么您至少可以尽可能经常临时去除特权。这不如永久地去除特权好,因为如果攻击者可以控制您的程序,攻击者就可以重新启用特权并利用它。尽管如此,还是值得去做。
很多攻击只有在它们欺骗有特权的程序做一些计划外的事情而且程序的特权被启用时才会成功(例如,通过创建不合常理的符号链接和硬链接)。如果程序通常不启用它的特权,那么攻击者想利用这个程序就会更困难。
较新的机制
到目前为止,我们所讨论的原则实际上适用于几乎所有操作系统,而且,自 19 世纪 70 年代以来,几乎所有的类 UNIX 系统的常规机制都是类似的。那并不是说它们没有用处;简单和时间的检验是它们本身的优势。不过,一些较新的类 UNIX 系统已经增加了支持最少权限的机制,值得去了解。虽然很容易找出经过时间检验的机制,可是关于较新的机制的资料还没有广为人知。所以,在这里我将讨论选出的一些有价值的机制:FreeBSD jail() 、Linux 安全模块(LSM)框架和 Security-Enhanced Linux(SELinux)。
FreeBSD jail()
chroot() 系统调用有很多问题,如前所述。例如,它难以正确使用,root 用户还是可以从中脱离,而且它根本不去控制网络访问。FreeBSD 开发者决定增加一个新的系统调用来解决这些问题,这个新的系统调用叫做 jail() 。这个调用类似于 chroot() ,不过尽力更易用且更用效。在一个 jail 中,所有的请求(即使是 root 的)都被 jail 所限,进程只能与 jail 中的其他进程通信,而且系统封锁了 root 用户试图从 jail 中脱离的典型途径。jail 会被分配一个特定的 IP 地址,不能使用任何其他地址作为它自己的地址。
jail() 调用是 FreeBSD 所独有的,这就限制了它的效用。不过,各个 OSS/FS 内核之间有很多交叉影响(cross-pollination)。例如,已经使用 Linux 安全框架为 Linux 开发了一个 jail 版本。而且, FreeBSD 5 已经添加了一个灵活的 MAC 框架(来自 TrustedBSD 项目),包括一个具有类似 SELinux 基本功能的模块。所有,将来看到更多这种情况不要感到奇怪。
Linux 安全模块(LSM)
在 2001 年的 Linux Kernel Summit 上,Linus Torvalds 遇到了一个问题。一些不同的安全项目,包括 Security-Enhanced Linux(SELinux)项目,要求他将他们的安全方法添加到 Linux 内核中。问题是,这些不同的方法常常是不兼容的。 Torvalds 没有简单的方法可以判定哪个是最好的,所以他要求那些项目为 Linux 合作创建某种通用的安全框架。那样,管理员就可以给他们特别的系统安装任意他们想要的安全方法。与 Torvalds 讨论了几次以后,Crispin Cowan 建立了一个小组来创建这个通用的安全框架。这个框架被命名为 Linux 安全模块(LSM)框架,现在是标准 Linux 内核的一部分(如 2.6 版本内核)。
概念上讲,LSM 框架特别简单。Linux 内核仍去做它常规的安全检测;例如,如果您要写一个文件,您仍需要对其有写权限。
不过,不论何时如果 Linux 内核需要判定是否应该准许访问,它还要进行核对——通过一个“book”去要求一个安全模块来进行—— 来确定动作是否得到准许。这样,管理员可以简单地选出他想要使用的安全模块,并像其他 Linux 内核模块一样将其插入。从那时以后,那个安全模块将判定什么是允许的。
LSM 框架设计得如此灵活,它可以实现很多不同种类的安全策略。实际上,一些不同的项目进行合作以确保 LSM 框架足以胜任真正的工作。例如,当内部对象被创建或被删除时 LSM 引入一些调用——不是因为那些操作可能会中止,而是让安全模块可以保持对重要数据的追踪。使用了一些不同的分析工具来确保 LSM 框架不会遗漏其目标的任何重要异常分支。结果证明,这个项目比很多人想像的要困难,它的成功是来之不易的。
有必要理解 LSM 所做的基本的设计决定。基本上,LSM 框架故意设计为几乎所有异常分支都是受限的,而不是可信的(authoritative)。一个 可信的 异常分支做出绝对最终的决定:如果异常分支认为一个请求应该被准许,那么它就会被无条件准许。相反, 可信的 异常分支只能增加另外的限制;它不能授与新权限。理论上,如果所有 LSM 异常分支都是可信的,LSM 框架将会更加灵活。有一个名为 capable() 的异常分支是可信任的——但是只是因为它不得不支持常规的 POSIX 能力。不过,要让 所有 异常分支都可信,就要对 Linux 内核进行很多根本的改变,还说不准这种改变是不是会被接受。
有很多人担心,如果大部分异常分支都可信,即使是最小的缺陷也将成为灾难;而让异常分支受限意味着用户将不会感到意外(不管怎样,原来的 UNIX 权限仍正常工作)。所以 LSM 框架开发者有意选择了限制方法,而且它的大部分开发者自信他们可以在框架内工作。
理解 LSM 框架的其他限制也是重要的。LSM 框架设计只是用来支持访问控制,不是审计等其他安全问题。 LSM 模块本身不能记录所有请求或它们的结果,因为它们不能看到全部。为什么?一个原因是,内核可能没有调用 LSM 模块就拒绝了请求;如果您想审计这个拒绝就会有问题。还有,出于性能的考虑,有一些提议的用于网络的 LSM 异常分支和数据域没有被主线内核所采用。它可以控制一些网络访问,但不足以支持“labelled”网络数据流(在这种情况下,不同的数据包有不同的由操作系统处理的安全标签)。这些都是不合适的限制,也不符合一般思想的基本原则;LSM 框架有希望终有一天得到扩展以破除这些限制。
尽管如此,即使有这些限制,LSM 框架对给特权添加限制来说仍是非常实用。Torvalds 的目标由 LSM 框架根本上实现了:“我不喜欢在不同的安全人群之间斗争。我希望是间接的,让 我跳出这场斗争,然后市场斗争就可以决定哪个策略和实现最终得到 应用。”
所以,如果您想在 Linux 上限制授与您的程序的特权,您可以创建完全您自己的安全模块。如果您利用真正外来的限制,可能会需要那样做——幸好这是可能的。不过,这很重要;不管怎样,您还是要编写内核代码。如果可能,您最好不要使用已有的 Linux 安全模块,而是尝试去编写自己安全模块。有一些可用的 LSM 模块,不过,Security-Enhanced Linux (SELinux)是最成熟的 Linux 安全模块之一,所以让我们来研究这个模块。
Security-Enhanced Linux(SELinux)的历史
一个小历史将有助于帮助您理解 Security-Enhanced Linux(SELinux)——而且它本身也是段有趣的历史。美国国家安全局(National Security Agency,NSA)长时间以来就关注大部分操作系统中受限的安全能力。毕竟,他们的工作之一就是要确保美国国防部使用的计算机在面临没完没了的攻击时保持安全。NSA 发现大部分操作系统的安全机制,包括 Windows 和大部分 UNIX 和 Linux 系统,只实现了“选择性访问控制(discretionary access control)”(DAC)机制。DAC 机制只是根据运行程序的用户的身份和文件等对象的所有者来决定程序可以做什么。NSA 认为这是一个严重的问题,因为 DAC 本身对脆弱的或恶意的程序来说是一个不合格的防护者。取而代之的,NSA 长期以来一直希望操作系统同样能支持“强制访问控制(mandatory access control)”(MAC)机制。
MAC 机制使得系统管理员可以定义整个系统的安全策略,这个策略可以基于其他因素,像是用户的角色、程序的可信性及预期使用、程序将要使用的数据的类型等等,来限制程序可以做哪些事情。一个小例子,有了 MAC 后用户不能轻易地将“保密的(Secret)”数据转化为“不保密的(Unclassified)”的数据。不过,MAC 实际上可以做的比那要多得多。
NSA 已经与操作系统提供商合作了多年,但是很多占有最大市场的提供商对于将 MAC 集成进来没有兴趣。即使是那些集成了 MAC 的提供商也通常是将其做为“单独的产品”,而不是常规产品。一部分原因只是因为旧式的 MAC 不够灵活。
于是 NSA 的研究力量尽力去使 MAC 更灵活并且并容易被包含在操作系统中。他们使用 Mach 操作系统开发了他们的思想的原型,后来发起的工作扩展了“Fluke”研究操作系统。
不过,难以让人们信服这些思想可以适用于 “真实的”操作系统 ,因为所有这些工作都基于微型的“玩具级的”研究项目。极少可以在原型之外进行尝试以查看这些思想在真实的应用程序中工作得如何。NSA 不能说服具有所有权的提供商来添加这些思想,而且 NSA 也没有权利去修改私有的操作系统。这不是个新问题;多年前 DARPA 试图强制它的操作系统研究人员使用私有的操作系统 Windows,但遇到了很多问题(参见下面的 参考资料)。
于是,NSA 偶然发现了一个回想起来似乎显而易见的想法:使用一个 不是 玩具的开放源代码操作系统,并实现他们的安全思想,以显示(1)它可以工作,(2)它具体如何工作(通过为所有人提供源代码)。他们选择了主导市场的开放源代码内核(Linux)并在其中实现了他们的思想,即“security-enhanced Linux”(SELinux)。毫无意外,使用真正的系统(Linux)让 NSA 研究人员可以处理他们在玩具中无法处理的问题。例如,在大部分基于 Linux 的系统中,几乎所有都是动态链接的,所以他们不得不做一些关于程序如何执行的深入分析(查阅他们关于“entrypoint”和 “execute”权限的文档以获得更多资料)。这是一个更为成功的方法;正在使用 SELinux 的人比使用先前的原型的人多得多。
SELinux 如何工作
那么,SELinux 如何工作呢?SELinux 的方法实际上非常普通。每一个重要的内核对象,比如每个文件系统对象和每个进程,都有一个关联到它们的“安全上下文(security context)”。安全上下文可以基于军事安全层级(如不保密的、保密的和高度保密的)、基于用户角色、基于应用程序(这样,一个 Web 服务器可以拥有它自己的安全上下文),或者基于很多其他内容。当它执行另一个程序时,进程的安全上下文可以改变。甚至,取决于调用它的程序,一个给定的程序可以在不同的安全上下文中运行,即使是同一个用户启动了所有程序。
然后系统管理员就可以创建一个指定哪些特权授与哪个安全上下文的“安全策略(security policy)”。当发生系统调用时,SELinux 去检查是否所有需要的特权都已经授与了——如果没有,它就拒绝那个请求。
例如,要创建一个文件,当前进程的安全上下文必须对父目录的安全上下文的“搜索(search)”和“add_name”特权,而且它需要有对于(要创建的)文件的安全上下文的“创建(create)”特权。同样,那个文件的安全上下文必须有特权与文件系统“关联(associated)”(所以,举例来说,“高度保密”的文件不能写到一个“不保密”的磁盘)。还有用于套接字、网络接口、主机和端口的网络访问控制。如果安全策略为那些全部授与了权限,那么请求就会被 SELinux 所允许。否则,就会被禁止。如果按部就班地去做所有这些检查将会较慢,不过有很多优化方案(基于多年的研究)使其变得很迅速。
这一检查完全独立于类 UNIX 系统中的通常的权限位;在 SELinux 系统中,您必须 既 有标准的类 UNIX 权限, 又 有 SELinux 权限才能去做一些事情。不过,SELinux 检查可以做很多对传统的类 UNIX 权限来说难以完成的事情。
使用 SELinux,您可以方便地创建一个只能运行特定程序并且只能在特定的上下文中写文件的 Web 服务器。更有趣的是,如果一个攻击者攻入了 Web 服务器并成为 root,攻击者不会获得整个系统的控制权——如果有一个好的安全策略的话。
那就有了困难:为了使 SELinux 有效,您需要有一个好的安全策略来由 SELinux 执行。大部分用户将需要一个他们容易修改的实用的初始策略。几年前我开始体验 SELinux;那时,初始策略还不成熟,有很多问题。例如,在那些以前的日子中我发现早期的样例策略不允许系统更新硬件时钟(最后我提交了一个补丁以解决这一问题)。设计好的初始安全策略类似对产品分类, NSA 希望由商业界来做,而且看起来是要这样做。Red Hat、一些 Debian 开发者、Gentoo 以及其他人正在使用基本的 SELinux 框架,并且正在创建初始安全策略,这样用户可以马上开始使用它。的确,Red Hat 计划为所有用户在他们的 Fedora 内核中都启用 SELinux,并提供简单的工具来使得非专业用户可以通过选择一些常见选项来修改他们的安全策略。Gentoo 有一个可引导的 SELinux LiveCD。这些团体将使得最小化程序特权变得更简单,而不需要大量代码。
在这里我们又回到了原处。SELinux 只有当程序执行时才允许发生安全传输,它控制进程的权限(不是一个进程的一部分)。所以,为了充分发挥 SELinux 的潜力,您需要将您的应用程序分解为独立的进程和程序,只有一些小的有特权的组件—— 这恰恰如同如何在没有 SELinux 的情况开发安全的程序。像 SELinux 这样的工具让您可以更好地控制授与的权限,并这样创建一个更强有力的防御,但是,您仍需要将您的程序拆分为更小的组件,以使得那些控制能发挥最大的效用。
结束语
最小化特权是对各种安全问题的最重要防御。由于缺陷是不可避免的,您会希望大大降低缺陷导致安全问题的可能性。不过,至少一个安全的程序的 一些 部分必须有涉及安全的代码,所以您不能只是最小化特权而忽视所有其他。甚至在您已经最小化了那些涉及安全的部分以后,那些部分还是必须是正确的。为了是正确的,您需要避免常见的错误。