From c69eb860c6619cdfb3e09e38968bd9fe3feeede3 Mon Sep 17 00:00:00 2001 From: Xore Date: Mon, 28 Sep 2026 09:49:19 +0200 Subject: [PATCH 1/2] feat(citrix-honeypot): KEV-metadata coverage for the NetScaler zero-day RCE pair (#3467) CVE-2026-88771 and CVE-2026-88772 are a Citrix NetScaler ADC/Gateway zero-day RCE pair, added to CISA KEV on 2026-09-27, each independently sufficient for RCE. This is detection coverage for citrix-honeypot, and it is deliberately narrower than the issue's Signal A/B/C sketch. There is no payload signature in this change, because none is published. CTX697096 and the KEV catalog give preconditions, CVSS vectors, CWEs and fixed builds, and nothing else: no path, method, header, parameter, body field or byte sequence for either CVE. The "provided IOCs" both sources point at are, per watchTowr, run as an IOC scan on the NetScaler console advisory page or requested from Citrix Support -- appliance-console artifacts gated on a build this decoy does not run, not wire patterns. Shipping an invented signature here would produce detection events an analyst trusts and that correspond to nothing, so the deliverable is KEV-metadata-driven coverage instead. Verified this run against the live KEV feed (catalog 2026.09.27, 1728 entries; both CVEs dateAdded 2026-09-27, dueDate 2026-09-30, forensicTriage Yes) and against CTX697096. What ships: - netscaler_kev_exposure, once per process start, one event per CVE carrying the verified KEV/bulletin metadata and a decoy_exercises_precondition field. The CVE id goes in `path` so `path: "CVE-2026-88771"` is a working query. Uses the existing emit path; no new fields on the shared event struct. - netscaler_cmd_metachar_shape_inferred, an INFERRED classifier over the one documented primitive ("an unauthenticated attacker to execute arbitrary commands"). The primitive is documented; where the unvalidated input lands is not, so the event name carries "inferred" and the code comment says so. Two tiers: high-conviction tokens (backtick, $(, ${, LF, CR) fire alone; low-conviction ones (&&, ||, ;, |, >, <, &) need two distinct, because & is the query separator and ; is the Jetty/Tomcat matrix-parameter form. A bare "/vpn/;id" deliberately does not classify -- it is indistinguishable from "/store;jsessionid=..." by shape alone, and that miss is recorded in a comment rather than left to be discovered. CVE-2026-88772 has no classifier. Its precondition is DTLS, a UDP transport, and this decoy is TCP-only (net.Listen("tcp") behind tls.NewListener, fronted tcp:4443 -> 10.8.0.2:443:pp), so the precondition is structurally unobservable here. The gap is recorded as queryable data in a precondition_gap field rather than papered over. Also recorded: KEV lists cwes ["CWE-119"] for both entries, contradicting CTX697096's CWE-20 for 88771. The code uses the vendor's own classification of its own CVE and notes the discrepancy in a comment. Tests: every classifier has a CAN-fire and a does-not-fire test, the benign corpus drawn from traffic this decoy actually serves (SAML wctx/SAMLRequest base64, JWT bearer, a=1&b=2, /store;jsessionid=, newbm.pl, an ordinary credential POST). Four mutants run and reverted: emptying the high tier (7 CAN-fire failures), reverting to per-token strings.Contains (5 benign failures -- the Contains scan made "a=1&&b=2" match both & and && and fire on the very artifact the low tier exists to tolerate), moving the classifier after the early-returning auth block (the ordering test fails), and bolting a fabricated payload= field into the KEV metadata (the anti-fabrication guard fails on both CVEs). Out of scope, noted in the file and the doc rather than coded: 88772 needs a UDP/DTLS listener, which is a new exposed surface and its own issue; 88773 request smuggling belongs in http-honeypot per #3464; 88774-88778 are configuration-dependent and not reported exploited; a "no preceding session" classifier needs per-source state this package does not hold. Not verified: nothing deployed, so no live detection rate is claimable. The Citrix IoC blog section could not be read (community.citrix.com returns 403 to fetch and to a browser-UA curl), so the IoC conclusion rests on watchTowr's description of where those IOCs live, not on having read the list. No live Suricata ruleset re-audit. No traffic sent anywhere: all fixtures are inert strings in unit tests. --- .../citrix-honeypot/cve_2026_88771.go | 421 ++++++++++++++++++ .../citrix-honeypot/cve_2026_88771_test.go | 389 ++++++++++++++++ .../citrix-honeypot/main.go | 18 + docs/research/3467-netscaler-kev.md | 205 +++++++++ 4 files changed, 1033 insertions(+) create mode 100644 arcane/home/honeypot-citrix-honeypot/citrix-honeypot/cve_2026_88771.go create mode 100644 arcane/home/honeypot-citrix-honeypot/citrix-honeypot/cve_2026_88771_test.go create mode 100644 docs/research/3467-netscaler-kev.md diff --git a/arcane/home/honeypot-citrix-honeypot/citrix-honeypot/cve_2026_88771.go b/arcane/home/honeypot-citrix-honeypot/citrix-honeypot/cve_2026_88771.go new file mode 100644 index 000000000..b5be03c99 --- /dev/null +++ b/arcane/home/honeypot-citrix-honeypot/citrix-honeypot/cve_2026_88771.go @@ -0,0 +1,421 @@ +package main + +// #3467 -- CVE-2026-88771 and CVE-2026-88772, the Citrix NetScaler ADC / +// NetScaler Gateway zero-day RCE pair that CISA added to KEV on 2026-09-27. +// +// WHAT THIS FILE SHIPS, STATED UP FRONT SO IT CANNOT BE MISREAD +// +// This ships KEV-metadata-driven coverage. It does not ship exploit +// detection, because exploit detection for this pair is not yet possible +// from public information, and a fabricated signature in a honeypot is +// worse than no signature at all: it produces detection events that an +// analyst will trust and that correspond to nothing on the wire. +// +// Re-verified for this change, 2026-09-28, against primary sources: +// +// - CISA KEV feed, catalog version 2026.09.27, released +// 2026-09-27T21:30:35Z, 1728 entries. Both CVEs present, both +// dateAdded 2026-09-27, both dueDate 2026-09-30, both +// forensicTriage "Yes". Their Notes field points at CTX697096 and at +// a "provided IOCs" scan -- see the "no payload bytes" note below. +// - Citrix bulletin CTX697096, Changelog 2026-09-27 "Initial +// Publication". Carries the per-CVE preconditions, the CVSS v4.0 +// vectors, the CWE ids and the fixed builds reproduced below. +// +// Verified ABSENT from both primary sources, which is what shapes this +// whole file: +// +// 1. No request path, HTTP method, header, query parameter, body field +// or payload byte sequence for either CVE. CTX697096 is a +// precondition/upgrade table plus regexes for reading a customer's +// OWN ns.conf. It contains no attack traffic and no exploit shape. +// 2. No network-observable precondition to filter on for CVE-2026-88771. +// Its precondition is "All NetScaler ADC and NetScaler Gateway +// deployments (Default configuration / No additional feature +// required)" -- every deployment qualifies, so there is nothing to +// test for. A decoy standing in for the product is affected by +// definition. +// 3. The "provided IOCs" that both KEV and CTX697096 point at are not +// request signatures. Per watchTowr's 2026-09-27 write-up they are +// run as an "IOC scan on the NetScaler Console Security Advisory +// page (version 14.1-73.36 or later, telemetry enabled)" or +// requested from Citrix Support -- i.e. appliance-console artifacts +// gated on a build this decoy does not run, not wire patterns. There +// is nothing there for a network decoy to match even in principle. +// Citrix's own caveat, quoted by watchTowr, is that the IOCs "do not +// cover every technique" so "a clean result is not proof" -- which +// cuts against over-reading any single signal, including this one. +// 4. watchTowr states 88773-88778 are "Not reported" exploited and +// that 88778 is "Fixed by enabling Enhanced ISN Generation, not by +// the upgrade alone". Out of scope here; see the follow-ups at the +// bottom of this file. +// +// CONSEQUENCE FOR CVE-2026-88772: its precondition is "DTLS configuration +// enabled on NetScaler ADC or NetScaler Gateway (Note: Enabled by default +// on VPN vServer)" -- CTX697096, verbatim. DTLS is a UDP transport. This +// decoy is TCP-only: main() listens with net.Listen("tcp", ...) behind +// tls.NewListener, and portbridge fronts it as tcp:4443 -> 10.8.0.2:443:pp. +// There is no UDP socket and no DTLS handshake anywhere in the stack, so +// the 88772 precondition is not observable on this surface at all. The +// honest deliverable is therefore a documented gap, not a classifier. See +// emitKEVCoverage's decoy_exercises_precondition field, which records the +// gap as queryable data rather than leaving it in a comment nobody +// triages on. +// +// WHAT IS THEREFORE INFERRED, AND LABELLED AS SUCH +// +// The one observable this file adds is a command-metacharacter shape +// check, and its provenance is uneven: +// +// DOCUMENTED: CVE-2026-88771's primitive. CTX697096: "A remote code +// execution vulnerability exists due to improper input validation, which +// can allow an unauthenticated attacker to execute arbitrary commands." +// KEV agrees: "an unauthenticated attacker to execute arbitrary +// commands." Note this is CWE-20 (Improper Input Validation), NOT +// CWE-78 (OS Command Injection) -- Citrix did not characterise it as +// command injection, and the distinction matters: a CWE-20 primitive +// tells you the input is not validated, not that it arrives in the path. +// +// INFERRED: that the unvalidated input arrives in the path, the query +// or the body, and that it therefore shows up as shell metacharacters. +// Neither CTX697096 nor KEV nor the watchTowr write-up says where the +// input lands, and the primitive could equally be a header, a cookie, a +// typed field or a protocol opcode. This file therefore checks all +// three request-controlled surfaces the issue named, treats a match as +// evidence of a COMMAND-METACHARACTER SHAPE and nothing more, and +// encodes the inference in the event name itself. +// +// The event name carries "inferred" deliberately: +// +// netscaler_cmd_metachar_shape_inferred +// +// authSurfaceEvent's own comment records why a CVE-numbered event name is +// the wrong tool when the shape is unconfirmed: a classifier that "looks +// like real detection coverage in the dashboard while never having been +// checked against a real request" is the failure mode, and this package +// already declined to add a cve_2026_19490 event for exactly that +// reason. A triage dashboard that renders +// "netscaler_cmd_metachar_shape_inferred" cannot mislead anyone into +// reading a semicolon in a query string as a confirmed CVE-2026-88771 +// exploit. The CVE association is carried instead by the +// netscaler_kev_exposure events below and by docs/research/3467-*. + +import ( + "net/http" + "net/url" + "sort" + "strings" +) + +// netscalerKEVEntry is the verified, citable metadata for one CVE of the +// CTX697096 pair. Every field here is transcribed from a primary source; +// none of it is inferred. Keeping it as data rather than prose is what +// lets emitKEVCoverage put it in front of an analyst instead of leaving +// it buried in a comment. +type netscalerKEVEntry struct { + CVE string + // KEVAdded / KEVDue are the catalog's own dateAdded / dueDate. + KEVAdded string + KEVDue string + // CVSSv4 is the full vector from CTX697096, not just the 9.5 base + // score, because the vectors differ in the two fields that matter for + // detection: 88771 is AC:L/AT:P (low complexity, but requires + // attacker preparation) and 88772 is AC:H (high complexity). + CVSSv4 string + // CWE is CTX697096's classification. Worth being precise about: KEV + // lists cwes ["CWE-119"] for BOTH entries, which contradicts the + // bulletin for 88771 (CWE-20) and looks like a catalog-side copy of + // 88772's row. CTX697096 is the vendor's own classification of its own + // CVE, so it wins here; the discrepancy is noted rather than silently + // resolved. + CWE string + Precondition string + // FixedIn is CTX697096's "What Customers Should Do" list. 13.1 has a + // second fixed build (13.1-64.24) that watchTowr documents for + // appliances where `show ns variable` returns anything, because of a + // reboot loop; CTX697096 does not mention it, so it is not asserted + // here as vendor guidance. + FixedIn string + // decoyExercisesPrecondition is the honest answer to "could this + // decoy ever satisfy this CVE's precondition?", which for a network + // decoy is the question that decides whether classifier work is + // possible at all. + decoyExercisesPrecondition bool + preconditionGap string +} + +var netscalerKEV2026 = []netscalerKEVEntry{ + { + CVE: "CVE-2026-88771", + KEVAdded: "2026-09-27", + KEVDue: "2026-09-30", + CVSSv4: "9.5 CVSS:4.0/AV:N/AC:L/AT:P/PR:N/UI:N/VC:H/VI:H/VA:H/SC:H/SI:H/SA:H", + CWE: "CWE-20", + Precondition: "all NetScaler ADC and NetScaler Gateway deployments " + + "(default configuration, no additional feature required)", + FixedIn: "14.1-73.37; 13.1-64.23; 13.1-37.279 (FIPS/NDcPP)", + // True, and vacuously so: the precondition is "every deployment", + // so there is nothing to test and nothing to miss. A decoy + // impersonating the product always meets it. + decoyExercisesPrecondition: true, + }, + { + CVE: "CVE-2026-88772", + KEVAdded: "2026-09-27", + KEVDue: "2026-09-30", + CVSSv4: "9.5 CVSS:4.0/AV:N/AC:H/AT:N/PR:N/UI:N/VC:H/VI:H/VA:H/SC:H/SI:H/SA:H", + CWE: "CWE-119", + Precondition: "DTLS configuration enabled on NetScaler ADC or NetScaler " + + "Gateway (enabled by default on VPN vServer)", + FixedIn: "14.1-73.37; 13.1-64.23; 13.1-37.279 (FIPS/NDcPP)", + // False. See the DTLS paragraph in this file's header. + decoyExercisesPrecondition: false, + preconditionGap: "DTLS is a UDP transport; this decoy listens on TCP " + + "only (net.Listen(\"tcp\") behind tls.NewListener, fronted " + + "tcp:4443 -> 10.8.0.2:443:pp) and terminates no DTLS handshake, " + + "so CVE-2026-88772's precondition is not observable here. " + + "Closing this needs a UDP/DTLS listener, which is a new exposed " + + "surface and therefore out of scope for a detection change.", + }, +} + +// summary renders one entry as a stable, greppable, single-line string. +// Deliberately not JSON: this lands in the existing `data` string field, and +// a key=value list is readable to an analyst in a raw document without a +// mapping change, which keeps the change additive to the emitted schema +// (no new fields on the shared `event` struct, nothing to migrate). +func (e netscalerKEVEntry) summary() string { + fields := []string{ + "kev_added=" + e.KEVAdded, + "kev_due=" + e.KEVDue, + "cvss4=" + e.CVSSv4, + "cwe=" + e.CWE, + "precondition=" + e.Precondition, + "fixed_in=" + e.FixedIn, + "decoy_exercises_precondition=" + boolWord(e.decoyExercisesPrecondition), + } + if e.preconditionGap != "" { + fields = append(fields, "precondition_gap="+e.preconditionGap) + } + return strings.Join(fields, " ") +} + +func boolWord(b bool) string { + if b { + return "yes" + } + return "no" +} + +// emitKEVCoverage logs the verified KEV/bulletin metadata once per process +// start, through the sensor's existing emit path -- no new logging +// mechanism, no new event struct fields. +// +// This is the "KEV-metadata-driven coverage" the issue's honest fallback +// amounts to, made queryable: an analyst can now ask which KEV entries +// this decoy is standing in for, what each one's precondition is, and -- +// for 88772 -- that this surface structurally cannot exercise it, without +// reading Go source. Emitted next to the existing "listening" event, once, +// rather than per request: this is deployment metadata, not traffic, and +// repeating it on every request would inflate the event stream for no +// gain. +// +// The CVE id goes in `path` because that is the field analysts already +// filter this sensor on, and it makes `path: "CVE-2026-88771"` a working +// query against the real schema. These are static compile-time constants +// transcribed from CTX697096, not attacker-controlled input, so putting +// them in a field the decoy otherwise uses for request paths cannot be +// influenced by a client. +func emitKEVCoverage(l *logger, port int) { + for _, e := range netscalerKEV2026 { + l.emit(event{Port: port, Event: "netscaler_kev_exposure", Path: e.CVE, Data: e.summary()}) + } +} + +// netscalerCmdMetacharEvent is the event kind cmdShapeEvent returns. +// +// Named for the observable, not for the CVE, and carrying "inferred" in +// the name on purpose -- see the header. An analyst who sees this in a +// triage view learns that a request to this decoy contained a +// command-metacharacter shape, which is a fact; they do not learn that a +// CVE-2026-88771 exploit was seen, which is not a fact anyone can assert +// yet. +const netscalerCmdMetacharEvent = "netscaler_cmd_metachar_shape_inferred" + +// highConvictionShellTokens fire the classifier on their own, because +// cmdShapeEvent is handed values that have already been through a +// percent-decoder, and no browser, SDK or application form serialiser +// emits a raw backtick, a command substitution or a newline into a +// decoded request target. A percent-encoded one (%60, %24%28, %0a) -- +// which is what an exploit client would send, precisely so the target +// looks inert to a middlebox -- arrives back as the literal character. +// +// Which decoder runs depends on the surface, and this is not uniform, so +// it is worth being exact: +// +// - r.URL.Path IS decoded by net/http. Nothing to do. +// - r.URL.RawQuery is NOT: it is the raw wire form, still "%60"-shaped. +// queryForShape below unescapes it, and is the reason that helper +// exists. +// - a POST body is NOT decoded either, and deliberately stays that way. +// See queryForShape's comment for why the body is left alone rather +// than run through a form parser this sensor does not otherwise have. +// +// Kept to exactly these five on purpose. Widening the high tier is the +// easy way to raise recall and the fastest way to start crying wolf. +var highConvictionShellTokens = map[string]bool{ + "`": true, "$(": true, "${": true, "\n": true, "\r": true, +} + +// lowConvictionShellTokens each have a real benign use in HTTP traffic, so +// ONE of them must never fire this classifier; two *distinct* ones in the +// same request is the threshold. +// +// ; matrix/session parameters: "/store;jsessionid=ABC123" is Jetty +// and Tomcat, and a NetScaler AAA vserver fronts plenty of those. +// This is also why a bare "/vpn/;id" is NOT classified: it is +// indistinguishable from the benign form by shape alone. +// | filter/pipe syntax in some templating and WAF test strings +// & the query-string separator, i.e. "a=1&b=2" on essentially every +// authenticated request this decoy will ever see +// > < URL and template punctuation +// && a sloppy or empty-parameter encoder emitting "a=1&&b=2" +// || the same class of encoder artifact +var lowConvictionShellTokens = []string{"&&", "||", ";", "|", ">", "<", "&"} + +// allShellTokens is the single matcher list, longest-first, because the +// scan below must be NON-OVERLAPPING and PREFER THE LONGEST MATCH. +// +// This ordering is load-bearing, not cosmetic. An earlier draft used +// strings.Contains per token, which made "a=1&&b=2" match both "&" and +// "&&" -- two "distinct" low tokens, so the classifier fired on the exact +// sloppy-encoder artifact the low tier exists to tolerate. Left-to-right +// consumption with longest-match-first makes "&&" one token and "a&b" one +// token, which is what the tiering actually means. There is a regression +// test pinning that specific case. +var allShellTokens = buildShellTokens() + +func buildShellTokens() []string { + all := make([]string, 0, len(highConvictionShellTokens)+len(lowConvictionShellTokens)) + for tok := range highConvictionShellTokens { + all = append(all, tok) + } + all = append(all, lowConvictionShellTokens...) + // Longest first: the scan returns the first match at a position, so + // "&&" has to be offered before "&" and "$(" before anything that + // could match a prefix of it. Sort by descending length, stable + // within a length so the map iteration above cannot make the tier + // boundary wobble between runs. + sort.SliceStable(all, func(i, j int) bool { return len(all[i]) > len(all[j]) }) + return all +} + +// hasShellShape reports whether s contains a command-metacharacter shape +// worth classifying: any high-conviction token, or two or more distinct +// low-conviction tokens. +// +// Scans left to right, consuming a matched token whole, so overlapping +// tokens cannot double-count. Returns at the first high-conviction hit or +// the second distinct low-conviction one -- there is nothing to learn +// from further tokens, and a decoy request is not a hot path worth +// scanning twice. +func hasShellShape(s string) bool { + seenLow := make(map[string]bool, len(lowConvictionShellTokens)) + for i := 0; i < len(s); { + matched := "" + for _, tok := range allShellTokens { + if strings.HasPrefix(s[i:], tok) { + matched = tok + break + } + } + if matched == "" { + i++ + continue + } + if highConvictionShellTokens[matched] { + return true + } + seenLow[matched] = true + if len(seenLow) >= 2 { + return true + } + i += len(matched) + } + return false +} + +// cmdShapeEvent classifies the INFERRED command-metacharacter shape that +// #3467 derives from CVE-2026-88771's documented "execute arbitrary +// commands" primitive, returning "" for everything else. +// +// The three arguments are the three request-controlled surfaces the issue +// named (path, query, body); they are joined with a space so that a token +// cannot be synthesised across a field boundary -- a path ending in "$" +// and a query starting with "(" must not combine into "$(". +func cmdShapeEvent(reqPath, query, body string) string { + if hasShellShape(reqPath + " " + query + " " + body) { + return netscalerCmdMetacharEvent + } + return "" +} + +// queryForShape returns r's raw query string with percent-escapes +// resolved, for cmdShapeEvent to inspect. +// +// r.URL.RawQuery is the raw wire form, so an exploit client that sends +// "?cmd=%60id%60" leaves the backtick encoded and the high-conviction +// tier would never see it. r.URL.Path has the opposite property -- +// net/http decodes it before the handler runs -- so the path needs +// nothing here. Resolving the query is what makes the two surfaces behave +// the same way. +// +// Lenient on failure, on purpose: a malformed escape (%zz, a bare %) is +// attacker traffic and must reach the classifier as-is rather than being +// dropped to "" on an error path, because a scanner probing decoders is +// precisely the traffic worth classifying. The raw string is the fallback. +// +// The known cost of decoding: a benign query that percent-encodes shell +// punctuation in a search box ("q=a%20%3E%20b%20%3C%20c") decodes into +// two distinct low-conviction tokens and will classify. That is a real +// false-positive path, stated here rather than discovered later. It is +// acceptable for three reasons -- a decoy attracts no organic search +// box, the event is explicitly named as inferred, and the alternative +// (missing every encoded payload) is the worse error for a sensor whose +// entire job is to be attacked. +// +// The POST body is deliberately NOT decoded here. It arrives as raw bytes +// and stays that way: inventing a form parser to unescape it would mean +// guessing which content type the attacker meant, and a body that +// arrives already containing the literal byte -- which is what an +// exploit client does -- is caught either way. Percent-encoded bodies +// are a known miss, not an oversight. +func queryForShape(r *http.Request) string { + if unescaped, err := url.QueryUnescape(r.URL.RawQuery); err == nil { + return unescaped + } + return r.URL.RawQuery +} + +// FOLLOW-UPS, deliberately not code in this change +// +// - CVE-2026-88772 needs a UDP/DTLS listener before it can be detected +// at all (see the header). A new exposed transport is a deception- +// design change with its own review, the same call #3032 (a) recorded +// for the AAA/SAML surface, so it belongs in its own issue. +// - CVE-2026-88773 (HTTP request smuggling, CWE-444, 9.3, "HTTP +// Configuration enabled") is a real request-shape CVE and the +// contradictory-framing-header classifier the issue sketches would +// match it. It is not reported exploited (watchTowr's table), it is +// outside the "88771 + 88772" scope this change was given, and +// http-honeypot already has Content-Length handling to sit alongside, +// so it belongs in the http-honeypot per-CVE file work (#3464) rather +// than here. +// - A "probe with no preceding session-establishment request" classifier +// (the issue's Signal A bullet 1) needs per-source connection state, +// which no classifier in this package holds today. Worth doing, and +// worth doing once there is a stateful base to hang it on. +// - When a real PoC or a Citrix-published wire-level indicator exists, +// this is the place it goes: add the confirmed literal with its +// primary-source citation and drop the "inferred" from the event name. +// Until then it stays where it is. diff --git a/arcane/home/honeypot-citrix-honeypot/citrix-honeypot/cve_2026_88771_test.go b/arcane/home/honeypot-citrix-honeypot/citrix-honeypot/cve_2026_88771_test.go new file mode 100644 index 000000000..f7af9be0c --- /dev/null +++ b/arcane/home/honeypot-citrix-honeypot/citrix-honeypot/cve_2026_88771_test.go @@ -0,0 +1,389 @@ +package main + +// Tests for #3467 (CVE-2026-88771 / CVE-2026-88772, Citrix NetScaler +// ADC/Gateway zero-day RCE pair, KEV 2026-09-27). +// +// The house rule this file exists to satisfy, from #2977's own write-up: +// a classifier with only a positive test is half a classifier. Every test +// name below therefore says which side it is on -- CAN fire, or does NOT +// fire on benign traffic -- and the benign corpus is drawn from traffic +// this decoy actually serves, not from strings chosen to be obviously +// clean. + +import ( + "bytes" + "io" + "net/http/httptest" + "os" + "strings" + "testing" +) + +// TestInferredCmdMetacharShapeCANFire proves the classifier fires on the +// shapes it claims to. INFERRED pattern -- see cve_2026_88771.go's header: +// the primitive ("execute arbitrary commands") is documented in CTX697096, +// the location of the unvalidated input is not. +func TestInferredCmdMetacharShapeCANFire(t *testing.T) { + cases := []struct { + name string + path, query, body string + }{ + {"backtick in path", "/vpn/`id`", "", ""}, + {"command substitution in path", "/vpn/$(id)", "", ""}, + {"brace expansion in path", "/vpn/${IFS}", "", ""}, + {"two distinct low tokens, semicolon and pipe", "/vpn/x;id|cat", "", ""}, + {"two distinct low tokens, ampersand and redirect", "/vpn/a&b>c", "", ""}, + {"semicolon and redirect", "/vpn/;id>/tmp/p", "", ""}, + {"newline in body", "/vpn/", "", "title=x\nrm -rf /"}, + {"backtick in body", "/vpn/", "", "title=`id`"}, + {"command substitution in body", "/vpn/", "", "title=$(whoami)"}, + {"backtick in query", "/vpn/", "cmd=`id`", ""}, + {"two distinct low tokens in query", "/vpn/", "a=1;b|c", ""}, + } + for _, c := range cases { + if got := cmdShapeEvent(c.path, c.query, c.body); got != netscalerCmdMetacharEvent { + t.Errorf("%s: cmdShapeEvent(%q, %q, %q) = %q, want %q", + c.name, c.path, c.query, c.body, got, netscalerCmdMetacharEvent) + } + } +} + +// TestInferredCmdMetacharShapeDoesNotFireOnBenignTraffic is the other +// half. Every case here is traffic this decoy really serves, and every +// one of them must classify as nothing. +func TestInferredCmdMetacharShapeDoesNotFireOnBenignTraffic(t *testing.T) { + cases := []struct { + name string + path, query, body string + why string + }{ + {"root", "/", "", "", "the login page"}, + {"vpn", "/vpn", "", "", "the login page"}, + {"plain path", "/some/random/path", "", "", "the default empty 200"}, + {"language query", "/vpn/index.html", "lang=en-US", "", "ordinary query"}, + {"matrix parameter", "/store;jsessionid=ABC123", "", "", + "Jetty/Tomcat session parameter -- the canonical benign ';'"}, + {"single semicolon", "/vpn/;id", "", "", + "documented limitation: indistinguishable from the matrix-parameter form by shape alone"}, + {"query separators", "/vpn/", "a=1&b=2&c=3", "", + "'&' is the query separator on essentially every real request"}, + {"sloppy encoder double ampersand", "/vpn/", "a=1&&b=2", "", + "empty-parameter artifact; must stay ONE token, not '&' plus '&&'"}, + {"sloppy encoder double pipe", "/vpn/", "a=1||b=2", "", + "same class of artifact"}, + {"repeated single ampersand", "/vpn/", "a=1&b=2&c=3&d=4", "", + "four '&' are still one distinct token"}, + {"single pipe", "/vpn/", "filter=a|b", "", "templating/filter syntax"}, + {"saml wctx base64", "/wsfed/passive", "wctx=PHNhbWxwOkV4cHRpb24vPjwvc3RhbGxhYmxlLz4=", "", + "#2977's own CVE-2026-3055 M1 shape: base64 has no token characters"}, + {"saml authnrequest base64", "/saml/login", "SAMLRequest=PHNhbWxwOkF1dGhuUmVxdWVzdD48L1NhbWxwPg==", "", + "same, on the AAA surface"}, + {"jwt bearer", "/oauth/idp/.well-known/openid-configuration", + "access_token=eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxIn0.9z3Jrbm93bg", "", + "JWT is base64url plus dots; none are tokens"}, + {"base64 with padding", "/vpn/", "RelayState=aGVsbG8gd29ybGQhPT0=", "", "base64 padding"}, + {"encoded semicolon in query", "/vpn/", "a=1%3Bb=2", "", + "percent-encoded ';' resolves to ';' -- one token, still suppressed"}, + {"the 2019 traversal path", "/vpn/../vpns/portal/scripts/newbm.pl", "", "", + "this decoy's own CVE-2019-19781 path, no metacharacters in it"}, + {"newbm payload field", "/vpns/portal/scripts/newbm.pl", "", "title=id", + "the existing newbm.pl capture path, benign value"}, + {"login form post", "/vpn/login", "", "username=alice&password=hunter2", + "an ordinary credential POST -- '&' appears twice and stays suppressed"}, + } + for _, c := range cases { + if got := cmdShapeEvent(c.path, c.query, c.body); got != "" { + t.Errorf("%s (%s): cmdShapeEvent(%q, %q, %q) = %q, want no classification", + c.name, c.why, c.path, c.query, c.body, got) + } + } +} + +// TestInferredCmdMetacharShapeIsLoggedThroughTheExistingPath is the +// end-to-end proof: a classified request reaches the event stream through +// the same log2 the rest of the sensor uses, alongside the unconditional +// get, and a benign request through the same handler emits no such event. +func TestInferredCmdMetacharShapeIsLoggedThroughTheExistingPath(t *testing.T) { + capture := func(run func()) string { + t.Helper() + r, w, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + orig := os.Stdout + os.Stdout = w + run() + os.Stdout = orig + w.Close() + var buf bytes.Buffer + io.Copy(&buf, r) + return buf.String() + } + + h := newTestHandler() + + // Fires: backtick in the path. + metachar := capture(func() { + req := httptest.NewRequest("GET", "/vpn/`id`", nil) + h.ServeHTTP(httptest.NewRecorder(), req) + }) + if !strings.Contains(metachar, `"event":"`+netscalerCmdMetacharEvent+`"`) { + t.Errorf("expected the classified event for a metacharacter path, got %q", metachar) + } + if !strings.Contains(metachar, `"event":"get"`) { + t.Errorf("the unconditional get event must still be emitted, got %q", metachar) + } + + // Does not fire: ordinary query string. + benign := capture(func() { + req := httptest.NewRequest("GET", "/vpn/index.html?lang=en-US&theme=dark", nil) + h.ServeHTTP(httptest.NewRecorder(), req) + }) + if strings.Contains(benign, netscalerCmdMetacharEvent) { + t.Errorf("benign query must not classify, got %q", benign) + } + if !strings.Contains(benign, `"event":"get"`) { + t.Errorf("the unconditional get event must still be emitted, got %q", benign) + } +} + +// TestInferredCmdMetacharShapeFiresOnAnAuthSurfacePath pins the ordering +// in serveGET. authSurfaceResponse returns early for the AAA/SAML paths, +// so a classifier placed after that block would silently never run on +// them -- a metacharacter probe dressed up as an auth request would go +// unclassified. This is the regression test for that placement. +func TestInferredCmdMetacharShapeFiresOnAnAuthSurfacePath(t *testing.T) { + r, w, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + orig := os.Stdout + os.Stdout = w + defer func() { os.Stdout = orig }() + + req := httptest.NewRequest("GET", "/saml/login?RelayState=`id`", nil) + rec := httptest.NewRecorder() + newTestHandler().ServeHTTP(rec, req) + + w.Close() + var buf bytes.Buffer + io.Copy(&buf, r) + + out := buf.String() + if !strings.Contains(out, `"event":"`+netscalerCmdMetacharEvent+`"`) { + t.Fatalf("a metacharacter on an auth-surface path must still classify, got %q", out) + } + if !strings.Contains(out, `"event":"netscaler_saml_surface_probe"`) { + t.Fatalf("the existing auth-surface classification must be unaffected, got %q", out) + } +} + +// TestInferredCmdMetacharShapeReadsThePOSTBody covers the third surface. +// An unvalidated-input primitive is at least as likely to arrive in a +// form field as in the request target, and the existing newbm.pl capture +// proves POST bodies reach this handler. +func TestInferredCmdMetacharShapeReadsThePOSTBody(t *testing.T) { + r, w, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + orig := os.Stdout + os.Stdout = w + defer func() { os.Stdout = orig }() + + req := httptest.NewRequest("POST", "/vpn/login", strings.NewReader("title=$(whoami)")) + rec := httptest.NewRecorder() + newTestHandler().ServeHTTP(rec, req) + + w.Close() + var buf bytes.Buffer + io.Copy(&buf, r) + + out := buf.String() + if !strings.Contains(out, `"event":"`+netscalerCmdMetacharEvent+`"`) { + t.Fatalf("expected the classified event from the POST body, got %q", out) + } + if !strings.Contains(out, "title=$(whoami)") { + t.Fatalf("expected the body captured on the classified event, got %q", out) + } +} + +// TestQueryForShapeDecodesPercentEscapes is why queryForShape exists. +// r.URL.RawQuery is the raw wire form, so an encoded payload would never +// reach the high-conviction tier without this; r.URL.Path does not have +// the problem because net/http decodes it before the handler runs. +func TestQueryForShapeDecodesPercentEscapes(t *testing.T) { + cases := []struct { + target string + want string + }{ + {"/vpn/?cmd=%60id%60", "cmd=`id`"}, + {"/vpn/?cmd=%24%28id%29", "cmd=$(id)"}, + {"/vpn/?a=1&b=2", "a=1&b=2"}, + // Malformed escape: lenient by design, the raw value must survive + // so a scanner probing decoders still reaches the classifier. + {"/vpn/?a=%zz", "a=%zz"}, + {"/vpn/?a=100%", "a=100%"}, + } + for _, c := range cases { + got := queryForShape(httptest.NewRequest("GET", c.target, nil)) + if got != c.want { + t.Errorf("queryForShape(%q) = %q, want %q", c.target, got, c.want) + } + } +} + +// TestShellShapeTokenScanIsNonOverlapping pins the fix for the design bug +// the first draft had: with per-token strings.Contains, "a=1&&b=2" matched +// both "&" and "&&" and counted as two distinct low-conviction tokens, so +// the classifier fired on the very sloppy-encoder artifact the low tier +// exists to tolerate. Left-to-right consumption must make "&&" one token. +func TestShellShapeTokenScanIsNonOverlapping(t *testing.T) { + cases := []struct { + s string + want bool + why string + }{ + {"a=1&b=2", false, "one '&'"}, + {"a=1&&b=2", false, "'&&' consumed whole, not also '&'"}, + {"a=1||b=2", false, "'||' consumed whole, not also '|'"}, + {"a=1&b=2&c=3", false, "three '&', still one distinct token"}, + {"a=1&&b=2&&c=3", false, "three '&&', still one distinct token"}, + {"a=1;b", false, "one ';'"}, + {"a=1;b|c", true, "two distinct low tokens"}, + {"`id`", true, "high-conviction backtick"}, + {"$(id)", true, "high-conviction command substitution"}, + {"${IFS}", true, "high-conviction brace expansion"}, + {"$(id)", true, "'$(' consumed whole"}, + {"x\ny", true, "high-conviction newline"}, + {"x\ry", true, "high-conviction carriage return"}, + {"a=1;>b", true, "';' and '>' are two distinct tokens"}, + } + for _, c := range cases { + if got := hasShellShape(c.s); got != c.want { + t.Errorf("hasShellShape(%q) = %v, want %v (%s)", c.s, got, c.want, c.why) + } + } +} + +// TestKEVCoverageEmitsVerifiedBulletinMetadata checks the metadata-driven +// half of this change actually carries the primary-source values, and +// that both CVEs of the pair are present. +func TestKEVCoverageEmitsVerifiedBulletinMetadata(t *testing.T) { + if len(netscalerKEV2026) != 2 { + t.Fatalf("expected exactly the 88771/88772 pair, got %d entries", len(netscalerKEV2026)) + } + + r, w, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + orig := os.Stdout + os.Stdout = w + emitKEVCoverage(newLogger(""), 443) + os.Stdout = orig + w.Close() + var buf bytes.Buffer + io.Copy(&buf, r) + out := buf.String() + + for _, want := range []string{ + `"event":"netscaler_kev_exposure"`, + `"path":"CVE-2026-88771"`, + `"path":"CVE-2026-88772"`, + "kev_added=2026-09-27", + "kev_due=2026-09-30", + "9.5", + "cwe=CWE-20", + "cwe=CWE-119", + "AT:P", // 88771 is AC:L/AT:P + "AC:H", // 88772 is AC:H + "14.1-73.37", + "13.1-64.23", + "13.1-37.279", + "DTLS", + } { + if !strings.Contains(out, want) { + t.Errorf("expected %q in the emitted KEV coverage, got %q", want, out) + } + } +} + +// TestKEVCoverageRecordsTheDTLSGapFor88772 is the honest-negative half of +// the KEV half. CVE-2026-88772's precondition is DTLS, and this decoy +// terminates no DTLS handshake, so the gap has to be recorded as queryable +// data rather than left as a comment. If this test ever starts failing +// because a UDP/DTLS listener was added, that is the moment the classifier +// work for 88772 becomes possible. +func TestKEVCoverageRecordsTheDTLSGapFor88772(t *testing.T) { + var dtls netscalerKEVEntry + for _, e := range netscalerKEV2026 { + if e.CVE == "CVE-2026-88772" { + dtls = e + } + } + if dtls.CVE == "" { + t.Fatal("CVE-2026-88772 missing from the KEV coverage set") + } + if dtls.decoyExercisesPrecondition { + t.Error("CVE-2026-88772 claims the decoy exercises its DTLS precondition; " + + "this sensor is TCP-only, so that claim would be false") + } + summary := dtls.summary() + for _, want := range []string{ + "decoy_exercises_precondition=no", + "precondition_gap=", + "DTLS is a UDP transport", + } { + if !strings.Contains(summary, want) { + t.Errorf("expected %q in the 88772 summary, got %q", want, summary) + } + } +} + +// TestKEVCoverageMetadataClaimsNoPayloadSignature is an anti-fabrication +// guard, and the most important test in this file. +// +// Everything netscaler_kev_exposure asserts is transcribed from CTX697096 +// and the CISA KEV catalog. As of this change neither source publishes a +// request path, payload byte sequence or wire-level indicator for either +// CVE, so a future edit that bolts an invented "signature" onto this +// metadata would be shipping a fabricated IoC into a honeypot -- the one +// failure mode this whole change is written to avoid. If a real primary +// source ever supplies one, this test is the thing to delete deliberately, +// in the same commit that cites the source. +func TestKEVCoverageMetadataClaimsNoPayloadSignature(t *testing.T) { + banned := []string{ + "signature", "payload", "poc", "exploit_string", "magic", + "detect these bytes", "known exploit", + } + for _, e := range netscalerKEV2026 { + summary := e.summary() + lower := strings.ToLower(summary) + for _, b := range banned { + if strings.Contains(lower, b) { + t.Errorf("%s metadata contains %q -- the KEV entry and CTX697096 "+ + "publish no request shape or payload bytes, so any such claim is "+ + "fabricated: %q", e.CVE, b, summary) + } + } + } +} + +// TestKEVCoverageEventNamesTheInferenceWhereItBelongs is the other +// anti-fabrication guard. The event kind for the command-metacharacter +// classifier must keep carrying "inferred" in its name; a future tidy-up +// that renames it to something CVE-flavoured is exactly the drift this +// change is guarding against. +func TestKEVCoverageEventNamesTheInferenceWhereItBelongs(t *testing.T) { + if !strings.Contains(netscalerCmdMetacharEvent, "inferred") { + t.Errorf("event kind %q must keep 'inferred' in its name -- the pattern is "+ + "an inference from CVE-2026-88771's documented primitive, not a confirmed "+ + "indicator", netscalerCmdMetacharEvent) + } + if strings.Contains(netscalerCmdMetacharEvent, "2026_88771") || + strings.Contains(netscalerCmdMetacharEvent, "2026-88771") { + t.Errorf("event kind %q must not name the CVE: no request shape for it is "+ + "published, and a CVE-named event reads as confirmed coverage", netscalerCmdMetacharEvent) + } +} diff --git a/arcane/home/honeypot-citrix-honeypot/citrix-honeypot/main.go b/arcane/home/honeypot-citrix-honeypot/citrix-honeypot/main.go index e22a97b81..2b12dec23 100644 --- a/arcane/home/honeypot-citrix-honeypot/citrix-honeypot/main.go +++ b/arcane/home/honeypot-citrix-honeypot/citrix-honeypot/main.go @@ -355,6 +355,13 @@ func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { func (h *handler) serveGET(w http.ResponseWriter, r *http.Request, reqPath string) { h.log2(r, "get", reqPath, "") + // #3467, before the auth-surface block below: that block can return + // early (authSurfaceResponse), and this classifier must run on every + // request regardless of which branch serves it. No body is read on a + // GET, so body is empty here. + if kind := cmdShapeEvent(reqPath, queryForShape(r), ""); kind != "" { + h.log2(r, kind, reqPath, "") + } if kind := authSurfaceEvent(reqPath); kind != "" { h.log2(r, kind, reqPath, "") if h.authSurfaceResponse(w, r, reqPath) { @@ -402,6 +409,12 @@ func (h *handler) serveGET(w http.ResponseWriter, r *http.Request, reqPath strin func (h *handler) servePOST(w http.ResponseWriter, r *http.Request, reqPath string) { body, _ := io.ReadAll(io.LimitReader(r.Body, 1<<20)) h.log2(r, "post", reqPath, string(body)) + // #3467: same ordering reason as serveGET, and here the body is the + // third surface cmdShapeEvent checks -- an unvalidated-input primitive + // is at least as likely to arrive in a form field as in the target. + if kind := cmdShapeEvent(reqPath, queryForShape(r), string(body)); kind != "" { + h.log2(r, kind, reqPath, string(body)) + } if kind := authSurfaceEvent(reqPath); kind != "" { h.log2(r, kind, reqPath, string(body)) if h.authSurfaceResponse(w, r, reqPath) { @@ -524,6 +537,11 @@ func main() { }, } log.emit(event{Port: port, Event: "listening"}) + // #3467: the KEV metadata for the NetScaler zero-day RCE pair this + // decoy impersonates, once per start, on the same emit path. See + // cve_2026_88771.go for why this is metadata-driven coverage and not + // exploit detection. + emitKEVCoverage(log, port) if err := srv.Serve(tlsLn); err != nil { panic(err) } diff --git a/docs/research/3467-netscaler-kev.md b/docs/research/3467-netscaler-kev.md new file mode 100644 index 000000000..cce8f6e3e --- /dev/null +++ b/docs/research/3467-netscaler-kev.md @@ -0,0 +1,205 @@ +# Research: CVE-2026-88771 / CVE-2026-88772 — Citrix NetScaler ADC/Gateway zero-day RCE pair — citrix-honeypot coverage (#3467) + +**Verdict: this ships KEV-metadata-driven coverage, not exploit detection.** +That is the honest ceiling for this pair today, and the reasoning is in +§2. It is a complete answer to the issue, not a partial one — but it is a +narrower one than the issue's Signal A/B/C sketch, and §6 says which +parts were dropped and why. + +Gathered and re-verified 2026-09-28 against primary sources, against +`origin/main` at `9bca6c81`. + +## 1. Sources, and what each one actually says + +| source | what it establishes | what it does **not** contain | +|---|---|---| +| CISA KEV feed, catalog `2026.09.27`, released 2026-09-27T21:30:35Z, 1728 entries | both CVEs present; `dateAdded` 2026-09-27; `dueDate` 2026-09-30; `forensicTriage` Yes; `knownRansomwareCampaignUse` Unknown | no CVSS, no request shape, no payload | +| Citrix **CTX697096** (Changelog: 2026-09-27 Initial Publication) | per-CVE preconditions, CVSS v4.0 vectors, CWE ids, fixed builds, and per-CVE ns.conf regexes for checking a customer's *own* config | no attack traffic, no exploit shape, no IoC bytes | +| watchTowr Rapid Reaction, 2026-09-27 | discovery timeline; 88773–88778 "Not reported" exploited; where the IOCs actually live | no request shape either | + +Reproduced from CTX697096, which is the vendor's own classification of its +own CVEs and therefore the authority here: + +| CVE | CVSS v4.0 | CWE | Precondition (verbatim) | +|---|---|---|---| +| CVE-2026-88771 | 9.5 `AV:N/AC:L/AT:P/PR:N/UI:N/…` | CWE-20 | "All NetScaler ADC and NetScaler Gateway deployments (Default configuration / No additional feature required)" | +| CVE-2026-88772 | 9.5 `AV:N/AC:H/AT:N/PR:N/UI:N/…` | CWE-119 | "DTLS configuration enabled on NetScaler ADC or NetScaler Gateway (Note: Enabled by default on VPN vServer)" | + +Fixed builds: 14.1-73.37, 13.1-64.23, 13.1-37.279 (FIPS/NDcPP). + +Two discrepancies worth recording rather than silently resolving: + +- **KEV lists `cwes: ["CWE-119"]` for both entries**, which contradicts + CTX697096's CWE-20 for 88771 and looks like the catalog copying 88772's + row. The code uses CTX697096's CWE and notes this in a comment. +- **88771 is CWE-20 (Improper Input Validation), not CWE-78 (OS Command + Injection).** Citrix did not characterise it as command injection. The + distinction is load-bearing for detection — see §3. + +## 2. Why there is no payload signature to ship + +Four things are true, and together they close off the obvious approach: + +1. **No request shape is published.** Neither CTX697096 nor KEV nor + watchTowr gives a path, method, header, parameter, body field or byte + sequence for either CVE. CTX697096 is a precondition/upgrade table plus + regexes for reading a customer's own `ns.conf`. +2. **88771 has no network-observable precondition to filter on.** Its + precondition is *every* deployment. A decoy standing in for the product + is affected by definition, so there is nothing to test. +3. **The published IOCs are not wire patterns.** KEV and CTX697096 both + point at an IOC scan. Per watchTowr it is "an IOC scan on the NetScaler + Console Security Advisory page (version 14.1-73.36 or later, telemetry + enabled)", or requestable from Citrix Support — appliance-console + artifacts gated on a build this decoy does not run. A network decoy + cannot match them even in principle. +4. **The vendor says the IoCs are incomplete.** Citrix's own caveat, quoted + by watchTowr: the IOCs "do not cover every technique", so "a clean + result is not proof." That argues against over-reading *any* single + signal — including a classifier this change adds. + +Inventing a magic string here would produce detection events an analyst +trusts and that correspond to nothing. That is a worse outcome than no +coverage, so the honest deliverable is metadata-driven. + +## 3. What shipped + +`arcane/home/honeypot-citrix-honeypot/citrix-honeypot/cve_2026_88771.go`, +classified through the existing `log2` path with no new logging mechanism +and no new fields on the shared `event` struct. + +**Metadata half — `netscaler_kev_exposure`, once per process start.** One +event per CVE carrying the verified metadata above, plus +`decoy_exercises_precondition`. The CVE id goes in `path` so +`path: "CVE-2026-88771"` is a working query. The 88772 row records its +gap as data (`precondition_gap=…`), not as a comment nobody triages on. +Emitted once next to `listening`, not per request: this is deployment +metadata, and repeating it per request would inflate the stream for +nothing. + +**Classifier half — `netscaler_cmd_metachar_shape_inferred`.** An +inference, and labelled as one in the event name itself: + +- **Documented:** the primitive. CTX697096 — "an unauthenticated attacker + to execute arbitrary commands"; KEV agrees. +- **Inferred:** that the unvalidated input lands in the path, query or + body. No source says where it lands; it could equally be a header, a + cookie or a typed field. + +Two tiers, because the cheap version of this classifier is a lie detector +in the other direction: + +- *high-conviction* (fire alone): `` ` `` `$(` `${` LF CR — no legitimate + client emits these into a decoded request target. +- *low-conviction* (need two **distinct**): `&&` `||` `;` `|` `>` `<` `&` — + each has a real benign use. `&` is the query separator; `;` is the + Jetty/Tomcat matrix-parameter form. A bare `/vpn/;id` deliberately does + **not** classify: it is indistinguishable from `/store;jsessionid=…` by + shape alone. That is a real miss, stated in a comment rather than + discovered later. + +The scan is left-to-right with longest-match-first, which is load-bearing: +an earlier draft used per-token `strings.Contains`, which made `a=1&&b=2` +match both `&` and `&&` as two distinct tokens and fired on the exact +sloppy-encoder artifact the low tier exists to tolerate. Mutant-tested +below; `TestShellShapeTokenScanIsNonOverlapping` pins it. + +`queryForShape` unescapes `r.URL.RawQuery`, because `RawQuery` is the raw +wire form (`%60id%60` stays encoded) while `r.URL.Path` is decoded by +`net/http` before the handler runs. The POST body is deliberately **not** +decoded — inventing a form parser would mean guessing the attacker's +content type, and a body carrying the literal byte is caught either way. +Percent-encoded bodies are a known miss. + +**Naming.** The event is named for the observable, not the CVE, and keeps +`inferred` in its name. `authSurfaceEvent`'s comment already records why: +a CVE-numbered event name produces "a classifier that looks like real +detection coverage in the dashboard while never having been checked +against a real request", and this package already declined to add a +`cve_2026_19490` event for exactly that reason. The CVE association rides +on `netscaler_kev_exposure` and this file instead. + +## 4. Tests, and proof they can fail + +`cve_2026_88771_test.go`. Every classifier has both a CAN-fire and a +does-not-fire test; the benign corpus is traffic this decoy actually +serves (SAML `wctx`/`SAMLRequest` base64, JWT bearer, `a=1&b=2`, +`/store;jsessionid=`, the `newbm.pl` capture path, a normal credential +POST), not strings chosen to be obviously clean. + +Four mutants, each reverted after watching it go red: + +| mutant | result | +|---|---| +| empty the high-conviction tier | 7 CAN-fire cases fail | +| revert to per-token `Contains` | 5 benign cases fail (`;jsessionid`, `a=1&b=2`, `a=1&&b=2`, …) | +| move the classifier after the early-returning auth block | `…FiresOnAnAuthSurfacePath` fails | +| bolt a fabricated `payload=NS-ICCV-88771-EXEC-V1` into the KEV metadata | `…ClaimsNoPayloadSignature` fails on both CVEs | + +That last one is the important one. It is a standing guard against the +exact failure this change is written to avoid, and its comment says to +delete it deliberately, in the commit that cites a real source. + +## 5. What was not verified + +- **No live detection rate, and none is claimable.** Nothing was deployed; + the change is repo-only. An Arcane build + redeploy is needed before any + new event kind can appear in ES, and the first real hit must be verified + on a document indexed *after* that deploy. +- **The Citrix "Indicators of Compromise" blog section could not be read** — + `community.citrix.com` returned HTTP 403 to every attempt, via fetch and + via a browser-UA curl. Its IoC list is therefore unverified. The + conclusion in §2 rests on watchTowr's description of where those IOCs + live (console-side, build-gated), not on having read the list. If that + section turns out to publish wire-level indicators, §2 needs revisiting + and this becomes a payload-signature change. +- **No live Suricata ruleset check** (the 2977 doc's `grep -ci citrix` + audit). The fleet's ET Open rules were not re-counted for this change, so + this doc does not claim anything about rule coverage. +- **No traffic was sent anywhere.** All fixtures are inert strings in unit + tests. No exploitation, no docker, no Elasticsearch, no model load. +- **The false-positive path is reasoned about, not measured.** A benign + query that percent-encodes shell punctuation (`q=a%20%3E%20b%20%3C%20c`) + decodes into two low-conviction tokens and will classify. Acceptable + because a decoy attracts no organic search box, the event says + `inferred`, and the alternative — missing every encoded payload — is the + worse error. Unverified against real traffic. +- **13.1-64.24 is not asserted** as vendor guidance. watchTowr documents + it for appliances where `show ns variable` returns anything (reboot + loop); CTX697096 does not mention it. + +## 6. Out of scope, deliberately + +- **CVE-2026-88773** (HTTP request smuggling, CWE-444, 9.3, "HTTP + Configuration enabled") is a real request-shape CVE and the + contradictory-framing classifier the issue sketches would match it. Not + reported exploited (watchTowr's table), outside the "88771 + 88772" + scope of this change, and `http-honeypot` already has Content-Length + handling to sit alongside. Belongs in the per-CVE file work (#3464). +- **CVE-2026-88774 through 88778.** Configuration-dependent, not reported + exploited, and 88778 is fixed by enabling Enhanced ISN Generation rather + than by upgrade alone. +- **CVE-2026-88772 needs a UDP/DTLS listener** before it is detectable at + all (§2, and the `precondition_gap` field). A new exposed transport is a + deception-design change with its own review — the same call #3032 (a) + recorded for the AAA/SAML surface. Its own issue. +- **A "probe with no preceding session-establishment request" classifier** + (issue Signal A bullet 1) needs per-source connection state, which no + classifier in this package holds today. +- **Fingerprint divergence** (issue Signal C) is already largely covered: + `ja3.go`/`ja4.go` put `x-ja3`/`x-ja4` on every event, and + `canonical.rs` promotes them to a fingerprint. Nothing to add. + +## 7. Bottom line + +The right product for this pair is **KEV-metadata-driven coverage**: the +sensor declares which KEV entries it impersonates, with the preconditions +and the fixed builds, and adds one clearly-labelled inference for the +documented command-execution primitive. The exploit payloads are not +public, and **this ships no fabricated IoC** — every pattern is either +transcribed from CTX697096/KEV or marked `inferred` in the code comment, +the event name and the test name. + +CVE-2026-88772 has no classifier at all, because its DTLS precondition is +structurally unobservable on a TCP-only decoy, and that gap is recorded +as queryable data rather than papered over with a guess. From da13e9ff09c55b0a0ee6f4f5edaf6cfbd3b95819 Mon Sep 17 00:00:00 2001 From: Xore Date: Mon, 28 Sep 2026 11:08:50 +0200 Subject: [PATCH 2/2] ci: retrigger cancelled runs