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.