「译文」理解现实中 Go 语言的并发漏洞

2019-07-26 22:52:11 +08:00
 darluc

查看全文

对于编程(数据)模型的设计不仅会使得一些问题变得易于(或更难)解决,也会导致某些类型的漏洞更容易(或更难)产生、侦测和修复。本文的研究对象就是 Go 语言的并发机制。在深入研究之前先思考一下会更有意思,你现在对 Go 语言可能会有以下几点认识:

这些陈述中的第一点是毋庸置疑的。至于其它几点,你可以先参考本文的研究数据,再重新评估一下你还会有多坚决地继续持有这些观点...

我们使用真实的 Go 程序应用来进行对于并发漏洞的第一个系统性研究。我们研究了六个 Go 的软件(项目),包括:Docker,Kubernetes 和 gRPC。我们一共分析了 171 个并发相关的漏洞,其中超过一半是非传统的、Go 语言特有的问题。除了造成这些漏洞的根本原因外,我们还研究了它们的修复方式,通过实验重现这些漏洞,并使用两个公开的 Go 漏洞侦测工具进行了漏洞扫描。

这六个用于研究的应用是:Docker,Kubernetes,etcd,CockroachDB,gRPC 以及 BoltDB,显然这些都是现实世界中重量级的 Go 代码。

在继续研究它们的并发漏洞之前,我们先从研究这些应用实际是如何使用 Go 的并发原语开始。这些漏洞可以从两个主要的维度进行分类:表现行为(阻塞或非阻塞),以及造成问题的并发原语的机制(共享内存或消息传递)。我们先快速回顾一下 Go 语言的主要并发机制。

Go 的并发机制

Go 语言的一个主要设计目的,就是改进传统的多线程编程方式,简化并发编程使其不易出错。为了达到这个目的,Go 语言将它的多线程设计汇聚在了两点原则上:1 )使线程(称之为 goroutines,go 协程)变得轻量且易于创建; 2 )使用显式的消息传送(通过 channels 实现)进行线程通信。

Go 协程是轻量的用户态线程(「绿色」线程)。在函数调用(包括匿名函数)前面加上 go 关键字,就能创建一个协程。匿名函数可以访问到在其之前申明的本地变量,而且它们是被共享使用的。Channels 用于在协程之间传送数据和状态,而且可以使用缓冲或不使用缓冲。当使用无缓冲 channel 的时候,一个协程在发送(或者接收)时会被阻塞,直到其它的协程进行了数据接收(或者发送)。select 语句可以让一个 go 协程同时监听多个 channel,如果多于一个 channel 可用的时候,Go 会随机选择一个分支执行。Go 语言还支持传统的同步机制原语包括互斥,条件变量和原子变量。

Go 并发原语在实践中的应用

这六个应用都大量使用了 Go 协程,尤其是用于匿名方法。

在研究 gRPC 的时候,由于它既有 C 的实现也有 Go 的实现,比较起来结果就很有趣。下面的表格展示了处理相同数量的请求时,使用 gRPC-Go 和 gRPC-C 创建的协程数量比率。

在对比表格中,go 协程相比 C 版本创建的线程有更短的生命周期,但是创建的频度更高。这种高频繁使用协程的行为是 Go 语言所推崇的。

如果我们审视所有这些应用对并发原语的使用统计,会有一个更加令人惊讶的发现,共享内存的同步操作仍然比消息传递使用得多。

最常出现的消息传递原语是 chan,它的使用量中在所有应用中占比 18.5% 到 43%。所以,现在的情形是传统的共享内存方式的通信还是被大量使用,与大量的消息传递原语同时并存。从漏洞的角度来看,我们拥有了不漏洞类型发生的可能性数据:共享内存通信造成的,消息传递造成的以及两者共同作用造成的!

Go 并发漏洞

作者搜索了这些应用的 Github 提交历史,从中找到了修复并发漏洞的提交(共 3211 个)。从这些漏洞中随机选取了 171 个用于研究。

这些漏洞被分为阻塞漏洞和非阻塞漏洞。当一个或多个协程在执行中意外卡主无法推进时,阻塞漏洞就产生了。这个定义比死锁更宽泛,包含了循环等待以外的情况,但是不包括对其它非协程提供资源的等待。其中包含 85 个阻塞漏洞和 86 个非阻塞漏洞。

我们还从另一个维度对漏洞进行了划分,看它们是否与共享内存保护相关( 105 个)还是和消息传递相关( 66 个)。

阻塞型漏洞

首先让我们来看看阻塞漏洞,其中的 42% 都与共享内存相关,另外 58% 与消息传递相关。上文提到过共享内存原语实际上比消息传递原语使用得更多。

与普遍认为的消息传递不易犯错相比,我们的研究显示更多的阻塞漏洞是由错误的消息传递造成的,而不是由错误的共享内存保护造成的。

共享内存相关的漏洞包括一般的常见情况,和因 Go 语言中 RWMutext 和 Wait 的实现而产生的新情况。

对于消息传送相关的漏洞,许多都是因为 channel 丢失了发送或接受方,或者忘记了关闭 channel。

所有消息传递引起的阻塞漏洞都与 Go 特有的消息传递语法相关,比如 channel。这些漏洞很难发现,尤其是当消息传递和其它的同步原语一起使用的时候。与一般的认识不同,消息传递会比共享内存方式造成更多的阻塞漏洞。

在调查了这些漏洞的修复之后,会发现弄明白漏洞产生的原因之后,它们修复起来都相当简单,而且修复的类型都与造成漏洞的起因相关。这说明在 Go 语言中,使用全自动或半自动的工具修复阻塞型漏洞是很有前景的一个方向。

Go 的内置死锁探测器只能检测到此次研究中 21 个阻塞漏洞中的两个。

非阻塞型漏洞

与消息传递相比,非阻塞型漏洞更多是由于共享内存的错误使用造成的。有一半的非阻塞漏洞符合「传统」的内存共享漏洞模式。还有一些漏洞是由于缺乏对 Go 语言特性的理解,尤其是前置申明的本地变量,在协程中被调用的匿名函数共享使用,以及 WaitGroup 的语法。

Go 语言为简化多线程编程而引入的新编程模型和新工具库,可能造成更多的并发漏洞。

消息传送型的非阻塞漏洞则相对不那么常见,“编程语言中这些复杂的消息传递机制,与其它的语言特性组合起来,可能造成这些漏洞很难被发现”

有趣的是,修复共享内存漏洞的程序员,更喜欢使用消息传送机制来修复这些问题。

Go 语言的数据竞争探测器可以探测出此次研究中一半的非阻塞漏洞。

查看全文

2921 次点击
所在节点    Go 编程语言
1 条回复
lhx2008
2019-07-27 15:16:21 +08:00
emmm,在说啥?

这是一个专为移动设备优化的页面(即为了让你能够在 Google 搜索结果里秒开这个页面),如果你希望参与 V2EX 社区的讨论,你可以继续到 V2EX 上打开本讨论主题的完整版本。

https://www.v2ex.com/t/586591

V2EX 是创意工作者们的社区,是一个分享自己正在做的有趣事物、交流想法,可以遇见新朋友甚至新机会的地方。

V2EX is a community of developers, designers and creative people.

© 2021 V2EX