Conversation
…zation 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 (45f41ef), 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
Dependency Review✅ No vulnerabilities or license issues or OpenSSF Scorecard issues found.Scanned FilesNone |
Xore
enabled auto-merge (squash)
September 27, 2026 20:09
Xore
disabled auto-merge
September 27, 2026 23:22
Owner
Author
|
Superseded by #3444, which resolves the rebase conflict on this branch as a pure union (+662/-0, 2 files) against current main. Same content, landable state. Leaving this open only preserves the CONFLICTING branch. |
Xore
added a commit
that referenced
this pull request
Sep 28, 2026
…er (#3464) main.go's classifyPayload switch and the helper block below it are the two regions every CVE classifier edits, so any two CVE branches in flight collide in both at once -- #3423/#3444, #3425/#3449 and #3442 have all hit it, and each resolution had to be re-derived by hand. The cases move out into classify_roundcube.go, classify_wordpress.go, classify_ollure.go, classify_odata.go and classify_generic.go, each holding its own case and its own helpers. What is left in classify.go is the ordered slice and a first-match-wins loop over it, so a new CVE is a new file and one line of a list rather than two regions of a 700-line switch. The order is semantic, so it is now asserted rather than trusted. TestPayloadClassOrderIsPinned compares the whole dispatch to a pinned list position by position, and a precedence table gives one payload per deliberate overlap -- a base64 dropper that is also php-code, a pagename traversal that is also a pearcmd chain, version.bind that is also a bare hostname, a new class that would land below serialized-object and never fire. Swapping any two entries fails a row. The same table adds the one payload per class, which is also the first coverage jndi-lookup, xxe, template-injection and info-disclosure have had through classifyPayload. TestClassifyPayloadCorpusCoversEveryDispatchClass is what keeps the file from going stale again. The pinned order and the two corpora are three separate lists and nothing forced a rebase that adds a class to update all three; this does. It is the failure the issue exists to prevent, in the shape it takes after a merge rather than before one. Rebased onto main, which grew the classifier while this branch was open, so what the rebase reconciled is part of the change rather than a footnote to it: - #3449 added wordpress-template-inclusion, CVE-2026-87902's second reading, and main puts it at position 4: after wordpress-pagename-traversal, which shares the CVE and whose labels must not move, and before pearcmd-rce and the generic cases below. It is a class, so it is a case in classify_wordpress.go next to the one it divides with, one line in the dispatch, one payload of its own and four overlaps in the precedence table -- the split-request PEAR argv it claims from pearcmd-rce, the credential read it claims from secret-read, the pagename traversal it must lose to, and the generic LFI it must not take at all. - #3449 also refactored the pagename case onto shared formValues and decodeUpTo helpers, because url.ParseQuery drops a pair containing a semicolon and `data://text/plain;base64,...` is one value at WordPress. The rewrite moved with the case into classify_wordpress.go. Left behind it would have been a stale copy of a helper main had already changed, which is a silent detection gap rather than a compile error. - The refactor had put teamcity-agent-deserialization first and roundcube-virtuser-query-sqli second; main has them the other way round. No payload currently matches both, so the transposition changed no label -- but it is still a change to a first-match-wins list, and the pinned order is main's order rather than the refactor's. No behaviour change. Every label is distinct, so the label is the identity of the case. The 34 cases, their order and every helper body are main's; the one rewrite a case body needs is a comma becoming an ||. Verified rather than asserted: a corpus of 167 payloads -- every (query, body) pair the package's own test tables contain, harvested with go/ast from all seven of them -- was replayed through the pre-split classifier at b15213e and through this tree, and the two label streams are identical row for row, all 34 classes included and none of them reached by a payload the other tree labelled differently.
Xore
added a commit
that referenced
this pull request
Sep 28, 2026
…er (#3464) (#3470) main.go's classifyPayload switch and the helper block below it are the two regions every CVE classifier edits, so any two CVE branches in flight collide in both at once -- #3423/#3444, #3425/#3449 and #3442 have all hit it, and each resolution had to be re-derived by hand. The cases move out into classify_roundcube.go, classify_wordpress.go, classify_ollure.go, classify_odata.go and classify_generic.go, each holding its own case and its own helpers. What is left in classify.go is the ordered slice and a first-match-wins loop over it, so a new CVE is a new file and one line of a list rather than two regions of a 700-line switch. The order is semantic, so it is now asserted rather than trusted. TestPayloadClassOrderIsPinned compares the whole dispatch to a pinned list position by position, and a precedence table gives one payload per deliberate overlap -- a base64 dropper that is also php-code, a pagename traversal that is also a pearcmd chain, version.bind that is also a bare hostname, a new class that would land below serialized-object and never fire. Swapping any two entries fails a row. The same table adds the one payload per class, which is also the first coverage jndi-lookup, xxe, template-injection and info-disclosure have had through classifyPayload. TestClassifyPayloadCorpusCoversEveryDispatchClass is what keeps the file from going stale again. The pinned order and the two corpora are three separate lists and nothing forced a rebase that adds a class to update all three; this does. It is the failure the issue exists to prevent, in the shape it takes after a merge rather than before one. Rebased onto main, which grew the classifier while this branch was open, so what the rebase reconciled is part of the change rather than a footnote to it: - #3449 added wordpress-template-inclusion, CVE-2026-87902's second reading, and main puts it at position 4: after wordpress-pagename-traversal, which shares the CVE and whose labels must not move, and before pearcmd-rce and the generic cases below. It is a class, so it is a case in classify_wordpress.go next to the one it divides with, one line in the dispatch, one payload of its own and four overlaps in the precedence table -- the split-request PEAR argv it claims from pearcmd-rce, the credential read it claims from secret-read, the pagename traversal it must lose to, and the generic LFI it must not take at all. - #3449 also refactored the pagename case onto shared formValues and decodeUpTo helpers, because url.ParseQuery drops a pair containing a semicolon and `data://text/plain;base64,...` is one value at WordPress. The rewrite moved with the case into classify_wordpress.go. Left behind it would have been a stale copy of a helper main had already changed, which is a silent detection gap rather than a compile error. - The refactor had put teamcity-agent-deserialization first and roundcube-virtuser-query-sqli second; main has them the other way round. No payload currently matches both, so the transposition changed no label -- but it is still a change to a first-match-wins list, and the pinned order is main's order rather than the refactor's. No behaviour change. Every label is distinct, so the label is the identity of the case. The 34 cases, their order and every helper body are main's; the one rewrite a case body needs is a comma becoming an ||. Verified rather than asserted: a corpus of 167 payloads -- every (query, body) pair the package's own test tables contain, harvested with go/ast from all seven of them -- was replayed through the pre-split classifier at b15213e and through this tree, and the two label streams are identical row for row, all 34 classes included and none of them reached by a payload the other tree labelled differently.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes nothing — do not merge. Refs #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, CISA KEVdateAdded2026-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 no credential is involved — and the request lands on whatever endpoint the application's own dispatch resolves, not on a bait path this fleet could name. Same reasoning #2919 and #3309 followed for their own cases.What is matched — bytes, not objects
New payload class
teamcity-agent-deserialization: one more branch of the existingclassifyPayloadbyte-pattern classifier. Nothing on the path deserializes anything received. The attempt is recognised as the container on the wire:AC ED 00 05rO0ABat a base64-token startorg.apache.commons.collections[4].functors,com.sun.rowset.jdbcrowsetimpl,…beanutils.beancomparator,…annotationinvocationhandler,…xsltc.trax.templatesimpl,…xbean…jndiconverter,com.ysoserialNo decoder, no parser, no object reader, no new import.
teamcityAgentDeserializationis called with both the raw and the lowered form of each channel, because the container must be read from the raw bytes:strings.ToLowerreplaces every non-UTF-8 byte with U+FFFD, which is precisely how a rawAC ED 00 05header would be erased. The product names are ASCII and are read from the lowered copiesclassifyPayloadalready holds, so the hot path costs two linear scans and allocates nothing.Why two halves, both required
xmlrpc/allowRegistration,xmlrpc/canRegisterAgent,xmlrpc/registerAgent,xmlrpc/getUnregisteredAgents,xmlrpc/unregisterAgent,xmlrpc/remote,agentUnload,agentUnload2), taken from a parameter or from the XML-RPC<methodName>element; or a product-qualified marker (teamcity.server.message,org.jetbrains.teamcity,jetbrains.buildServer,buildServer.action).Each half alone is ordinary traffic, which is the whole argument for the conjunction:
/xmlrpc.php(#238) and bucketsxmlrpcpaths aswordpress, so containers on that shared transport are expected here and must keep their own label.Checked first, ahead of the generic
serialized-objectcase, which would otherwise claim the raw forms and leave the base64 ones unlabelled — the same attempt currently arrives under three different labels depending on transport, none naming a product or a CVE. Outside this gate that generic case is untouched, and the tests pin that.The
teamcity.*namespace at large is deliberately not a marker: an agent's own property bag is full ofteamcity.agent.jvm.os.name, and that bag is what a normal poll looks like.Red on origin/main, green here
Proven against pristine
origin/main(45f41eff) in a separate worktree with the new test file and nothing else — 69 pre-existing tests pass, only the 3 new ones fail:With the change:
ok http-honeypot, 72/72 top-level tests pass, 0 fail.gofmtclean,go vetclean. No existing test weakened, deleted, skipped or relaxed — 69 pre-existing top-level tests pass identically before and after.The mandatory negatives
All pinned, all passing on both sides of the change where they are boundary guards:
xmlrpc/allowRegistrationcall with ordinary parameters and no container → unlabelled;methodName=xmlrpc/allowRegistrationcarrying the agent's own property bag (teamcity.agent.jvm.os.name=Linux,teamcity.build.id=9014) → unlabelled;teamcity agent build-agent-07 connected, pool Default, authorized→ unlabelled; plus a plain-text query naming the product.Plus the boundaries around them: WordPress XML-RPC carrying the same container in the base64 transport (unlabelled, and not widened here) and in the raw transport (
serialized-object, unchanged); a bare container with no product shape in both forms (serialized-object, unchanged); a gadget class name with no container and no product (unlabelled — a string in a request is a string); and a neighbouring call namexmlrpc/allowRegistrationAndPing(whole-value match, so not TeamCity's).Nothing deserialized — asserted, not just claimed
TestTeamcityDeserializationMatchesBytesWithoutDecoding: a container whose tail is not valid base64 still classifies, and one truncated to four bytes with nothing behind it still classifies, while valid base64 of harmless bytes does not. A decoder-based implementation could not pass the first two — it would have to fail, skip the token, or reject the request. The test file imports onlynet/http,net/http/httptest,stringsandtesting: noencoding/gob, noencoding/jsondecode, noencoding/base64, noyaml, nopickle, no XML unmarshaller. The diff adds no import line at all tomain.go.The end-to-end test also asserts the decoy is not a persona:
status404 from the generic branch,categorystill the genericwordpress,credential_statusabsentandauth_outcomeunknown— the pre-auth half, since the CVE needs no credential — with the container signature surviving #3213's opaque redaction.Scope and honesty notes
payload_classonly, asserted as such.openapi.jsontherefore needs no regeneration and was not hand-edited; for the record the Go event struct is not in the spec at all (the/api/v1/eventsresponses carry an empty schema) and the backend holdspayload_classas a free-formString, so a new value needs no backend or frontend change.