如果这些信息已经为人所知,我表示歉意,但我没有找到相关参考,我想理解正在发生的事情,并与你们分享,因为我认为这样做有些价值。


如果这还不为人所知,我向 Go 团队道歉,没有先与他们商量就直接公开了(我认为问题没那么严重)。我真的很喜欢 Go!感谢你们的所有出色工作。

 问题


昨晚,我在探索 Go 的校验和数据库的内容时,注意到一个奇怪的结果:

sqlite> select path, count(path) from modules group by path order by count(path) desc;
github.com/homebrew/homebrew-core|39438
github.com/Homebrew/homebrew-core|30896
github.com/concourse/concourse|25372
github.com/openshift/release|24065
github.com/cilium/cilium|22138


家庭酿啤酒/Homebrew 的差异由 Go 的文档解释(感谢 Filippo Valsorda!):


为了避免在不区分大小写的文件系统中提供服务时产生歧义,$module 和 $version 元素通过将每个大写字母替换为与其对应的小写字母前加感叹号的方式进行大小写编码。这样,模块 example.com/M 和 example.com/m 都可以存储在磁盘上,因为前者被编码为 example.com/!m。


总之,这引起了我的注意,因为众所周知 Homebrew 使用 Ruby,所以我去检查了仓库的内容。


GitHub 语言统计数据证实了这一点:

language stats


这个结果有些出人意料,因为没有 Go 的痕迹,而且 Go 的校验和数据库中有超过 70,000 个条目。为了确认,我克隆了存储库并尝试查找任何与 Go 相关的内容,如 go.mod 或 Go 源文件;但什么也没有找到。


所以我发布了一条推文(在 Mastodon 上实际上是“嘟文”),没有收到回复,就继续前行了。


在继续探索数据库时,我注意到了 github.com/Edu4rdSHL/rust-headless-chrome 中的另一个不寻常的案例。它只是 rust-headless-chrome 的一个分支,并且这个分支或原始仓库都没有什么特别之处,除了它们都是 Rust 语言的仓库,而且再次与 Go 没有任何关联。


现在我感到好奇,而邪恶意图模式开启。似乎可以无需通过 Go 语言连接,就能将任意数据推送到校验和数据库中。为什么要推送数据?又是如何推送的呢?带着这个问题入睡,这是安全研究中最危险的时刻。在试图入睡的过程中,我会想到很多主意,但通常因为太累或懒惰而不记笔记,所以经常早上醒来就忘记了。但这次我没有忘记!

 研究


新的一天,充满好奇的心灵渴望答案。为什么,如何,如果会怎样,这些问题在这个领域最为危险。如果一个 Git 仓库与 Go 代码无关,那么它是如何出现在 Go 的校验和数据库中的呢?


从之前的文档阅读中,我知道 proxy.golang.org 是默认的模块代理,而 sum.golang.org 用于校验和数据库。在 Go 源代码中进行了几次 ripgrep 搜索,没有找到任何有趣的内容,所以是时候阅读 Go 的文档了,它通常非常不错。


从哪里开始呢?Go Modules 参考文档是一个很好的起点,我最终找到了问题的答案


如果`go`命令查询校验和数据库,那么第一步是通过/lookup 端点检索记录数据。如果模块版本尚未记录在日志中,校验和数据库会在回复之前尝试从原始服务器获取它。


好的,这很简单!如果模块不在校验和数据库(和代理)中,它将由校验和和代理基础设施下载。我的问题之一是:校验和数据库如何检索模块,因为它们可以位于任何位置?我在 Go 代码中找不到负责此操作的任何内容(这根本无法解释 Ruby 和 Rust 代码是如何进入数据库的)。


那么,接下来的逻辑步骤就很简单了。我能让你的 Go 校验和服务器下载任意数据吗?


根据文档,尝试此功能的端点是 $base/lookup/$module@$version


返回关于$module 在$version 的条目的日志记录号,后跟记录的数据(即,$module 在$version 的 go.sum 行)和包含记录的签名编码的树描述。


首先,让我们用一个已知的记录来测试它,看看是否以及如何工作:

$ curl https://sum.golang.org/lookup/github.com/homebrew/homebrew-core@v0.0.0-20240524162643-646fe2715a1c
26235981
github.com/homebrew/homebrew-core v0.0.0-20240524162643-646fe2715a1c h1:U32osaj3vZGypOtq7tsIHhZAYNOmiShiXJysIFGTqyM=
github.com/homebrew/homebrew-core v0.0.0-20240524162643-646fe2715a1c/go.mod h1:TM9a6pxWZJZZWuMzxESXhb6yaBaH9JAKDM4wpIzJsDE=

go.sum database tree
26238433
TQyXJYWJL6Z1OnKk5JXLAb9xfWrtHKjAUXKx5UQCa9Q=

