Skip to content

feat(http-honeypot): classify CVE-2026-63077 TeamCity agent deserialization - #3444

Merged
Xore merged 2 commits into
mainfrom
fix/3189-teamcity
Sep 28, 2026
Merged

Xore merged 2 commits into
mainfrom
fix/3189-teamcity

Conversation

@Xore

@Xore Xore commented Sep 27, 2026

Copy link
Copy Markdown
Owner

Rebased oc/3189-fix (#3423) onto current main and 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 predated main'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 same switch at the head of classifyPayload in arcane/home/honeypot-http/http-honeypot/main.go, and both added a block of new package-level helpers immediately before containsAny. 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.go did not compile against the rest of current main.

How main had restructured that region (relevant to any sibling rebase)

Two things matter to anyone resolving a conflict in this file again:

  1. classifyPayload is one flat switch, most-specific first. New CVEs are added as a case at 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 generic serialized-object case (line 734) that a TeamCity attempt would otherwise be swallowed by.
  2. The helper block below the switch is shared. roundcubeVirtuserSQLi/roundcubeSQLPayload/pregReplaceEscapeBypass (main) and teamcityAgentDeserialization/javaSerializationContainer/base64TokenHasPrefix/isBase64Char/teamcityAgentProtocol/… (this branch) live side by side ahead of the single shared containsAny. The only shared symbol is containsAny itself, 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 was gofmt removing 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:

$ cd arcane/home/honeypot-http/http-honeypot
$ go build ./...      # clean
$ go vet ./...        # clean
$ gofmt -l .          # no output
$ go test ./...
Go test: 227 passed in 1 packages

$ go test -run 'Teamcity|Roundcube|Serializ' ./...
Go test: 40 passed in 1 packages

Docs gate, repo root:

$ python3 -m pytest tests/docs/ -q
650 passed, 0 failed, 1 xfailed

The single xfail is pre-existing on main and unrelated (tests/docs/test_2055_fix.py, known). No test on main was failing beforehand, so no "was it already red?" caveat is needed for this branch.

Refs #3189 (issue stays open — using Refs, not Closes, as the issue may be tracked elsewhere).

Supersedes #3423, which can be closed.

@github-actions

Copy link
Copy Markdown

Dependency Review

✅ No vulnerabilities or license issues or OpenSSF Scorecard issues found.

Scanned Files

None

…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
Xore force-pushed the fix/3189-teamcity branch from d8bafa1 to 1b56456 Compare September 28, 2026 07:20
@Xore
Xore merged commit 5545607 into main Sep 28, 2026
121 checks passed
@Xore
Xore deleted the fix/3189-teamcity branch September 28, 2026 07:43
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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant