这是“无畏保护”文章系列的第二部分。 首先,我们讨论了内存安全性现代应用程序是多线程的:该程序使用线程来同时执行多个任务,而不是顺序执行任务。 我们每个人每天都在
同时进行工作和
并发 :
- 网站由多个用户同时提供服务。
- UI的后台工作不会打扰用户(想象一下,每次键入字符时,应用程序都会冻结以检查拼写)。
- 一台计算机可以同时运行多个应用程序。
并行流加快了工作速度,但引入了一系列同步问题,即死锁和竞争条件。 从安全角度来看,我们为什么要关心线程安全? 因为内存和线程的安全性存在一个相同的主要问题:对资源的不适当使用。 此处的攻击与内存攻击具有相同的效果,包括特权提升,任意代码执行(ACE)和绕过安全检查。
并发错误(如实现错误)与程序正确性密切相关。 虽然内存漏洞几乎总是危险的,但如果实现/逻辑错误未在与安全性合同遵守相关的代码部分中发生(例如,绕过安全性检查的权限),则并不总是表明存在安全问题。 但是并发错误有其特殊性。 如果由于逻辑错误而导致的安全性问题经常出现在相应的代码旁边,那么并发错误通常发生
在其他功能中,而不是直接发生错误的
功能中 ,这会导致难以跟踪和消除它们。 另一个困难是不正确的内存处理和并发错误之间存在一定的重叠,这在数据竞争中可以看到。
编程语言开发了各种并发策略,以帮助开发人员管理多线程应用程序的性能和安全性问题。
并发问题
人们普遍认为,并行编程比平时更加困难:我们的大脑更适合顺序推理。 并行代码在线程之间可能具有意外的和有害的交互,包括死锁,争用和数据争用。
当多个线程彼此期望执行某些操作以继续工作时,就会发生
死锁 。 尽管此不良行为可能会导致拒绝服务攻击,但不会导致诸如ACE之类的漏洞。
竞争条件是一种情况,其中任务的时间或顺序可能会影响程序的正确性。 当多个流尝试至少进行一次写入尝试同时访问同一内存位置时,就会发生数据争用。 发生争用条件和数据争用
是彼此
独立发生的 。 但是
数据竞赛总是很危险的 。
并发错误的潜在后果
- 死锁
- 信息丢失:另一个线程覆盖信息
- 完整性丧失:来自多个流的信息交织在一起
- 生存能力丧失:由于对共享资源的访问不均而导致的性能问题
最著名的并发攻击类型称为
TOCTOU (检查时间到使用时间):本质上,竞争的状态介于检查条件(例如安全凭证)和使用结果之间。 TOCTOU攻击会导致完整性丧失。
互锁和生存能力的损失被认为是性能问题,而不是安全性问题,而信息丢失和完整性损失很可能与安全性有关。
Red Balloon Security文章探讨了一些可能的利用。 一个例子是指针损坏,然后是特权升级或远程执行代码。 在此漏洞利用中,加载ELF(可执行和可链接格式)共享库的函数仅在第一次调用时才正确启动信号量,然后错误地限制了线程数,这会导致内核内存损坏。 这种攻击是信息丢失的一个例子。
并发编程中最困难的部分是测试和调试,因为并发错误很难重现。 事件的时间安排,操作系统的决策,网络流量和其他因素……所有这些都会在每次启动时更改程序的行为。
有时候,删除整个程序比查找错误要容易得多。 黑森臭虫行为不仅在每次启动时都会改变,甚至插入输出或调试语句也可以改变行为,从而导致出现“海森堡错误”(非确定性,难以重现的并行编程错误),并神秘地消失。
并行编程很困难。 很难预测并行代码将如何与其他并行代码交互。 当出现错误时,很难找到并纠正它们。 让我们看看开发程序的方法以及使用使编写并行代码更容易的语言的方法,而不是依赖测试人员。
首先,我们制定“线程安全”的概念:
“如果一个数据类型或静态方法在多个线程中调用时表现正确,则无论这些线程如何执行,并且都不需要调用代码的额外协调,如果该数据类型或静态方法正确运行,则被认为是线程安全的。” 麻省理工学院
编程语言如何与并行性一起工作
在没有静态线程安全性的语言中,程序员必须不断监视与另一个线程共享的内存,并且该内存可以随时更改。 在顺序编程中,我们被教导要避免全局变量,如果代码的另一部分悄悄地更改了它们。 要求程序员保证共享数据的安全更改以及手动内存管理是不可能的。
“保持警惕!”通常,编程语言仅限于两种方法:
- 可变性限制或共享访问限制
- 手动线程安全(例如锁,信号灯)
具有线程限制的语言要么对可变变量设置1个线程限制,要么要求所有公共变量都是不可变的。 两种方法都解决了数据争用的基本问题-共享数据修改不正确-但限制过于严格。 为了解决该问题,语言制作了低级同步原语,例如互斥体。 它们可用于构建线程安全的数据结构。
Python和解释器的全局锁定
Python和Cpython中的参考实现具有一种称为全局解释器锁(GIL)的互斥量,当一个线程访问对象时,该互斥量会阻塞所有其他线程。 由于GIL延迟,多线程Python
效率低下 。 因此,大多数并发Python程序都在多个进程中工作,因此每个程序都有自己的GIL。
Java和运行时异常
Java通过共享内存模型支持并发编程。 每个线程都有自己的执行路径,但是它可以访问程序中的任何对象:程序员必须使用内置的Java原语在线程之间同步访问。
尽管Java具有用于创建线程安全程序的构建块,
但是编译器
不能保证 线程安全 (与内存安全相反)。 如果发生不同步的内存访问(即数据争用),则Java将抛出运行时异常,但程序员必须正确使用并发原语。
C ++和程序员的大脑
尽管Python通过GIL避免了竞争条件,而Java在运行时抛出了异常,但C ++希望程序员手动同步内存访问。 在C ++ 11之前,标准库
不包含并发原语 。
大多数语言都提供了用于编写线程安全代码的工具,并且有一些特殊的方法可以检测数据争用和争用状态。 但是它不能保证线程安全,也不能防止数据竞争。
如何解决Rust问题?
Rust使用权属规则和安全类型采取多方面的方法消除竞争条件,从而在编译时完全抵御竞争条件。
在
第一篇文章中,我们介绍了所有权的概念,这是Rust的基本概念之一。 每个变量都有一个唯一的所有者,所有权可以转让或借用。 如果另一个线程想要更改资源,那么我们通过将变量移到新线程来转移所有权。
移动会引发异常:多个线程可以写入同一内存,但不能同时写入。 由于所有者总是一个人,如果另一个线程借用一个变量怎么办?
在Rust中,您可以进行一次可变借贷,也可以进行多次不可变借贷。 不可能同时引入可变和不可变的借贷(或几种可变的借贷)。 在内存安全中,正确释放资源很重要,在线程安全中,只有一个线程有权在任何给定时间更改变量,这一点很重要。 此外,在这种情况下,没有其他流程会涉及过时的借用:既可以录制也可以共享,但不能同时使用。
所有权概念旨在解决内存漏洞。 事实证明,这还可以防止数据争用。
尽管许多语言都有内存安全性方法(例如,链接计数和垃圾回收),但它们通常依赖于手动同步或禁止并发共享来防止数据竞争。 Rust方法处理两种类型的安全性,试图解决确定可接受的资源使用并在编译时确保其有效性的主要问题。
但是等等! 那还不是全部!
所有权规则可防止多个线程将数据写入同一内存位置,并禁止同时在线程之间进行数据交换和可变性,但这不一定提供线程安全的数据结构。 Rust中的每个数据结构是否都是线程安全的。 这使用类型系统传递给编译器。
“类型正确的程序不能犯错误。” -罗宾·米尔纳(Robin Milner),1978年
在编程语言中,类型系统描述了可接受的行为。 换句话说,一个良好类型的程序是定义良好的。 只要我们的类型具有足够的表现力以捕获预期的含义,那么类型良好的程序就会按预期运行。
Rust是一种类型安全的语言,在这里编译器会检查所有类型的一致性。 例如,以下代码无法编译:
let mut x = "I am a string"; x = 6;
error[E0308]: mismatched types --> src/main.rs:6:5 | 6 | x = 6;
Rust中的所有变量通常都是隐式的。 我们还可以定义新类型,并使用
特征系统描述每种类型的功能。 特性提供了接口的抽象。 编译器默认为每种类型提供两个重要的内置特征:
Send
和
Sync
:
Send
表示该结构可以在线程之间安全地转移(需要转移所有权)
Sync
表明线程可以安全地使用该结构。
下面的示例是
标准库中产生线程的
代码的简化版本:
fn spawn<Closure: Fn() + Send>(closure: Closure){ ... } let x = std::rc::Rc::new(6); spawn(|| { x; });
spawn
函数采用单个参数
closure
并且需要后者的类型来实现
Send
和
Fn
特性。 尝试创建流并使用变量
x
传递
closure
值时
x
编译器将引发错误:
错误[E0277]:“ std :: rc :: Rc <i32>”无法在线程之间安全地发送
-> src / main.rs:8:1
|
8 | 产生(移动|| {x;});
| ^^^^^`std :: rc :: Rc <i32>`无法在线程之间安全地发送
|
= help:在`[closure@src/main.rs:8:7:8:21 x:std :: rc :: Rc <i32>]`中,特性`std :: marker :: Send`未实现对于`std :: rc :: Rc <i32>`
=注意:必填,因为它出现在`[closure@src/main.rs:8:7:8:21 x:std :: rc :: Rc <i32>]类型内
注意:`spawn`必需
Send
和
Sync
特征使Rust类型的系统了解可以共享哪些数据。 通过在类型系统中包含此信息,线程安全性成为类型安全性的一部分。
线程安全性是由编译器法实现的,而不是文档化的。
程序员可以清楚地看到线程之间的公共对象,并且编译器保证了此安装的可靠性。
尽管并行编程工具支持多种语言,但是防止竞争条件并不容易。 如果您要求程序员复杂地替换指令并在线程之间进行交互,那么错误是不可避免的。 尽管线程和内存安全漏洞会导致类似的后果,但是传统的内存保护(例如链接计数和垃圾回收)并不能防止争用情况。 除了静态保证内存安全之外,Rust所有权模型还可以防止不安全的数据更改和线程之间对象的不正确共享,而类型系统则在编译时提供了线程安全性。