— sum.golang.org Az3grm+I35+HBcG+YvxlX+nzkXah3cWlBac/4EytsG24bEHFLrJNvyz5SphrKAHSS0EeDKJXpnb3cvdUtqVSiaNLVAY=


由于存储库似乎没有任何版本标签,因此使用了伪版本。Go 文档解释了伪版本背后的逻辑。


下一步是验证是否通过调用 lookup 端点,如所述,将新的 Go 模块存储库添加到校验和数据库和代理中。


创建了一个简单的新的 Go 模块并上传到我的 GitHub 账户后,我尝试以两种不同的形式发出 lookup 命令,一种没有完全按照文档来,另一种也不正确但试图遵循文档。两者都返回了错误,尽管错误不同。

$ curl https://sum.golang.org/lookup/github.com/gdbinit/fluxmatter@latest
bad request: version "latest" is not canonical (wanted "")

$ curl https://sum.golang.org/lookup/github.com/gdbinit/fluxmatter@v0.0.0
not found: github.com/gdbinit/fluxmatter@v0.0.0: invalid version: unknown revision v0.0.0


由于我没有为模块打版本号,也没有使用正确的伪版本号,出现这些错误是意料之中的。但我们可以按照文档描述来验证是否成功获取了新模块。最简单的方法是生成正确的伪版本号,然后再次查询校验和数据库。如果模块确实被下载了,那么条目就会存在,并像 homebrew-core 测试中那样返回。


另一种方法是重新同步我的校验和数据库,然后查询我的模块

sqlite> select * from modules where path = 'github.com/gdbinit/fluxmatter';
github.com/gdbinit/fluxmatter|v0.0.0-20240524163826-a7e64ffd69f2|2024-05-24T16:40:51.203837Z


最后,我们可以查询代理,并使用 latest 查询来返回模块的最新已知版本。然后下载模块 zip ,证明我们刚刚在 Go 基础设施中存储了任意数据。

$ curl https://proxy.golang.org/github.com/gdbinit/fluxmatter/@latest
{"Version":"v0.0.0-20240524163826-a7e64ffd69f2","Time":"2024-05-24T16:38:26Z","Origin":{"VCS":"git","URL":"https://github.com/gdbinit/fluxmatter","Hash":"a7e64ffd69f2d0751a52736e832a8d77a21059e7"}}

$ curl -O https://proxy.golang.org/github.com/gdbinit/fluxmatter/@v/v0.0.0-20240524163826-a7e64ffd69f2.zip
$ file v0.0.0-20240524163826-a7e64ffd69f2.zip
v0.0.0-20240524163826-a7e64ffd69f2.zip: Zip archive data, at least v2.0 to extract

$ unzip -t v0.0.0-20240524163826-a7e64ffd69f2.zip
Archive:  v0.0.0-20240524163826-a7e64ffd69f2.zip
    testing: github.com/gdbinit/fluxmatter@v0.0.0-20240524163826-a7e64ffd69f2/LICENSE   OK
    testing: github.com/gdbinit/fluxmatter@v0.0.0-20240524163826-a7e64ffd69f2/fluxmatter.go   OK
    testing: github.com/gdbinit/fluxmatter@v0.0.0-20240524163826-a7e64ffd69f2/go.mod   OK
No errors detected in compressed data of v0.0.0-20240524163826-a7e64ffd69f2.zip.


好了,一切正常工作!在查找时无需指定版本(至少对于初始播种来说是这样),只需要包含模块路径和类似版本的查询。


接下来,我尝试对一个完全没有 Go 代码的仓库做同样的事情,以证明一切都能照常工作。

$ curl https://sum.golang.org/lookup/github.com/gdbinit/readmem@v0.0.0
not found: github.com/gdbinit/readmem@v0.0.0: invalid version: unknown revision v0.0.0

sqlite> select * from modules where path = 'github.com/gdbinit/readmem';
github.com/gdbinit/readmem|v0.0.0-20131006075740-407cb0a56933|2024-05-24T16:45:35.88456Z

$ curl https://proxy.golang.org/github.com/gdbinit/readmem/@latest
{"Version":"v0.0.0-20131006075740-407cb0a56933","Time":"2013-10-06T07:57:40Z","Origin":{"VCS":"git","URL":"https://github.com/gdbinit/readmem","Hash":"407cb0a569336f98f3772582a31c17aa080caf66"}}

$ curl -O https://proxy.golang.org/github.com/gdbinit/readmem/@v/v0.0.0-20131006075740-407cb0a56933.zip
$ file v0.0.0-20131006075740-407cb0a56933.zip
v0.0.0-20131006075740-407cb0a56933.zip: Zip archive data, at least v2.0 to extract

