第 3 章。安全编程

3.1. 概述

本章介绍了困扰 UNIX® 程序员数十年的安全问题,以及一些新的工具,帮助程序员避免编写可利用的代码。

3.2. 安全设计方法

编写安全的应用程序需要非常仔细和悲观地看待生活。应用程序应该以“最小特权”原则运行,这样任何进程都不会运行超出完成其功能所需的最低限度访问权限。尽可能重用以前经过测试的代码,以避免他人可能已经修复的常见错误。

UNIX® 环境的一个陷阱是,对环境的健全性做出假设是多么容易。应用程序永远不应该信任用户输入(以所有形式)、系统资源、进程间通信或事件的时序。UNIX® 进程并非同步执行,因此逻辑操作很少是原子的。

3.3. 缓冲区溢出

缓冲区溢出从冯·诺依曼 1 架构的早期就存在。它们在 1988 年随着莫里斯互联网蠕虫的出现而声名大噪。不幸的是,同样的基本攻击直到今天仍然有效。迄今为止,最常见的缓冲区溢出攻击类型是基于破坏堆栈的。

大多数现代计算机系统使用堆栈将参数传递给过程并存储局部变量。堆栈是进程映像高内存区域中的后进先出 (LIFO) 缓冲区。当程序调用函数时,会创建一个新的“堆栈帧”。该堆栈帧包含传递给函数的参数,以及动态数量的局部变量空间。“堆栈指针”是一个寄存器,保存堆栈顶部的当前位置。由于该值在堆栈顶部压入新值时会不断变化,因此许多实现还提供一个“帧指针”,该指针位于堆栈帧的开头附近,以便可以更轻松地相对于该值寻址局部变量。 1 函数调用的返回地址也存储在堆栈上,这就是堆栈溢出利用的根源,因为溢出函数中的局部变量可能会覆盖该函数的返回地址,从而可能允许恶意用户执行他或她想要执行的任何代码。

