feat(http-honeypot): classify CVE-2026-63077 TeamCity agent deserialization - #3444
Merged
Merged
Conversation
Dependency Review✅ No vulnerabilities or license issues or OpenSSF Scorecard issues found.Scanned FilesNone |
This was referenced Sep 28, 2026
Merged
…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
Xore
force-pushed
the
fix/3189-teamcity
branch
from
September 28, 2026 07:20
d8bafa1 to
1b56456
Compare
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.
Xore
added a commit
that referenced
this pull request
Sep 29, 2026
…etector The issue body's STATUS block says the implementation PRs are open and awaiting merge. That is stale: the classifier shipped in #3444 and survived #3464's move of the cases out of main.go. The dispatch line in classify.go is on main, not behind a PR. This adds the missing research document and nothing else. No new detector, no re-implementation, no source change. The document describes the classifier as it actually exists, read from source: a conjunction of a serialization container (raw AC ED 00 05, its base64 form rO0AB anchored to a token start, or a gadget class name) and TeamCity's agent-protocol shape (a product-qualified marker, or one of eight call names as a whole value), at classify.go:78 -- second in a first-match-wins dispatch and ahead of the generic serialized-object case, which would otherwise claim the raw forms and leave the base64 and query forms unlabelled. Every claim is cited to file:line. Where the shipped implementation is narrower than the issue asked for, the gap is stated in Known gaps rather than glossed: the class labels an attempt and not an exploitation; the AWS/S3/backup chain, the Cadence connection and the attributed JetBrains advice all remain UNVERIFIED in the issue's own research and none of them is covered here; there is no persona, no bait endpoint and no path category. CVE facts are transcribed from the issue's primary-source verification matrix and carry its verdicts rather than a fresh reading. The matrix's negative findings are restated as negatives, and the 404 advisory URL is listed only so it is not re-derived as a citation. Elasticsearch field names beyond honeypot.payload_class are flagged as unmapped rather than invented, and no detection or false-positive rate is claimed: the pinned corpus has no entry of this class. tests/docs/ 659 passed, 1 xfailed, 17 subtests, before and after. check-docs-reachable and check-public-leaks both pass.
Xore
added a commit
that referenced
this pull request
Sep 29, 2026
) (#3481) * docs(research): #3189 TeamCity deserialization — record the shipped detector The issue body's STATUS block says the implementation PRs are open and awaiting merge. That is stale: the classifier shipped in #3444 and survived #3464's move of the cases out of main.go. The dispatch line in classify.go is on main, not behind a PR. This adds the missing research document and nothing else. No new detector, no re-implementation, no source change. The document describes the classifier as it actually exists, read from source: a conjunction of a serialization container (raw AC ED 00 05, its base64 form rO0AB anchored to a token start, or a gadget class name) and TeamCity's agent-protocol shape (a product-qualified marker, or one of eight call names as a whole value), at classify.go:78 -- second in a first-match-wins dispatch and ahead of the generic serialized-object case, which would otherwise claim the raw forms and leave the base64 and query forms unlabelled. Every claim is cited to file:line. Where the shipped implementation is narrower than the issue asked for, the gap is stated in Known gaps rather than glossed: the class labels an attempt and not an exploitation; the AWS/S3/backup chain, the Cadence connection and the attributed JetBrains advice all remain UNVERIFIED in the issue's own research and none of them is covered here; there is no persona, no bait endpoint and no path category. CVE facts are transcribed from the issue's primary-source verification matrix and carry its verdicts rather than a fresh reading. The matrix's negative findings are restated as negatives, and the 404 advisory URL is listed only so it is not re-derived as a citation. Elasticsearch field names beyond honeypot.payload_class are flagged as unmapped rather than invented, and no detection or false-positive rate is claimed: the pinned corpus has no entry of this class. tests/docs/ 659 passed, 1 xfailed, 17 subtests, before and after. check-docs-reachable and check-public-leaks both pass. * docs(research): #3189 cite filebeat.yml without a line range in the path token
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.
Rebased
oc/3189-fix(#3423) onto currentmainand resolved its rebase conflict.Why the branch conflicted
The branch carried one commit,
041dd7d3— the TeamCity agent-deserialization classifier for CVE-2026-63077 (#3189). It predatedmain's namespace fix (#3419,3ac9703f) and, more directly, it predated main's own Roundcube CVE-2026-48842 classifier (#3364,roundcubeVirtuserSQLi). Both commits added a case to the sameswitchat the head ofclassifyPayloadinarcane/home/honeypot-http/http-honeypot/main.go, and both added a block of new package-level helpers immediately beforecontainsAny. Git had no clean way to interleave them, so all three regions reported as content conflicts.The CI redness was the same fact seen from the other end: the branch's
main.godid not compile against the rest of currentmain.How
mainhad restructured that region (relevant to any sibling rebase)Two things matter to anyone resolving a conflict in this file again:
classifyPayloadis one flatswitch, most-specific first. New CVEs are added as acaseat the top of the switch, not appended at the bottom, and the file's own comment says why: a real payload nests, so the outermost/most identifying shape must be checked first. Ordering is therefore semantic, not cosmetic — a new case inserted at the wrong depth silently mislabels. Both new cases belong at the top, and both must sit above the genericserialized-objectcase (line 734) that a TeamCity attempt would otherwise be swallowed by.roundcubeVirtuserSQLi/roundcubeSQLPayload/pregReplaceEscapeBypass(main) andteamcityAgentDeserialization/javaSerializationContainer/base64TokenHasPrefix/isBase64Char/teamcityAgentProtocol/… (this branch) live side by side ahead of the single sharedcontainsAny. The only shared symbol iscontainsAnyitself, which neither side modifies.Which side won, and why
Neither — both were kept, verbatim, as a pure union. The resolution adds 330 lines and deletes 0 relative to
main. No main behaviour was altered, no TeamCity logic was reworded, and no test was weakened, skipped or xfailed. The one edit beyond mechanical union wasgofmtremoving two double blank lines introduced by the splice.The conflict was purely additive on both sides, so there was no behavioural trade-off to adjudicate. Had the two cases been genuinely mutually exclusive the specific-to-generic ordering would have decided it; they are not, because a Roundcube login probe and a TeamCity agent poll have no bytes in common.
Measured verification
Full package, on the rebased branch:
Docs gate, repo root:
The single xfail is pre-existing on
mainand unrelated (tests/docs/test_2055_fix.py, known). No test onmainwas failing beforehand, so no "was it already red?" caveat is needed for this branch.Refs #3189 (issue stays open — using
Refs, notCloses, as the issue may be tracked elsewhere).Supersedes #3423, which can be closed.