$ unzip -t v0.0.0-20131006075740-407cb0a56933.zip
Archive:  v0.0.0-20131006075740-407cb0a56933.zip
    testing: github.com/gdbinit/readmem@v0.0.0-20131006075740-407cb0a56933/Entitlements.plist   OK
    testing: github.com/gdbinit/readmem@v0.0.0-20131006075740-407cb0a56933/README   OK
    testing: github.com/gdbinit/readmem@v0.0.0-20131006075740-407cb0a56933/readmem.xcodeproj/project.pbxproj   OK
    testing: github.com/gdbinit/readmem@v0.0.0-20131006075740-407cb0a56933/readmem/main.c   OK
No errors detected in compressed data of v0.0.0-20131006075740-407cb0a56933.zip.


这表明可以将任意数据加载到 Go 的公共代理中。实验是使用 GitHub 进行的,但应该与其他托管站点兼容。


一个有趣的数据是 GitHub 上存储的 Go 模块数量:

sqlite> select count(distinct path) from modules;
1591375
sqlite> select count(distinct path) from modules where path like 'github.com%';
1515957


大约 95%的 sum.golang.org 中独特的路径都托管在 GitHub 上。这是一个未经清理的数据,未去除如 forks 和并非真正 Go 代码的目标等无效信息。但这仍然显示了 Go 生态系统对 GitHub 的依赖程度之大。


Go 语言的作者似乎并非完全忽视了这类情况,并实现了一些限制,这些限制在“文件路径和大小限制”部分有描述。其中最相关的是:


模块的 zip 文件大小不得超过 500MiB,其文件解压缩后的总大小也限制为 500MiB。go.mod 文件限制为 16MiB。LICENSE 文件也限制为 16MiB。这些限制是为了减轻对用户、代理和模块生态系统的其他部分的拒绝服务攻击。如果仓库中模块目录树包含超过 500MiB 的文件,应将模块版本标记在仅包含构建模块包所需文件的提交上;通常,视频、模型和其他大型资产在构建时是不需要的。


500MiB 对于大量的滥用来说绰绰有余,而其他都不是问题。

 滥用什么?


例如,它可以用来绕过开发机器和 CI/CD 服务器上的目标下载限制(假设没有私有 GOPROXY)。恶意软件可以简单地存储有效载荷,并在需要时从代理检索。而且,由于我们对源域名没有限制(只要它是一个有效的 VCS),我们可以从任何地方加载有效载荷,使这些源消失,只在校验和数据库条目中留下一小部分痕迹。


proxy.golang.org 的拒绝服务(DoS)攻击可能难以实施。我已经证明,我们可以请求代理下载任何随机的 Git 仓库(以及可能支持的其他 VCS)。为了进行可能的攻击,我们需要首先收集尽可能多的 GitHub 网址,然后向 lookup API 发出尽可能多的请求。我对服务器实现一无所知,但我想可能会实现类似工作队列的东西,因此并行处理的请求数量有限制。GitHub 方面也可能触发带宽保护。还有可能对存储空间进行 DoS 攻击。我只是在这里猜测。


这种情况下很容易实现命令与控制(C2)功能。使用 latest 查询可以轻松找到任何模块的最新版本,因此无需查询校验和数据库以查找可用的有效载荷版本。有效载荷可以是一个简单文件,也可以隐藏在 go.mod 或任何其他 Go 源文件中以增加隐蔽性。可以使用 DGA(域生成算法)模块来避免只使用单一仓库进行 C2 通信。我最初的打算是编写一个示例 C2 来展示这一点,但这里实际上没有太多工作要做。


从 C2 下载命令,植入物只需要以下步骤:


  • https://proxy.golang.org/module_path/@latest 发起请求。


  • 解析 JSON 结果并提取伪版本(或如果使用的话,提取版本)。


  • https://proxy.golang.org/module_path/@v/version.zip 发起另一个请求以下载 zip 文件。


  • 提取 zip 文件内容并解析命令。


用 Go 语言写成,大约 300 行或更少的代码,相当简单。

 结论


我的问题已经得到了解答,现在我明白了校验和数据库流程是如何工作的。目前,在 Go 的基础设施中,这并不是一个严重的问题。这是一个容易被滥用但也可以改进的地方。也许(无论是否记录)有让非 Go 代码上传到代理和校验和数据库的原因。或者可能已经有滥用这种情况的人,我们可以在这个近 160 万个唯一存储库(我的最新数据库副本包含近 2200 万个条目)中进行寻宝游戏。


我仍然有疑问,为什么某些有效的非 Go 项目在数据库中。他们是故意的吗?为什么?使用 Go 的透明日志作为安全备份?对此有什么提示吗?


我玩得很开心,还有很多想法想要继续研究。我有种感觉……😉

 玩得开心
fG!