虽然基于堆栈的攻击是最常见的,但也可以通过基于堆的 (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. 缓冲区溢出示例

以下示例代码包含一个缓冲区溢出,旨在覆盖返回地址并跳过紧随函数调用的指令。(受 4 的启发)

#include <stdio.h>

void manipulate(char *buffer) {
  char newbuffer[80];
  strcpy(newbuffer,buffer);
}

int main() {
  char ch,buffer[4096];
  int i=0;

  while ((buffer[i++] = getchar()) != '\n') {};

  i=1;
  manipulate(buffer);
  i=2;
  printf("The value of i is : %d\n",i);
  return 0;
}

让我们检查一下,如果我们在按回车键之前将 160 个空格输入到我们的程序中,该进程的内存映像会是什么样子。

显然,可以设计出更恶意的输入来执行实际的已编译指令(例如 exec(/bin/sh))。

3.3.2. 避免缓冲区溢出

解决堆栈溢出问题的最直接方法是始终使用长度受限的内存和字符串复制函数。strncpystrncat 是标准 C 库的一部分。这些函数接受一个长度值作为参数,该参数不应大于目标缓冲区的大小。然后,这些函数将最多从源复制“长度”字节到目标。但是,这些函数存在一些问题。如果输入缓冲区的大小与目标一样大,则这两个函数都不能保证以 NUL 结尾。长度参数在 strncpy 和 strncat 之间的使用方式也不一致,因此程序员很容易混淆其正确用法。与 strcpy 相比,将短字符串复制到大型缓冲区时,也存在显著的性能损失,因为 strncpy 会用 NUL 填充指定的大小。

存在另一种内存复制实现来解决这些问题。strlcpystrlcat 函数保证在给定非零长度参数时,始终会将目标字符串以空字符结尾。

3.3.2.1. 基于编译器的运行时边界检查

不幸的是,仍然有大量的代码在公共使用中,它们在没有使用我们刚刚讨论的任何有界复制例程的情况下,盲目地复制内存。幸运的是,有一种方法可以帮助防止此类攻击 - 运行时边界检查,由几个 C/C++ 编译器实现。

ProPolice 就是这样的一个编译器特性,它已集成到 gcc(1) 4.1 及更高版本中。它替换并扩展了早期的 StackGuard gcc(1) 扩展。

ProPolice 通过在调用任何函数之前将伪随机数放在堆栈的关键区域来帮助保护免受基于堆栈的缓冲区溢出和其他攻击。当函数返回时,将检查这些“金丝雀”,如果发现它们已更改,则可执行文件将立即中止。因此,任何试图修改返回地址或存储在堆栈上的其他变量以试图让恶意代码运行的尝试都可能失败,因为攻击者还必须设法保持伪随机金丝雀不受影响。

使用 ProPolice 重新编译应用程序是阻止大多数缓冲区溢出攻击的有效方法,但它仍然可以被利用。

3.3.2.2. 基于库的运行时边界检查

对于无法重新编译的二进制软件,基于编译器的机制毫无用处。对于这些情况,存在一些库,它们重新实现了 C 库(strcpyfscanfgetwd 等)的不安全函数,并确保这些函数永远不会写入堆栈指针之外。

  • libsafe

  • libverify

  • libparanoia

不幸的是,这些基于库的防御措施存在一些缺陷。这些库只能防御一小部分安全相关问题,而且没有解决根本问题。如果应用程序使用 -fomit-frame-pointer 编译,这些防御措施可能会失效。此外,用户可以覆盖或取消设置 LD_PRELOAD 和 LD_LIBRARY_PATH 环境变量。

3.4. SetUID 问题

任何给定进程至少有 6 个不同的 ID,因此您必须非常小心地处理进程在任何给定时间具有的访问权限。特别是,所有 seteuid 应用程序都应该在不再需要时放弃其特权。

真实用户 ID 只能由超级用户进程更改。登录程序在用户首次登录时设置此 ID,并且很少更改。

如果程序设置了 seteuid 位,则有效用户 ID 由 exec() 函数设置。应用程序可以随时调用 seteuid() 将有效用户 ID 设置为真实用户 ID 或保存的设置用户 ID。当有效用户 ID 由 exec() 函数设置时,先前值将保存在保存的设置用户 ID 中。

3.5. 限制程序的环境

限制进程的传统方法是使用 chroot() 系统调用。此系统调用更改根目录,所有其他路径都将从该目录引用进程及其所有子进程。为了使此调用成功,进程必须对被引用的目录具有执行(搜索)权限。新环境只有在您 chdir() 到新环境中时才会真正生效。还应注意,如果进程具有根权限,它可以轻松地从 chroot 环境中跳出。这可以通过创建用于读取内核内存的设备节点、将调试器附加到 chroot(8) 环境之外的进程,或通过许多其他创造性的方式来实现。

chroot() 系统调用的行为可以通过 kern.chroot_allow_open_directories sysctl 变量进行一定程度的控制。当此值设置为 0 时,如果存在任何打开的目录,chroot() 将使用 EPERM 失败。如果设置为默认值 1,则如果存在任何打开的目录并且进程已受到 chroot() 调用的影响,chroot() 将使用 EPERM 失败。对于任何其他值,将完全绕过对打开目录的检查。

3.5.1. FreeBSD 的 jail 功能

Jail 的概念扩展了 chroot(),通过限制超级用户的权力来创建一个真正的“虚拟服务器”。一旦设置了监狱,所有网络通信都必须通过指定的 IP 地址进行,并且此监狱中“根特权”的权力受到严格限制。

在监狱中,使用 suser() 调用对内核中的超级用户权限进行的任何测试都将失败。但是,一些对 suser() 的调用已更改为新的接口 suser_xxx()。此函数负责识别或拒绝对被囚禁进程的超级用户权限的访问。

监狱环境中的超级用户进程有权

  • 使用 setuidseteuidsetgidsetegidsetgroupssetreuidsetregidsetlogin 操作凭据

  • 使用 setrlimit 设置资源限制

  • 修改一些 sysctl 节点 (kern.hostname)

  • chroot()

  • 在 vnode 上设置标志:chflagsfchflags

  • 设置 vnode 的属性,如文件权限、所有者、组、大小、访问时间和修改时间。

  • 绑定到 Internet 域中的特权端口(端口 < 1024)

Jail 是在安全环境中运行应用程序的非常有用的工具,但它也有一些缺点。目前,IPC 机制尚未转换为 suser_xxx,因此无法在监狱中运行 MySQL 等应用程序。超级用户访问在监狱中可能具有非常有限的意义,但无法准确指定“非常有限”的含义。

3.5.2. POSIX®.1e 进程功能

POSIX® 发布了一个工作草案,增加了事件审计、访问控制列表、细粒度特权、信息标记和强制访问控制。

这仍在进行中,也是 TrustedBSD 项目的重点。一些初始工作已提交到 FreeBSD-CURRENT (cap_set_proc(3))。

3.6. 信任

应用程序不应该假设用户环境中的任何内容都是正常的。这包括(但肯定不限于):用户输入、信号、环境变量、资源、IPC、mmap、文件系统工作目录、文件描述符、打开文件数量等。

您不应该假设可以捕获用户可能提供的任何形式的无效输入。相反,您的应用程序应该使用正向过滤,只允许您认为安全的特定输入子集。不当的数据验证是许多漏洞的根源,特别是在万维网上使用 CGI 脚本时。对于文件名,您需要格外小心路径(“../”、“/”)、符号链接和 shell 转义字符。

Perl 有一项非常酷的功能,称为“Taint”模式,它可以用来防止脚本以不安全的方式使用从程序外部派生的数据。此模式将检查命令行参数、环境变量、区域设置信息、某些系统调用的结果(readdir()readlink()getpwxxx())以及所有文件输入。

3.7. 竞争条件

竞争条件是由事件的相对时间安排意外依赖引起的异常行为。换句话说,程序员错误地假设某个特定事件总是会在另一个事件之前发生。

竞争条件的一些常见原因是信号、访问检查和文件打开。信号本质上是异步事件,因此在处理它们时必须格外小心。使用 access(2) 然后 open(2) 进行访问检查显然不是原子的。用户可以在两次调用之间移动文件。相反,特权应用程序应该 seteuid(),然后直接调用 open()。同样,应用程序应该始终在 open() 之前设置适当的 umask,以避免需要无端的 chmod() 调用。


最后修改时间:2024 年 3 月 9 日,由 Danilo G. Baio 修改