diff --git a/arcane/home/honeypot-http/http-honeypot/main.go b/arcane/home/honeypot-http/http-honeypot/main.go index 92b1e8fe..98e0d4bc 100644 --- a/arcane/home/honeypot-http/http-honeypot/main.go +++ b/arcane/home/honeypot-http/http-honeypot/main.go @@ -386,6 +386,40 @@ func classifyPayload(query, body string) string { switch { // --- named exploit chains, most identifying first --- + // #3189, CVE-2026-63077: unauthenticated deserialization RCE in + // JetBrains TeamCity. CVSS 3.1 9.8 (AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H), + // CWE-502, in CISA KEV since 2026-08-05, fixed in 2025.11.7 and + // 2026.1.3. The JetBrains CNA record names the mechanism -- + // deserialization of untrusted data reached through the agent polling + // protocol -- so no credential is involved, which is what PR:N above + // means and why a bait login form would be the wrong shape to look for. + // + // What is matched is the ATTEMPT, as bytes: a serialization container + // on the wire, which is the mark of a deserialization sink being aimed + // at. Nothing here is decoded, reconstructed, evaluated or answered -- + // a classifier that read the object graph to confirm it would be a + // second copy of the sink it exists to observe, which is the whole + // thing this sensor must never be. See + // teamcityAgentDeserialization for the two halves and why both are + // required. + // + // Checked first, ahead of the generic serialized-object case near the + // bottom of this switch, which is where a TeamCity attempt would + // otherwise land. That case recognises a stream at the very start of a + // body or raw magic bytes anywhere in one, so the same attempt arrives + // under three different labels depending on transport and none of them + // says which product or which CVE it was. Outside this gate that case + // is untouched, and the tests pin that it is. + // + // Not measured, and that is a fact about the evidence rather than a + // formatting choice: #3189's research note records no capture, no PoC + // and no published exploitation detail for this signature, and the + // fleet's corpus is not reachable from here. There is no event count to + // quote, so none is invented. What the tests pin instead is the + // boundary, because that is what decides whether the class is usable. + case teamcityAgentDeserialization(query, body, q, b): + return "teamcity-agent-deserialization" + // #3309, CVE-2026-87902 (KEV 2026-09-25): WordPress resolves the page // template from `pagename`, which it urldecodes once more itself, so a // double-encoded traversal walks out of the theme directory into any @@ -586,6 +620,302 @@ func wordpressPagenameTraversal(query, body string) bool { return false } +// teamcityAgentDeserialization reports a request aimed at a Java +// deserialization sink through TeamCity's build-agent polling protocol +// (CVE-2026-63077), as a conjunction of two things seen on the wire. +// +// Half one is the product's own agent protocol: a call name from TeamCity's +// polling channel, or one of its qualified DTO/package names. Half two is a +// serialization container: the Java stream header, raw or base64, or a gadget +// class name -- see javaSerializationContainer. +// +// Both are required, and neither alone is worth anything. Every TeamCity +// server on earth has agents polling it, so the product half on its own is +// the fleet's background traffic, and a classified agent poll would be worse +// than no classification at all. A serialized object on an HTTP port is +// somebody else's finding: this sensor serves a WordPress XML-RPC bait of +// its own (#238) and buckets xmlrpc paths as "wordpress", so containers on +// that transport are expected to arrive here, and they are not TeamCity's. +// The conjunction is the CVE, which is why this takes the product AND the +// container and cannot be reduced to a single substring. +// +// The raw and lowered forms of each channel are both passed in, and the +// distinction is load-bearing rather than tidiness. The container has to be +// read from the RAW bytes: strings.ToLower replaces every byte that is not +// valid UTF-8 with U+FFFD, so the lowered form of a raw AC ED 00 05 header +// is replacement characters and the signature is gone. That is the same +// reason the generic serialized-object case reads `body` rather than this +// function's `b`. The product names are ASCII and are read from the lowered +// copies, which the caller has already built for its own cases -- so this +// function allocates nothing. +// +// What it does not do: deserialize. No decoder, no parser, no object +// reader, no evaluation of anything received. The class reports that a +// request carried the marks of a deserialization attempt on a named +// product's protocol, and nothing more: not that the object graph was +// well-formed, not that the sink existed, and emphatically not that anything +// was executed. Those are different claims, and only this one is available +// from the bytes a sensor receives. +func teamcityAgentDeserialization(rawQuery, rawBody, lowerQuery, lowerBody string) bool { + if !javaSerializationContainer(rawQuery, rawBody, lowerQuery, lowerBody) { + return false + } + return teamcityAgentProtocol(lowerQuery, lowerBody) +} + +const ( + // javaStreamHeader is the four bytes every java.io ObjectOutputStream + // begins with: STREAM_MAGIC (0xAC 0xED) followed by the serialization + // protocol version (0x00 0x05). It is the only place in a Java stream + // that says "this is a Java object stream", which is what makes it worth + // matching on its own -- a payload whose class descriptor has been + // mangled to slip a filter still starts with these four bytes. + // + // Read as bytes and compared. Never handed to a deserializer: that + // would be the vulnerability, not the detection. + javaStreamHeader = "\xac\xed\x00\x05" + // javaStreamHeaderBase64 is base64(javaStreamHeader), truncated to the + // five characters that are fixed whatever follows it. The first three + // bytes fill one base64 group exactly, and the fifth character is the + // first six bits of the version byte, so it is 'B' in every stream. An + // attacker who rewrites the tail of their blob cannot move the header + // without rewriting the transport that carries it. + // + // This is how the XML-RPC string/base64 types and TeamCity's own JSP + // entry point actually put a serialized object on the wire, so it is + // the form this class is most likely to meet in the clear. + javaStreamHeaderBase64 = "rO0AB" +) + +// javaGadgetClasses are the class names a published Java gadget chain needs, +// lowercased for the case-insensitive comparison +// javaSerializationContainer makes against the caller's lowered copies. +// +// A Java stream carries its class descriptor in cleartext -- the descriptor +// names the class before any of it has been read -- which makes a gadget +// class name in the request two things at once: the reason a container on +// the wire is an attack rather than an accident, and the signal that +// survives an attempt whose header bytes were tampered with. The list is the +// gadget families that are actually published, not a guess at what might +// work: the commons-collections chains that are the ysoserial default, the +// JNDI datasource rowset, BeanComparator, the annotation proxy that wraps +// it, the Xalan TemplatesImpl bytecode sink that nearly every chain ends +// in, the xbean JNDI converter, and the ysoserial marker itself. +// +// Only ever matched in company with TeamCity's protocol shape, so ordinary +// traffic that happens to contain one of these words is unaffected. +var javaGadgetClasses = []string{ + // commons-collections 3 and 4: InvokerTransformer, ChainedTransformer + // and the rest of the default chain. + "org.apache.commons.collections.functors", + "org.apache.commons.collections4.functors", + // The JNDI route: a rowset that will be told where to look up. + "com.sun.rowset.jdbcrowsetimpl", + // BeanComparator, and the annotation proxy that carries it. + "org.apache.commons.beanutils.beancomparator", + "sun.reflect.annotation.annotationinvocationhandler", + // The bytecode sink. + "com.sun.org.apache.xalan.internal.xsltc.trax.templatesimpl", + // The xbean converter, for the chains that need a different entry. + "org.apache.xbean.propertyeditor.jndiconverter", + // A scanner naming its own toolchain, which is the clearest possible + // statement of intent and costs nothing to recognise. + "com.ysoserial", +} + +// javaSerializationContainer reports whether either channel carries the +// marks of an object stream that something might deserialize. +// +// Three marks, in the order they are worth matching: the stream header +// itself, the same header in the base64 form the transports use, and a +// gadget class name. The first two are byte comparisons over the raw +// strings and the third is a substring search over copies the caller +// already holds, so the common case -- ordinary traffic with no container +// anywhere -- costs two linear scans and no allocation. +// +// There is no decoder anywhere in this function, and that is the point +// rather than an accident of implementation. Decoding in order to "confirm" +// the object is the sink reimplemented inside the thing meant to observe it: +// a second place to get parsing wrong, on attacker-chosen bytes, for no +// extra signal, since every container worth naming is already caught by its +// header. One consequence is worth stating, because it reads like a +// limitation and is not -- a payload whose bytes would not survive a +// decoder still matches here, and one that would is caught by the same four +// bytes the decoder would have needed. +func javaSerializationContainer(rawQuery, rawBody, lowerQuery, lowerBody string) bool { + // The header, raw. Containment rather than a prefix test, because the + // raw transport is not always a body on its own: the same four bytes + // arrive inside a form field, inside an XML element, and on a JSP entry + // point's query string. + if strings.Contains(rawQuery, javaStreamHeader) || strings.Contains(rawBody, javaStreamHeader) { + return true + } + // The header, base64'd, anchored to the start of a base64 token, and + // case-sensitively -- because base64 is. Lowercasing this token would + // match attempts that cannot work and reject ones that can. + if base64TokenHasPrefix(rawQuery, javaStreamHeaderBase64) || + base64TokenHasPrefix(rawBody, javaStreamHeaderBase64) { + return true + } + // A gadget class name, in either channel. + return containsAny(lowerQuery, javaGadgetClasses...) || + containsAny(lowerBody, javaGadgetClasses...) +} + +// base64TokenHasPrefix reports whether a base64 token in s begins with +// prefix -- that is, whether prefix sits at a position where a base64 blob +// could begin rather than somewhere inside one. +// +// Deep inside a long blob any five characters turn up by chance; at the +// start of one they are the header. Every character a base64 value travels +// inside -- the XML element's angle brackets, a URL's own separators, +// whitespace -- is outside the base64 alphabet, which is what lets a +// five-byte comparison stand in for "the start of the blob" without parsing +// the surrounding transport, let alone decoding anything. +func base64TokenHasPrefix(s, prefix string) bool { + if len(prefix) == 0 || len(s) < len(prefix) { + return false + } + for i := 0; i+len(prefix) <= len(s); i++ { + if s[i:i+len(prefix)] != prefix { + continue + } + if i == 0 || !isBase64Char(s[i-1]) { + return true + } + } + return false +} + +// isBase64Char reports whether c can appear inside a base64 token. +// +// Padding is deliberately excluded: '=' ends a token, so a '=' immediately +// before a match is a delimiter, and in a query string it is the very +// separator that introduces the parameter. data=rO0AB... has to match. +func isBase64Char(c byte) bool { + return c >= 'A' && c <= 'Z' || c >= 'a' && c <= 'z' || c >= '0' && c <= '9' || + c == '+' || c == '/' +} + +// teamcityAgentCallNames are the call names TeamCity's own build agents use +// on the polling channel, lowercased. +// +// Matched as WHOLE values, because the transport is not TeamCity's alone: +// xmlrpc/remote in particular is an XML-RPC convention, and a product a +// request merely mentions is not a product a request is aimed at. The +// registration family is the part of this channel that needs no credential, +// which is the shape the CVE actually takes. +var teamcityAgentCallNames = []string{ + "xmlrpc/allowregistration", + "xmlrpc/canregisteragent", + "xmlrpc/registeragent", + "xmlrpc/getunregisteredagents", + "xmlrpc/unregisteragent", + // The generic envelope the rest of the protocol travels inside, and the + // two work-download calls by which an agent is handed a build. + "xmlrpc/remote", + "agentunload", + "agentunload2", +} + +// teamcityProtocolMarkers are TeamCity's own qualified names: a DTO the +// agent protocol exchanges, the product's Java packages, and the JSP entry +// point's file name. +// +// A substring match is right here, unlike for the call names: these are +// package-qualified identifiers rather than words, and +// "org.jetbrains.teamcity" is not something ordinary traffic carries. The +// teamcity.* namespace at large is deliberately NOT in this list -- an +// agent's own property bag is full of teamcity.agent.jvm.os.name and +// neighbours, and that bag is what a normal poll looks like. The namespace +// is the product's; these are its internals. +var teamcityProtocolMarkers = []string{ + "teamcity.server.message", + "org.jetbrains.teamcity", + "jetbrains.buildserver", + "buildserver.action", +} + +// teamcityAgentProtocol reports whether a request names TeamCity's own +// build-agent protocol, on the lowered copies of the two channels. +// +// Two ways, and the cheap one comes first: a product-qualified marker +// anywhere, or a call name as a whole value. The call name is extracted +// rather than substring-matched -- from the parameters a caller addressed it +// with, and from the XML-RPC element it arrives in -- so that +// xmlrpc/allowRegistrationAndPing, and a call name sitting inside somebody +// else's parameter value, both keep their own answers. +// +// Nothing is parsed in order to answer this. url.ParseQuery splits a string +// into key/value pairs and the element extractor is two index searches; +// neither is told what to do with what it found, and neither can fail in a +// way that changes the answer. Parsing a request to learn what it asked for +// is a different job from noticing what it carried, and the second one is +// this sensor's. +func teamcityAgentProtocol(lowerQuery, lowerBody string) bool { + if containsAny(lowerQuery, teamcityProtocolMarkers...) || + containsAny(lowerBody, teamcityProtocolMarkers...) { + return true + } + for _, channel := range []string{lowerQuery, lowerBody} { + // A body that is not a parameter list at all parses into junk keys + // and values, which is harmless: the key test below rejects them. + // An error alongside real values is tolerated for the same reason + // wordpressPagenameTraversal tolerates it. + values, err := url.ParseQuery(channel) + if err != nil && len(values) == 0 { + continue + } + for key, vals := range values { + if key != "methodname" && key != "method" { + continue + } + for _, v := range vals { + if teamcityAgentCall(v) { + return true + } + } + } + } + return teamcityAgentCall(xmlrpcMethodName(lowerBody)) +} + +// teamcityAgentCall reports whether a lowercased call name is one of +// TeamCity's. +func teamcityAgentCall(lowerName string) bool { + if lowerName == "" { + return false + } + for _, name := range teamcityAgentCallNames { + if lowerName == name { + return true + } + } + return false +} + +// xmlrpcMethodName returns the text of an XML-RPC element from +// an already-lowercased body, or "" when there is none. +// +// Two index searches over the same string, and deliberately consistent about +// which string they search: lowercasing can change a body's length, so +// indices taken from one copy must never be used to slice another. Working +// throughout on the lowered copy sidesteps that instead of measuring around +// it, and the result is only ever compared as a name, never acted on. +func xmlrpcMethodName(lowerBody string) string { + const open, closing = "", "" + i := strings.Index(lowerBody, open) + if i < 0 { + return "" + } + rest := lowerBody[i+len(open):] + j := strings.Index(rest, closing) + if j < 0 { + return "" + } + return rest[:j] +} + func containsAny(s string, needles ...string) bool { for _, needle := range needles { if strings.Contains(s, needle) { diff --git a/arcane/home/honeypot-http/http-honeypot/teamcity_deser_test.go b/arcane/home/honeypot-http/http-honeypot/teamcity_deser_test.go new file mode 100644 index 00000000..d8b7947a --- /dev/null +++ b/arcane/home/honeypot-http/http-honeypot/teamcity_deser_test.go @@ -0,0 +1,332 @@ +package main + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +// #3189 / CVE-2026-63077: unauthenticated deserialization RCE in JetBrains +// TeamCity, CVSS 3.1 9.8 (AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H), CWE-502, in +// CISA KEV since 2026-08-05, fixed in TeamCity 2025.11.7 and 2026.1.3. The +// JetBrains CNA record names the mechanism: deserialization of untrusted data +// reached through the agent polling protocol, so nothing about it needs a +// credential, and the vendor describes command execution with the server +// process's own privileges. +// +// The class is a statement about BYTES. The sensor never reconstructs the +// object, never feeds it to a deserializer, and never answers a question the +// deserialization would have answered -- a classifier that read the stream to +// confirm it would be a second copy of the sink it is meant to observe. What +// is matched is the container itself: +// +// - the Java serialization stream header, raw (AC ED 00 05) or as the +// base64 XML-RPC and JSP transports actually carry it (rO0AB...), and +// - a gadget class name, which a Java stream carries in cleartext and +// which is the whole reason a container on the wire is an attack rather +// than an accident. +// +// ...both required, together with TeamCity's own agent-protocol shape. Each +// half alone is ordinary: every TeamCity server has agents polling it, and a +// serialized object on an HTTP port is somebody else's finding. The +// conjunction is the CVE, and it is the conjunction -- not a broad substring +// -- that is what the negatives below pin. +// +// The fixtures follow the published request shape rather than a capture: the +// research note on #3189 records that no exploitation detail, no PoC and no +// corpus of this signature exists, and that the backup/AWS-key/S3 chain, +// the Cadence connection and the quoted JetBrains advice in the original +// issue are all UNVERIFIED. Nothing below depends on any of them, and +// nothing here claims a working payload: a request carrying a container is an +// ATTEMPT to reach a deserialization sink, which is a different and much +// smaller claim than "the CVE was exploited". +// +// The negatives are the ones that decide whether this is usable. The +// classifier's own rule -- stated above classifyPayload -- is that a pattern +// that fires on ordinary traffic is worse than no pattern. WordPress +// XML-RPC shares the transport with TeamCity's agent protocol, and this +// sensor serves a bait endpoint of its own at /xmlrpc.php, so containers on +// that transport are expected to reach this code -- and they are not +// TeamCity's. The case that must not be stolen is the one with the same +// bytes and a different product. +func TestTeamcityAgentDeserialization(t *testing.T) { + const want = "teamcity-agent-deserialization" + + cases := []struct { + name, query, body string + want string + }{ + // --- the published shape: an unauthenticated agent-poll request + // whose parameter is a serialized object, base64 in the XML-RPC + // string transport. methodName is the dispatch name, so this needs + // no session, which is the "PR:N" half of the CVSS vector. + + { + name: "agent registration poll carrying a base64 stream", + body: `xmlrpc/allowRegistrationrO0ABXNyABNvcmcuYXBhY2hlLmNvbW1vbnN0AAtUcmFuc2Zvcm1lcnAAAAAA`, + want: want, + }, + { + // The same envelope carrying the stream raw rather than + // base64'd, which is what an agent talking to a JSP endpoint + // sends. Byte-identical signature, different transport. + name: "raw stream on the generic xmlrpc envelope", + query: "methodName=xmlrpc%2Fremote", + body: "\xac\xed\x00\x05\x73\x72\x00\x1borg.jetbrains.teamcity\x74\x00\x1cTeamCityMessageDto\x70\x00\x00\x00\x00", + want: want, + }, + { + // TeamCity's own JSP fallback entry point, which takes the + // call name and its argument as plain query parameters. The + // container rides in the query here rather than the body, + // and it is the query this function is handed first. + name: "work-download call with the container in the query", + query: "methodName=agentUnload&buildServerId=9014&agentName=build-agent-07&data=rO0ABXNyABtvcmcuamV0YnJhaW5zLnRlYW1jaXR5dAAcVGVhbUNpdHlNZXNzYWdlRHRv", + want: want, + }, + { + // The container at the very front of the body, with the + // product named in the query. On origin/main this is filed as + // the generic serialized-object class, which is the whole + // reason the new case sits above it. + name: "container at offset zero, product in the query", + query: "methodName=xmlrpc%2FgetUnregisteredAgents", + body: "rO0ABXNyABNvcmcuYXBhY2hlLmNvbW1vbnN0AAtUcmFuc2Zvcm1lcnAAAAAA", + want: want, + }, + { + // The half of the conjunction that is not a magic number: a + // Java stream carries its class descriptor in cleartext, so a + // gadget class name arriving as a plain string is the same + // reach -- a scanner that cannot encode binary probes the sink + // by naming the class. No container, so the class says "aimed + // at", and this test exists to keep that distinction honest. + name: "gadget class named in the clear with the TeamCity DTO named beside it", + body: `xmlrpc/remoteteamcity.server.messageorg.apache.commons.collections.functors.InvokerTransformer`, + want: want, + }, + + // --- negatives. The three the change is required to get right, + // then the boundary cases around them. + + { + // A normal TeamCity-looking request: the same call, the same + // product, ordinary parameters. Every TeamCity server has + // agents doing exactly this, forever. Flagging it would put + // this class on the fleet's ordinary background traffic. + name: "an ordinary agent registration poll, no container", + body: `xmlrpc/allowRegistrationbuild-agent-071`, + want: "", + }, + { + // The same keyword with no magic bytes, in the other + // transport: TeamCity's agent property bag is full of + // teamcity.* names, and none of them is a container. + name: "the product's own parameters, no magic bytes", + query: "methodName=xmlrpc%2FallowRegistration", + body: "agentName=build-agent-07&poolId=0&properties=teamcity.agent.jvm.os.name=Linux&teamcity.build.id=9014", + want: "", + }, + { + // A plain-text body that mentions the product, which is what + // a CI system's own log line or an error page looks like. + name: "plain text naming the product", + body: "teamcity agent build-agent-07 connected, pool Default, authorized", + want: "", + }, + { + name: "plain text query naming the product", + query: "q=teamcity&buildTypeId=Build&status=success", + want: "", + }, + { + // The boundary that matters most in production: same bytes, + // different product. WordPress XML-RPC shares the transport + // with TeamCity's agent protocol and this sensor serves a bait + // at /xmlrpc.php, so a serialized object here is far more + // likely to be somebody else's. A WordPress call name must not + // be claimed by the TeamCity class, or the attribution is + // worse than having no class at all. + // + // "no label" is the honest expectation, not a gap this change + // fills: the generic case below only recognises a stream at the + // very start of a body or raw magic bytes anywhere in one, and a + // base64 token in the middle of an XML document is neither. + // Widening that is a separate decision about the generic class + // with its own false-positive evidence, and it is not what + // #3189 asks for. + name: "WordPress XML-RPC carrying the same container is not TeamCity's", + body: `wp.getUsersBlogs1adminrO0ABXNyABNvcmcuYXBhY2hlLmNvbW1vbnN0AAtUcmFuc2Zvcm1lcnAAAAAA`, + want: "", + }, + { + // The same request with the stream in the raw transport the + // generic case does recognise, which is where the product + // attribution is easiest to get wrong: this is the shape that + // was already a serialized-object event on this sensor before + // this change, and it has to still be one afterwards. + name: "WordPress XML-RPC with a raw stream keeps the generic class", + body: "wp.getUsersBlogsadmin\xac\xed\x00\x05\x73\x72\x00\x13org.apache.commons", + want: "serialized-object", + }, + { + // The same in the raw transport, and the case that proves the + // new branch only reclassifies inside its own gate: with no + // TeamCity shape present the generic case is unchanged. + name: "a bare stream with no product shape keeps the generic class", + body: "\xac\xed\x00\x05\x73\x72\x00\x13org.apache.commons\x70\x00\x00\x00\x00", + want: "serialized-object", + }, + { + // A container at offset zero and nothing else. Before this + // change the generic case caught it on its prefix; it still + // does, and the generic case is not weakened. + name: "base64 stream with no product shape keeps the generic class", + body: "rO0ABXNyABNvcmcuYXBhY2hlLmNvbW1vbnN0AAtUcmFuc2Zvcm1lcnAAAAAA", + want: "serialized-object", + }, + { + // A gadget class name with no container and no product is not + // an attempt at all -- a string in a request is a string, and + // this class does not get to guess that somebody meant to + // hand it to a deserializer somewhere else. + name: "a gadget class name on its own is not an attempt", + body: `org.apache.commons.collections.functors.InvokerTransformer`, + want: "", + }, + { + // A call name that merely contains a TeamCity one. TeamCity + // is not the only product with an XML-RPC agent channel, and + // the gate is a whole-value match for that reason. + name: "a neighbouring call name is not TeamCity's", + query: "methodName=xmlrpc%2FallowRegistrationAndPing", + body: "rO0ABXNyABNvcmcuYXBhY2hlLmNvbW1vbnN0AAtUcmFuc2Zvcm1lcnAAAAAA", + want: "serialized-object", + }, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + if got := classifyPayload(c.query, c.body); got != c.want { + t.Errorf("classifyPayload(%q, %q) = %q, want %q", c.query, c.body, got, c.want) + } + }) + } +} + +// TestTeamcityDeserializationMatchesBytesWithoutDecoding is the assertion +// behind the hard rule: nothing on this path deserializes, decodes or +// evaluates anything it received. +// +// A decoder-based implementation could not pass the first case. The token +// after the container header is not valid base64, so anything that tried to +// decode it -- to confirm the object graph, to walk its class table, to +// count its fields -- would have to fail, skip the token, or reject the +// request. The classifier cannot: it compares five bytes at a token +// boundary and returns. The second case is the other direction, so the +// matcher cannot be "something that looks like base64": valid base64 that +// decodes cleanly to ordinary bytes carries no container and is not flagged. +// +// Nothing here is fed to encoding/gob, to a struct decode, or to any other +// object reader, and the test would not compile if it were -- a deserializer +// of any family would have to be imported, and this package imports none. +func TestTeamcityDeserializationMatchesBytesWithoutDecoding(t *testing.T) { + const want = "teamcity-agent-deserialization" + + cases := []struct { + name, query, body string + want string + }{ + { + // Header present, tail not decodable. A decoder stops here. + name: "header present, rest of the token undecodable", + query: "methodName=xmlrpc%2Fremote", + body: "rO0AB\xff\xfe\x00\x01not base64 at all", + want: want, + }, + { + // The same on the raw transport, truncated mid-header: four + // bytes is the whole signature and the match does not care + // that there is nothing behind it. + name: "raw header truncated to nothing behind it", + query: "methodName=xmlrpc%2Fremote", + body: "\xac\xed\x00\x05", + want: want, + }, + { + // Valid base64, decodes to ordinary text, no container. + name: "valid base64 that decodes to something harmless", + query: "methodName=xmlrpc%2Fremote", + body: "" + "aGVsbG8gdGhlcmUgYWdlbnQgaXMgcmVnaXN0ZXJlZA==" + "", + want: "", + }, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + if got := classifyPayload(c.query, c.body); got != c.want { + t.Errorf("classifyPayload(%q, %q) = %q, want %q", c.query, c.body, got, c.want) + } + }) + } +} + +// TestTeamcityDeserializationReachesTheEvent covers the half a classifier +// unit test cannot: that the class lands on the emitted event, and that the +// rest of the event keeps saying what it says today. +// +// Three things are asserted rather than assumed. +// +// The unauthenticated half: this sensor keeps no session and consults no +// backend, so "pre-auth" here is a statement about the request, not about a +// session the decoy never had. credential_status is absent and +// auth_outcome is unknown -- no credential was presented and nothing decided +// anything about an identity -- while the payload is still classified. The +// class must not require a login to have happened first, because the CVE does +// not. +// +// The decoy is not a TeamCity: it answers with the generic 404 and category +// stays the generic "wordpress" for an /xmlrpc path. The research note on +// #3189 is explicit that a generic response is not a persona, and that a +// product classifier must have a cited benign basis rather than be inferred +// from a path. So the CVE reading lives in payload_class only, and this +// assertion fails if somebody later grows it into a persona. +// +// The redaction contract of #3213: the opaque scrubber ran over this text/xml +// body, and it must not have cost the fleet the signature. +func TestTeamcityDeserializationReachesTheEvent(t *testing.T) { + s, output := newTestServer() + + const body = `xmlrpc/allowRegistrationrO0ABXNyABNvcmcuYXBhY2hlLmNvbW1vbnN0AAtUcmFuc2Zvcm1lcnAAAAAA` + + r := httptest.NewRequest(http.MethodPost, "http://example/xmlrpc", strings.NewReader(body)) + r.Header.Set("Content-Type", "text/xml") + r.RemoteAddr = "203.0.113.9:51000" + w := httptest.NewRecorder() + s.ServeHTTP(w, r) + + line := output.String() + if !strings.Contains(line, `"payload_class":"teamcity-agent-deserialization"`) { + t.Fatalf("the event did not carry the payload class: %s", line) + } + // Unauthenticated: nothing was presented and nothing was decided. + if !strings.Contains(line, `"credential_status":"absent"`) { + t.Fatalf("the request was not recorded as carrying no credential: %s", line) + } + if !strings.Contains(line, `"auth_outcome":"unknown"`) { + t.Fatalf("an authentication decision was recorded for a pre-auth probe: %s", line) + } + // The container itself must survive redaction, or an analyst cannot + // read the payload out of the event that was classified. + if !strings.Contains(line, "rO0AB") { + t.Fatalf("the container signature was scrubbed out of the stored body: %s", line) + } + // No persona: the decoy answered with its generic 404 and the path + // category is the generic one it was before this change. + if w.Code != http.StatusNotFound { + t.Fatalf("the decoy answered %d; a TeamCity persona is out of scope", w.Code) + } + if !strings.Contains(line, `"category":"wordpress"`) { + t.Fatalf("the path category changed: %s", line) + } +}