feat(repos): a WordPress plugin with a verified CVE, and the WordPress DB API

contributor-scan 5.1's detection half. Paid Memberships Pro 2.9.7 pinned at
1a900be3, carrying CVE-2023-23488 -- unauthenticated SQL injection through
the `code` parameter of the /pmpro/v1/order REST route.

**Verified in the code, not taken from the advisory.** At this commit
classes/class.memberorder.php:936 reads

  $id = $wpdb->get_var("SELECT id FROM $wpdb->pmpro_membership_orders
                        WHERE code = '" . $code . "' LIMIT 1");

and tag 2.9.8 replaces it with `$wpdb->prepare( "... code = %s ...", $code )`.
637 PHP files and 117k lines, so it is also the first PHP checkout large
enough to say anything about an envelope.

**WordPress never calls mysqli; it goes through $wpdb**, so the facts could
not have found this. Added qualified: `$wpdb->get_var`, `->get_results`,
`->get_row`, `->get_col`, `->query`, `->replace`. Qualified because a bare
`get_var` or `query` matches any method of that name anywhere -- the
generic-name trap that made `query.get` match every `.get(` in a repository
this morning. `->insert`, `->update` and `->delete` are deliberately absent:
they take arrays and WordPress prepares them itself.

**And the thing that nearly went wrong.** The obvious next step when
writing PHP facts is to add WordPress's sanitizers -- sanitize_text_field,
esc_html, esc_attr. Doing so would have made this CVE invisible, because
its handler calls `sanitize_text_field( $params['code'] )` and then
concatenates the result into a query. sanitize_text_field is a real
sanitizer that does nothing whatsoever about SQL injection.

`TaintSpec.sanitizers` is one flat list per language whose comment states
the assumption outright -- "a sanitizer that breaks one flow breaks it
whatever the sink's family" -- and PHP falsifies it. So only SQL-specific
cleansers are listed, which leaves XSS under-sanitized instead, and the
real fix is recorded as task 5.6: a discharge counts for the obligation it
discharges. That is task 2.6's finding arriving in the taint family, where
the dominance arbiter already reads kind from the facts and the taint spec
has no equivalent.

The end-to-end scan of this checkout is not yet reported: the first run
returned 1577 regions with 0 scanned, and the CPG builds standalone at
4.3MB with no errors, so the two do not yet agree. Numbers when they do.

Gates byte-identical.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01T7XD7x5KonMdmGRkp63EQG
This commit is contained in:
Marius Ciepluch
2026-09-09 16:13:17 +02:00
co-authored by Claude Opus 5
parent 47b00d739b
commit 922ed84053
4 changed files with 124 additions and 1 deletions
+37
View File
@@ -0,0 +1,37 @@
# Paid Memberships Pro 2.9.7 -- a WordPress plugin with an unauthenticated SQL injection.
#
# This is the WordPress DETECTION checkout, and the first one whose bug class the PHP fact tables were built
# for. 637 PHP files and 117k lines, so it is also the first PHP envelope measurement.
#
# The CVE was verified in the code rather than taken from an advisory. At this commit
# `classes/class.memberorder.php:936` reads:
#
# $id = $wpdb->get_var("SELECT id FROM $wpdb->pmpro_membership_orders WHERE code = '" . $code . "' LIMIT 1");
#
# and 2.9.8 replaces it with `$wpdb->get_var( $wpdb->prepare( "... code = %s LIMIT 1", $code ) )`. The value
# reaches it from the `/pmpro/v1/order` REST route, whose handler does
# `$code = sanitize_text_field( $params['code'] )` -- which is a real WordPress sanitizer that does nothing
# whatsoever about SQL injection, and is why the flat sanitizer list must not carry it.
name = "pmpro"
url = "https://github.com/strangerstudios/paid-memberships-pro.git"
commit = "1a900be3f09f243876150fc3c04a97bcb24ef84b"
language = "php"
license = "GPL-2.0"
license_file = "license.txt"
measures = ["detection", "envelope"]
note = """
Tag 2.9.7, dated 2022-12-01; the fix is tag 2.9.8. Nothing is vendored -- the recipe fetches on request and
CI never does. 637 PHP files, 117302 lines, which makes this the first PHP checkout large enough to say
anything about the envelope.
"""
[[known]]
id = "CVE-2023-23488"
family = "injection"
weakness = "unauthenticated SQL injection: the `code` parameter of the /pmpro/v1/order REST route is concatenated into a query without preparation"
file = "classes/class.memberorder.php"
function = "getMemberOrderByCode"
line = 936
reference = "fixed in tag 2.9.8, which wraps the same query in $wpdb->prepare with a %s placeholder; the vulnerable line is verified in the checkout"
in_scope = true