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
+33 -1
View File
@@ -37,6 +37,26 @@ patterns = ["php://input"]
# --- injection sinks ---------------------------------------------------------------------------------
# WordPress does not call mysqli directly; it goes through $wpdb. Verified against a CPG: `$wpdb->get_var(..)`
# is a call NAMED `get_var` whose CODE carries the qualifier, so the qualified form is what belongs here. A
# bare `get_var` or `query` would match any method of that name anywhere -- the generic-name trap that made
# `query.get` match every `.get(` in a repository.
#
# `$wpdb->insert`, `->update` and `->delete` are deliberately absent: they take arrays and WordPress prepares
# them itself, so they are not injection sinks.
[[sink]]
id = "wpdb"
cwe = "CWE-89"
calls = [
"$wpdb->get_var",
"$wpdb->get_results",
"$wpdb->get_row",
"$wpdb->get_col",
"$wpdb->query",
"$wpdb->replace",
]
rule_ids = []
[[sink]]
id = "sql"
cwe = "CWE-89"
@@ -135,7 +155,19 @@ calls = ["htmlspecialchars", "htmlentities", "html_entity_decode_safe"]
[[sanitizer]]
id = "sql_escape"
calls = ["mysqli_real_escape_string", "mysql_real_escape_string", "pg_escape_string", "pg_escape_literal"]
calls = ["mysqli_real_escape_string", "mysql_real_escape_string", "pg_escape_string", "pg_escape_literal", "esc_sql"]
# `$wpdb->prepare` is WordPress's parameterized form and appears ON the flow path, so it reads as a cleansing
# call rather than as a shape qualifier.
#
# NOTE what is NOT here. `sanitize_text_field`, `esc_html` and `esc_attr` are real WordPress sanitizers and
# none of them stops SQL injection -- CVE-2023-23488 is exactly a `sanitize_text_field` value concatenated
# into a query. Adding them would make that CVE, and its whole class, invisible. The sanitizer list is
# currently flat across families, which cannot express "escapes for HTML but not for SQL"; until it can, only
# SQL-specific cleansers belong here.
[[sanitizer]]
id = "wpdb_prepare"
calls = ["$wpdb->prepare"]
[[sanitizer]]
id = "shell_escape"