CVE-2026-39532

Events Calendar for GeoDirectory <= 2.3.25 - Authenticated (Contributor+) PHP Object Injection

highDeserialization of Untrusted Data
7.5
CVSS Score
7.5
CVSS Score
high
Severity
2.3.26
Patched in
6d
Time to patch

Description

The Events Calendar for GeoDirectory plugin for WordPress is vulnerable to PHP Object Injection in versions up to, and including, 2.3.25 via deserialization of untrusted input. This makes it possible for authenticated attackers, with contributor-level access and above, to inject a PHP Object. No known POP chain is present in the vulnerable software. If a POP chain is present via an additional plugin or theme installed on the target system, it could allow the attacker to delete arbitrary files, retrieve sensitive data, or execute code.

CVSS Vector Breakdown

CVSS:3.1/AV:N/AC:H/PR:L/UI:N/S:U/C:H/I:H/A:H
Attack Vector
Network
Attack Complexity
High
Privileges Required
Low
User Interaction
None
Scope
Unchanged
High
Confidentiality
High
Integrity
High
Availability

Technical Details

Affected versions<=2.3.25
PublishedApril 16, 2026
Last updatedApril 21, 2026

What Changed in the Fix

Changes introduced in v2.3.26

Loading patch diff...

Source Code

WordPress.org SVN
Research Plan
Unverified

This plan focuses on a **PHP Object Injection** vulnerability in the **Events Calendar for GeoDirectory** plugin. The vulnerability stems from the use of a dangerous parsing function, `GeoDir_Event_Fields::parse_array()`, which calls `unserialize()` on strings starting with `a:`. This function is us…

Show full research plan

This plan focuses on a PHP Object Injection vulnerability in the Events Calendar for GeoDirectory plugin. The vulnerability stems from the use of a dangerous parsing function, GeoDir_Event_Fields::parse_array(), which calls unserialize() on strings starting with a:. This function is used to process various event-related fields when a post is saved or imported.

1. Vulnerability Summary

  • ID: CVE-2026-39532
  • Type: PHP Object Injection (Deserialization of Untrusted Data)
  • Location: includes/class-geodir-event-fields.php (inferred sink) and includes/admin/class-geodir-event-admin-import-export.php (confirmed usage).
  • Sink: unserialize() inside GeoDir_Event_Fields::parse_array().
  • Condition: A string passed to parse_array() must start with a:.
  • Access: Authenticated (Contributor+). Contributors can create and edit their own "Events" (or post types that support events), allowing them to trigger the metadata sanitization logic.

2. Attack Vector Analysis

  • Endpoint: wp-admin/post.php (via the standard post save/edit process).
  • Action: editpost.
  • Parameter: Sub-fields of the event data, specifically recurring_dates, start_times, or end_times.
  • Authentication: Required (Contributor, Author, Editor, or Admin).
  • Precondition: The target WordPress site must have a GeoDirectory Custom Post Type (CPT) configured to support "Events". This is the default behavior for the plugin's "Events" CPT.

3. Code Flow

  1. A Contributor-level user edits or creates a post of a type that supports Events (e.g., the default gd_event).
  2. Upon submission, WordPress triggers the saving process.
  3. The plugin has registered a filter: add_filter( 'geodir_custom_field_value_event', array( 'GeoDir_Event_Fields', 'sanitize_event_data' ), 10, 6 ); (see includes/class-geodir-event-fields.php).
  4. The sanitize_event_data function (inferred) processes the event_dates field data.
  5. Based on the logic seen in includes/admin/class-geodir-event-admin-import-export.php, the plugin utilizes GeoDir_Event_Fields::parse_array() to handle inputs that might be arrays or serialized strings.
  6. GeoDir_Event_Fields::parse_array( $input ) checks if $input is a string and if strpos( $input, 'a:' ) === 0.
  7. If the condition is met, it calls unserialize( $input ), triggering the injection.

4. Nonce Acquisition Strategy

This exploit targets the standard WordPress post-editing flow.

  1. Login: Authenticate as a Contributor.
  2. Navigate: Use browser_navigate to go to wp-admin/post-new.php?post_type=gd_event (replace gd_event with the appropriate CPT slug if different).
  3. Extract: Use browser_eval to extract the _wpnonce from the form and the post_ID (if one is already assigned in the hidden inputs).
    • _wpnonce: document.querySelector('#_wpnonce').value
    • post_ID: document.querySelector('#post_ID').value

5. Exploitation Strategy

Step 1: Identify the CPT and Event Support

