Failing Revenge - nsec 2020 CTF

A fun web challenge from the NorthSec 2020 CTF which I tackled with some friends from dc902.

Intro

In this challenge, Severity High forum member el_133t is challenging us to get revenge on Wilton Trojan, a student from another school who has been spamming the Severity High IRC.

The plan for revenge is to change Wilton’s grade in Computer Science from an A+ to an F on the school’s grade server.

el_1337's forum post

el_1337’s forum post

The challenge contains two flags to find. We are given the source code, and a link to the application’s front-end at grades.ctf

The Application

The application consists of grades.ctf, a front-end application written in flask, which communicates with gradeservers.ctf, another flask app working as the back-end, via a forwarder.

The grades application

The grades application network diagram from the source code

Taking a quick look at grades.ctf in a browser shows that it is a pretty simple application which lists schools, courses, students, and their grades. If we look at the routes defined in the front-end’s routes.py, we can see that they each make requests to the grades server by calling _api(endpoint), which is defined as follows:

def _api(endpoint):
    """
    Keyword Arguments:
    API endpoint to reach endpoint -- 
    """
    high_school_uri = _get_school_uri()
    if high_school_uri:
        from front-end import legacy_http
        req = legacy_http.request(request.method, f'{high_school_uri}{endpoint}')
        ans = json.loads(req.output)

        _err_handle(ans)
        return (ans)

Notably, the legacy_http request inherits the request method, and sets the requested url to {high_school_uri}{endpoint}, where high_school_uri is defined as http://{Config.FORWARD_SERVER}/{Config.SCHOOL_GRADESERVERS[school_name]}.

A quick check of the config.py shows the forward server and gradeserver variables:

    FORWARD_SERVER = 'gradeservers.ctf:80'

    # http is X001
    # https is X002
    SCHOOL_GRADESERVERS = {'Severity High':   '[::1]:5001',
                           'Stuyvesant High': '[::1]:6001'}

So, making a request for Wilton Trojan‘s grades on the front-end,

http://grades.ctf/Stuyvesant High/student/1

becomes

http://gradeservers.ctf:80/[::1]:6001/student/1

We can quickly see how the forwarder is determining which host it forwards to: [::1]:6001 corresponds to localhost on port 6001.

This is important to note because this could potentially lead to an SSRF vulnerability, which could allow us to hijack the forwarder to make requests on our behalf.

Attempting to access this url directly gives a 403 Forbidden, but we’ll come back to this for Flag 2.

Once the request hits gradeservers.ctf, the request is translated into a query for the relevant data from the grades database.

Finally, looking through the source reveals where our two flags will be displayed if we can reach them:


