feat(cpg): taint a field where it is assigned — the field half of 5.11

`_delete_files()` takes no parameters. CVE-2023-6559 arrives as a FIELD,
written by one method and read by another:

    public function __construct( ..., array $attachments = array() )
        $this->attachments = $attachments;          // half one ends here
    protected function _delete_files()
        foreach ( $this->attachments as $file )     // half two starts here
            unlink( $file );

Both halves were already in the graph. Only the join was missing, and the key
it joins on is the literal `$this->attachments`, written identically at both
ends -- so this is a second question asked with the query that already exists,
not an edge a frontend has to invent.

A read of `$this->F` is a source when some assignment to the same literal
`$this->F` in the same file is fed by an unsanitized flow from a seed. Scoped
three ways so a field name common to every class ever written cannot become a
wormhole: only fields the sink scope reads, only assignments in the same file,
and the right-hand side must itself be reachable from a source.

The receiver could then leave `parameterNodes` -- the precision fix refused
two commits ago, now sound, because the path it was standing in for is carried
by the field it actually travels through:

                          before                     after
  mwwpform vulnerable     1, from `this`             1, from $this->attachments
  mwwpform fixed          0                          0
  pmpro vulnerable        15 -- 4 named, 11 receiver 18 -- all named or field
  pmpro fixed             1, receiver-sourced        1
  VAmPI                   4 entailed                 4 entailed, unchanged

CVE-2023-23488 is still at class.memberorder.php:936 and CVE-2023-6559 at
class.mail.php:259. Every finding now names the value it followed instead of
naming the object.

Three things cost a measurement each, recorded in the task:

  * A summary must carry the SANITIZATION status of the half it summarises,
    not merely its reachability. Without that clause,
    `$this->sqlQuery = "..." . esc_sql($x) . "..."` reads as tainted -- and
    PMPro builds most of its queries that way, so the fixed side went from 1
    finding to 13. That is the pair no longer separating, which is the only
    number that matters.
  * Two-phase `reachableBy` then `reachableByFlows` is SLOWER, not faster:
    183s -> 256s. Running the analysis twice costs more than skipping path
    reconstruction saves. Reverted.
  * Memoizing per field rather than per file took it to 97s, about 1.6x the
    pre-field cost for that class, because only fields some region reads are
    worth the flow query.

`inScope` is factored out of `sinkCalls` so the field sources are scoped to
exactly the same place as the sinks, rather than a second copy of those three
branches drifting from the first.

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 19:42:20 +02:00
co-authored by Claude Opus 5
parent e41d6dd7ea
commit dcdbe322a1
3 changed files with 151 additions and 34 deletions
+23 -10
View File
@@ -81,17 +81,19 @@ side because the fix is a call the fact tables already carry: `$wpdb->prepare` f
other. That is the distinction worth teaching. The engine is not recognising a bug; it is failing to find a
cleansing call on a path it resolved, and then finding one.
The precision is not clean, and saying so is the point. On PMPro's order class the vulnerable side reports
**15 entailed findings**, of which **4** are sourced from a named parameter and **11** from the receiver,
`$this`. One receiver-sourced finding survives into 2.9.8 at a query whose every fragment is wrapped in
`esc_sql` — a false positive, and a legible one: the flow reaches the sink through `$this->sqlQuery`, so the
sanitizer is on the fragments and not on the path.
Every finding now names the value it followed. On PMPro's order class the vulnerable side reports **18
entailed findings** and the fixed side **1**, and each one is sourced either from a named parameter (`$id`,
`$code`, `$token`) or from a named field (`$this->sqlQuery`, `$this->Email`). None is sourced from `$this`.
Excluding the receiver was tried and reverted. It cuts that file to 6 findings, all named-parameter sourced,
and makes the PMPro pair separate cleanly — and it **loses CVE-2023-6559**, because `_delete_files()` takes
no parameters at all and its attacker-controlled path arrives as `$this->attachments`, assigned by another
method. Trading a verified CVE for findings merely suspected of being false is the wrong trade. The real
defect is field-sensitivity: a field should be tainted where it is assigned.
That took two changes, and the order between them is the whole point. Excluding the receiver from parameter
sources was tried **first** and reverted: it cuts the file to 6 findings and makes the pair separate cleanly,
and it **loses CVE-2023-6559**, because `_delete_files()` takes no parameters at all and its
attacker-controlled path arrives as `$this->attachments`, assigned by another method entirely. Trading a
verified CVE for findings merely suspected of being false is the wrong trade.
With the field half of the join in place (below), that path is carried by the field it actually travels
through, and the receiver no longer has to stand in for it — so it can go, and the eleven receiver-sourced
findings go with it. The precision fix became available once the mechanism underneath it was right.
### The one it misses, and why
@@ -124,6 +126,17 @@ synthetic endpoint, build the table by reading literals rather than inferring an
carries the join's uncertainty — one registered callback and a literal key entails, several callbacks or a
computed hook name corroborates. Task 5.11 states it, with both pinned pairs as its observable.
**The field half is implemented and measured.** `$this->attachments` is tainted at the assignment that fills
it, joined to the read by the literal field code, so CVE-2023-6559 is found as
`class.mail.php:259:_delete_files ← $this->attachments` and is absent on the fixed side. One thing it needed
that is easy to get wrong: the summary has to carry the **sanitization** status of the half it summarises,
not merely its reachability. Asking only "does a source reach this assignment" marks
`$this->sqlQuery = "..." . esc_sql($x) . "..."` tainted, and PMPro builds most of its queries that way — the
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.
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
noise: upstream now annotates both with `// phpcs:ignore WordPress.DB.PreparedSQL...`, and later rewrote