Skip to content

feat(http-honeypot): classify CVE-2026-87902 template inclusion, not just pagename traversal - #3449

Merged
Xore merged 2 commits into
mainfrom
fix/3309-3309
Sep 28, 2026
Merged

Xore merged 2 commits into
mainfrom
fix/3309-3309

Conversation

@Xore

@Xore Xore commented Sep 28, 2026

Copy link
Copy Markdown
Owner

Rebased oc/3309-fix (#3425) onto current main and resolved its rebase conflict.

Why the branch conflicted

The branch carried one commit, a1443034 — the WordPress Core CVE-2026-87902 template-inclusion classifier. It predated main's Roundcube CVE-2026-48842 classifier (#3364, roundcubeVirtuserSQLi), and both commits added a case to the same flat switch at the head of classifyPayload in arcane/home/honeypot-http/http-honeypot/main.go, plus a block of new package-level helpers immediately before containsAny. 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) and wordpressTemplateInclusion (line 582) both sit above the generic serialized-object case (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: wordpressPagenameTraversal is defined on both sides. Main's version and the branch's version parse parameters differently —

  • main: url.ParseQuery
  • branch: formValues / decodeUpTo — split on & and the first =, then percent-decode, with up to three decode rounds

The 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.ParseQuery rejects any pair containing a ; and drops it, while PHP's only separator is & — so data://text/plain;base64,... arrives at WordPress as one intact value that the Go parser would drop. Since wordpressTemplateInclusion consumes formValues output, main's url.ParseQuery variant could not have been kept alongside it anyway. Main's wordpressPagenameTraversal call site at line 554 is unchanged and still reads the same pagename shape; 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) alongside roundcubeVirtuserSQLi, roundcubeSQLPayload, pregReplaceEscapeBypass (main). The single shared symbol is containsAny, which neither side modifies. The 19 deleted lines in the diff are exactly main's superseded wordpressPagenameTraversal body and one comment line that relocated; nothing else from main is touched, and no test was weakened, skipped or xfailed.

Measured verification

$ cd arcane/home/honeypot-http/http-honeypot
$ go build ./...      # clean
$ go vet ./...        # clean
$ gofmt -l .          # no output
$ go test ./...
Go test: 241 passed in 1 packages

$ cd ../../.. && python3 -m pytest tests/docs/ -q
650 passed, 0 failed, 1 xfailed

The xfail is pre-existing on main and unrelated (tests/docs/test_2055_fix.py, known). The Go count is 241 rather than the ~227 of the sibling #3444 because this branch adds wordpress_template_inclusion_test.go; the two counts are not comparable.

Refs #3309 (issue stays open — using Refs, not Closes).

Supersedes #3425, which can be closed.

@github-actions

Copy link
Copy Markdown

Dependency Review

✅ No vulnerabilities or license issues or OpenSSF Scorecard issues found.

Scanned Files

None

…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
Xore merged commit 1951316 into main Sep 28, 2026
117 checks passed
@Xore
Xore deleted the fix/3309-3309 branch September 28, 2026 08:47
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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant