Conversation
…just pagename traversal Second half of #3309. #3359 added `wordpress-pagename-traversal` on the published request shape: a double-encoded `pagename`. That case can only see `../` and `..\` in a parameter named `pagename`, which is the traversal story and not the whole of the flaw. The bug is a file *inclusion* -- get_page_template() urldecodes `pagename` and hands the result to locate_template(), which checks only that the candidate exists and ends in .php/.html, never that it is still inside a theme root -- so a PHP stream wrapper is the same primitive reached a different way, and `template`, `page_template`, `theme` and `stylesheet` name a template through the same hierarchy as `pagename` does. New payload class `wordpress-template-inclusion`, one more branch of the existing byte-pattern classifier and nothing else: no new engine, no new language, nothing deserialized, nothing evaluated, and no request-supplied name ever joined to a directory, opened or included. Three things must be true, and each alone is ordinary traffic. A template selector is present. A WordPress signal that is not the attacker's target is present -- `page_id` (the advisory's documented precondition) or `rest_route` (WordPress's own REST multiplexer), or a WordPress core path in a value. And an inclusion payload sits in a template selector: traversal, a stream wrapper, a remote URL, or the PEAR command channel. That second condition is the design and it is what the previous class did not require. `pagename` and `template` are WordPress *query variables*, not WordPress-exclusive parameter names, so without it a scanner probing some other application would have its generic LFI relabelled as a WordPress CVE. `wp-config.php` is excluded from the core-path list for the same reason: naming a file is what an attacker does, and treating that as evidence of what was being attacked is how a real disclosure probe becomes a CVE label. The PEAR stage is its own branch because the split request defeats both existing cases. The verified PoC puts WordPress routing in the form body and PEAR's argv in the raw query string (PHP splits the query on literal `+` without decoding the arguments); `pearcmd-rce` reads the query only and needs both markers in it. The advisory tells defenders to look for "+config-create+" in the query of a request to the WordPress front end, and that pair was labelled nothing. Query and body are therefore read together, because WP::parse_request() merges them. Ordered after the pagename case, so #3359's labels do not move -- a request it already claims keeps the class it has shipped with. Before the generic cases, because an inclusion aimed at /etc/passwd is a narrower reading than "somebody asked for /etc/passwd", the precedence the corpus table already gives command-injection over secret-read. Both WordPress cases now share a parser that matches what the target parses. url.ParseQuery rejects and drops any pair containing a semicolon; PHP's only separator is "&", so `data://text/plain;base64,...` reaches WordPress as one value with its media type intact and the sensor, parsing it the Go way, could not see the parameter at all. Split on "&", then on the first "=", then unescape each side; an undecodable side is kept rather than dropped. A differential run over every input the existing suites pin -- the 30-day corpus table, #3359's 9 cases, this file's cases, and parser edge cases -- differs from the old parser on exactly one input: a `pagename` value containing a semicolon, which Go threw away and PHP does not. No existing test was changed to accommodate any of this; the whole suite passes untouched. Red on origin/main, green here: 14 positives, 9 benign near-misses and 8 "keeps its own class" negatives, plus an end-to-end case asserting the class reaches the emitted event on a request with auth_outcome=unknown and that #3213's redaction did not cost the payload signature. Every expected value in the negative tables is the value origin/main already produced for that input, measured rather than assumed. The negatives are the part that decides whether this is usable: the full WordPress shape fetching an ordinary page, an ordinary login carrying a WordPress marker, a normal plugin read, a `..` with no separator after it, and a rewritten asset URL walking out of a WordPress directory. Real traffic keeps its own class -- the index.php pearcmd LFI (25 events), bare traversal (81), the theme css.php disclosure (14) and wordpress-rest-probe (372). Not measured against the fleet: Elasticsearch is not reachable from here, and the 30-day window #3309 measured contains no request of this CVE's shape. The corpus counts quoted above are #3309's, not re-measured. Event contract unchanged: no field added, so openapi.json needs no regeneration. No workflow touched, so no action to pin and no zizmor finding to allowlist. Refs #3309
Dependency Review✅ No vulnerabilities or license issues or OpenSSF Scorecard issues found.Scanned FilesNone |
Xore
enabled auto-merge (squash)
September 27, 2026 20:05
Xore
disabled auto-merge
September 27, 2026 23:22
Owner
Author
|
Superseded by #3449, which resolves the rebase conflict on this branch against current main. Same content, landable state, with one symbol-level adjudication documented in that PR's body. |
Xore
added a commit
that referenced
this pull request
Sep 28, 2026
…er (#3464) main.go's classifyPayload switch and the helper block below it are the two regions every CVE classifier edits, so any two CVE branches in flight collide in both at once -- #3423/#3444, #3425/#3449 and #3442 have all hit it, and each resolution had to be re-derived by hand. The cases move out into classify_roundcube.go, classify_wordpress.go, classify_ollure.go, classify_odata.go and classify_generic.go, each holding its own case and its own helpers. What is left in classify.go is the ordered slice and a first-match-wins loop over it, so a new CVE is a new file and one line of a list rather than two regions of a 700-line switch. The order is semantic, so it is now asserted rather than trusted. TestPayloadClassOrderIsPinned compares the whole dispatch to a pinned list position by position, and a precedence table gives one payload per deliberate overlap -- a base64 dropper that is also php-code, a pagename traversal that is also a pearcmd chain, version.bind that is also a bare hostname, a new class that would land below serialized-object and never fire. Swapping any two entries fails a row. The same table adds the one payload per class, which is also the first coverage jndi-lookup, xxe, template-injection and info-disclosure have had through classifyPayload. TestClassifyPayloadCorpusCoversEveryDispatchClass is what keeps the file from going stale again. The pinned order and the two corpora are three separate lists and nothing forced a rebase that adds a class to update all three; this does. It is the failure the issue exists to prevent, in the shape it takes after a merge rather than before one. Rebased onto main, which grew the classifier while this branch was open, so what the rebase reconciled is part of the change rather than a footnote to it: - #3449 added wordpress-template-inclusion, CVE-2026-87902's second reading, and main puts it at position 4: after wordpress-pagename-traversal, which shares the CVE and whose labels must not move, and before pearcmd-rce and the generic cases below. It is a class, so it is a case in classify_wordpress.go next to the one it divides with, one line in the dispatch, one payload of its own and four overlaps in the precedence table -- the split-request PEAR argv it claims from pearcmd-rce, the credential read it claims from secret-read, the pagename traversal it must lose to, and the generic LFI it must not take at all. - #3449 also refactored the pagename case onto shared formValues and decodeUpTo helpers, because url.ParseQuery drops a pair containing a semicolon and `data://text/plain;base64,...` is one value at WordPress. The rewrite moved with the case into classify_wordpress.go. Left behind it would have been a stale copy of a helper main had already changed, which is a silent detection gap rather than a compile error. - The refactor had put teamcity-agent-deserialization first and roundcube-virtuser-query-sqli second; main has them the other way round. No payload currently matches both, so the transposition changed no label -- but it is still a change to a first-match-wins list, and the pinned order is main's order rather than the refactor's. No behaviour change. Every label is distinct, so the label is the identity of the case. The 34 cases, their order and every helper body are main's; the one rewrite a case body needs is a comma becoming an ||. Verified rather than asserted: a corpus of 167 payloads -- every (query, body) pair the package's own test tables contain, harvested with go/ast from all seven of them -- was replayed through the pre-split classifier at b15213e and through this tree, and the two label streams are identical row for row, all 34 classes included and none of them reached by a payload the other tree labelled differently.
Xore
added a commit
that referenced
this pull request
Sep 28, 2026
…er (#3464) (#3470) main.go's classifyPayload switch and the helper block below it are the two regions every CVE classifier edits, so any two CVE branches in flight collide in both at once -- #3423/#3444, #3425/#3449 and #3442 have all hit it, and each resolution had to be re-derived by hand. The cases move out into classify_roundcube.go, classify_wordpress.go, classify_ollure.go, classify_odata.go and classify_generic.go, each holding its own case and its own helpers. What is left in classify.go is the ordered slice and a first-match-wins loop over it, so a new CVE is a new file and one line of a list rather than two regions of a 700-line switch. The order is semantic, so it is now asserted rather than trusted. TestPayloadClassOrderIsPinned compares the whole dispatch to a pinned list position by position, and a precedence table gives one payload per deliberate overlap -- a base64 dropper that is also php-code, a pagename traversal that is also a pearcmd chain, version.bind that is also a bare hostname, a new class that would land below serialized-object and never fire. Swapping any two entries fails a row. The same table adds the one payload per class, which is also the first coverage jndi-lookup, xxe, template-injection and info-disclosure have had through classifyPayload. TestClassifyPayloadCorpusCoversEveryDispatchClass is what keeps the file from going stale again. The pinned order and the two corpora are three separate lists and nothing forced a rebase that adds a class to update all three; this does. It is the failure the issue exists to prevent, in the shape it takes after a merge rather than before one. Rebased onto main, which grew the classifier while this branch was open, so what the rebase reconciled is part of the change rather than a footnote to it: - #3449 added wordpress-template-inclusion, CVE-2026-87902's second reading, and main puts it at position 4: after wordpress-pagename-traversal, which shares the CVE and whose labels must not move, and before pearcmd-rce and the generic cases below. It is a class, so it is a case in classify_wordpress.go next to the one it divides with, one line in the dispatch, one payload of its own and four overlaps in the precedence table -- the split-request PEAR argv it claims from pearcmd-rce, the credential read it claims from secret-read, the pagename traversal it must lose to, and the generic LFI it must not take at all. - #3449 also refactored the pagename case onto shared formValues and decodeUpTo helpers, because url.ParseQuery drops a pair containing a semicolon and `data://text/plain;base64,...` is one value at WordPress. The rewrite moved with the case into classify_wordpress.go. Left behind it would have been a stale copy of a helper main had already changed, which is a silent detection gap rather than a compile error. - The refactor had put teamcity-agent-deserialization first and roundcube-virtuser-query-sqli second; main has them the other way round. No payload currently matches both, so the transposition changed no label -- but it is still a change to a first-match-wins list, and the pinned order is main's order rather than the refactor's. No behaviour change. Every label is distinct, so the label is the identity of the case. The 34 cases, their order and every helper body are main's; the one rewrite a case body needs is a comma becoming an ||. Verified rather than asserted: a corpus of 167 payloads -- every (query, body) pair the package's own test tables contain, harvested with go/ast from all seven of them -- was replayed through the pre-split classifier at b15213e and through this tree, and the two label streams are identical row for row, all 34 classes included and none of them reached by a payload the other tree labelled differently.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Second half of #3309. #3359 added
wordpress-pagename-traversalon the published request shape — a double-encodedpagename. That case sees../and..\in a parameter namedpagename, which is the traversal story and not the whole of the flaw. The bug is a file inclusion:get_page_template()urldecodespagenameand hands the result tolocate_template(), which checks only that the candidate exists and ends in.php/.html— never that it is still inside a theme root. So a PHP stream wrapper is the same primitive reached a different way, andtemplate,page_template,themeandstylesheetname a template through the same hierarchy.New
honeypot.payload_classvaluewordpress-template-inclusion, one more branch ofclassifyPayloadand nothing else. No new engine, no new language, nothing deserialized, nothing evaluated, and no request-supplied name is ever joined to a directory, opened or included.The gate: WordPress shape AND an inclusion payload
Three things must hold, and each alone is ordinary traffic:
pagename,page_template,template,theme,stylesheet;page_id(the advisory's documented precondition),rest_route(WordPress's own REST multiplexer), or a WordPress core path in a value;Condition 2 is the design, and it is what the earlier class did not require.
pagenameandtemplateare WordPress query variables, not WordPress-exclusive parameter names — without it, a scanner probing some other application gets its generic LFI relabelled as a WordPress CVE.wp-config.phpis deliberately excluded from the core-path list for the same reason: naming a file is what an attacker does, and treating that as evidence of what was being attacked is how a real disclosure probe becomes a CVE label.The PEAR stage is its own branch
The verified PoC splits one request in two, and that split defeats both existing cases. WordPress routing travels in the form body; PEAR's argv travels in the raw query string, because PHP splits the query on literal
+without decoding the arguments.pearcmd-rcereads the query only and needs both markers in it, so the shape the advisory tells defenders to look for —+config-create+in the query of a request to the WordPress front end — was labelled nothing. Query and body are therefore read together, becauseWP::parse_request()merges them.Ordering
Checked after
wordpressPagenameTraversal, so #3359's labels do not move: a request it already claims keeps the class it has shipped with. Before the generic cases, because an inclusion aimed at/etc/passwdis a narrower and more actionable reading than "somebody asked for/etc/passwd" — the precedence the corpus table already givescommand-injectionoversecret-read.Why a parser change was needed
Both WordPress cases now share a parser that matches what the target parses.
url.ParseQueryrejects and drops any pair containing a semicolon; PHP's only separator is&, sodata://text/plain;base64,…reaches WordPress as one value with its media type intact — and the sensor, parsing it the Go way, could not see the parameter at all.formValuessplits on&, then on the first=, then unescapes each side, keeping an undecodable side rather than dropping it.A throwaway differential run compared the pre-change function body against the new one over every input the existing suites pin (the 30-day corpus table, #3359's 9 cases, this PR's cases, plus parser edge cases). One input differs, and it is the intended one:
Go threw that pair away; PHP does not. No existing test was changed, weakened or deleted to accommodate any of this.
RED against origin/main, GREEN with the change
main.gountouched, new test file only — 14 positives + the end-to-end case fail:With the change:
Full suite: 73 top-level tests, 107 subtests, 0 failures.
gofmt -lclean,go vetclean,go test -race -count=2clean. The negative tables pass on origin/main too — by design; they are regression pins for classes this change must not take.False positives — the part that decides whether this is usable
Nine benign near-misses, every expected value measured against origin/main rather than assumed:
page_id=2&theme=twentytwentyfive&file=/wp-content/themes/…) → unlabelledredirect_to=/wp-admin/→ unlabelledaction=plugin&plugin=akismet&…) → unlabelledpagename=my..slug— a..with no separator after it → unlabellednext=/themes/../uploads/photo.jpgandnext=/wp-admin/../wp-login.php— a path that walks out and back, the hard version of the same near-miss → unlabelledwp-content/in prose on a pager → unlabelledAnd eight "keeps its own class" negatives, four of them real traffic from the window #3309 measured:
+config-create+/&lang=../pearcmd&/<?=phpinfo()?>…(25 events)pearcmd-rcetemplate=../../../../../../etc/passwd— a WordPress variable name on a non-WordPress appsecret-readfiles=../../../../wp-config.php— theme css.php disclosure (14 events)path-traversallang=../../…/tmp/index1(81 events)path-traversalrest_route=/wp/v2/users&per_page=100(372 events)wordpress-rest-probefile=php://filter/…/etc/passwd— generic RFIsecret-readinclude=https://…,file=phar://…The
pearcmdpair is a controlled twin: identical query string, one body changed, opposite answer — the CVE class withpage_id=2&page_template=…in the body,pearcmd-rcewithout it.What this still misses
Stated plainly, because the gate is deliberately strict:
pagenamewith no second WordPress signal is left unlabelled. For traversal, the older class still catches it; for a wrapper there is nothing, and guessing would mean labelling onpagenamealone.pagename,wordpress-pagename-traversalstill wins — its surviving%2fis exactly what it looks for. The new class's added value there is the singly-encoded wrapper, the other selectors, and the PEAR channel.pear://is not matched as a wrapper: PHP has no such wrapper, so it would be a label with no mechanism behind it. The PEAR stage is caught byconfig-create/pearcmdinstead./, which is the least identifying path there is, andhttp.ServeMuxcleans..out ofr.URL.Pathbefore a handler sees it — so path matching has no discriminating power for this CVE. Where the theme path travels, it travels as a parameter value, which is what thestylesheetcase covers.Checks
eval'd, no request-supplied path resolved, opened or included on disk.payload_test.goand every other test file are byte-identical.openapi.jsonneeds no regeneration and was not touched.scripts/check-public-leaks.pypasses. Test payloads use the published shape and RFC 5737203.0.113.0/24; no live target.Refs #3309 (not closing it — the issue closes after the rebuilt sensor is deployed, as #3359's comment recorded).