记忆中总有一些东西。每个位始终包含 0 或 1。如果内存中的同一空间用于两个不同的目的,计算机(编译器或运行时系统)无法分辨:目的是人类的概念,而不是机器的概念。
在当今大多数您认为是计算机的系统上(PC、服务器、手机、路由器等,但不是大多数电子元件中普遍存在的微处理器和微控制器),都有一个内存管理单元 (MMU) . MMU 在程序使用的内存地址(虚拟地址)和实际内存库中的内存地址(物理地址)之间进行转换。在这个转换层,可以将一些虚拟内存区域标记为不可访问。如果一个程序试图使用不可访问的内存,该程序将被杀死(Windows 将此称为一般保护错误,Unix 将此称为分段错误)。
可以使用 MMU 进行内存保护。事实上,这就是多任务操作系统如何保护应用程序不访问彼此的内存。但是,在程序内部,应用程序是有限的。您不能将程序的每个变量都放在被不可访问区域包围的自己的区域中。嗯,你可以,并且一些调试程序会这样做,但这非常低效,因为地址转换的粒度很粗(在大多数处理器架构上为 4kB)。它并不能解决所有错误。如果您有一个 64 字节的缓冲区,后跟 4032 字节的未使用内存,然后是 4096 个不可访问的字节,则不会检测到第 65 字节的溢出(您可以确保它不会破坏程序的其他变量); 溢出到第 4097 个字节将覆盖其他内容。如果您释放一个对象并将其内存地址重用于另一个对象,但程序的某些部分仍然引用旧对象,则也不会被检测到。(你不能让旧对象的地址永远无效,因为你最终会用完地址。)
可以自动检测许多类型的内存滥用,但运行时并不是一个好方法。有时您可以捕获错误——除了访问不可访问的内存之外,您还可以在程序或运行时环境中进行显式检查。例如,您可以尝试通过将已知值放在堆栈底部(金丝雀) 并不时检查。如果在损坏发生之前检查金丝雀值,这确实有助于防止意外错误。当涉及恶意攻击时,金丝雀值会使编写漏洞利用变得更加困难,但它们并不能阻止一切。还有更复杂的运行时检查方法,例如获取部分内存的校验和,如果这些校验和稍后不匹配,则安排中止;这些主要用于敌对(“灰盒”)环境中,攻击者有一些有限的手段来扰乱程序的执行。
检测内存滥用的正确时间是在编译时。在那里,可以通过确保对于访问数组元素的每条指令来检测缓冲区溢出:
- 编译器可以证明索引始终在数组的范围内;
- 或者在访问数组元素之前立即对索引进行范围检查。
缓冲区溢出只是问题之一。另一个常见问题是程序释放分配给对象的内存,但保留指向该对象的指针并稍后尝试访问该对象。有一个解决方案,但它对语言设计有很大的影响。这意味着该语言必须禁止程序员以不受控制的方式存储指针。有几种方法:
- 当对象的内存被释放时,编程环境使所有指向对象的指针无效;
- 当周围仍有指向该对象的指针时,编译器拒绝编译释放该对象的程序;
- 编译器推迟释放对象的请求,直到它可以满意地证明没有指向该对象的指针。
大多数编程语言通过某种形式的自动内存管理来解决这些问题,其中释放对象(如果它们存在的话)的请求不会立即处理,并且所有指针都在编译器的控制之下,因此它可以知道这是什么对象指针正在访问。例如,编译器看到一条指令正在访问某个对象 A 的第 42 个字节,因此它发出一条指令来检查 A 是否至少有那么多字节,并将 A 记录为在该点之前正在使用,以便 A不会提前释放。自动内存管理最常用的技术是垃圾收集:运行时环境有一种方法可以探索程序的所有指针(这意味着程序员不能以某种隐藏的方式构成指针),并且它只会在程序没有指向它们的指针时释放对象可以访问。
大多数编程语言都使用垃圾收集和强制边界检查(两者通常一起使用):C#、Java、JavaScript、Perl、PHP、Python、Ruby……。但例外情况包括一些非常常见的语言:C 和 C++。这些语言旨在为程序员提供大量控制权。它们与禁止程序员编写指针的方法不兼容。
为什么人们用这种容易出错的语言编程?其中一些原因包括:
- 历史的重量:程序是用 C 编写的,并且它们在 C 中不断发展。
- 表现。真实的或感知的。自动内存管理有运行时成本(但是,手动内存管理也是如此)。
- 历史的重量:程序员懂 C,所以他们用 C 写的程序更多。
- 过度自信。许多程序员认为,由于他们比计算机更聪明,计算机不应该凌驾于他们的决定之上。(我比阳台上的护栏聪明,为什么它阻止我从10楼走下来?)
- 历史的重要性:每个平台都有一个 C 编译器。其他语言往往不太便携。
程序员无法编写指针的语言会将由于缓冲区溢出或访问已释放对象而导致任意代码执行的编程错误转变为拒绝服务:程序可能由于未捕获的越界异常而中止,或者它可能会耗尽内存,但至少它不会按照攻击者的指示行事。所以他们并没有完全解决问题——没有灵丹妙药——他们只是减少了它的影响。
虽然具有自动内存管理的语言更上一层楼,但它们并没有完全解决最初无法解决的问题,即内存用于与预期不同的目的。事实上,它们只是一个进步。攻击者可能会滥用字符串,而不是滥用内存中的字节(这是缓冲区溢出可以做的):本应仅被解释为供人类使用的文本的字符串,反而被解释为某些编程语言中的指令。例如,SQL 注入攻击包括在字符串中插入单引号这导致该字符串的一部分被解释为 SQL 而不是某人的名字。有一些方法可以依靠编译器来检测这种误用,但是大多数流行的语言都没有实现它们,而且大多数程序员甚至不知道这种技术甚至存在(请参阅在打印函数中处理不受信任的字符串输入并避免缓冲区溢出)它是如何完成的)。