From 041dd7d3183b49325ae1a0952494d6fbc88152dc Mon Sep 17 00:00:00 2001 From: Xore Date: Sun, 27 Sep 2026 19:57:06 +0200 Subject: [PATCH] feat(http-honeypot): classify CVE-2026-63077 TeamCity agent deserialization 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 CNA record names the mechanism -- deserialization of untrusted data reached through the agent polling protocol -- so no credential is involved, and the request goes to whatever endpoint the app's dispatch resolves rather than to a bait path we could name. Same reasoning #2919 and #3309 followed for their own cases. New payload class `teamcity-agent-deserialization`, one more branch of the existing byte-pattern classifier and nothing else. Nothing on the path deserializes: the ATTEMPT is matched as raw bytes, the container itself -- the Java object-stream header AC ED 00 05 raw or as the base64 its XML-RPC and JSP transports carry (rO0AB...), or a gadget class name, which such a stream holds in cleartext. No decoder, no parser, no object reader, and no new import: a classifier that read the object graph to confirm it would be a second copy of the sink it exists to observe. TeamCity's own agent-protocol shape is required on top of the container, and both halves are required together. Each alone is ordinary: every TeamCity server has agents polling it, and a serialized object on an HTTP port is somebody else's class -- this sensor already serves a WordPress XML-RPC bait at /xmlrpc.php, and that traffic shares the transport and must keep its own label. Checked first, ahead of the generic serialized-object case, which would otherwise claim the raw forms and leave the base64 ones unlabelled; outside this gate that case is untouched, which the tests pin. The container is read from the raw bytes, not the classifier's lowered `b`: strings.ToLower replaces every non-UTF-8 byte with U+FFFD, which is exactly how a raw AC ED 00 05 header would be erased. The product names are ASCII and read from the lowered copies the caller already holds, so the hot path pays two linear scans and allocates nothing. No TeamCity persona, no path category, no bait endpoint. The decoy still answers its generic 404, which the event test asserts -- a generic response is not a persona, and a product classifier needs a cited benign basis rather than a guessed path. Not measured: #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, so no event count is quoted. The backup/AWS-key/S3 chain, the Cadence connection and the attributed JetBrains advice in the issue all remain UNVERIFIED and nothing here depends on any of them. Red on origin/main (45f41eff), green here: 3 test functions, 5 positive cases and 7 negatives. Positives cover the base64 XML-RPC transport, the raw transport, a container in the query string, one at offset zero, and a gadget class named in the clear. The negatives are the required ones -- an ordinary agent registration poll, the product's own parameters with no magic bytes, a plain-text body, a plain-text query -- plus WordPress XML-RPC carrying the same container in both transports, a bare container with no product shape, a gadget class on its own, and a neighbouring call name. A second test pins that nothing decodes: a container whose tail is not decodable base64, and one truncated to four bytes, both still classify, while valid base64 of harmless bytes does not. A decoder-based implementation could not pass those, and the test would not compile if one were imported. Event contract unchanged: no field added, so openapi.json needs no regeneration -- the Go event struct is not in the spec at all, and the backend carries payload_class as a free-form string. No workflow touched, so no zizmor finding to allowlist and no action to pin. Refs #3189 --- .../home/honeypot-http/http-honeypot/main.go | 330 +++++++++++++++++ .../http-honeypot/teamcity_deser_test.go | 332 ++++++++++++++++++ 2 files changed, 662 insertions(+) create mode 100644 arcane/home/honeypot-http/http-honeypot/teamcity_deser_test.go 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) + } +}