第 3 章 安全编程
3.1. 概要
本章描述了困扰 UNIX®程序员数十年的一些安全问题,以及可用于帮助程序员避免编写易受攻击代码的新工具。
3.2. 安全设计方法论
编写安全应用程序需要对生活持非常谨慎和悲观的态度。应用程序应该以“最小权限”原则运行,以确保没有任何进程以超出其完成功能所需的最低访问权限。应尽可能重用先前经过测试的代码,以避免他人可能已经修复的常见错误。
UNIX®环境的一个陷阱是很容易对环境的健全性做出假设。应用程序永远不应信任用户输入(以各种形式出现)、系统资源、进程间通信或事件的定时。UNIX®进程不是同步执行的,因此逻辑操作很少是原子的。
3.3. 缓冲区溢出
缓冲区溢出自冯·诺伊曼 1 架构的最初阶段就存在。它们首次在 1988 年与莫里斯互联网蠕虫一起广为人知。不幸的是,同一基本攻击手法至今仍然有效。迄今为止,最常见的缓冲区溢出攻击类型是基于破坏堆栈。
大多数现代计算机系统使用栈来传递参数给过程并存储局部变量。栈是进程映像的高内存区域中的后进先出(LIFO)缓冲区。当程序调用函数时,将创建一个新的“栈帧”。这个栈帧包括传递给函数的参数以及动态数量的局部变量空间。 “栈指针”是一个保存栈顶当前位置的寄存器。由于随着新值被推送到栈顶,该值不断改变,因此许多实现还提供了一个“帧指针”,该指针位于栈帧的开始附近,以便局部变量可以更容易地相对于此值进行寻址。函数调用的返回地址也存储在栈中,这是堆栈溢出攻击的原因,因为溢出函数中的局部变量可能会覆盖该函数的返回地址,从而使恶意用户可以执行任何他或她想要的代码。
尽管基于栈的攻击远远是最常见的,但也可能使用基于堆的(malloc/free)攻击来溢出堆栈。
C 语言不像许多其他语言那样对数组或指针执行自动边界检查。此外,标准 C 库中充斥着一些非常危险的函数。
strcat (char *dest,const char *src)
可能会溢出目标缓冲区
getwd (char *buf)
可能会溢出缓冲区 buf
gets (字符 *s)
可能会溢出 s 缓冲区
[vf]scanf (常量字符 *format, …)
可能会溢出其参数。
realpath (char *路径,char resolved_path[])
可能会溢出路径缓冲区。
[v]sprintf
(char *str, const char *format, …)
可能导致 str 缓冲区溢出。
3.3.1. 缓冲区溢出示例
以下示例代码包含一个缓冲区溢出,旨在覆盖返回地址并跳过函数调用后紧接的指令。(受 4 启发)
让我们看看如果我们在按回车键之前向我们的小程序输入 160 个空格会导致该进程的内存映像是什么样子。
显然可以设计更恶意的输入来执行实际的编译指令(比如 exec(/bin/sh))。
3.3.2. 避免缓冲区溢出
解决栈溢出问题最直接的方法是始终使用长度限制的内存和字符串复制函数。 strncpy 和 strncat 是标准 C 库的一部分。这些函数接受一个长度值作为参数,该值不应大于目标缓冲区的大小。然后,这些函数将从源复制最多'长度'字节到目的地。然而,这些函数存在一些问题。如果输入缓冲区的大小与目标相同大,这两个函数都不保证 NUL 终止。长度参数在 strncpy 和 strncat 之间也使用不一致,因此程序员很容易混淆它们的正确用法。与 strcpy 相比,当将短字符串复制到大缓冲区时,性能损失也很大,因为 strncpy NUL 填充了指定的大小。
另一个内存复制实现存在以解决这些问题。 strlcpy 和 strlcat 函数保证在给定非零长度参数时始终将目标字符串以 NUL 终止。
基于编译器的运行时边界检查
不幸的是,仍然有大量的公共代码在使用中,它们在内存中盲目复制数据而不使用我们刚讨论过的任何有界复制例程。幸运的是,有一种方法可以帮助防止这种攻击 - 运行时边界检查,这是由几个 C/C++编译器实现的。
ProPolice 是其中一个编译器特性,已集成到 gcc(1)版本 4.1 及更高版本中。它取代并扩展了早期的 StackGuard gcc(1)扩展。
ProPolice 通过在调用任何函数之前在堆栈的关键区域放置伪随机数来帮助防护堆栈型缓冲区溢出和其他攻击。当函数返回时,这些“canaries”会被检查,如果发现它们已被更改,则立即中止可执行文件。因此,任何试图修改返回地址或在堆栈上存储其他变量以运行恶意代码的尝试都不太可能成功,因为攻击者还必须设法保持伪随机 canaries 不变。
使用 ProPolice 重新编译应用是阻止大多数缓冲区溢出攻击的有效手段,但仍可能会被破坏。
3.3.2.2. 基于库的运行时边界检查
编译器基于机制对于只有二进制可用的软件是完全无用的,对于这种情况下你无法重新编译软件。针对这些情况,有许多库重新实现 C 库的不安全函数 ( strcpy , fscanf , getwd , 等等..) 并确保这些函数永远不会写出栈指针。
libsafe
libverify
自卫
不幸的是,这些基于库的防御措施存在许多缺点。这些库只保护非常少量与安全相关的问题,而忽略了修复实际问题。如果应用程序是使用 -fomit-frame-pointer 编译的,这些防御可能会失败。此外,LD_PRELOAD 和 LD_LIBRARY_PATH 环境变量可能会被用户覆盖/取消设置。
3.4. SetUID 问题
任何给定进程都关联至少 6 个不同的 ID,因此您必须非常小心处理进程在任何给定时间具有的访问权限。特别是,所有 seteuid 应用程序应在不再需要特权时放弃特权。
只有超级用户进程才能更改真实用户 ID。登录程序在用户最初登录时设置此 ID,很少更改。
如果程序设置了其 seteuid 位, exec() 函数将设置有效用户 ID。应用程序可以随时调用 seteuid() 将有效用户 ID 设置为真实用户 ID 或保存的设置用户 ID。当有效用户 ID 由 exec() 函数设置时,先前的值将保存在保存的设置用户 ID 中。
3.5. 限制你的程序环境
限制进程的传统方法是使用 chroot() 系统调用。该系统调用会改变进程及其子进程引用的所有其他路径的根目录。要使此调用成功,进程必须对被引用的目录具有执行(搜索)权限。新环境实际上直到您切换到新环境后才生效。还应该注意,如果进程具有 root 特权,它可以很容易地跳出 chroot 环境。可以通过创建设备节点来读取内核内存,将调试器连接到 chroot 环境外的进程,或以许多其他创造性的方式来实现这一点。
chroot() 系统调用的行为可以通过 kern.chroot_allow_open_directories sysctl 变量来进行一定程度的控制。当此值设置为 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 的属性,如文件权限、所有者、组、大小、访问时间和修改时间。
绑定到互联网域中的特权ports(ports < 1024)
Jail 是一个非常有用的工具,可在安全环境中运行应用程序,但它确实有一些缺点。当前,IPC 机制尚未转换为 suser_xxx ,因此无法在jail中运行诸如 MySQL 之类的应用程序。超级用户访问可能在jail中意味非常有限,但无法精确指定"非常有限"具体意味着什么。
3.5.2. POSIX®.1e 进程功能
POSIX®发布了一个工作草案,其中添加了事件审计、访问控制列表、细粒度特权、信息标记和强制访问控制。
这是一个正在进行中的工作,是 TrustedBSD 项目的重点。一些最初的工作已经提交到 FreeBSD-CURRENT(cap_set_proc(3))。
3.6. 信任
应用程序永远不应假设用户环境的任何内容是健全的。这包括(但绝对不限于):用户输入、信号、环境变量、资源、IPC、内存映射、文件系统工作目录、文件描述符、打开文件的数量等。
您永远不应假设自己可以捕获用户可能提供的所有形式的无效输入。相反,您的应用程序应使用积极过滤,只允许您认为安全的特定输入子集。不正确的数据验证是许多漏洞的根源,特别是在全球网络上的 CGI 脚本中。对于文件名,您需要特别注意路径("../","/"),符号链接和shell转义字符。
Perl 有一个非常酷的功能称为“Taint”模式,可用于防止脚本以不安全的方式使用程序外部获取的数据。此模式将检查命令行参数、环境变量、区域设置信息、某些系统调用的结果( readdir() , readlink() , getpwxxx() )以及所有文件输入。
3.7. 竞争条件
竞争条件是由事件的相对时序的意外依赖引起的异常行为。换句话说,程序员错误地假定一个特定事件总会在另一个事件之前发生。
竞争条件的一些常见原因是信号、访问检查和文件打开。信号本质上是异步事件,因此在处理它们时必须格外小心。用 access(2) 和 open(2) 检查访问明显不是原子操作。用户可以在两次调用之间移动文件。而是,特权应用程序应该首先 seteuid() ,然后直接调用 open() 。沿着同样的思路,应用程序在 open() 之前应该始终设置适当的 umask,以避免产生不必要的 chmod() 调用。
最后更新于