The first flag (defined as FLAG_ONE in gradeservers.ctf's config.py) will be returned if we can hit the /flag endpoint on the back-end:

class FlagApi(Resource):
    def get(self):
        return jsonify({'status':'err','description':Config.FLAG_ONE})

The second flag (defined as FLAG_TWO in grades.ctf's config.py) will be returned if we can manage to have Wilton Trojan‘s grade for Computer Science return from gradeservers.ctf as an F on the front-end’s /course/ endpoint, as seen in grades.html in the front-end source:

{% if grade.student_name == 'Wilton Trojan' and grade.grade_score == 'F' and course_name == 'Computer Science' %}
<td class="grade_score">{{config.FLAG_TWO}}</td>
{% else %}
<td class="grade_score">{{ grade.grade_score }}</td>
{% endif %}

It is also worth noting that the only inputs we can control as an attacker hitting the front-end are the school name (which is verified and errors out if it isn’t valid) and the student or course ids when making requests to /<school_name>/course/<course_id> or <school_name>/student/<student_id>. Luckily, student and course ids are not validated before being passed to the forwarder.

We will use all of this information to find the challenge’s 2 flags.


Flag 1 - Capturing the /flag

As noted above, we know exactly what we need to do to get this flag, which is to trick the forwarder to hit /flag on the back-end instead of /student/id or /course/id.

We control the student or course ids, which are not validated before being forwarded as part of a request to the back-end.

Immediately what comes to mind are the techniques used for path/directory traversal, because if we can force the forwarder’s request to become /student/../flag we win.

This was initially attempted by passing %2e%2e%2fflag, which failed as the url-encoded characters were still interpreted for the initial request, and resulted in a 404.

The trick was to double url-encode the %2f character as %252f, which when passed to the forwarder became %2f, which means the request made to the front-end, /student/..%252fflag, hits the back-end as /student/../flag.

capturing the /flag

Capturing the /flag via BurpSuite


Flag 2 - Revenge on Wilton

Flag 2 is a little bit more involved, as we need to somehow change Wilton’s grade in computer science. We now know that we can hit any back-end endpoint we want using the technique used to get flag 1.

Additionally, since we know that the forwarder uses the first segment of the url passed to it to determine what host it sends a request to, we can achieve a fully attacker-controlled SSRF.

Initially, we thought this was going to be a more involved problem than it it ended up being. Though we didn’t fully test the potentially more complicated solution, walking through it led us to the method we ultimately used to solve the challenge, so it is worth taking a look.

The path not taken

Looking at the routes defined in gradeservers.ctf's _init_.py we see one which looks perfect for altering Walton’s grade:

------ __init__.py ------
    ## HTTPS only
    # Change student grade for course
    api.add_resource(GradeApi, '/grade/<student_id>/<course_id>/<grade_score>')

------ grade.py -------

    @https  # this calls a function from utils.py which 
            # checks whether HTTPS is being used

    def patch(self, student_id, course_id, grade_score):
        grade = Grade.query.filter(Grade.student_id == student_id,
                                   Grade.course_id == course_id).first()
        if grade:
            grade.grade_score = grade_score
            db.session.commit()
            return jsonify({'status':'ok'})
        return jsonify({'status':'err'})

This endpoint points to the GradeApi() class, which can handle normal GET requests, but also defines a handler for PATCH requests. If we could pass the appropriate parameters (student_id, course_id, and grade) to GradeApi() with a PATCH request, we could change Wilton’s grade. Unfortunately, making PATCH requests to this endpoint requires that the request use HTTPS instead of HTTP.

As noted in the section config.py of grades.ctf above, the [::1]:6001 address points to the HTTP grade server for Stuyvesant High, where as [::1]:6002 points to the HTTPS version. The front-end only sends HTTP requests to the forwarder, which only appeared to forward HTTP requests, even if pointing at the HTTPS port, so we need to find another way to manipulate the request.

A potential way of exploiting this would be to use the SSRF to send the request to our attacker controlled host at shell.ctf, and use something like mitmproxy to intercept the forwarder traffic, change it to an HTTPS request and point it to the appropriate endpoint with the proper parameters.

This would actually update Wilton’s grade in the database, which is ideally what we were trying to achieve.

While this wouldn’t be impossible, it was bit more complicated than the option we went with : we could just pretend to be the grade server and return the appropriate response to the forwarder’s request.

Tricking the front-end into giving us flag 2

All we have to do is point the forwarder’s request to an attacker controlled host (in this case, shell.ctf was our attacker controlled machine for the CTF), and have the attacker machine respond with an appropriate JSON body which indicates that Wilton failed his course.

Remembering the SSRF we mentioned above, we know that when the forwarder receives http://gradeservers.ctf:80/<some ip>:<port>/some_dir it forwards the response to http://<some ip>:<port>/some_dir. Knowing this, we can force it to look for Wilton’s grades on our attacker controlled server.

To complete this we need to take the following steps:

  • Host a json file (grades.json) on an http server (python’s SimpleHTTPServer in this case) listening on the attacker machine (shell.ctf), with the json file containing the following payload:
# Wilton's id: 15, Computer Science course id: 1
{ 
  course_name":"Computer Science",
  "grades":
  [{ 
    "student_name":"Wilton Trojan", 
    "grade_score":"F", 
    "student_id":15
    }]
}
  • Send the following request to the front-end, which uses the trick from Flag 1 to force the forwarder to make its request to our attacker server:
http://grades.ctf/Stuyvesant High/course/..%252f..%252fshell.ctf:8080/grades.json

The forwarded request will find the falsified grades in grades.json, and return them to the front-end. When checking the response and seeing that Wilton’s grade in computer science is now an F, the front-end application gives us the flag:

flag 2

Tricking the front-end into giving us the flag.


Further reading: