This is a method, and it is four steps. Read once to learn the rule — the invariant you actually care about. Compile it into a queryable fingerprint over the tree indexed as security facts: one document per function carrying its taint sources, its sinks, whether it sanitizes, and its outgoing call edges. Query the fingerprint, so only the violators come back. Read only the survivors — and execute the check where the code can be executed; in the proof run that was the SSRF validator, ported and run. XERJ is the agent's second brain in that loop: it holds a whole codebase's structure as queryable facts, so the agent reasons over all of it without ever loading the whole tree into context — it reads only the survivors the queries name. The proof run below is real WordPress core — 1,492 PHP files, ~619,000 lines, about 26× a 200k-token context window — indexed in ≈3.6 seconds. The entire structured audit summed to ~26,000 tokens, against ~5,200,000 just to load the tree once.
TOKEN COST TO THE ANSWER · NOT LATENCY · THE LAST TWO ROWS ARE THE PUBLISHED COUNTER-EXAMPLES, WHERE GREP IS THE CHEAPER TOOL — REACHABILITY IS THE AUDIT-TIME MEASUREMENT, WHERE THE KEYWORD-ARRAY TERM BUG (02 BELOW) FORCED A SCROLL-AND-TRAVERSE WORKAROUND; THE ~170-TOK SINGLE QUERY IS A PROJECTION FOR AFTER THE FIX FULLY LANDS, NOT A MEASUREMENT · SOURCES IN docs/case-studies/wordpress-security-audit →
tree-sitter-php parse rate, plus an 11,191-site dangerous-call census built as a separate pass.file:line code rather than recollection.WordPress accreted four request-entry surfaces over twenty years, and a correct check on AJAX means nothing if the same operation is reachable unguarded over REST or XML-RPC. The auth-posture facts — is the capability bound to the object, is there a nonce, which caps — are one query per surface over the index: 310 per-entry-point facts across AJAX, REST and XML-RPC, plus the 41 file-scope wp-admin handlers whose guards live in the shared preamble. Then the agent reads what looks wrong.
wp_ajax_**_permissions_checkThat is the honest headline of the structural pass: on the flows tested, core is hardened. The zeros are scoped exactly as the source scopes them — no missing, insufficient, broken-'==' or race auth flaw on any state-changing entry point, and 0 IDOR across the swept sets: 95 authenticated AJAX handlers and 90 REST permission checks. The negative result is the result — and it is only worth anything because the census can show what was covered. Note precisely what those zeros are, though: they are what a presence-and-scoping model can prove. They cannot express "a guard the sibling sinks have is absent here", which is exactly the shape the later per-file read caught in wp-admin/user-new.php — stated in full further down.
ALL OF CORE HOLDS 11 COMMAND-EXECUTION AND 13 DESERIALIZATION SINKS — 24/24 CARRY A FULL AGENT VERDICT; 1,472 SITES ARE QUEUED AT SEVERITY:REVIEW AND THE REMAINING ~9,700 ARE CENSUSED BUT NOT YET TRIAGED · SINK COVERAGE IS NOT TOTAL VULNERABILITY COVERAGE: LOGIC BUGS, MISSING-AUTHZ AND INCOMPLETE VALIDATORS ARE OUT OF ITS SCOPE
"A request source and a SQL sink in the same function, with no sanitizer on the path" is not a text pattern — it is a join over facts. Asked as a query, it narrows 11,990 functions to 4 candidates that the agent then reads in full. Asked with grep, it hands you 51 whole files and a reading job.
$ curl -s "$XERJ/wpaudit/_search" -H 'Content-Type: application/json' \
-d '{"query":{"bool":{"filter":[
{"term":{"has_source":"true"}},
{"term":{"sinks":"sql"}},
{"term":{"sanit":"false"}}]}}}'
# 11,990 functions → 4 candidates · the agent reads the 4
The rest of the sweep has the same shape. Only 2 of 1,343 registered hooks are unauthenticated, and the single unauth data path is heartbeat_nopriv_received. The check-vs-use detector returned 12 IDOR candidates, and the one genuinely risky shape — a revisions controller that checks the capability on the parent and returns by id — is explicitly guarded by a parent-mismatch 404. Twenty-three sanitizer-order flags all cleared on reading, because core holds escaper-last-before-sink everywhere. Every one of those answers is a query plus a short read, not a reading pass over the tree.
Core's SSRF gatekeeper — wp_http_validate_url, which guards wp_safe_remote_get/post/request and XML-RPC pingbacks — rejects loopback, 10/8, 0/8, 172.16/12 and 192.168/16, but not 169.254.0.0/16, the link-local range that contains the cloud-metadata endpoint 169.254.169.254. CGNAT 100.64/10 and IPv6 are missing as well. It surfaced by reading the range coverage: a pattern scanner cannot see an allow-list that is merely incomplete.
It was then verified by execution rather than by argument. With no PHP runtime available, the validator was ported line-for-line to Python — same scheme filter, same strpbrk(host, ':#?[]'), same octet test — and run with real DNS resolution: 127.0.0.1, 10.1.2.3, 192.168.0.1 and [::1] were rejected, and the metadata URL was allowed. Reachability was traced to real input: WP_XMLRPC_Server::pingback_ping takes an attacker-supplied source URL where XML-RPC is enabled, and WP_REST_URL_Details_Controller::get_remote_url takes a user-supplied url parameter at author level. The finding was independently re-discovered by a per-file sweep at wp-includes/http.php:598.
The honest framing the case study insists on: this is a known-class limitation at Medium severity, not a novel 0-day and not claimed as one. Core has long treated wp_http_validate_url as best-effort and points hardening at the http_request_host_is_external filter; exploitation requires the SSRF path enabled in a cloud environment, and nothing was exploited here — the ALLOWED verdict is the validator's own, on a ported copy. The suggested remediation is defense-in-depth: add 169.254.0.0/16, 100.64.0.0/10 and IPv6 loopback/ULA to the default deny-list, and re-resolve the host at request time to blunt DNS-rebinding TOCTOU. And it is the headline of three Medium-severity results, not the only one: the other two are the deploy-dependent ImageTragick surface in the Imagick image editor, which parses uploaded image content, and the role-guard inconsistency in wp-admin/user-new.php, which the next section states in full. The published table carries one further numbered entry, at informational severity — an option-injection pattern in the bundled Snoopy library that traces to dead code in core and matters only to plugins — and then the documented negatives. Four findings, three of them Medium, none claimed above the severity the source assigns it.
The second result worth stating at full length is an inconsistency, not an absence — and inconsistency is the shape a presence-of-a-check model is structurally unable to see. In upstream WordPress 7.0.2, wp-admin/user-new.php holds three sinks that assign a request-supplied role. Two call the role-editability guard wp_ensure_editable_role(), introduced in WordPress 6.8.0. The third does not. That half is unconditional. The escalation it enables is not, and both halves belong in the same breath: on a stock install get_editable_roles() returns every role, so wp_ensure_editable_role() is a no-op, the inviter could already assign the role legitimately, and no privilege boundary is crossed — in stock this is defense-in-depth, not escalation.
# wp-admin/user-new.php — three sinks, one request-supplied role # line 73 · adduser + noconfirmation branch — GUARDED wp_ensure_editable_role( $_REQUEST['role'] ); add_existing_user_to_blog([ ... 'role' => $_REQUEST['role'] ]); # line 231 · createuser branch — GUARDED wp_ensure_editable_role( $_REQUEST['role'] ); # lines 95-102 · adduser email-invitation else branch — NOT GUARDED $newuser_key = wp_generate_password( 20, false ); add_option( 'new_user_' . $newuser_key, array( 'user_id' => $user_id, 'email' => $user_details->user_email, 'role' => $_REQUEST['role'], # stored verbatim at line 100 ) ); $roles = get_editable_roles(); $role = $roles[ $_REQUEST['role'] ]; # AFTER the store · feeds the email text · warning, not wp_die
The get_editable_roles() lookup two lines below the store is not a gate: it runs after the value is already persisted, it feeds the invitation email text, and an out-of-policy role produces a warning rather than a wp_die. The authorization that is present on this path is real but coarse — current_user_can( 'promote_user', $target ) asks whether this user may promote that user at all, not whether they may hand out that role. Saying "there is no capability check here" would be false; saying the check is role-agnostic is the accurate claim.
Traced from the code, the trigger is one POST and one later click. Nothing below was fired at anything: these requests are reconstructed from the real implementation, and the finding was verified by reading WordPress 7.0.2 plus an independent agent whose only job was to refute it. No live or lab WordPress instance was attacked anywhere in this audit — the only finding verified by executing a port of core's own code was Finding 1's SSRF verdict, obtained from a ported copy of the validator. The run's other executed check was a PHP-semantics control test — whether a throwing __wakeup suppresses a __destruct gadget — run on synthetic classes in Docker PHP 7.4.33/8.0.30/8.3.32, not on WordPress code and not against any instance.
# the site-admin Add User screen — the file whose lines 73 / 95-102 / 231 are quoted above POST /wp-admin/user-new.php HTTP/1.1 Host: site.victim.network.example Cookie: wordpress_logged_in_<hash>=<restricted-admin session> Content-Type: application/x-www-form-urlencoded action=adduser&_wpnonce_add-user=<nonce from the Add-User form>&_wp_http_referer=%2Fwp-admin%2Fuser-new.php&email=attacker-alt%40example.com&role=administrator # noconfirmation is OMITTED — supplying it routes to the guarded line-73 branch # email must resolve to an existing user via get_user_by('email') # server effect: add_option() writes new_user_{key} = { user_id, email, role:'administrator' } # at user-new.php:100, with no wp_ensure_editable_role() anywhere on that path # SECOND ORDER — the invited address, chosen by the inviter, visits the link GET /newbloguser/<key-from-email>/ HTTP/1.1 Host: site.victim.network.example # maybe_add_existing_user_to_blog() in wp-includes/ms-functions.php runs on every # request (init hook), reads the tainted role back out of the option, and applies it: # add_existing_user_to_blog() -> add_user_to_blog( $blog_id, $user_id, $role ) # apply_filters( 'can_add_user_to_blog', true, ... ); // defaults to TRUE # $user->set_role( $role ); // no cap / editable-role re-check
There is no re-check on confirmation: the only thing standing between the stored role and set_role() is the default-true can_add_user_to_blog filter. So a role the inviter was not authorized to assign is persisted and later applied — and "later" is load-bearing, because the escalation is second-order and requires the invited, attacker-controlled address to actually visit /newbloguser/<key>/. It does not happen at store time and it does not happen without that interaction.
Everything that has to be true — with the finding, not under it. Multisite: the vulnerable branch is the multisite add-existing-user invitation flow, so a single-site install is not the target here. An authenticated session holding create_users and promote_users over the target but not manage_network_users. A valid add-user nonce harvested from the real form — the intent check is satisfied normally, not bypassed. noconfirmation omitted, so the email-invitation branch runs. An email that resolves to an existing network user, and, for the escalation to benefit anyone, an address the attacker controls. And the deciding precondition: an editable_roles filter that restricts this inviter below the injected role — the shape membership plugins, role managers and multi-tenant "site manager" delegation setups create. Without that filter, wp_ensure_editable_role() would have been a no-op anyway and the inviter could already have assigned the role. Medium, conditional, real. Not a stock-install escalation, not code execution of any kind, not unauthenticated, not instantaneous. It is worth reporting to WordPress as a hardening fix, and it is not claimed as anything beyond that. The fix is one line: call wp_ensure_editable_role( $_REQUEST['role'] ); at the top of the email-invitation branch, matching its two siblings.
Which technique found it, and which missed it — that is the transferable part. It came out of the exhaustive per-file sweep: 883 security-relevant files, 177 review batches, 222 agents, ~10.8M tokens, ~31 minutes, 44 candidates cut to 5 by adversarial verification. The verify phase was itself truncated and the source says so: all 177 review batches finished, so every one of the 883 files was analysed, but only 36 of the 44 candidates were machine-verified — 8 were dropped when the run hit a usage limit and were hand-read by the lead afterwards, of which two remain plausible-but-unconfirmed and are excluded from the 5. It is confirmed item #1 in that table, at wp-admin/user-new.php:100, and one of only two the lead independently re-read; the sweep's other lead-read item is its independent re-discovery of the SSRF range gap at wp-includes/http.php:598, which is the control showing the sweep was working rather than lucky. (The sweep is published as ZERODAY-SWEEP.md; the file name describes what it went looking for, not what it found — nothing here is a 0-day, and the remaining three confirmed items sit at adversarial-verifier confidence, reported as candidates rather than asserted.) What missed it is the structural authorization graph — the cheap, fast, high-coverage pass that had already declared this area clean. The reason is exact and is the whole lesson: the graph asked "is there a capability check?", user-new.php has one, so the file passed. A presence-of-a-check model cannot represent a missing sibling guard, because every predicate it evaluates returns true.
And "just read everything" would not have caught it either. Three reasons, all specific to this bug. It does not fit: core is ~5,200,000 tokens and a 200k window holds about 4%, so you must chunk — and chunking is exactly where the interprocedural flow is lost, because the tainted store is in wp-admin/user-new.php while the sink that applies it lives in wp-includes/ms-functions.php, two files away. It cannot compare siblings without a map: the defect is only visible with all three role sinks side by side, and a windowed scan reads them in different chunks and never lines them up. And it re-reads the whole tree for every new hypothesis, paying the full token cost per question. The index is what returned "every add_option / add_existing_user_to_blog call carrying a role argument" as one result set — handing the reviewer the three siblings to compare — and what let the sweep target 883 security-relevant files instead of blindly chunking 1,492. To be plain about the division of labour: XERJ did not find this bug, and no index finds bugs. It narrowed the search and put the right neighbours next to each other; the per-file agent read and the adversarial verification did the rest.
The lesson, compiled. Structural coverage and per-file reading are complementary, not competing: the graph narrows the search and proves coverage, while the read catches the inconsistency and logic gaps no fact schema encodes — "a capability check exists" simply cannot express "a guard its siblings have is absent here". Neither technique alone found this one. The practical dial is to run the cheap structural passes as the daily driver, at ~26,000 tokens for the whole structured audit, and the exhaustive per-file sweep at ~10.8M tokens as the periodic backstop when you suspect misses. And when the backstop finds what the graph missed, the loop closes the way it is supposed to: compile the new invariant — this guard must be applied to ALL sibling sinks of the same kind, not merely present somewhere in the file — into a fingerprint, and the next audit gets it for free.
An AI-assisted audit is only worth publishing if it reports its own failures. This one ships four, in the same repository as the findings.
$GLOBALS, is invisible to it — and the detectors have known
false-positive drivers. The 70 source-to-sink flows the taint pass returned are
high-recall candidates, not vulnerabilities: three of the top SQL flows are
documented false positives, and the one real candidate is gated behind an opt-in.
wp_safe_remote_get" —
returned 1 hit from the structured term index against 9 from full-text and 14 files
from grep. A silent false negative is the worst possible error in a security audit.
The root cause was proven rather than guessed: a keyword field stored one value per
document, so for an array only element [0] was indexed — a real compatibility break,
since a keyword array is multi-valued in Elasticsearch. The memtable half of the fix
has landed; the segment half is an open design follow-up and its regression test is
still ignore-marked in the repo. A second, smaller bug: a boolean term
matched as a string. The audit's interprocedural conclusions were computed over pulled
_source and cross-checked against full-text and grep, never through
the broken path — which is why they still hold, and why the published playbook ships
the workarounds as caveats to the agent.
__destruct methods are dangerous" —
twelve short methods — grep plus reading cost ~649 tokens against XERJ's ~14,700, and both
returned the identical answer. The published rule is to use the cheaper tool for the question
at hand. An index earns its cost when the question is broad, interprocedural, or asked
repeatedly over the same corpus; for raw "who calls X" reachability, grep needs no index and
no server, and it was the more reliable tool at the time of this audit.
wp-admin/user-new.php
because a capability check was present, and a presence-of-check model cannot see a missing
sibling guard. The exhaustive per-file sweep — 222 agents, ~10.8M tokens, ~31 minutes over the 883
security-relevant files, 44 candidates cut to 5 by adversarial verification (36 of the 44 machine-verified;
8 dropped at a usage limit and hand-read by the lead) — caught what it missed.
The section above states that finding in full, including its ceiling: on a stock install
get_editable_roles() returns every role, so the guard is a no-op and no
privilege boundary is crossed. Neither pass alone finds it — the deep sweep is the periodic backstop,
the ~26,000-token structural passes are the daily driver.
Everything above is published: the findings with their honest severities, the reproduction commands, the counter-examples where the index lost, and the verbatim prompts that drove the agent. Setup is roughly two minutes — install the extractor, clone WordPress, run three index scripts — and the method ships as a copyable skill that retargets to another stack by editing the catalog, because the taint model is data, not code.
Send us your stack and the invariant you actually care about — the sanitizer rule, the capability shape, the range check. We'll come back with the index schema, the queries that express it, and the token budget for the sweep.