本文翻译自:Fifty Years of Open Source Software Supply Chain Security
几十年来,软件复用是一个遥远的目标。现在,它变得非常真实。
1972年3月,美国空军开始审查霍尼韦尔Multics系统,以了解它是否可以在安全环境中使用。该报告于1974年中期发布,结论是Multics虽然不安全,但优于其同类系统,可能是一个构建安全系统的合理起点。报告提出了在“无害”的系统调用中添加后门(当时称为“陷阱门”)的潜在可能性。
当传递一个特定且极不可能的输入时,系统调用允许读取或写入内核内存的任意字。这个微小的变化将完全破坏系统的安全性,报告调查了这种变化可能如何被实施和隐藏的机制。
2024年3月,安德烈斯·弗雷德(Andres Freund),一位在微软工作的Postgres开发者,注意到他的Debian Linux系统的ssh守护进程在处理互联网上常见的背景攻击流量时,CPU占用率比平时高。经过进一步调查,弗雷德发现,Debian系统上ssh链接的压缩库liblzma的最新版本中存在一个针对ssh的特定后门。现在,当传递一个特定且非常不可能的输入时,ssh守护进程会允许互联网上的攻击者执行任意shell命令。这个微小的变化完全破坏了尖端Debian系统的安全性,在接下来的几周里,世界各地的安全研究人员都在调查这个变化是如何被做出并隐藏起来的。
由于liblzma是作为xz项目的一部分分发的,因此这次攻击现在被称为xz攻击。
软件供应链安全问题在过去半个世纪中轮廓没有改变,因为它们是根本性的。在计算机安全领域没有简单的答案;软件供应链安全也不例外。我们能做的最好的事情就是不断提升我们的防御措施,而许多有希望的加强措施尚未得到普遍部署。本文旨在突出一些应该更广泛使用的有希望的方法,并指出需要更多工作的领域。
我领导了Go编程语言和环境的开发工作超过十年,软件供应链安全是那个努力的一个具体焦点。本文基于那项工作,并从我的个人经验中汲取了一些例子,以及从整个软件行业汲取的例子。
探索问题
开源软件供应链安全是一个热门话题,尤其是在xz攻击之后,但究竟这意味着什么呢?在没有达成共识的定义的情况下,我建议以下这个包含三个部分的定义:
-
开源软件供应链攻击是指在交付前将恶意开源代码插入到可信软件中的行为。(借鉴了Kim Zetter的一个定义)
-
开源软件供应链漏洞是指由第三方开源组件引起的,在可信软件中存在的可利用的弱点。
-
开源软件供应链安全是针对开源软件供应链攻击和漏洞的防御工程。
这个定义有几个重要的细微差别。
首先,这里的硬件供应链并不在考虑范围内。例如,2013年,《明镜》杂志报道,美国国家安全局(National Security Agency,简称NSA)可以拦截针对目标的新计算机订单,并在其中安装后门软件或硬件组件。这种物理攻击超出了本讨论软件供应链的范围,尽管在某些情况下仍然值得考虑和防御。
第二个细微差别是,封闭源代码的软件组件并不引起关注。例如,在2012年,攻击者入侵了Juniper Networks,并更改了VPN(虚拟专用网络)的源代码,将随机数生成器中的一些关键常量进行了替换。结果是创建了一个后门,使得攻击者能够解密Juniper客户通过这些设备发送的所有VPN流量。这是一种软件供应链攻击,因为它在软件交付给Juniper客户之前就更改了软件。但它不是开源软件供应链攻击,因为恶意更改并未发生在Jupiter VPN所使用的开源组件之一。(随机数生成器最初之所以容易被后门攻击,多亏了NSA可能被认为是一次算法供应链攻击。)
作为一个例子,中国的开发者经常在中国国内的文件分享网站上寻找Xcode的副本,这些副本下载速度更快。2015年,安全研究人员发现攻击者发布了一个修改过的Xcode副本,并努力使其成为“Xcode下载”的中文搜索结果之首。这个版本,研究人员将其命名为XcodeGhost,被恶意修改,向其构建的每个iOS应用中添加恶意代码。许多应用开发者下载并使用了它,注入的恶意软件至少进入了两个广泛使用的应用中。这是对分发机制的软件供应链攻击,而不是针对原始软件,但,同样,它并不是针对开源软件。
第三个细微差别在于,受影响的软件本身并不需要是开源的。例如,2021年安全研究人员发现,开源的Java日志库Log4j在以特定格式记录文本时,会从任意URL下载并执行Java代码。Log4j在Java生态系统中被广泛使用。以数百万个受影响程序中的一个为例,在流行的游戏Minecraft中,只需发送一条聊天消息就足以在游戏服务器上实现远程代码执行。因此,Minecraft是一个受开源软件供应链漏洞影响的闭源程序的例子。由于几乎所有闭源程序都使用开源组件,它们都依赖于良好的开源软件供应链安全。
一个细微的区别是,涉及恶意编写代码的攻击与涉及无辜错误的漏洞是有区别的。例如,2021年苹果修复了一个漏洞,该漏洞允许通过发送带有特别定制的图像附件的iMessage来实现所谓的零点击接管iPhone设备。附件自称为GIF,但实际上是一个包含JBIG2图像的PDF。苹果的软件使用了开源的Xpdf JBIG2解码器,该解码器是用C语言编写的,而这个解码器没有正确验证图像中的编码Huffman树;这使得攻击者能够在分配区域之外的控制偏移量处触发内存中的位操作。攻击者利用这些位操作实现了一个完整的虚拟CPU,然后在虚拟指令集中编写代码来扫描进程内存,跳出iMessage沙盒,并接管电话。
JBIG2漏洞是意外(而非恶意)引入的,因此它是一个开源软件供应链漏洞,而不是攻击。漏洞和攻击是不同的问题,具有不同的潜在解决方案。
作为另一个例子,2018年研究人员发现npm软件包event-stream中包含隐藏的代码,当其被链接到Copay移动应用时,会窃取比特币钱包。这段恶意代码明确针对Copay,使该应用成为受开源软件供应链攻击影响的闭源程序的例子。
尽管最后两个例子最终影响了闭源应用,对纯开源软件栈的攻击也是可能的。xz攻击正是这样做的,它通过OpenSSH软件供应链的一个组件——liblzma——攻击了OpenSSH,而不是直接攻击OpenSSH的源代码或项目本身。即使是纯开源攻击也可能造成毁灭性的后果:如果在其被发现之前再过几个月,被后门化的sshd(Secure Shell守护进程)就会在全球敏感环境中部署。
尽管没有一劳永逸的解决方案,但本文的剩余部分将突出工程更好防御的一般主题以及目前正在采取的实用步骤。
理解软件供应链
为了确保您的软件供应链安全,您首先必须了解它是什么。让我们从定义开始:软件供应链 是软件供应链攻击可能发生或漏洞可能被引入的所有地方。然而,理解 的更重要意义是知道您特定的软件供应链是什么样子,而这实际上非常困难。单词 chain 听起来很简单,但供应链就像分形:无论您多么仔细地观察,它都是复杂的。
在最低层面,您可以查看构建单个程序所执行的命令以及这些命令之间的依赖关系。这些构建图与程序本身的包依赖结构相一致。即使是简单的程序,这些图也复杂到无法打印出来。
Go项目将避免不必要的依赖并保持软件简单作为一项优先任务,然而,在我撰写本文时,构建go
命令需要执行714个命令来构建297个包,其包图中有3,132条依赖边。go
命令不同寻常,因为它没有外部依赖:它所使用的所有包都是Go项目本身的一部分。观察一个稍微复杂一些的命令,Kubernetes的kubelet在其构建中执行了3,289个命令,并依赖于137个Go模块中的1,581个包,其中包括许多来自Kubernetes项目外部的包。
这两个例子都是相当小的、低层面的实用工具。更高层次的程序甚至有更复杂的构建。在本期《acmqueue》的另一篇文章中,Josie Anugerah和Eve Martin-Jones详细探讨了开源构建图的复杂性,以及大多数程序都有许多可能的构建图这一令人惊讶的事实,这取决于它们构建的确切上下文。
这类依赖图很容易让人误以为这就是整个软件供应链的全部,但实际上它们只是最显而易见的一部分。依赖图中的每个包或模块可能由不同的人或组织编写,他们可能拥有不同的安全实践、代码审查标准等等。了解每个依赖项的这些细节会有所帮助,但通常这些信息是不可用的,并且随着时间的推移可能会发生变化。
另一种图表展示了软件在构建过程中以及向用户分发过程中所经过的计算机和服务。在这些计算机或服务中的任何一个上进行的恶意修改都可能成为不同的潜在攻击点,更不用说它们可能成为漏洞的潜在来源。你应该关注谁有权访问每个依赖项目,未来谁可能有权访问,他们使用的基础设施是什么,等等。在很大程度上,如果没有对这些信息的可见性,它们就会被忽视。但所有这些仍然存在。
了解软件供应链对于确定哪些环节需要加强至关重要。作为一个行业,我们在这里还有许多工作要做,但为了这篇文章,让我们继续探讨已经知道可以帮助加强的具体措施。
验证软件
Multics审查曾考虑在“分发阶段”插入后门,利用“不安全的电信”以及发送带有伪造信笺的恶意更新。虽然措辞可能已经过时,但思想本身并未过时。XcodeGhost就是这种方法的现代例子。解决这个问题是现代软件供应链安全领域最接近真正成功故事的事情。加密签名使得在签名和验证之间恶意篡改代码变得不可能。唯一剩下的问题是密钥分发:验证者必须知道谁应该对代码进行签名。
关于密钥分发问题,有许多可能的解决方案。最简单的一种是忽略身份问题,仅仅记录和分发在特定构建或包管理器中使用的特定依赖版本的预期加密哈希值。这些预先分发的哈希值的验证完全消除了下载服务器、代理和其他网络中间节点作为潜在攻击点的可能性。Debian的依赖项打包系统包括这样的检查,这意味着xz攻击者不能简单地修改现有的xz副本;他们需要发布一个新版本。这并没有阻止攻击,但它确实使得攻击变得更加困难。
在更大规模上,而不是预先分发所有这些哈希值,它们可以保存在一个可信的数据库中。Go校验和数据库是一个现实世界中的例子,该例子采用了这种方法,保护了数百万名Go开发者。该数据库包含了每个公共Go模块每个版本的SHA256校验和。每个数据库条目都由数据库服务器的私钥签名。相应的公钥在Go命令源代码中硬编码,因此密钥分发依赖于Go分发的其余部分。
每次执行 go
命令下载新的开源 Go 包时,它都会查找预期的校验和。对于给定项目中的依赖项,存在一个本地的校验和缓存,因此只有在进行升级或添加新依赖项时才会发生对校验和服务器的网络调用,但无论如何,每个下载都会进行检查。这意味着在代码托管和用户计算机之间的所有代理和其他盒子都不能成为攻击站点。即使攻击代码托管站点,也无法更改旧包。
当然,有一个问题需要考虑,那就是将哪个校验和放入数据库中。对于Go语言来说,如果数据库还没有记录特定软件包的版本,它会直接获取代码并存储获取到的代码的校验和。这种“首次使用即信任”的方法并不意味着代码是可信赖的,但它确实意味着如果明天有人在另一台电脑上下载它,代码不会发生变化。这种不可变性确保了整个Go生态系统对Kubernetes版本1.28.4的含义达成一致,这为任何其他分析工作奠定了基础。
在解决身份问题的范围内,让作者为其软件签名可以提供更强的保证。以xz为例,发行版软件包使用了个别作者的GPG(Gnu Privacy Guard)密钥进行签名,这使得可以区分由xz的原始(可信)维护者签名的软件包和由控制了项目的攻击者签名的软件包。
使构建可重现
Multics 审查指出,一个恶意的更改“最好隐藏在编译例程的二进制代码更改中”,而相应的源代码则保持不变。这种更改只有在从源代码重新构建后才会持续存在,但大多数安装在没有理由的情况下不会重新构建源代码。这至今仍是一个问题。例如,在构建过程中触发 xz 攻击的关键代码行仅包含在打包的分发中,而不是实际的源代码控制库中。
验证二进制文件未被修改的最佳且最明显的方法是重新构建它们,并将结果与分发的二进制文件进行比对,但这假设构建过程是可重复的。由于计算机是确定性的,这似乎应该很简单,但实际上,由于构建机器的架构或机器名称、临时目录的名称或当前时间等上下文信息很容易出现在某些构建输出中,导致整体构建不可重复。可重复构建项目旨在提高人们对可重复构建的认识,同时构建工具以帮助所有Linux软件实现完全可重复构建的进步。
Go项目最近安排了让Go语言本身在只有源代码的情况下完全可重现,这意味着尽管构建需要一个运行某些操作系统和某些早期Go工具链的计算机,但这些选择并不重要。针对特定目标的构建,无论是在Linux、Windows还是Mac上构建,无论是在X86还是ARM上构建,都会产生相同的分发二进制文件。强大的可重现性使得其他人可以轻松地验证发布的二进制文件与源代码是否匹配。这些二进制文件也记录在Go校验和数据库中,当go
命令下载新的工具链时进行验证,以确保下载过程中无法修改。
验证软件和使构建可重现消除了潜在的攻击向量,尽管这当然不是全部。现在让我们将注意力转向漏洞。
快速发现和修复漏洞
五十年前,人们曾抱有一丝希望,认为通过正确的设计和谨慎的实施,可以使软件完全安全。如今我们深知这一点。既然我们承认软件始终存在漏洞,那么我们就必须做好准备,在漏洞出现时尽快发现并修复它们。
攻击者也在寻找这些漏洞,因此最好的防御策略就是先于他们发现并修复这些漏洞。最简单的例子是过时的依赖项,其中包含已知的漏洞。有许多可用的漏洞扫描工具可以识别这种情况,无论是特定语言的工具如govulncheck和npm audit,还是通用的开源工具如osv-scanner,或者是商业工具。所有这些工具的工作原理都是通过将构建软件的输入列表——即“软件物料清单”——与已知漏洞数据库进行交叉核对。
工具或数据库的具体选择现在并不像过去那样重要。开源软件社区已经将 OSV(开源漏洞)格式标准化,用于描述单个漏洞,包括受影响软件包和版本的精确、算法性描述。然后,OSV 数据库聚合了所有特定语言的数据库。CVE(通用漏洞与暴露)数据库的 JSON 5.0 架构也采用了 OSV 关于受影响软件包和版本的精确信息,使得 OSV 和 CVE 之间能够进行信息交换。对于所有工具都能访问关于已知漏洞的相同、完整信息,这对每个人都是有益的。
定期扫描您的软件非常重要,理想情况下是每天一次,因为即使您的软件没有发生变化,数据库中总是会有新的条目被添加。然后您需要准备好更新到该依赖项的修复版本。这需要进行全面测试以确保修复版本不会引入任何新的错误,以及拥有自动化部署,以便您的软件的修复版本可以在数小时或数天内发布,而不是数周或数月。
测试和部署是标准软件工程关注的问题,虽然它们并非专门关于供应链安全,但如果没有这些措施,您的安全态势会受到损害,您的法律风险也可能增加。当2021年发现Log4j漏洞时,大多数公司需要花费数周或数月(或更长的时间)来清点他们所有的软件,以确定哪些软件受到影响,然后更新和重新部署这些软件。
即使是美国联邦贸易委员会(Federal Trade Commission, FTC)也发布了一份声明,警告公司更新Log4j以“降低对消费者造成伤害的可能性,并避免联邦贸易委员会的法律行动”,并指出了Equifax因未打补丁的软件导致的入侵而承担的前期责任。
扫描已知漏洞只是最低要求。理想情况下,还应该花费精力寻找你开源依赖项中尚未发现的漏洞。当你对自己的源代码进行安全审计时,识别关键的开源依赖项并对它们进行审计通常也是值得的。对你的软件及其依赖项运行漏洞发现分析工具或模糊器也可能是有效的。攻击者将会使用所有这些方法;你不妨先使用它们。
预防漏洞
尽管软件总会存在漏洞,但你可以采取一些措施来预防某些类型的漏洞或降低它们发生的可能性。
首先,避免不必要的依赖项。戈登·贝尔曾观察到:“[t]计算机系统中成本最低、速度最快、最可靠的组件是那些不存在的组件。”最安全的软件依赖项是那些一开始就没有使用的依赖项:每个依赖项都增加了风险。
OpenSSH项目非常谨慎,避免承担不必要的依赖,但Debian并非如此。该发行版修补了sshd,使其链接到libsystemd库,而libsystemd库又链接到了各种压缩包,包括xz的liblzma库。Debian对sshd依赖性的放宽是攻击的关键触发因素,也是其影响仅限于基于Debian的系统(如Debian、Ubuntu和Fedora)的原因,避免了像Arch、Gentoo和NixOS这样的其他发行版。
在xz攻击部署几周前,系统开发者一直在讨论移除对liblzma等压缩器的依赖,特别是为了提高安全性。这只是一种纯粹的猜测,但这些讨论可能加速了攻击的部署时间表。
同样的教训适用于所有项目,无论大小。如果可能的话,没有依赖项通常是最好的选择。如果不行,小依赖项比大依赖项更好,并且传递依赖项的数量也很重要。不仅要看新增的一个依赖项,还要看它对整体依赖图的影响,可以使用像 Open Source Insights 这样的工具。
另一种预防漏洞的好方法是使用更安全的编程语言,这些语言移除了容易出错的特性或使这些特性变得不那么频繁地被使用。
在2022年,美国国家安全局(NSA)发布了一项关于“软件内存安全”的建议,鼓励使用内存安全语言,如C#、Go、Java或Rust,而不是C和C++。在C和C++的众多缺点中,手动内存管理和缺乏任何类型的边界检查使得程序过于容易出错,从而创造出安全漏洞。它们对“未定义行为”的依赖又增加了另一个危险层级。当然,世界上有大量的C和C++代码,这些程序不可能一夜之间就被放弃。然而,对于新的努力,采用更安全的语言具有显著的安全优势。
支持开源
2020年著名xkcd漫画描绘了“所有现代数字基础设施”都是建立在“内布拉斯加州某个随机人员在2003年以来默默维护的项目”之上。这幅漫画对现状的描述令人不安。
2014年,研究人员发现,OpenSSL库,这是一个被互联网HTTPS服务器广泛使用的库,会对一种特定的损坏数据包做出响应,发送回服务器内存的任意块。在某些情况下,这些内存包括服务器的密钥材料。这不是一次攻击,而是一个无辜的编码错误。(OpenSSL是用C语言编写的,因此这个错误很容易犯而且难以发现;在一个内存安全的语言中,带有适当的边界检查,这种情况几乎不可能发生。)
该漏洞被命名为“心脏出血”,并促使整个行业重新审视了xkcd漫画中所描述的 precisely the situation. 当时,OpenSSL 由少数志愿者维护,只有一名全职开发者。研究人员估计,一次大约需要 10 万美元的安全审计就能捕捉到这个错误,但该项目每年只收到了 2,000 美元的捐赠,尽管每年有数十亿美元的商务依赖于这款软件。
这次重新审视的一个结果是,Linux 基金会成立了核心基础设施计划(Core Infrastructure Initiative),后来演变成了开源安全基金会(Open Source Security Foundation,简称 OpenSSF)。
OpenSSF 是一个重要的进步,但它并没有解决现代数字基础设施依赖于资金不足的关键项目的问题。还需要做更多的工作。
xz攻击是最清晰的证明,表明问题并未得到解决。它之所以得以启用,既是因为开源项目资金不足,也因技术细节的问题。以下是xz攻击的故事。
Lasse Collin于2005年启动了xz项目,该项目使用了LZMA压缩算法,将文件压缩到gzip的约70%。随着时间的推移,这种格式被广泛用于压缩tar文件、Linux内核镜像以及其他许多用途。总的来说,该软件稳定可靠,不需要持续的大量关注。Collin并未因此获得报酬,这也不是他的全职工作。
2021年底,一名使用(几乎肯定不是真实)名字“Jia Tan”的攻击者开始在xz开发邮件列表上发送无害的补丁,这些补丁包含了一些小的改进。2022年中,该攻击者开始在邮件列表上使用其他账户和名字发帖,抱怨发布速度缓慢和新功能的缺乏,并施压要求Collin将控制权交给更有时间的人。他们写道:“当前维护者失去了兴趣或不再关心维护。看到这样一个仓库如此令人遗憾。”还有,“我明白这对所有贡献者来说都是一个爱好项目,但社区希望得到更多。为什么不传给其他维护者……?”
压力攻势取得了成效。在接下来的一年半时间里,柯林将越来越多的开发责任交给了这位攻击者,攻击者通过进行诚实的改进和完成重要的维护工作赢得了信任。2023年初,这位攻击者发布了他们的第一个官方xz版本;随着2023年的推进,他们为实际的攻击打下了技术基础,最终在2024年初发起了攻击。
在攻击被发现后的几个月里,大多数猜测都集中在xz攻击很可能是由国家黑客实施的这一可能性上,但双方都没有确凿的证据。无论责任在谁,这很可能并没有花费太多。一名合格的软件工程师全职在开源项目上工作两年以赢得维护者的信任,可能花费不到一百万美元。开发本身相当复杂的漏洞利用代码可能又要花费大约一百万美元。一个隐藏的后门,可以进入互联网上绝大多数Linux ssh服务器,其价值可能远超过这些,可能是数十亿美元。开源项目的普遍资金不足使它们直接容易受到这种看似诚实的免费帮助的影响。
xz攻击的社会工程学也不是一个孤立的事件。在前面提到的event-stream攻击中,攻击者只是简单地询问原作者是否希望有人接管维护。xz攻击之后,OpenSSF和OpenJS Foundation发布了一则警告,关于一个针对OpenJS未遂进行的类似活动。
如何最好地资助开源开发远非显而易见。在Heartbleed漏洞和Core Infrastructure Initiative启动十多年后,这个问题显然仍未得到解决。
结论
我们都正在努力应对在过去10到20年里软件行业发生的巨大转变。几十年来,软件重用只是一个宏伟的目标。而现在,这已经成为现实。现代编程环境,如Go、Node和Rust,使得重用他人的工作变得轻而易举,但我们的责任感本能尚未适应这一新现实。
1974年Multics审查预见到我们今天面临许多问题的现实,证明了这些问题是根本性的,并且没有简单的解决方案。我们必须努力不断改进开源软件供应链的安全性,使攻击变得越来越困难和昂贵。
我们今天可以采取的重要步骤包括,以某种形式采用软件签名,定期扫描已知漏洞,并在发现关键新漏洞时准备好更新和重新部署软件。越来越多的开发应转移到更安全的语言,以降低漏洞和攻击的可能性。我们还需要找到方法来资助开源开发,使其不太可能仅仅因为提供免费帮助而被接管。对OpenSSL和xz开发的相对较小投资本可以防止Heartbleed漏洞和xz攻击。
xz攻击似乎是对开源软件供应链的首次重大攻击。event-stream攻击类似,但并非重大,而Heartbleed和Log4j是漏洞,而不是攻击。但xz攻击实际上是被偶然发现的,因为它使得sshd在启动时速度变得略微缓慢。攻击的本质是试图保持隐蔽。我们有多大几率会在几周内意外地发现对开源软件供应链的首次重大攻击呢?也许我们非常幸运,或者也许我们错过了其他攻击。
Multics的审查因其指出向编译器添加后门的可能性而闻名,这种后门可以在编译过程中将后门插入关键系统程序中,正如XcodeGhost后来所做的那样,以及编译器自身可能成为后门的可能性,这样修改就会在编译器完全重新编译后仍然持续存在。阅读这份报告启发了Ken Thompson在1975年早期对早期Unix系统实施这种攻击。他在1983年的图灵奖演讲中解释了这种攻击,该演讲发表在《ACM通讯》上,题为“信任的反思”。Thompson保留了原始攻击源代码,并在他的许可下,我在2023年11月发布了一个带注释的副本。最令人不安的部分可能是它的简短:99行C代码和20行shell脚本。
在他的讲座中,汤普森说:“道德显而易见:你不能信任你自己没有完全创造出来的代码。”但如今,我们每天都在做这样的事情,不论这种信任是否合理。我们在最关键的应用中使用了从互联网上陌生人那里下载的源代码;几乎没有人检查这些代码。
劳伦斯·凯斯特洛特的优秀短篇小说《编码机器》想象了一个受到汤普森后门攻击的计算机世界。在我们实际的世界中,这种后门的复杂性根本不是必要的。有更简单的方法可以进行供应链攻击,比如问维护者是否需要一些帮助。如果能生活在一个需要汤普森和凯斯特洛特描述的那种复杂性的攻击的世界里,那会很好。
我们都有更多的工作要做。
阅读更多:XZ 开源攻击时间线