Verify which post types support events using WP-CLI:
wp eval "print_r(GeoDir_Event_Post_Type::get_event_post_types());"

Step 2: Observe Normal Request

As a Contributor, submit a valid "Event" and observe the POST structure to post.php. Look for how the event_dates field is structured. It is likely either top-level parameters or nested within an event_dates array.

Step 3: Send Injection Payload

Construct a POST request to wp-admin/post.php using the http_request tool.

Example Request (assuming top-level params based on Import mapper):

  • Method: POST
  • URL: http://vulnerable-site.com/wp-admin/post.php
  • Headers: Content-Type: application/x-www-form-urlencoded
  • Body Parameters:
    • action: editpost
    • post_ID: [EXTRACTED_ID]
    • _wpnonce: [EXTRACTED_NONCE]
    • recurring: 1
    • repeat_type: custom
    • recurring_dates: a:1:{i:0;O:8:"stdClass":0:{}} (Payload)
    • start_date: 2025-01-01
    • end_date: 2025-01-01

Alternative Payload (if nested):
If Step 2 shows nesting, use event_dates[recurring_dates]=a:1:{i:0;O:8:"stdClass":0:{}}.

6. Test Data Setup

  1. Ensure "Events for GeoDirectory" is active.
  2. Ensure a Post Type supports events. If not, use WP-CLI:
    wp option update geodir_event_post_types '["gd_place"]' (or similar).
  3. Create a Contributor user:
    wp user create attacker attacker@example.com --role=contributor --user_pass=password

7. Expected Results

  • The unserialize() call will be executed.
  • If using a simple stdClass payload, no visible error may occur, but the application will process the object.
  • To confirm execution without a POP chain, one can attempt to inject an object of a class that exists but will cause a noticeable error when treated as a string (e.g., O:20:"WP_Block_List_Custom":0:{} which may cause a fatal error if the plugin expects a date string).
  • If a POP chain is available in the environment (e.g., via another plugin), it will trigger its magic methods (__destruct, __wakeup).

8. Verification Steps

  1. Check the wp-content/debug.log (if WP_DEBUG is on) for deserialization errors:
    grep "unserialize" /var/www/html/wp-content/debug.log
  2. Verify if the metadata was saved (though the goal is the injection during the process of saving):
    wp post get [ID] --field=event_dates

9. Alternative Approaches

  • Import Vector: If the Contributor has access to the GeoDirectory Import tool (unlikely, usually Admin), use the CSV import feature. Place the serialized payload in the recurring_custom_dates column.
  • REST API: The plugin registers REST routes (see includes/class-geodir-event-api.php). If the REST API update handlers for gd_event categories or tags also pass data through the same event field sanitization logic, they could be used as an alternative endpoint via PATCH /wp/v2/gd_event/[id].
Research Findings
Static analysis — not yet PoC-verified

Summary

The Events Calendar for GeoDirectory plugin is vulnerable to PHP Object Injection due to the insecure use of the maybe_unserialize() function on user-supplied event metadata. Authenticated attackers with contributor-level permissions can exploit this by submitting crafted serialized strings in fields like recurring dates, potentially leading to remote code execution if a suitable POP chain is available on the system.

Vulnerable Code

// includes/class-geodir-event-fields.php:1152
$event_data = maybe_unserialize( $value );
$event_data = maybe_unserialize( $event_data ); // includes\post_functions.php#296

---

// includes/admin/class-geodir-event-admin-import-export.php:236
$event_data = ! empty( $row['event_dates'] ) ? maybe_unserialize( $row['event_dates'] ) : array();

Security Fix

