CVE-2026-97293 Injecting past the closing parenthesis in Media Library Assistant
Intrudify's autonomous pentesting engine discovered a SQL injection vulnerability in Media Library Assistant, affecting all versions up to and including 3.41. A request-controlled value is concatenated onto an already-prepared query string, landing immediately after ORDER BY. A Contributor reaches the injection point and a Subscriber sets up the one option it depends on, with every plugin setting left at its shipped default.
Summary
Media Library Assistant extends the WordPress media library with gallery shortcodes, taxonomy support and bulk editing. Intrudify's autonomous testing engine found a single place in the plugin where a value is appended to a query after prepare() has already run, and traced a path to it that needs no privileged account at any point.
The result is full read access to the database through a boolean and timing oracle: password hashes, user email addresses, session tokens in wp_usermeta, and whatever API keys and secrets live in wp_options.
Technical detail
The sink
In includes/class-mla-list-table.php, the only place in the plugin where a prepare() call is followed by string concatenation, and the line the vendor marked to silence their own linter:
$values = $wpdb->get_col( $wpdb->prepare(
"SELECT DISTINCT meta_value FROM $wpdb->postmeta WHERE meta_key = %s ORDER BY meta_value ",
$tax_metakey
) . $tax_metakey_sort ); // phpcs:ignore$tax_metakey is bound with %s and is safe. The problem is $tax_metakey_sort, appended after the closing parenthesis of prepare(), so it reaches MySQL verbatim in an unquoted ORDER BY context.
Where the tainted value comes from
In MLACore::mla_taxonomy_support():
case 'metakey_sort':
if ( ! empty( $_REQUEST['mla-general-options-save'] ) ) {
return isset( $_REQUEST['tax_metakey_sort'] )
? sanitize_text_field( wp_unslash( $_REQUEST['tax_metakey_sort'] ) ) : '';
}This is a low-level configuration reader, called on ordinary requests. It contains no current_user_can(), no wp_verify_nonce() and no check_admin_referer() anywhere. Simply sending mla-general-options-save=1 makes it return attacker input in place of the stored option.
sanitize_text_field() strips tags and control octets. Quotes, commas, parentheses, sub-selects and SQL keywords all survive, and since the destination is an unquoted context there is no string literal to escape from in the first place.
The prerequisite, and how the attacker meets it
The dropdown that reaches the sink is only built when a stored option equals the literal string (custom field). The shipped default is something else, so on a fresh install the path is closed.
That option is written by MLA::mla_set_screen_option_filter() from a request parameter with no capability check, registered on init for every user. A Subscriber can therefore set it, site-wide and persistently, using the ordinary Screen Options form on the dashboard they are entitled to load.
We measured this in both directions. Unauthenticated, no nonce is obtainable and the option stays absent. As a Subscriber, it becomes (custom field) site-wide. The prerequisite is a Subscriber capability, not an unauthenticated one, and we describe it that way rather than claiming the stronger case.
Reaching it as a Contributor
The dropdown is built off the media_view_settings filter, which the plugin registers whenever its media modal toolbar option is enabled. That option's shipped default is enabled. Any page calling wp_enqueue_media() reaches it, and /wp-admin/post-new.php does, which a Contributor can load.
Verification
We confirmed this on a clean installation of WordPress 7.1, PHP 8.3, MariaDB 11 and Media Library Assistant 3.41 from the official wordpress.org package, with no other plugins and all plugin settings at their defaults. The Contributor's admin menu contains only Dashboard, Posts, Comments, Profile and Tools: no Users, no Plugins, no Settings, no Media.
| tax_metakey_sort value | Time | Meaning |
|---|---|---|
| ASC | 0.19 s | baseline |
| ASC,(SELECT SLEEP(3)) | 3.31 s | expression evaluated |
| ASC,(SELECT IF(1=1,SLEEP(3),0)) | 3.32 s | true branch |
| ASC,(SELECT IF(1=2,SLEEP(3),0)) | 0.15 s | false branch |
Binary searching that oracle per character recovered the first twelve characters of the administrator password hash from wp_users, matching the database exactly, from the Contributor session. We extracted data rather than only demonstrating a delay.
Two traps that make the bug look absent
Both are worth knowing for anyone reproducing this, because either one produces a clean negative result on a genuinely vulnerable site.
First, never use < in a payload. sanitize_text_field() runs wp_strip_all_tags(), which discards everything from an unclosed < to the end of the string, so the payload loses its tail and nothing sleeps. The same comparison written with > works: we measured 0.146 s for the destroyed variant against 3.268 s for the working one. A binary search using only > is unaffected.
Second, the site needs at least one attachment. The injected expression sits in ORDER BY, which MySQL evaluates only over a non-empty result set. With no row in wp_postmeta for the attached-file key, the payload still reaches the database and nothing sleeps.
One operational note: each sleep holds a web server worker for its full duration, and killing the client does not cancel the query. Requests need pacing and a reused connection, or the site stops answering and the finding appears to have died.
Impact
Contributor is the lowest role that can author a post, and Subscriber is the role granted by open registration. Between them they achieve full read access to the database, with no administrator involved at any stage and nothing configured away from defaults.
That covers password hashes, every user email address, session tokens stored in wp_usermeta, and any API keys or integration secrets held in wp_options. Recovery of the administrator hash was demonstrated rather than inferred.
Remediation
Update Media Library Assistant to 3.42 or later.
For maintainers, three changes are worth making independently. Bring the sort fragment inside prepare(), or restrict it to an ASC and DESC whitelist, so nothing is concatenated onto a prepared statement. Add capability and nonce checks to the configuration reader, so a request parameter cannot substitute itself for a stored option. And add a capability check to the screen-option writer, so a Subscriber cannot change a site-wide setting.
The wider lesson
The sink is a textbook case of a correct API used incorrectly. prepare() is called, a placeholder is used, one parameter is properly bound, and the whole line looks like defended code. The tainted value simply sits outside the parentheses. Searching a codebase for prepare() and confirming it is present finds nothing here; searching for prepare( followed by concatenation finds it immediately.
The linter suppression on that line is worth dwelling on. Someone ran a static analyser, the analyser flagged exactly this construct, and the finding was silenced rather than fixed. A suppression comment is a record of a tool having been right, and it is one of the highest value patterns to grep for in any audit.
The third point concerns privilege. The path needs two different low-privileged accounts doing two unrelated things: a Subscriber changing a stored option through a screen-options form, and a Contributor loading a post editor. Neither action is suspicious alone, and neither account can see what the other enabled. Threat models that ask what a given role can do miss chains that compose across roles, which is exactly the kind of reasoning an automated system does exhaustively and a human reviewer samples.
Disclosure timeline
- 2026-09-30 Public disclosure by Patchstack after coordinated disclosure by Intrudify · CVE-2026-97293 assigned
Questions
What is CVE-2026-97293?
CVE-2026-97293 is a SQL injection vulnerability in the Media Library Assistant WordPress plugin, versions 3.41 and earlier. It was discovered autonomously by the Intrudify AI penetration testing engine. A request-controlled value is concatenated onto an already-prepared SQL string, landing immediately after ORDER BY, and the configuration reader that supplies it has no capability or nonce check.
What privileges does CVE-2026-97293 require?
A Contributor reaches the injection point, and a Subscriber sets up the single stored option it depends on. No administrator is involved at any stage, and every plugin setting is left at its shipped default.
How do I fix CVE-2026-97293?
Update Media Library Assistant to version 3.42 or later. The underlying fix is to bring the sort fragment inside prepare, or restrict it to an ASC and DESC whitelist, and to add capability and nonce checks to the configuration reader and the screen-option writer.