feat(http-honeypot): classify CVE-2026-87902 template inclusion, not just pagename traversal - #3449
Merged
Merged
Conversation
Dependency Review✅ No vulnerabilities or license issues or OpenSSF Scorecard issues found.Scanned FilesNone |
This was referenced Sep 28, 2026
Closed
…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 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
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.
Xore
added a commit
that referenced
this pull request
Sep 29, 2026
…asion under it The research document the issue asked for, and a root-cause parser fix that came out of writing it. The detector itself is not new: #3420 added the class, #3441 closed its case-folding gap, #3464 gave it a file. The issue's STATUS block ("implementation PRs are OPEN") is stale. docs/research/3364-roundcube-sqli.md documents the CVE as implemented, citing file:line, and carries forward the honest caveat from the source: coverage is NOT measured against the fleet corpus, because the honeypot-v2-* indices are not reachable from a branch. What is measured is #1888's pinned 30-day fixture, mirrored in roundcube_coverage_3364_test.go: 0 claims, 9/9 published shapes. No CVSS, build, or date is asserted beyond what the issue body and the class's own comment carry; no Kibana field names are invented. THE FIX. Four cases parsed parameters with url.ParseQuery behind if err != nil && len(values) == 0 { continue } Since Go 1.17 that parser rejects and drops any pair containing a semicolon, returning a map of the pairs that had none -- so the guard does not fire, parsing continues on a truncated map, and the attacker's parameter is simply absent: q="_user=x&;_action=login%27+OR+1%3D1--" err=invalid semicolon separator in query url.Values{"_user":[]string{"x"}} The targets are PHP, whose only separator is "&", so the payload reaches Roundcube in full while the sensor cannot see it. The guard's other silent case is the same bug: a value that will not decode makes ParseQuery return an empty map, the guard fires, and a deliberately broken escape (%zz) deletes the sensor's own evidence. The parser this needs already exists. classify_wordpress.go's formValues (#3449) splits on &, then the first =, unescapes each side, and keeps an undecodable side as-is rather than dropping it. All four call sites now use it -- classify_roundcube.go, classify_odata.go, classify_teamcity.go, laundering.go -- so this is one root cause retired rather than four bugs left. No new parser, no new abstraction, no new file; no deps, env vars, routes or ports. Surrounding logic and bounds are unchanged, and the 64 KiB body cap upstream of ServeHTTP still holds. BEHAVIOUR CHANGE, stated: a `;` in a value no longer makes the parameter invisible, and an undecodable value is no longer discarded. No false-positive surface is widened where it matters -- `;_action` is still not `_action`, `;$filter` is still not an OData system option, a semicolon with no payload behind it stays unlabelled, and qualifyingODataRequest's value-consistency gate is untouched and still runs on the value it is shown. One existing expectation changed: `$filter=Year%zz%2520eq` was unlabelled and is now odata-double-encode-probe. The old answer was url.ParseQuery deleting the pair on the %zz, not the gate declining -- the value carries a real %25, which is a % that decodes twice, and the target reads `Year%zz%20eq`. The rule is unchanged (a malformed escape is still not a residual escape, now pinned separately on a value whose only escape is broken); which requests reach the rule did change. Justified at the pin in odata_double_encode_test.go and in scanner_laundering_3430_test.go. classify_order_3464_test.go, the deliberate dispatch-order gate, passes unmodified and is not in this diff. PROOF. form_values_semicolon_3364_test.go fails on unmodified main (7 failing subtests, recorded before any source change) and passes after. It drives classifyPayload, qualifyingODataRequest and the real two-request laundering state, plus one ServeHTTP end-to-end for the event. Mutation-checked by watching each go red: dropping formUnescape's undecodable-side case (2 tests), re-introducing ';' as a separator in formValues (5 tests, including a pre-existing #3447 one), and reverting each of the three productive call sites individually. The teamcity site is pinned as gaining nothing, measured rather than assumed: its call name is a whole-string test, so the target's parser reaches the same verdict. Baseline -> final, same package: 129 top-level / 293 subtests -> 134 / 311, 0 fail, 0 skip, no new xfail, no weakened assertion. gofmt -l clean, go vet clean. tests/docs: 659 passed, 1 xfailed, 17 subtests, before and after.
Xore
added a commit
that referenced
this pull request
Sep 29, 2026
…rs (#3364) (#3483) * docs(#3364): record CVE-2026-48842 coverage, and fix the semicolon evasion under it The research document the issue asked for, and a root-cause parser fix that came out of writing it. The detector itself is not new: #3420 added the class, #3441 closed its case-folding gap, #3464 gave it a file. The issue's STATUS block ("implementation PRs are OPEN") is stale. docs/research/3364-roundcube-sqli.md documents the CVE as implemented, citing file:line, and carries forward the honest caveat from the source: coverage is NOT measured against the fleet corpus, because the honeypot-v2-* indices are not reachable from a branch. What is measured is #1888's pinned 30-day fixture, mirrored in roundcube_coverage_3364_test.go: 0 claims, 9/9 published shapes. No CVSS, build, or date is asserted beyond what the issue body and the class's own comment carry; no Kibana field names are invented. THE FIX. Four cases parsed parameters with url.ParseQuery behind if err != nil && len(values) == 0 { continue } Since Go 1.17 that parser rejects and drops any pair containing a semicolon, returning a map of the pairs that had none -- so the guard does not fire, parsing continues on a truncated map, and the attacker's parameter is simply absent: q="_user=x&;_action=login%27+OR+1%3D1--" err=invalid semicolon separator in query url.Values{"_user":[]string{"x"}} The targets are PHP, whose only separator is "&", so the payload reaches Roundcube in full while the sensor cannot see it. The guard's other silent case is the same bug: a value that will not decode makes ParseQuery return an empty map, the guard fires, and a deliberately broken escape (%zz) deletes the sensor's own evidence. The parser this needs already exists. classify_wordpress.go's formValues (#3449) splits on &, then the first =, unescapes each side, and keeps an undecodable side as-is rather than dropping it. All four call sites now use it -- classify_roundcube.go, classify_odata.go, classify_teamcity.go, laundering.go -- so this is one root cause retired rather than four bugs left. No new parser, no new abstraction, no new file; no deps, env vars, routes or ports. Surrounding logic and bounds are unchanged, and the 64 KiB body cap upstream of ServeHTTP still holds. BEHAVIOUR CHANGE, stated: a `;` in a value no longer makes the parameter invisible, and an undecodable value is no longer discarded. No false-positive surface is widened where it matters -- `;_action` is still not `_action`, `;$filter` is still not an OData system option, a semicolon with no payload behind it stays unlabelled, and qualifyingODataRequest's value-consistency gate is untouched and still runs on the value it is shown. One existing expectation changed: `$filter=Year%zz%2520eq` was unlabelled and is now odata-double-encode-probe. The old answer was url.ParseQuery deleting the pair on the %zz, not the gate declining -- the value carries a real %25, which is a % that decodes twice, and the target reads `Year%zz%20eq`. The rule is unchanged (a malformed escape is still not a residual escape, now pinned separately on a value whose only escape is broken); which requests reach the rule did change. Justified at the pin in odata_double_encode_test.go and in scanner_laundering_3430_test.go. classify_order_3464_test.go, the deliberate dispatch-order gate, passes unmodified and is not in this diff. PROOF. form_values_semicolon_3364_test.go fails on unmodified main (7 failing subtests, recorded before any source change) and passes after. It drives classifyPayload, qualifyingODataRequest and the real two-request laundering state, plus one ServeHTTP end-to-end for the event. Mutation-checked by watching each go red: dropping formUnescape's undecodable-side case (2 tests), re-introducing ';' as a separator in formValues (5 tests, including a pre-existing #3447 one), and reverting each of the three productive call sites individually. The teamcity site is pinned as gaining nothing, measured rather than assumed: its call name is a whole-string test, so the target's parser reaches the same verdict. Baseline -> final, same package: 129 top-level / 293 subtests -> 134 / 311, 0 fail, 0 skip, no new xfail, no weakened assertion. gofmt -l clean, go vet clean. tests/docs: 659 passed, 1 xfailed, 17 subtests, before and after. * ci: retrigger after runner network failure
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.
Rebased
oc/3309-fix(#3425) onto currentmainand resolved its rebase conflict.Why the branch conflicted
The branch carried one commit,
a1443034— the WordPress Core CVE-2026-87902 template-inclusion classifier. It predatedmain's Roundcube CVE-2026-48842 classifier (#3364,roundcubeVirtuserSQLi), and both commits added acaseto the same flatswitchat the head ofclassifyPayloadinarcane/home/honeypot-http/http-honeypot/main.go, plus a block of new package-level helpers immediately beforecontainsAny. Git had no clean way to interleave them, so the helper block reported as three content conflicts. The switch region itself auto-merged.Result: union, with one symbol-level adjudication
The switch is a pure union.
roundcubeVirtuserSQLi(line 543) andwordpressTemplateInclusion(line 582) both sit above the genericserialized-objectcase (line 728) — the ordering the file's own comment calls semantic, since a real payload nests and the outermost/most identifying shape must be matched first. Had either landed below it, it would have been swallowed and silently never fire.The helper block is a pure union, except for one symbol:
wordpressPagenameTraversalis defined on both sides. Main's version and the branch's version parse parameters differently —url.ParseQueryformValues/decodeUpTo— split on&and the first=, then percent-decode, with up to three decode roundsThe branch's version is kept, verbatim, and main's is dropped. This is not a behavioural trade-off between the two CVEs but a strict supersession: the branch's parser exists because Go 1.17
url.ParseQueryrejects any pair containing a;and drops it, while PHP's only separator is&— sodata://text/plain;base64,...arrives at WordPress as one intact value that the Go parser would drop. SincewordpressTemplateInclusionconsumesformValuesoutput, main'surl.ParseQueryvariant could not have been kept alongside it anyway. Main'swordpressPagenameTraversalcall site at line 554 is unchanged and still reads the samepagenameshape; it now also benefits from the multi-round decode.Every other symbol is a verbatim union:
decodeUpTo,formValues,formUnescape,wpCorePaths,wordpressInclusionPayload,phpStreamWrappers,remoteIncludeSchemes(this branch) alongsideroundcubeVirtuserSQLi,roundcubeSQLPayload,pregReplaceEscapeBypass(main). The single shared symbol iscontainsAny, which neither side modifies. The 19 deleted lines in the diff are exactly main's supersededwordpressPagenameTraversalbody and one comment line that relocated; nothing else from main is touched, and no test was weakened, skipped or xfailed.Measured verification
The xfail is pre-existing on
mainand unrelated (tests/docs/test_2055_fix.py, known). The Go count is 241 rather than the ~227 of the sibling #3444 because this branch addswordpress_template_inclusion_test.go; the two counts are not comparable.Refs #3309 (issue stays open — using
Refs, notCloses).Supersedes #3425, which can be closed.