diff -ru /home/deploy/wp-safety.org/data/plugin-versions/events-for-geodirectory/2.3.25/includes/admin/class-geodir-event-admin-import-export.php /home/deploy/wp-safety.org/data/plugin-versions/events-for-geodirectory/2.3.26/includes/admin/class-geodir-event-admin-import-export.php
--- /home/deploy/wp-safety.org/data/plugin-versions/events-for-geodirectory/2.3.25/includes/admin/class-geodir-event-admin-import-export.php	2026-02-05 16:13:44.000000000 +0000
+++ /home/deploy/wp-safety.org/data/plugin-versions/events-for-geodirectory/2.3.26/includes/admin/class-geodir-event-admin-import-export.php	2026-03-11 14:10:50.000000000 +0000
@@ -233,7 +233,7 @@
 			$week_day_nos 			= self::week_days();
 
 			foreach ( $results as $key => $row ) {
-				$event_data = ! empty( $row['event_dates'] ) ? maybe_unserialize( $row['event_dates'] ) : array();
+				$event_data = ! empty( $row['event_dates'] ) ? geodir_event_maybe_unserialize( $row['event_dates'] ) : array();
 
 				if ( ! is_array( $event_data ) ) {
 					$event_data = array();
diff -ru /home/deploy/wp-safety.org/data/plugin-versions/events-for-geodirectory/2.3.25/includes/class-geodir-event-fields.php /home/deploy/wp-safety.org/data/plugin-versions/events-for-geodirectory/2.3.26/includes/class-geodir-event-fields.php
--- /home/deploy/wp-safety.org/data/plugin-versions/events-for-geodirectory/2.3.25/includes/class-geodir-event-fields.php	2026-02-05 16:13:44.000000000 +0000
+++ /home/deploy/wp-safety.org/data/plugin-versions/events-for-geodirectory/2.3.26/includes/class-geodir-event-fields.php	2026-03-11 14:10:50.000000000 +0000
@@ -1123,6 +1123,11 @@
 				$value = maybe_serialize( $event_data );
 			} else if ( is_object( $value ) ) {
 				$value = '';
+			} else if ( is_serialized( $value ) ) {
+				// Checks if a string contains PHP object.
+				if ( geodir_event_is_serialized_object( $value ) ) {
+					$value = '';
+				}
 			}
 		}
 
@@ -1149,8 +1154,8 @@
 			return $value;
 		}
 
-		$event_data = maybe_unserialize( $value );
-		$event_data = maybe_unserialize( $event_data ); // includes\post_functions.php#296
+		$event_data = geodir_event_maybe_unserialize( $value );
+		$event_data = geodir_event_maybe_unserialize( $event_data ); // includes\post_functions.php#296
 
 		if ( isset( $gd_post->recurring ) ) {
 			$recurring = ! empty( $gd_post->recurring ) ? true : false;
diff -ru /home/deploy/wp-safety.org/data/plugin-versions/events-for-geodirectory/2.3.25/includes/core-functions.php /home/deploy/wp-safety.org/data/plugin-versions/events-for-geodirectory/2.3.26/includes/core-functions.php
--- /home/deploy/wp-safety.org/data/plugin-versions/events-for-geodirectory/2.3.25/includes/core-functions.php	2023-08-07 14:24:04.000000000 +0000
+++ /home/deploy/wp-safety.org/data/plugin-versions/events-for-geodirectory/2.3.26/includes/core-functions.php	2026-03-11 14:10:50.000000000 +0000
@@ -733,4 +733,42 @@
 	}
 
 	return $time_format;
-}
\ No newline at end of file
+}
+
+/**
+ * Checks if a string contains a serialized PHP object.
+ *
+ * @since 2.3.26
+ *
+ * @param string $data The string to inspect.
+ * @return bool        True if an object pattern is found.
+ */
+function geodir_event_is_serialized_object( $data ) {
+	if ( ! is_string( $data ) || empty( $data ) ) {
+		return false;
+	}
+
+	$pattern = '/[OC]:[0-9]+:(\\\"|")[^"]+(\\\"|"):[0-9]+:[\{|:]/';
+
+	return (bool) preg_match( $pattern, $data );
+}
+
+/**
+ * Unserializes data only if it was serialized.
+ *
+ * @since 2.3.26
+ *
+ * @param string $data Data that might be unserialized.
+ * @return mixed Unserialized data can be any type.
+ */
+function geodir_event_maybe_unserialize( $data, $allowed_classes = false ) {
+	if ( is_serialized( $data ) ) { // Don't attempt to unserialize data that wasn't serialized going in.
+		if ( $allowed_classes !== null ) {
+			return @unserialize( trim( $data ), array( 'allowed_classes' => $allowed_classes ) );
+		} else {
+			return @unserialize( trim( $data ) );
+		}
+	}
+
+	return $data;
+}

Exploit Outline

To exploit this vulnerability, an attacker must have Contributor-level access or higher. The attacker navigates to the post-editing interface (wp-admin/post.php) for a post type that supports events (like 'gd_event'). By crafting a POST request to update the event, the attacker can supply a serialized PHP object payload to metadata fields such as 'recurring_dates', 'start_times', or 'end_times'. When the plugin saves the post, it passes these values through the sanitize_event_data function, which triggers maybe_unserialize() on the malicious string. This results in the instantiation of the injected object, allowing the attacker to leverage any available POP chains in the WordPress environment for further exploitation.

Check if your site is affected.

Run a free security audit to detect vulnerable plugins, outdated versions, and misconfigurations.