第 3 章 安全编程
3.1. 概要
本章描述了一些困扰 UNIX® 程序员数十年的安全问题,以及一些帮助程序员避免编写可被利用代码的新工具。
3.2. 安全设计方法论
编写安全的应用程序需要一种极其谨慎和悲观的世界观。应用程序应遵循“最小权限”原则运行,使得任何进程运行时都不拥有超过其完成功能所需的最小访问权限。应尽可能重用之前经过测试的代码,以避免他人已经修复过的常见错误。
UNIX® 环境的一个陷阱是,很容易对环境的健全性做出假设。应用程序绝不应信任用户输入(无论以何种形式)、系统资源、进程间通信或事件的时序。UNIX® 进程不是同步执行的,因此逻辑操作很少是原子的。
3.3. 缓冲区溢出
缓冲区溢出从冯·诺依曼架构诞生之初便已存在。它们在 1988 年因 Morris Internet 蠕虫而首次广泛引起关注。不幸的是,这种基本攻击方式至今仍然有效。迄今为止,最常见的缓冲区溢出攻击类型是基于破坏栈的攻击。
大多数现代计算机系统使用栈来向过程传递参数并存储局部变量。栈是一种先进后出(LIFO)的缓冲区,位于进程映像的高地址内存区域。当程序调用一个函数时,会创建一个新的“栈帧”。这个栈帧由传递给函数的参数以及一块动态大小的局部变量空间组成。“栈指针”是一个寄存器,保存当前栈顶的位置。由于这个值在新数据不断压入栈顶时不断变化,许多实现还提供一个“帧指针”,它位于栈帧的开头附近,以便更容易以它为基准寻址局部变量。函数调用的返回地址也存储在栈上,这正是栈溢出攻击的根源 —— 因为函数中局部变量的溢出可以覆盖该函数的返回地址,可能允许恶意用户执行任意代码。
虽然基于栈的攻击最为常见,但也有可能通过堆(malloc/free)实现栈的溢出。
C 编程语言不像许多其他语言那样对数组或指针执行自动边界检查。此外,标准 C 库中充斥着一些非常危险的函数:
strcpy
(char *dest, const char *src)
可能溢出 dest 缓冲区
strcat
(char *dest, const char *src)
可能溢出 dest 缓冲区
getwd
(char *buf)
可能溢出 buf 缓冲区
gets
(char *s)
可能溢出 s 缓冲区
[vf]scanf
(const char *format, …)
可能溢出其参数
realpath
(char *path, char resolved_path[])
可能溢出 path 缓冲区
[v]sprintf
(char *str, const char *format, …)
可能溢出 str 缓冲区
3.3.1. 缓冲区溢出示例
我们来看看,如果向这个小程序输入 160 个空格再按回车,这个进程的内存映像会是什么样子。
显然,可以设计出更具恶意的输入来执行实际的已编译指令(例如执行 exec(/bin/sh)
)。
3.3.2. 避免缓冲区溢出
解决栈溢出问题最直接的办法是始终使用带有长度限制的内存和字符串复制函数。strncpy
和 strncat
是标准 C 库的一部分。这些函数接受一个长度参数,该参数应不大于目标缓冲区的大小。这些函数会从源复制最多 “length” 个字节到目标中。但这些函数存在多个问题。若输入数据的长度与目标缓冲区相等,它们都不保证以 NUL 结尾。此外,这两个函数在长度参数上的语义不一致,程序员很容易混淆使用方法。若将短字符串复制到一个大缓冲区,与 strcpy
相比,这些函数的性能也大打折扣,因为 strncpy
会用 NUL 填满指定大小。
为了解决这些问题,出现了另一组内存复制实现:strlcpy
和 strlcat
。这些函数在传入非零长度参数时,始终保证目标字符串以 null 结尾。
3.3.2.1. 基于编译器的运行时边界检查
不幸的是,目前仍有大量代码在不使用任何边界限制复制函数的情况下盲目地进行内存拷贝。幸运的是,存在一种方式可以帮助防止此类攻击 —— 编译器实现的运行时边界检查。
ProPolice 通过在调用函数之前在栈的关键区域放置伪随机数来防止基于栈的缓冲区溢出及其他攻击。当函数返回时,这些“金丝雀值”会被检查,如果发现已被更改,程序会立即中止。因此,任何试图修改返回地址或栈上其他变量以执行恶意代码的行为都极不可能成功,因为攻击者还必须设法保持这些伪随机金丝雀值不被破坏。
使用 ProPolice 重新编译应用程序是防止大多数缓冲区溢出攻击的有效手段,尽管仍有被绕过的可能。
3.3.2.2. 基于库的运行时边界检查
对于无法重新编译的二进制软件,编译器机制完全无效。在这种情况下,有一些库对 C 库中的不安全函数(如 strcpy
、fscanf
、getwd
等)进行了重新实现,并确保这些函数绝不会写越过栈指针。
libsafe
libverify
libparanoia
不幸的是,这些基于库的防护措施存在一些缺陷。这些库仅能防御极少数与安全相关的问题,并未修复实际根本问题。如果应用程序使用了 -fomit-frame-pointer
编译选项,这些防护可能会失效。此外,用户可以覆盖或取消设置 LD_PRELOAD
和 LD_LIBRARY_PATH
环境变量,从而绕过这些防护。
3.4. SetUID 问题
每个进程至少关联有 6 个不同的 ID,因此必须非常谨慎地控制进程在任意时刻所拥有的访问权限。尤其是,所有调用 seteuid
的应用程序应在权限不再需要时立即放弃这些权限。
真实用户 ID 只能由超级用户进程更改。用户初次登录时由登录程序设置此 ID,之后很少再改变。
有效用户 ID 是在程序具有 setuid 位时由 exec()
函数设置的。应用程序可以随时调用 seteuid()
,将有效用户 ID 设置为真实用户 ID 或保存的 set-user-ID。当 exec()
函数设置有效用户 ID 时,先前的值会保存在保存的 set-user-ID 中。
3.5. 限制程序运行环境
限制进程的传统方法是使用 chroot()
系统调用。此调用会改变进程及其所有子进程所引用路径的根目录。要使该调用成功,进程必须对所引用目录具有执行(搜索)权限。新环境实际上要等到调用 chdir()
进入新目录后才会生效。还应注意,如果进程拥有 root 权限,是可以轻易逃出 chroot 环境的。例如可以通过创建设备节点读取内核内存,或将调试器附加到 chroot 环境之外的进程,或用其他多种创造性方式实现逃脱。
可以通过 sysctl
变量 kern.chroot_allow_open_directories
对 chroot()
系统调用的行为进行一定控制。当此值设置为 0 时,如果有任何目录是打开的,chroot()
将以 EPERM 失败。若设置为默认值 1,则在进程已经处于 chroot()
环境下且存在任何打开的目录时,chroot()
将以 EPERM 失败。若设置为其他值,则完全跳过对打开目录的检查。
3.5.1. FreeBSD 的 jail 功能
Jail 的概念在 chroot()
的基础上扩展,通过限制超级用户的权限来创建一个真正的“虚拟服务器”。一旦设置了 jail,所有网络通信都必须通过指定的 IP 地址进行,而该 jail 中的“root 权限”被大大限制。
在 jail 中,任何在内核中使用 suser()
调用进行的超级用户权限测试都会失败。不过,一些对 suser()
的调用已经被替换成了新的接口 suser_xxx()
。这个函数负责识别或拒绝 jail 中进程对超级用户权限的访问。
处于 jail 环境中的超级用户进程仍然拥有以下权限:
使用
setuid
、seteuid
、setgid
、setegid
、setgroups
、setreuid
、setregid
、setlogin
操作凭据;使用
setrlimit
设置资源限制;修改某些 sysctl 节点(如 kern.hostname);
执行
chroot()
;设置 vnode 的标志位:
chflags
、fchflags
;设置 vnode 的属性,如文件权限、所有者、组、大小、访问时间和修改时间;
在 Internet 域中绑定特权端口(端口号小于 1024)。
Jail
是一个非常有用的工具,可以在安全的环境中运行应用程序,但它也有一些不足。目前,IPC 机制尚未转换为 suser_xxx
接口,因此某些应用(如 MySQL)无法在 jail 中运行。虽然 jail 中的超级用户权限被大大限制,但目前还无法精确地定义“限制到什么程度”。
3.5.2. POSIX®·1e 进程能力
POSIX® 已发布一份工作草案,添加了事件审计、访问控制列表、细粒度权限、信息标记和强制访问控制等内容。
3.6. 信任
应用程序永远不应该假设用户环境中的任何内容是可靠的。这包括(但不限于):用户输入、信号、环境变量、资源、IPC、内存映射、文件系统工作目录、文件描述符、打开的文件数量等。
你永远不应假设可以捕获用户可能提供的所有非法输入。相反,应用程序应当使用正向过滤,仅允许你认为安全的特定输入子集。数据验证不当已经成为许多漏洞的根源,尤其是在面向万维网的 CGI 脚本中。对于文件名,应当格外注意路径(如 "../"
、"/"
)、符号链接和 shell 转义字符。
Perl 有一个非常强大的特性叫做 “Taint 模式”,可以防止脚本在不安全的方式下使用来自程序外部的数据。该模式会检查命令行参数、环境变量、本地化信息、某些系统调用的返回值(如 readdir()
、readlink()
、getpwxxx()
)以及所有文件输入。
3.7. 竞争条件
竞争条件是一种由于对事件相对时序的意外依赖而导致的异常行为。换句话说,程序员错误地假设某个事件总是会在另一个事件之前发生。
竞争条件的一些常见原因包括信号、访问检查和文件打开操作。信号本质上是异步事件,因此在处理它们时必须特别小心。使用 access(2)
进行权限检查后再调用 open(2)
显然不是原子操作。用户可能会在两次调用之间移动文件。因此,有特权的应用程序应该先调用 seteuid()
,然后直接使用 open()
。同理,应用程序在调用 open()
前应始终设置合适的 umask,从而避免不必要的 chmod()
调用。
最后更新于