滥用 Go 的基础设施 | 逆向工程

如果这些信息已经为人所知,我深感抱歉,但我找不到任何关于它的参考资料,而且我想了解发生了什么并与你们分享,因为我认为这样做是有价值。

以防万一有人不知道,我向 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

Go 的 文档 (感谢 Filippo Valsorda!):

为了避免在不区分大小写的文件系统中出现歧义,$module 和 $version 元素采用大小写编码,方法是将每个大写字母替换为感叹号,后跟相应的小写字母。这样可以将模块 example.com/M 和 example.com/m 都存储在磁盘上,因为前者被编码为 example.com/!m。

无论如何,这引起了我的注意,因为众所周知 Homebrew 使用 Ruby,所以我去检查了 存储库 内容。

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

这个结果似乎出乎意料,因为 Go 没有任何踪迹,而 Go 的校验和数据库中有超过 70,000 个条目。为了确定,我克隆了存储库并尝试查找任何与 Go 相关的文件,例如 go.mod 或 Go 源文件;但是,什么都不存在。

因此我发布了一条推文(严格来说是 Mastodon 上的一条推文),但没有得到回复,然后就继续了。

在继续探索数据库时,我注意到另一个不寻常的情况 github.com/Edu4rdSHL/rust-headless-chrome. 它只是 锈无头铬,除了它们都是 Rust 存储库并且再次与 Go 没有任何联系之外,这个 fork 版本和原始版本并没有什么特别之处。

现在我的好奇心被激起了,邪恶模式开始发挥作用。感觉就像可以将任意数据推送到校验和数据库而无需连接到 Go。为什么要推送数据?它是如何推送的?我上床睡觉时一直在思考这个问题,这是安全研究最危险的时刻。当我试图入睡时,我想出了很多想法,但我通常太累或懒得记笔记,所以,很多时候,我早上都记不住它们。但这个并没有被忘记!

研究

新的一天和好奇的头脑需要答案。为什么、如何以及如果是这个领域中最危险的问题。如果 Git 存储库与 Go 代码无关,那么它如何出现在 Go 校验和数据库中?

从以前的文献阅读中,我知道 proxy.golang.org 是默认模块代理,并且 sum.golang.org 用于校验和数据库。 ripgrep 在 Go 源代码中搜索没有返回任何有趣的内容,所以是时候阅读 Go 的文档了,它通常非常好。

从哪儿开始? Go 模块参考 是一位很棒的候选人,我终于找到了我的问题的答案:

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

好的,这很简单!如果模块不存在于校验和数据库(和代理)中,它将由校验和和代理基础结构下载。我的一个问题是:既然模块可能位于任何地方,校验和数据库如何检索模块?我在 Go 代码中找不到任何负责此操作的内容(这根本无法解释 Ruby 和 Rust 代码如何最终进入数据库)。

那么,下一步就很简单了。我能让 Go 校验和服务器下载任意数据吗?

根据 文档,尝试这个的终点是 $base/lookup/$module@$version

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

首先,让我们用已知记录来测试它,看看它是否有效以及如何有效:

$ curl 
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 文档 解释伪版本背后的逻辑。

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

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

$ curl 
bad request: version "latest" is not canonical (wanted "")

$ curl 
not found: github.com/gdbinit/[email protected]: 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 
{"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 
$ 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/[email protected]/LICENSE   OK
    testing: github.com/gdbinit/[email protected]/fluxmatter.go   OK
    testing: github.com/gdbinit/[email protected]/go.mod   OK
No errors detected in compressed data of v0.0.0-20240524163826-a7e64ffd69f2.zip.

瞧,一切正常!无需在查找中指定版本(至少对于初始播种);只需包含模块路径和类似版本的内容的查找查询即可。

接下来,我尝试对没有任何 Go 代码的存储库执行相同操作,以证明一切都以相同的方式工作。

$ curl 
not found: github.com/gdbinit/[email protected]: 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 
{"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 
$ 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/[email protected]/Entitlements.plist   OK
    testing: github.com/gdbinit/[email protected]/README   OK
    testing: github.com/gdbinit/[email protected]/readmem.xcodeproj/project.pbxproj   OK
    testing: github.com/gdbinit/[email protected]/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 上。这是原始统计数据,没有删除虚假数据,例如不是真正 Go 代码的分支和目标。但它仍然显示了 Go 生态系统对 GitHub 的依赖程度。

Go 的作者似乎并没有完全意识到这种情况,并实施了一些限制,这些限制在 文件路径和大小限制 部分。最相关的是:

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

500MiB 足以应对相当大的滥用,其他的都不是问题。

滥用什么?

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

拒绝服务 (DoS) 攻击 proxy.golang.org 执行起来可能很有挑战性。我已经展示了我们可以请求代理下载任何随机的 Git 存储库(可能还有任何其他受支持的 VCS)。对于可能的攻击,我们需要首先收集尽可能多的 GitHub URL,然后向代理发出尽可能多的请求 lookup API。我不知道服务器的实现,但我认为它实现了类似于工作队列的东西,因此处理的并行请求数量是有限的。带宽保护也可能从 GitHub 端触发。还有可能对存储空间进行 DoS 攻击。我只是在这里猜测 :-)。

还可以轻松在此基础上实现命令和控制 (C2)。使用 latest 查询,因此无需查询校验和数据库并查找有效载荷的所有可用版本。有效载荷可以是一个简单的文件,也可以伪装在 go.mod 或任何其他 Go 源文件,以实现额外的隐蔽性。可以使用模块 DGA(域生成算法)来避免对 C2 使用单个存储库。我最初的目标是编写一个示例 C2 来演示这一点,但这里实际上没有太多可做的事情。

要从 C2 下载命令,植入程序只需执行以下步骤:

  • 提出请求 https://proxy.golang.org/module_path/@latest

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

  • 再次提出请求 下载 zip 文件。

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

在 300 行或更少的 Go 代码中相当容易。

结论

我的问题得到了解答,现在我明白了校验和数据库过程的工作原理。到目前为止,这不是 Go 基础设施中的一个严重问题。它很容易被滥用,但也可以得到改进。也许有(记录在案的或没有记录的)理由让非 Go 代码上传到代理和校验和数据库。或者可能已经有人滥用它了,我们可以在近 160 万个唯一存储库中寻宝(我最新的数据库副本包含近 2200 万个条目)。

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

我玩得很开心,而且我有很多想法可以进一步探索。我有一种感觉…… ;-)。

玩得开心,
哎呀!

Leave a Reply

Your email address will not be published. Required fields are marked *

近期新闻​

编辑精选​