# CVE-2026-63077 — Root Cause Analysis

## Summary

JetBrains TeamCity On-Premises is vulnerable to unauthenticated remote code execution
(CWE-502, deserialization of untrusted data) in its **agent polling protocol**. The
server-side handler `jetbrains.buildServer.agentServer.polling.Error.fromXml()` (and the
sibling `XStreamHolder`s in `PollingRemoteAgentConnection`, `RunBuildCommandResult`, and
`NodesAwareLogMessagePersister`) deserializes attacker-controlled HTTP request bodies with
an XStream instance configured with `AnyTypePermission.ANY` and only a small denylist.
An unauthenticated attacker first registers a synthetic build agent via
`POST /app/agents/v1/register` (which issues a valid `TeamCity-AgentSessionId` without any
credentials), then posts a crafted XStream XML document to
`POST /app/agents/v1/commands/error`. The embedded gadget chain starts an HSQLDB
connection whose `connectionInitSqls` drop a self-deleting `.jspws` webshell into the
TeamCity webroot; a single GET to that file executes an arbitrary OS command with the
privileges of the TeamCity server process.

## Impact

- Package/component: JetBrains TeamCity On-Premises server (`webapps/ROOT` webapp,
  classes in `server-core.jar`, `common-impl.jar`, `messages.jar`, `web-core.jar`).
- Affected versions: all TeamCity On-Premises versions before 2025.11.7 / 2026.1.3
  (verified vulnerable: 2025.11.6, build 208214; verified fixed: 2025.11.7).
- Risk: CVSS 3.1 9.8 Critical (AV:N/AC:L/PR:N/UI:N). Listed in CISA KEV
  (added 2026-08-05) with confirmed in-the-wild exploitation. Full server compromise:
  arbitrary OS command execution as the TeamCity server user, access to build secrets,
  source code, CI/CD pipeline integrity.

## Impact Parity

- Disclosed/claimed maximum impact: unauthenticated remote code execution.
- Reproduced impact from this run: unauthenticated remote OS command execution
  (`touch <marker>` executed as `tcuser`, the TeamCity server process user, inside the
  official `jetbrains/teamcity-server:2025.11.6-linux` container), proven by the
  command-created marker file and by the one-shot JSPWS response token.
- Parity: **full**.
- Not demonstrated: nothing material — the claim is unauthenticated RCE and exactly that
  was demonstrated, twice, through the real HTTP surface.

## Root Cause

The agent polling protocol is served by
`jetbrains.buildServer.controllers.agentServer.AgentPollingProtocolController`
(`web-core.jar`), reachable under `/app/agents/v1/...` with **no servlet-level
authentication**: agent identity is established only by the `TeamCity-AgentSessionId`
header (`<agentId>:<authorizationToken>`), and a fresh valid session is handed out by the
unauthenticated `register` action to any caller
(`createRegisteredAgentWithPollingConnection` → `registerAgent` → session id in the
`TeamCity-AgentSessionId` response header).

For the `commands/error` sub-path, `AbstractAgentCommandsRequestsProcessor.
handleCommandIsFailedRequest` executes:

```java
Error error = Error.fromXml(StreamUtil.readTextFrom(request.getReader()));  // <- sink
int n = Integer.parseInt(request.getHeader("TeamCity-AgentCommandId"));
```

`Error.fromXml` → `XStreamWrapper.deserializeObject(xml, ourXStreamHolder)`.
`jetbrains.buildServer.messages.XStreamHolder` (messages.jar) configures its XStream as:

```java
xstream.addPermission(AnyTypePermission.ANY);
xstream.denyTypes(new String[]{ "java.beans.EventHandler", "java.lang.ProcessBuilder",
    "javax.imageio.ImageIO$ContainsFilter", "jdk.nashorn.internal.objects.NativeString",
    "com.sun.corba.se.impl.activation.ServerTableEntry",
    "com.sun.tools.javac.processing.JavacProcessingEnvironment$NameProcessIterator",
    "sun.awt.datatransfer.DataTransferer$IndexOrderComparator", "sun.swing.SwingLazyValue"});
xstream.denyTypesByRegExp(/* LazyIterator, LazyEnumeration, GetterSetterReflection,
    PrivilegedGetter, java.rmi, javax.crypto, ServiceNameIterator, JavaFX, BCEL */);
```

