Mozilla去年发布了适用于Firefox的
Quantum CSS ,并在长达八年的开发内存友好型系统编程语言Rust的最后阶段。 用了一年多的时间在Rust中重写了主要的浏览器组件。
到目前为止,所有主要的浏览器引擎都是使用C ++编写的,主要是出于效率方面的考虑。 但是,出色的性能带来了巨大的责任:C ++程序员必须手动管理内存,这将打开Pandora的漏洞框。 Rust不仅可以修复此类错误,而且其方法还可以防止
数据争用 ,从而使程序员可以更有效地实现并行代码。
什么是内存安全性?
当我们谈论创建安全的应用程序时,我们经常提到内存安全性。 非官方地,我们的意思是程序在任何状态下都不能访问无效的内存。 安全漏洞的原因:
- 释放内存后保存指针(释放后使用);
- 解引用空指针;
- 使用未初始化的内存;
- 程序尝试释放同一单元两次(两次释放);
- 缓冲区溢出。
有关更正式的定义,请参阅Michael Hicks的
“什么是内存安全性 ”以及有关此主题的
科学文章 。
此类违规行为可能导致意外崩溃或程序预期行为的更改。 潜在的后果:信息泄漏,任意代码执行和远程代码执行。
记忆体管理
内存管理对于应用程序性能和安全性至关重要。 在本节中,我们将考虑基本的内存模型。 关键概念之一是
指针 。 这些是存储内存地址的变量。 如果我们转到该地址,我们将在那里看到一些数据。 因此,我们说指针是对此数据的引用(或指向它们)。 就像家庭住址告诉人们在哪里可以找到您一样,内存地址也可以显示程序在哪里可以找到数据。
程序中的所有内容都位于特定的存储器地址,包括代码指令。 错误使用指针会导致严重的漏洞,包括信息泄漏和任意代码执行。
分配/发布
创建变量时,程序应在内存中分配足够的空间来存储该变量的数据。 由于每个进程的内存量有限,因此,您需要一种
释放资源的方法。 释放内存后,它可用于存储新数据,但是旧数据将保留在那里,直到该单元被覆盖为止。
缓冲液
缓冲区是一个连续的存储区,其中存储了相同数据类型的多个实例。 例如,短语“我的猫是蝙蝠侠”将存储在16字节缓冲区中。 缓冲区由起始地址和长度确定。 为了不损坏相邻存储器中的数据,重要的是要确保我们不要在缓冲区外进行读写。
控制流程
程序由按特定顺序运行的例程组成。 在子例程的末尾,计算机将转到存储的指针,该指针指向代码的下一部分(称为
返回地址 )。 当您转到寄信人地址时,会发生以下三种情况之一:
- 该过程正常继续(返回地址不变)。
- 进程崩溃(地址已更改,并指向不可执行的内存)。
- 该过程继续,但是没有按预期进行(返回地址已更改,控制流已更改)。
语言如何提供内存安全性
所有编程语言都属于
频谱的不同部分。 一方面是像C / C ++这样的语言。 它们很有效,但是需要手动进行内存管理。 另一方面,具有自动内存管理功能的解释型语言(例如,引用计数和垃圾回收(GC)),但它们会在性能上有所回报。 甚至具有垃圾回收优化的语言也无法与没有GC的语言进行
性能比较。
手动内存管理
某些语言(例如C)要求程序员手动管理内存:何时分配多少内存以及何时释放内存。 这使程序员可以完全控制程序如何使用资源,从而提供快速有效的代码。 但是这种方法容易出错,尤其是在复杂的代码库中。
容易犯的错误:
- 忘记资源是免费的,然后尝试使用它们;
- 没有为数据存储分配足够的空间;
- 在缓冲区外部读取内存。
适用于手动管理内存的人员的适当安全说明智能指针
智能指针可提供其他信息,以防止不正确的内存管理。 它们用于自动内存管理和边界检查。 与常规指针不同,智能指针能够自毁,并且不会等待程序员手动删除它。
这种构造有多种选择,可以将原始指针包装在几个有用的抽象中。 一些智能指针对对每个对象的
引用进行
计数 ,而其他一些智能指针则执行范围界定策略,以将指针的生存期限制为某些条件。
计数链接时,删除对对象的最后一个引用后,资源将被释放。 基本引用计数实现遭受性能低下,内存消耗增加以及在多线程环境中难以使用的困扰。 如果对象相互引用(圆形链接),则每个对象的引用计数将永远不会达到零,因此需要更复杂的方法。
垃圾收集
某些语言(例如Java,Go,Python)实现
垃圾回收 。 运行时环境的一部分,称为垃圾收集器(GC),它跟踪变量并在对象之间的链接图中标识不可访问的资源。 一旦对象不可用,GC就会释放基本内存以供将来重用。 没有显式的编程器命令,任何分配和释放内存都会发生。
尽管GC确保始终正确使用内存,但它不会以最有效的方式释放内存-有时,对象的最后一次使用发生在垃圾回收器释放内存的时间之前。 对于任务关键型应用程序,性能成本高得让人望而却步:有时,您需要使用5倍以上的内存来避免性能下降。
拥有
Rust使用所有权来确保高性能和内存安全性。 更正式地说,这是
相似性键入的示例。 所有Rust代码都遵循某些规则,这些规则允许编译器在不损失执行时间的情况下管理内存:
- 每个值都有一个称为所有者的变量。
- 一次只能有一个所有者。
- 当所有者移出范围时,该值将被删除。
值可以从一个变量
转移或
借用到另一个变量。 这些规则适用于编译器的一部分,称为借位检查器。
当变量超出范围时,Rust释放该内存。 在下面的示例中,变量
s1
和
s2
超出了范围,它们都尝试释放相同的内存,从而导致双释放错误。 为防止这种情况,当从变量传输值时,先前的所有者无效。 如果程序员随后尝试使用无效变量,则编译器将拒绝该代码。 通过创建数据的深层副本或使用链接可以避免这种情况。
示例1 :所有权转移
let s1 = String::from("hello"); let s2 = s1;
另一组借阅检查器规则与变量的生存期有关。 Rust禁止使用未初始化的变量和指向不存在对象的悬空指针。 如果从下面的示例中编译代码,则
r
将引用
x
超出范围时释放的内存:出现了悬空指针。 编译器监视所有区域并检查所有传输的有效性,有时需要程序员明确指示变量的生存期。
示例2 :悬吊指针
let r; { let x = 5; r = &x; } println!("r: {}", r);
所有权模型为正确访问内存提供了坚实的基础,可以防止未定义的行为。
内存漏洞
易受攻击的内存的主要后果是:
- 崩溃 :访问无效的内存可能会导致应用程序意外终止。
- 信息泄漏 :无意提供私人数据,包括密码等机密信息。
- 任意代码执行(ACE) :允许攻击者在目标计算机上执行任意命令。 如果这是通过网络发生的,我们称之为远程代码执行(RCE)。
另一个问题是程序终止后未释放分配的内存时
发生内存泄漏 。 因此,您可以耗尽所有可用内存:然后资源请求将被阻止,这将导致拒绝服务。 这是无法在PL级别解决的内存问题。
在最佳情况下,出现内存错误,应用程序将崩溃。 在最坏的情况下,攻击者通过漏洞来控制程序(这可能导致进一步的攻击)。
释放内存的滥用(释放后使用,两次释放)
当资源被释放但仍保留了指向其地址的链接时,会出现此漏洞子类。 这是一种
强大的黑客方法 ,可能导致超范围访问,信息泄漏,代码执行等。
具有垃圾回收和引用计数的语言可防止使用无效的指针,只会破坏无法访问的对象(这可能导致性能下降),并且手动控制的语言容易受到此漏洞的影响(尤其是在复杂的代码库中)。 Rust中的借位检查器工具不允许在引用对象时销毁对象,因此在编译阶段将这些错误删除。
未初始化的变量
如果在初始化之前使用了变量,则该数据可以包含任何数据,包括随机垃圾或先前丢弃的数据,这会导致信息泄漏(有时也称为
无效指针 )。 为避免这些问题,内存管理语言通常在分配内存后使用自动初始化过程。
与C中一样,Rust中的大多数变量最初都没有初始化。 但是与C不同,您无法在初始化之前读取它们。 以下代码无法编译:
示例3 :使用未初始化的变量
fn main() { let x: i32; println!("{}", x); }
空指针
当应用程序取消引用原来为空的指针时,它通常仅访问垃圾并导致崩溃。 在某些情况下,这些漏洞可能导致执行任意代码(
1、2、3 )。 Rust有两种类型的指针:
链接和原始指针。 链接是安全的,但原始指针可能会成为问题。
Rust防止通过两种方式取消引用空指针:
- 避免使用可为空的指针。
- 避免取消引用原始指针。
Rust通过将空指针替换为特殊的
Option
避免空指针。 要更改
Option
类型中可能的null值,该语言要求程序员使用null值显式处理大小写,否则程序将无法编译。
如果无法避免允许空值的指针(例如,与另一种语言的代码进行交互时)怎么办? 尝试隔离损坏。 原始指针的解除引用必须在隔离的不安全块中进行。 它
放宽了Rust规则,并解决了一些可能导致未定义行为的操作(例如,取消引用原始指针)。
“关于借来的东西的一切……那个黑暗的地方呢?”
-这是不安全的封锁。 永远不要去那里,辛巴缓冲区溢出
我们讨论了可以通过限制对未定义内存的访问来避免的漏洞。 但是问题是缓冲区溢出不能正确访问未定义但合法分配的内存。 像释放后使用的bug一样,这种访问可能会成为问题,因为它访问释放的内存,该内存仍包含不应再存在的机密信息。
缓冲区溢出只是意味着越界访问。 由于缓冲区存储在内存中的方式,它们经常泄漏可能包含敏感数据(包括密码)的信息。 在更严重的情况下,可以通过覆盖指令指针来实现ACE / RCE漏洞。
示例4:缓冲区溢出(C代码)
int main() { int buf[] = {0, 1, 2, 3, 4};
防止缓冲区溢出的最简单方法是在访问元素时始终要求进行边界检查,但这会导致
性能下降 。
锈有什么作用? 标准库中的内置缓冲区类型需要对任何随机访问进行边界检查,而且还提供迭代器API以加快顺序调用的速度。 这确保了对于这些类型不可能进行外部边界的读取和写入。 Rust提倡仅在几乎肯定要手动将其放置在C / C ++中的地方才需要边界检查的图案。
内存安全仅是成功的一半
安全漏洞导致漏洞,例如数据泄漏和远程代码执行。 有多种保护内存的方法,包括智能指针和垃圾回收。 您甚至可以
正式证明内存的安全性 。 虽然某些语言为了内存安全性而降低了性能,但Rust的所有权概念提供了安全性并最大程度地减少了开销。
不幸的是,当我们谈论编写安全代码时,内存错误只是故事的一部分。 在下一篇文章中,我们将考虑线程安全和对并行代码的攻击。
利用内存漏洞:其他资源