Skip to content

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

Closed
Xore wants to merge 1 commit into
mainfrom
oc/3189-fix
Closed

Xore wants to merge 1 commit into
mainfrom
oc/3189-fix

Conversation

@Xore

@Xore Xore commented Sep 27, 2026

Copy link
Copy Markdown
Owner

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 KEV dateAdded 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 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 existing classifyPayload byte-pattern classifier. Nothing on the path deserializes anything received. The attempt is recognised as the container on the wire:

mark bytes where
Java object-stream header, raw AC ED 00 05 anywhere in the raw query or body
the same, base64 rO0AB at a base64-token start the form the XML-RPC string/base64 types and TeamCity's JSP entry point actually carry
gadget class org.apache.commons.collections[4].functors, com.sun.rowset.jdbcrowsetimpl, …beanutils.beancomparator, …annotationinvocationhandler, …xsltc.trax.templatesimpl, …xbean…jndiconverter, com.ysoserial a Java stream holds its class descriptor in cleartext, so this is the reason a container is an attack and the signal that survives a tampered header

No decoder, no parser, no object reader, no new import. teamcityAgentDeserialization is called with both the raw and the lowered form of each channel, because the container must be read from the raw bytes: strings.ToLower replaces every non-UTF-8 byte with U+FFFD, which is precisely how a raw AC ED 00 05 header would be erased. The product names are ASCII and are read from the lowered copies classifyPayload already holds, so the hot path costs two linear scans and allocates nothing.

Why two halves, both required

  1. TeamCity's own agent-protocol shape — a call name from the polling channel as a whole value (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).
  2. A serialization container, as above.

Each half alone is ordinary traffic, which is the whole argument for the conjunction:

  • every TeamCity server has agents polling it, so the product half on its own is background traffic, and a classified agent poll is worse than no classification;
  • a serialized object on an HTTP port is somebody else's class. This sensor already serves a WordPress XML-RPC bait at /xmlrpc.php (#238) and buckets xmlrpc paths as wordpress, so containers on that shared transport are expected here and must keep their own label.

Checked first, ahead of the generic serialized-object case, 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 of teamcity.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:

--- FAIL: TestTeamcityAgentDeserialization (0.00s)
    --- FAIL: .../agent_registration_poll_carrying_a_base64_stream
    --- FAIL: .../raw_stream_on_the_generic_xmlrpc_envelope
    --- FAIL: .../work-download_call_with_the_container_in_the_query
    --- FAIL: .../container_at_offset_zero,_product_in_the_query
    --- FAIL: .../gadget_class_named_in_the_clear_with_the_TeamCity_DTO_named_beside_it
--- FAIL: TestTeamcityDeserializationMatchesBytesWithoutDecoding (0.00s)
    --- FAIL: .../header_present,_rest_of_the_token_undecodable
    --- FAIL: .../raw_header_truncated_to_nothing_behind_it
--- FAIL: TestTeamcityDeserializationReachesTheEvent (0.00s)
FAIL	http-honeypot	0.062s

With the change: ok http-honeypot, 72/72 top-level tests pass, 0 fail. gofmt clean, go vet clean. 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:

  • a normal TeamCity-looking request — the same xmlrpc/allowRegistration call with ordinary parameters and no container → unlabelled;
  • the same keyword with no magic bytes — methodName=xmlrpc/allowRegistration carrying the agent's own property bag (teamcity.agent.jvm.os.name=Linux, teamcity.build.id=9014) → unlabelled;
  • a plain-text body — 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 name xmlrpc/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 only net/http, net/http/httptest, strings and testing: no encoding/gob, no encoding/json decode, no encoding/base64, no yaml, no pickle, no XML unmarshaller. The diff adds no import line at all to main.go.

The end-to-end test also asserts the decoy is not a persona: status 404 from the generic branch, category still the generic wordpress, credential_status absent and auth_outcome unknown — the pre-auth half, since the CVE needs no credential — with the container signature surviving #3213's opaque redaction.

Scope and honesty notes

  • Not measured. research: CVE-2026-63077 JetBrains TeamCity deserialization RCE — APIARY decoy coverage #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. No event count is quoted, and none is invented. The fixtures follow the published request shape; what the tests pin is the boundary, because that is what decides whether the class is usable.
  • Unverified premises left unverified. The issue's backup/AWS-key/S3 chain, the Cadence connection and the attributed JetBrains input/output advice all remain UNVERIFIED per the research correction, and nothing here depends on any of them. No AWS token, credential, backup artifact or cloud API is involved.
  • No persona, no path category, no new endpoint. A generic response is not a TeamCity persona, and a product classifier needs an independently documented benign basis rather than a guessed path — so the CVE reading lives in payload_class only, asserted as such.
  • Event contract unchanged: no field added. openapi.json therefore 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/events responses carry an empty schema) and the backend holds payload_class as a free-form String, so a new value needs no backend or frontend change.
  • No workflow touched, so there is no new zizmor finding to allowlist and no action to pin.
  • Existing evidence about other CVE classes in this file is left exactly as it is; this is one added branch plus its own test file.

…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
@github-actions

Copy link
Copy Markdown

Dependency Review

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

Scanned Files

None

@Xore

Xore commented Sep 28, 2026

Copy link
Copy Markdown
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 Xore closed this Sep 28, 2026
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.
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