i.e. an "allow everything except a 2016-era blacklist" configuration. Bundled libraries
(commons-collections 3.2.2, freemarker 2.3.31, commons-dbcp2/pool2, hsqldb, plus
TeamCity's own classes) provide all the gadget classes needed for code execution.

The exploit gadget chain (identical to the in-the-wild chain captured by honeypots and
documented by Rapid7):

1. `linked-hash-map` entry value typed as TeamCity's own
   `jetbrains.buildServer.serverSide.metadata.impl.metadata.HSQLMetadataStorage$SchemaMismatchException`
   (a `Throwable`, so it passes XStream 1.4.20's default hierarchy permission). Its
   declared fields instantiate `HSQLStorage` with a DBCP2 `BasicDataSource` whose
   `driverClassName=org.hsqldb.jdbc.JDBCDriver`, `url=jdbc:hsqldb:mem:<rand>`, and three
   attacker-controlled `connectionInitSqls`.
2. A `freemarker.ext.beans.HashAdapter` whose `falseModel.object` is an XStream
   `reference=` to that `BasicDataSource`, giving a `Map` view whose `get("connection")`
   invokes `BasicDataSource.getConnection()` via FreeMarker bean introspection.
3. A `set` containing `org.apache.commons.collections.keyvalue.TiedMapEntry` (not covered
   by commons-collections 3.2.2's `readObject` serialization guard) bound to that map with
   key `"connection"`. During `HashSet` population, `TiedMapEntry.hashCode()` →
   `getValue()` → `map.get("connection")` → `BasicDataSource.getConnection()` → DBCP runs
   the three init SQL statements against the in-memory HSQLDB:
   `CREATE TABLE`, `INSERT '<JSP scriptlet>'`, and `SCRIPT '../webapps/ROOT/<rand>.jspws'`,
   which writes a polyglot SQL/JSP webshell into the TeamCity webroot.
4. `GET /<rand>.jspws` compiles and runs the scriptlet, which deletes itself and calls
   `java.lang.Runtime.getRuntime().exec(<attacker command>)`, printing a per-run token.

Fix (confirmed by decompiling the official `fix_CVE_2026_63077.zip` security patch plugin,
build limit `max-build="222648"`): the patch reflectively replaces every
`XStreamHolder` used by the polling protocol (`PollingRemoteAgentConnection.myXStreamHolder`,
`Error.xStreamHolder`, `RunBuildCommandResult.ourXStreamHolder`,
`NodesAwareLogMessagePersister.xStreamHolder`) with a wrapper whose `getXStream()` adds
`NoTypePermission.NONE` plus an explicit allowlist of ~100 `jetbrains.buildServer.*` data
classes. It also installs an `AddToQueuePreprocessor` that strips queued builds carrying
the `teamcity.agent.internal.passwords.values` parameter. Fixed releases 2025.11.7 /
2026.1.3 ship the same allowlist natively.

## Reproduction Steps

1. `bundle/repro/reproduction_steps.sh` (self-contained; requires docker, python3, curl).
2. The script:
   - pulls the pinned official images `jetbrains/teamcity-server@sha256:a435d8…4176`
     (2025.11.6, vulnerable) and `…@sha256:d3875b…56d8` (2025.11.7, fixed);
   - starts both servers and drives the real first-run setup wizard over HTTP
     (`/mnt/do/goNewInstallation` → `/mnt/do/goNewDatabase` (internal HSQLDB) →
     `/mnt/do/acceptLicenseAgreement`) until the server leaves maintenance mode;
   - health-checks the attack surface by registering an agent **without credentials** and
     verifying a `TeamCity-AgentSessionId` header is issued;
   - runs the exploit (`bundle/repro/exploit_cve_2026_63077.py`, vendored Rapid7 PoC)
     twice against the vulnerable server and twice against the fixed server, with
     per-run random markers;
   - requires, on the vulnerable server: exploit exit 0 **and** the marker file present
     inside the container (created by the TeamCity server process);
   - requires, on the fixed server: exploit failure, no marker file, and
     `com.thoughtworks.xstream.security.ForbiddenClassException` in the server log
     (the exact IoC JetBrains names for a blocked exploit attempt).
3. Expected evidence: `[+] Command executed: touch /tmp/CVE_2026_63077_PWNED_<rand>` for
   2025.11.6, `HTTP 404` for the webshell on 2025.11.7, and `RESULT: … CONFIRMED`.

## Evidence

- `bundle/logs/reproduction_steps.log` — full orchestration log.
- `bundle/logs/exploit_vulnerable.log` — two successful exploit runs:
  register → `TeamCity-AgentSessionId: <id>:<token>` → `/app/agents/v1/commands/error`
  HTTP 500 (deserialization side effects already committed) → `GET /<rand>.jspws` HTTP 200
  with the per-run response token.
- `bundle/repro/marker_vulnerable.txt` — `ls -la` of the marker file (owner `tcuser`) and
  `id` of the server process user inside the container.
- `bundle/logs/teamcity_vuln_server.log` — vulnerable server log containing the
  `com.thoughtworks.xstream.converters.ConversionException` IoC named in JetBrains'
  guidance.
- `bundle/logs/exploit_fixed.log`, `bundle/logs/teamcity_fixed_server.log` — fixed server:
  same requests, `ForbiddenClassException` ×2, webshell GET → HTTP 404, no marker.
- `bundle/repro/payload_vulnerable.xml` — the exact attack XML generated for the run.
- `bundle/repro/analysis/` — patch-diff evidence: decompiled JetBrains security patch
  plugin classes, decoded allowlist, decompiled `Error`/`AgentPollingProtocolController`/
  `AbstractAgentCommandsRequestsProcessor`/`XStreamHolder` from 2025.11.6, and the
  in-the-wild honeypot pcap (`CVE-2026-63077-itw.pcap`, BoredHackerBlog) showing the
  identical request sequence.
- Environment: official Docker images on linux/amd64; TeamCity 2025.11.6 (build 208214)
  with bundled Tomcat 9.0.109 / JetBrains Runtime 21; no sanitizer, no instrumentation.

## Recommendations / Next Steps

- Upgrade to TeamCity 2025.11.7 or 2026.1.3, or install JetBrains' `fix_CVE_2026_63077`
  security patch plugin (2017.1+; restart required on 2017.1–2018.1).
- Restrict network access to the server (the agent polling protocol is same-port HTTP(S))
  to trusted build-agent networks.
- Detection: server logs containing `ConversionException` (possible attempt/success) or
  `ForbiddenClassException` (blocked attempt on patched servers); unexpected unauthorized
  agents (in-the-wild agents used names starting with `scan`); unexpected `.jspws`/`.jsp`
  files under `webapps/ROOT`.
- The correct fix pattern is exactly what JetBrains shipped: never deserialize the polling
  protocol with `AnyTypePermission.ANY`; use `NoTypePermission.NONE` + a strict allowlist.

## Additional Notes

- Idempotency: the script recreates both containers from pinned image digests on every
  run and uses fresh random markers/tokens, so consecutive runs are independent.
- The exploit does not depend on the `TeamCity-AgentCommandId` value (deserialization
  happens before the header is parsed); any integer suffices.
- On the vulnerable server the `/commands/error` request returns HTTP 500 *after* the
  gadget side effects have executed — the 500 is expected and matches the in-the-wild
  capture.
- Exploit helper provenance: `bundle/repro/exploit_cve_2026_63077.py` is the public
  Rapid7 PoC (github.com/sfewer-r7/CVE-2026-63077), used unmodified; the same chain was
  independently captured in the wild (pcap in `bundle/repro/analysis/`).
- The default `--webroot-relative ../webapps/ROOT` is correct for the official Linux
  Docker image (JVM working directory `/opt/teamcity/bin`).
