feat(cpg): the hook half — CVE-2022-25148 found across an add_filter/apply_filters edge

The last of the three WordPress flaw classes 5.8 measured as missed. The
value crosses a registry inside WordPress core:

    add_filter('wp_statistics_current_page', array($this, 'set_current_page'));
    return apply_filters('wp_statistics_current_page', $current_page);

php2cpg drops the callback argument entirely -- the CPG holds
`add_filter("wp_statistics_current_page", )` -- so the edge cannot come from
the graph at any price. It comes from the source text instead, where both ends
are literals, via `mapping.php_hook_callbacks`.

                 vulnerable                              fixed
  wpstatistics   3 entailed, incl. pages.php:225:record  2, that one absent
  mwwpform       1 ($this->attachments)                  0
  pmpro          18, incl. CVE at :936                   1
  VAmPI          4                                       unchanged

The correctly-prepared sibling query one line below the CVE is not flagged on
either side.

Four things this cost, each measured rather than guessed:

  * Half one must be asked per ARRAY KEY, not per callback. "Does an
    unsanitized value reach this callback's return" is TRUE on both sides: the
    fix escapes `type` and `id` and leaves `search_query` alone. A
    callback-level answer flags the fixed side as readily as the vulnerable
    one, which is a leak, not a detection. The key is a literal at both ends,
    so it joins the way the hook name and the field name do -- the same idea
    for the third time.

  * The sanitizer test had to stop crediting a sibling's cleansing. The
    vulnerable query is ONE concatenation holding both `esc_sql($page_uri)`
    and the injectable `{$current_page['id']}`, so asking whether a
    sanitizer's name appears in a path element's text marked the whole flow
    sanitized. An element must now BE the cleansing -- a call to it, or its
    name as a string literal, which keeps `array_map('esc_sql', ...)` working.

  * Hook seeds are repository-wide where field seeds are file-scoped. A hook
    registry is global by construction: the callback is in one file and its
    `apply_filters` in another. The query returned nothing until those two
    scopes were separated.

  * `sourceKind` had to be REPORTED, not inferred. By the time a row reaches
    Python a hook source reads as `$current_page["id"]` and a parameter as
    `$args`, and `_usable_flows` discards parameter flows whenever a modelled
    one is present -- so seven sanitized `$_SERVER["REQUEST_URI"]` flows
    displaced the CVE between the query and the verdict. That preference then
    had to move PER SINK SITE: applied across a region it decides one site's
    question with another site's evidence, and PMPro's CVE at :936 vanished
    from a region still reporting fifteen other findings.

`verdict` now delegates to `verdicts` rather than running its own selection
over the union, because two copies of "which flow decides" is exactly the
duplication this module has been bitten by before -- and had been again: the
per-site preference did not reach the single-verdict path, so the two entry
points disagreed about the same rows.

841 tests pass; gates byte-identical; VAmPI unchanged.

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 20:42:35 +02:00
co-authored by Claude Opus 5
parent dcdbe322a1
commit d6908f18ba
6 changed files with 362 additions and 40 deletions
+12 -2
View File
@@ -70,7 +70,7 @@ text, and each measured against its own fixed commit.
|---|---|---|---|---|
| Unauthenticated injection, sink in the function that receives the value | `pmpro`, CVE-2023-23488 | entailed at `class.memberorder.php:936:getMemberOrderByCode`, sourced from `$code` | absent | **found**, and the pair separates |
| Arbitrary file operation | `mwwpform`, CVE-2023-6559 | entailed at `class.mail.php:259:_delete_files` | absent | **found**, and the pair separates |
| Unauthenticated injection, value crosses a WordPress hook | `wpstatistics`, CVE-2022-25148 | not reported | not reported | **missed**, structurally |
| Unauthenticated injection, value crosses a WordPress hook | `wpstatistics`, CVE-2022-25148 | entailed at `class-wp-statistics-pages.php:225:record` | absent | **found**, and the pair separates |
| Missing capability check | — | — | — | **cannot be asked**: PHP has no obligation facts |
| Unescaped output | — | — | — | **cannot be trusted**: the sanitizer list is flat |
@@ -135,7 +135,17 @@ not merely its reachability. Asking only "does a source reach this assignment" m
fixed side went from 1 finding to 13 before that clause existed, which is the pair no longer separating at
all. It costs about 1.6× on the taint query for that class (62s → 97s), memoised per field.
The hook half is the same machinery against a different key, and is not written yet.
**The hook half is implemented too, and CVE-2022-25148 is found**: `class-wp-statistics-pages.php:225:record`
on the vulnerable side, absent on the fixed side, with the correctly-`prepare`d sibling query one line below
it not flagged on either. The link table is read from the source text, because php2cpg drops a registration's
callback argument entirely -- the CPG holds `add_filter("wp_statistics_current_page", )`, so the edge cannot
come from the graph at any price.
The half that mattered was asking per ARRAY KEY rather than per callback. "Does an unsanitized value reach
this callback's return" is true on *both* sides, because the fix escapes `type` and `id` and leaves
`search_query` alone; a callback-level answer flags the fix as readily as the bug. The key is a literal at
both ends — `"id"` where the callback writes it, `['id']` where the sink reads it — so it joins the same way
the hook name and the field name do. That is the same idea for the third time.
What the engine reports on that plugin instead is two other sites — `getTop:406` and `TotalCount:443` — the
same weakness class at the wrong lines, identical on both sides, so the pair does not separate. They are not