【编者按】对于软件中存在的漏洞,工程师一般会优先修复那些影响用户数多的漏洞,比如导致服务器死机的漏洞。但一些特殊情况下才会出现且的漏洞,不易被人发现,就会长时间存在。本文就介绍了作者发现了GitHub上不易察觉的安全漏洞,通过这个漏洞可以窃取GitHub Actions上的敏感信息,并且作者还尝试用不同方法来触发漏洞看看还是否存在其他漏洞。一起来看看吧。
原文链接:https://blog.teddykatz.com/2022/02/23/ghosts-of-branches-past.html
译者 | 章雨铭 责编 | 屠敏
不久前,我在GitHub中发现的一个安全漏洞,这个漏洞会让攻击者获得对几乎所有公共库的写入权限。下面是简单回顾。
GitHub上的每个拉取请求都有一个 "基分支",也就是与拉取请求相同的库中的一个git分支。
GitHub Actions有时会执行拉取请求的基分支的代码,使代码能够访问库的敏感信息。(具体来说,如果基分支有一个pull_request_target工作流,就会发生这种情况)。这通常是安全的,因为任何可能推送到基分支的人都可以直接写到库。
由于一时疏忽,用户将其拉取请求的"基分支"名称设置为commit hash,而不是一个真正的分支。
因此,攻击者可以从fork中创建一个拉取请求,然后将"基分支"改为commit hash,在攻击者的fork中的恶意commit。由于commit hash在fork中是共享的,GitHub Actions会执行这个恶意commit的代码,使其能够访问父存储库及其敏感信息。
这篇文章描述了我在GitHub中发现的一个不同的安全漏洞,使用了类似的攻击策略。
去年夏天,我观察了git和GitHub对异常的分支名的表现。
在某些地方,git CLI允许使用分支或commit hash。例如,要切换到一个叫my-branch的分支,下面这个就可以使用。
$ git checkout my-branch
同样地,要切换到一个特定的commit,可以使用commit hash作为命令行参数。
$ git checkout a047be85247755cdbe0acce6f1dafc8beb84f2ac
GitHub也可以用类似的方式。例如,可以在github.com/someone/some-repo/tree/my-branch上查看某个分支的库的状态,或者在github.com/someone/somerepo/tree/a047be85247755cdbe0acce6f1dafc8beb84f2ac上查看某个commit hash的状态。
我们还可以自己给分支命名。如果创建一个名为a047be85247755cdbe0acce6f1dafc8beb84f2ac
的分支(与commit hash相同),会发生什么?
- $ git branch a047be 85247755cdbe0acce6f1dafc8beb84f2ac
- $ git checkout a047be85247755cdbe0acce6f1dafc8beb84f2ac
- warning: refname 'a047be85247755cdbe0acce6f1dafc8beb84f2ac' is ambiguous.
- ...
-
-
- $ git branch
- * a047be85247755cdbe0acce6f1dafc8beb84f2ac
- main
Git允许创建该分支,尽管会出现警告。然后在使用checkout和其他命令时,git将a047be85247755cdbe0acce6f1dafc8beb84f2ac解释为一个分支而不是一个commit hash。
另一方面,GitHub返回了一个错误:[1]
- $ git push
- remote: error: GH002: Sorry, branch or tag names consisting of 40 hex characters are not allowed.
- remote: error: Invalid branch or tag name "a047be85247755cdbe0acce6f1dafc8beb84f2ac"dafc8beb84f2ac"
这没什么意思。但如果我们用commit的短hash来命名分支,而不是用完整的hash来命名呢?
- $ git branch a047be8
- $ git checkout a047be8
- $ # ...
- $ git push
- * [new branch] a047be8 -> a047be8
- branch 'a047be8' set up to track 'a047be8'
GitHub接受了这次拉取!我们成功创建了一个模棱两可的分支。
总结一下上一节的内容:
我们在GitHub库中有一个commit,其短hash为a047be8。
再推送一个名为a047be8的分支到同一个库。
此时,当我们访问github.com/someone/some-repo/tree/a047be8时,GitHub选择显示a047be8分支的文件,而不是显示a047be8commit的文件[2]。这很合理。如果GitHub将a047be8解析为一个commit短hash,那么就不能用这个名字来指代这个分支了。就目前而言,将a047be8解析为一个分支,只会在有人在URL中使用短hash并期望它解析为一个commit时产生问题。但这种情况是不可取的,因为如果发生短hash冲突,它也会失败。
换句话说,a047be8分支实际上"影射"了带有短hasha047be8的commit,阻止了短hash提及该commit。
然而,该commit仍然存在。如果删除a047be8分支,那么a047be8这个名字就会立即被解析为a047be85247755cdbe0acce6f1dafc8beb84f2ac
的commit——即使有人不知道这个commit的存在,而只是想找到这个已经被删除的分支。这个边缘案例似乎是一个寻找bug的好方法。
我做了一个实验,看看GitHub能否很好地处理与commit短hash相匹配的已被删除的分支。
在前面的例子中,在储存库里有一个带有特定短hash的commit,然后我们推送了一个名字与短hash相同的分支。当然,我们也可以反过来做——如果一个分支已经存在,我们可以推送一个与该分支名称相同的短hash的commit。所以我进行了以下操作。
首先,我推送了一个名为deadbeef的分支到GitHub储存库。
然后,我从另一个分支创建了一个拉动请求到deadbeef。
接下来,我删除了deadbeef分支,这导致拉动请求自动关闭。
最后,我开始使用我的lucky-commit,生成了一个带有短hashdeadbeef的新commit。然后我把这个commit也推送到GitHub仓库。[3]
我在想,如果出了什么错误,可能是在显示拉动请求diff。例如,我以为UI可能会开始显示新的deadbeefcommit的diff,而不是旧的deadbeef分支。但实际上,GitHub显示的是已删除的deadbeef分支的历史diff,这才是正确的。(事后看来,拉动请求的diff只有在拉动请求开放时才会更新,这也很合理)。
我正准备放弃,去找别的问题时,我发现了一个奇怪的现象。我可以在GitHub的UI上重新打开拉动请求。
这有点奇怪。因为通常情况下,对于任何开放的拉取请求,拉取请求的head和基分支都需要存在。正如我们之前看到的,如果任何一个分支被删除,拉动请求就会立即关闭。但在这种情况下,GitHub允许我们在删除基分支后重新打开拉动请求。
为什么呢?GitHub会认为基分支deadbeef依然存在——因为当GitHub试图查找deadbeef分支的代码时,并不是什么都没查找到,而是查找到了有短hashdeadbeef的commit。因此,GitHub才允许重新打开拉动请求。
在这一点上,我们有一个开放的拉取请求,其中的基分支指向一个带有短hashdeadbeef的commit,而不是一个实际的分支。
这与之前提到的博文中描述的情况几乎相同,像这样的"无意义"拉取请求可以用来窃取GitHub Action的敏感信息。那篇博文中的漏洞路径在2021年被修复了,为"编辑基分支"端点增加了验证功能——有效防止该端点被用于创建无意义的拉取请求。然而,当时GitHub并没有在GitHub Actions后端添加分支存在的检查。换句话说,GitHub Actions仍然容易受到这些无意义的拉动请求的影响,但人们认为已经不能再创建一个无意义的拉取请求。
当我尝试使用GitHub Actions的这种新方法来创建无意义的拉动请求时,我发现使用pull_request_targetActions的工作流程仍有可能窃取敏感信息。
我们设定一个可行的攻击场景。
有写入权限的人在正常的开发工作流中无意中创建了一个名为deadbeef的分支(或AAAAAAA,或12345,或任何其他符合特定限制条件的名称)[4]。
攻击者从fork创建一个拉取请求到deadbeef分支,然后立即关闭该拉取请求(例如,假装它是错误创建的)。
后来,有人删除了deadbeef分支(比如,在这个修改被合并到main分支之后)。
在一个特意制作的有短hashdeadbeeff的commit中,攻击者将类似这样的恶意GitHub Actions工作流推送到他们的fork中。
攻击者重新打开拉动请求。这导致GitHub在拉动请求的基分支"deadbeef"寻找pull_request_target工作流。
由于deadbeef分支已不存在,GitHub将deadbeef解析为攻击者的commit,在该commit中找到恶意的Actions工作流,并继续给提供储存库的敏感信息,以及授予攻击者储存库的写入权限的GITHUB_TOKEN。[5]
请注意,这个攻击场景需要大量的用户互动(攻击者需要等待有人偶然推送一个异常命名的分支),所以它比之前博文中描述的攻击要轻得多。
在我向GitHub报告这个问题后,他们添加了一个修复程序,确保pull_request_target事件只能从分支触发,而不是从游离的commits中触发,这就防止了这种攻击的发生。在写这篇文章的时候,如果基分支的名字与储存库中的commit短hash相同,仍然有可能用一个被删除的基分支重新打开拉动请求。我认为这不影响安全,但看起来很有趣。
一些解释:(对应原文[1]-[5])
为什么GitHub不允许这种推送?我认为主要原因是为了维护permalinks和commit hash checkouts的完整性。例如,用户通常认为,github.com/someone/some-repo/blob/a047be85247755cdbe0acce6f1dafc8beb84f2ac/foo/bar.sh将始终解析为文件foo/bar.sh在指定commit hash处的不可变版本。但但如果有人成功推送了一个名为a047be85247755cdbe0acce6f1dafc8beb84f2ac的分支,那么GitHub就会开始从该分支返回foo/bar.sh的不同版本,而不是commit的原始版本。为了避免这种情况,GitHub屏蔽了正好由40个十六进制字符组成的分支名称。2019年,我给GitHub发了一份报告,说可以通过创建一个名为a047be85247755cdbe0acce6f1dafc8beb84f2ac/foo的分支,并在储存库底部放一个名为bar.sh的文件,绕开这个保护机制。由于这个问题,GitHub现在也屏蔽了以40个十六进制字符后的斜线开头的分支名。
git CLI也会进行相同的操作。git CLI和GitHub在这里的行为一致,可能不是巧合——它们似乎都在使用git rev-parse的结果。
这是我的实验第三次在发现安全漏洞方面发挥了作用。我真的不知道该如何看待这个问题。也许我现在可以把我所有的实验归类为"安全研究"。
(这是第一次:https://blog.teddykatz.com/2019/11/12/github-actions-dos.html。第二次是一个低严重性的问题,就略过不谈)。
具体来说,分支名需要能被git rev-parse解析为commit hash。这可以是一个十六进制的字符串,比如deadbeef,也可以是一个git describe格式的字符串,比如anything-123-gdeadbeef,这仍然会解析为deadbeefcommit。
上一篇博文发布后,GitHub引入了大量可选择的安全功能,以减少这种类型攻击的范围,如Actions environments。