TeamCity On-Premises is JetBrains’ self-hosted CI/CD server — the system that holds every build agent’s credentials, every source repository’s access token, and every deploy pipeline’s secrets in one place. In releases before 2025.11.7 and in the 2026.1 branch before 2026.1.3, TeamCity’s agent polling protocol deserializes attacker-supplied XML using XStream with an allowlist that is additive rather than exclusive. An unauthenticated attacker who can reach the server over HTTP registers a build agent, uses the session identifier that registration hands back, and sends one crafted XML error report. XStream’s still-permissive defaults admit a real, documented gadget chain that reaches the embedded database driver, abuses it to write a JSP file into the web root, and triggers that file’s compilation and execution over plain HTTP. Two requests, zero credentials, full server compromise.
Antoni Tremblay privately reported the vulnerability to JetBrains on 2026-07-10. JetBrains published the advisory and shipped the fix on 2026-07-27. Rapid7’s Stephen Fewer independently published the deepest public technical analysis, including the full gadget-chain walkthrough this write-up’s exploitation and root-cause sections are built from; DenizHalil and RedLegg followed with their own analyses. CISA added the CVE to its Known Exploited Vulnerabilities catalog on 2026-08-05 with confirmed in-the-wild exploitation.
Affected: TeamCity On-Premises before 2025.11.7, and the 2026.1 branch from 2026.1 up to and including 2026.1.2. Fixed in 2025.11.7 and 2026.1.3, with a security patch plugin available for installations on 2017.1 and later that cannot upgrade immediately — installations on 2017.1 through 2018.1 need a server restart after the patch plugin installs. TeamCity Cloud is not affected.
This is TeamCity’s third CVSS 9.8 pre-authentication CVE in roughly two years — CVE-2024-23917 and CVE-2024-27198 both hit the same product’s externally-reachable surfaces in early 2024. The pattern matters as much as the bug itself.
Setup the lab
TeamCity ships an official Docker image, which makes reproducing the vulnerable configuration straightforward.
yaml
# docker-compose.yml
services:
teamcity-server:
image: jetbrains/teamcity-server:2026.1.2 # last vulnerable 2026.1.x build
ports:
- "8111:8111"
volumes:
- tc-server-data:/data/teamcity_server/datadir
- tc-server-logs:/opt/teamcity/logs
volumes:
tc-server-data:
tc-server-logs:
bash
docker compose up -d
# wait ~60s for TeamCity to finish first-boot initialization
# open http://127.0.0.1:8111 and complete the setup wizard (default settings are fine)
The agent polling protocol listens on the same HTTP port as the web UI. No separate agent port needs to be exposed — agent registration and error reporting are part of the standard web application under /app/agents/v1/.
Verify the vulnerable endpoint is reachable
bash
curl -sk -o /dev/null -w "%{http_code}\n" \
-X POST "http://127.0.0.1:8111/app/agents/v1/register"
A response other than a hard connection failure confirms the agent registration endpoint accepts unauthenticated traffic — the entry point for the exploit chain below.
Verifying the patch
On a patched server (2025.11.7, 2026.1.3, or an unpatched install with the security patch plugin applied), the same crafted XML sent to /app/agents/v1/commands/error is rejected during deserialization: XStream raises a ForbiddenClassException because NoTypePermission.NONE now makes the allowlist exclusive, so the gadget chain’s classes are never instantiated and no file is written.
Proof of concept

Reconstructed from Rapid7’s published exploitation walkthrough (Stephen Fewer) — the request sequence:
bash
python exploit.py --target http://127.0.0.1:8111 --cmd "id"
[*] Targeting http://127.0.0.1:8111
[*] Step 1/5 -- POST /app/agents/v1/register
[+] TeamCity-AgentSessionId: 7f3a2c91-... (no credentials required)
[*] Step 2/5 -- POST /app/agents/v1/commands/error
[*] header: TeamCity-AgentSessionId: 7f3a2c91-...
[*] body: crafted XML -- HSQLMetadataStorage$SchemaMismatchException /
BasicDataSource / FreeMarker HashAdapter+BooleanModel / TiedMapEntry
[+] XStream deserialized the object graph -- gadget chain fired
[*] Step 3/5 -- HashSet.add() triggers TiedMapEntry.hashCode() -> HashAdapter.get()
[+] BasicDataSource.getConnection() reached
[*] Step 4/5 -- HSQLDB executes SCRIPT '../webapps/ROOT/a91f7c3e.jspws'
[+] JSP file written via path traversal into the webapp root
[*] Step 5/5 -- GET /a91f7c3e.jspws?cmd=id
[+] HTTP 200 OK -- Apache Jasper compiled and executed the file
[+] Response: uid=0(root) gid=0(root) groups=0(root)
[+] Exploit complete. Command executed as the TeamCity server process.
| Registration returns a TeamCity-AgentSessionId with no credentials | The polling protocol trusts unauthenticated senders |
| The error-report XML deserializes without ForbiddenClassException | The allowlist is additive, not exclusive — permissive defaults still active |
| TiedMapEntry/HashAdapter chain reaches BasicDataSource.getConnection() | The gadget chain is live end to end, not just class-admission |
| GET /a91f7c3e.jspws?cmd=id returns command output | HSQLDB’s SCRIPT command wrote a real, Jasper-executable file outside its intended directory |
| uid=0(root) / SYSTEM in the response | Code execution runs with the full privilege of the TeamCity server process |

The response returns command output inline through the .jspws file, making the attack non-blind — no out-of-band callback channel is needed to confirm success. Unlike a simpler “gadget chain calls Runtime.exec() directly” bug, this chain has to survive an extra hop through a real database driver and an on-disk file write before it becomes code execution, which is why the specific classes on the classpath (commons-dbcp2, FreeMarker, commons-collections) matter as much as XStream’s permission gap itself — swap any one of them out and this exact chain stops working, even though the underlying permission bug remains.
Static analysis and root cause
The vulnerability lives entirely in how TeamCity configures its XStream instance for the agent polling protocol, not in a memory-safety bug or an injection flaw. It is a permission-model mistake, but the path from “permissive allowlist” to “arbitrary file write” runs through a specific, documented gadget chain rather than a generic Runtime.exec() shortcut.
Component: jetbrains.buildServer.messages.XStreamHolder, method setupSecurityIfNeeded(XStreamWrapper xStream) — pre-2025.11.7 / pre-2026.1.3
XStream’s permission model, and the additive-vs-exclusive trap
XStream ships with a set of built-in default permissions that, historically, admitted a broad range of Java classes. Since XStream 1.4.7, applications are expected to explicitly restrict what the library is willing to deserialize by calling addPermission(NoTypePermission.NONE) before building an allowlist — that call clears every default permission first, so a subsequent allowTypes() becomes the entire permission set instead of an addition to it.
java
// VULNERABLE -- XStreamHolder.setupSecurityIfNeeded(), pre-2025.11.7 / pre-2026.1.3
xStream.allowTypes(OUR_STATIC_CLASSES_WHITE_LIST.keySet().toArray(new String[0]));
xStream.allowTypes(this.myAdditionalClassesWhiteList.toArray(new String[0]));
// No addPermission(NoTypePermission.NONE) call precedes these.
// XStream's built-in defaults are never cleared -- the two allowTypes()
// calls above only ever widen an already-permissive base configuration.
java
// VULNERABLE -- XStreamHolder.setupSecurityIfNeeded(), pre-2025.11.7 / pre-2026.1.3
xStream.allowTypes(OUR_STATIC_CLASSES_WHITE_LIST.keySet().toArray(new String[0]));
xStream.allowTypes(this.myAdditionalClassesWhiteList.toArray(new String[0]));
// No addPermission(NoTypePermission.NONE) call precedes these.
// XStream's built-in defaults are never cleared -- the two allowTypes()
// calls above only ever widen an already-permissive base configuration.
The bug is not that TeamCity forgot to write an allowlist — it wrote two. It forgot to clear the floor those allowlists were being added on top of.
The gadget chain: from a class name to a database connection
TeamCity’s agent registration hands back a TeamCity-AgentSessionId, which the attacker then presents when POSTing a crafted XML error report to /app/agents/v1/commands/error. Because the allowlist is additive, XStream is willing to instantiate classes well beyond TeamCity’s own protocol messages. Rapid7’s published chain uses:
- jetbrains.buildServer.serverSide.metadata.impl.metadata.HSQLMetadataStorage$SchemaMismatchException — an internal TeamCity exception class, used as an entry point the additive allowlist happens to admit.
- org.apache.commons.collections.keyvalue.TiedMapEntry — the classic Commons Collections gadget primitive. When XStream reconstructs a HashSet containing a TiedMapEntry, HashSet.add() calls the entry’s hashCode(), which calls getValue(), which performs map.get(key) against whatever map object the attacker supplied.
- freemarker.ext.beans.HashAdapter and freemarker.ext.beans.BooleanModel — FreeMarker’s template-engine object-wrapper classes, supplied as that backing map. Their get() implementation, reached through the TiedMapEntry chain, resolves into a call against a org.apache.commons.dbcp2.BasicDataSource object the same XML payload also constructed.
- BasicDataSource.getConnection() — the payload sets BasicDataSource’s JDBC URL and driver class fields before deserialization completes, so when the gadget chain forces a connection attempt, it connects to TeamCity’s own embedded HSQLDB instance under attacker-influenced parameters.
Crafted XML error report (POST /app/agents/v1/commands/error)
│ header: TeamCity-AgentSessionId: <from step 1, no credentials needed>
▼
XStream.fromXML() -- additive allowlist admits HSQLMetadataStorage$SchemaMismatchException,
TiedMapEntry, HashAdapter, BooleanModel, BasicDataSource
▼
HashSet.add(tiedMapEntry) -- triggers TiedMapEntry.hashCode()
▼
TiedMapEntry.getValue() -- map.get(key) against the FreeMarker HashAdapter
▼
resolves into BasicDataSource.getConnection()
▼
HSQLDB executes attacker-supplied SQL: SCRIPT '../webapps/ROOT/<random-hex>.jspws'
│ HSQLDB's SCRIPT command writes a file -- the '../' escapes the intended data directory
▼
<random-hex>.jspws now sits inside the live TeamCity webapp root
▼
GET /<random-hex>.jspws?cmd=<command>
│ Apache Jasper (Tomcat's JSP engine) compiles the file on first request
▼
embedded code executes as the TeamCity server process; output returned in the response
Why HSQLDB’s SCRIPT command is the pivot, not a coincidence
HSQLDB ships a SCRIPT SQL command intended for database-export tooling — writing the current schema and data to a file for backup or migration. It was never designed to be reachable from untrusted input, and it performs no path restriction: whatever path the caller supplies, including a ../ traversal sequence, is where the file gets written. BasicDataSource.getConnection() is the gadget chain’s target specifically because it hands the attacker a live JDBC connection to TeamCity’s own embedded database — at that point, the vulnerability stops being “arbitrary deserialization” and becomes “arbitrary SQL against a database engine that can write files,” which is a strictly more powerful primitive than most XStream gadget chains reach.
Why a .jspws file rather than a .jsp file, and why it still executes
.jspws is not a standard JSP extension, but Apache Jasper compiles and executes any file extension that the servlet container’s web.xml maps to the JSP servlet — TeamCity’s bundled Tomcat configuration includes such a mapping, which is what makes the written file live code rather than an inert static file. Using a non-obvious extension is a mild evasion choice on the exploit author’s part, not a requirement of the vulnerability itself.
Privileges gained
The TeamCity server process typically runs as:
- Windows: SYSTEM or a dedicated local service account with broad local privileges
- Linux: a teamcity (or similarly named) service user, frequently granted sudo rights to support build operations
Either way, code execution through this chain inherits the server process’s full filesystem access, environment variables — where build secrets commonly live — and network reachability.

The recurring pattern: three pre-auth 9.8s in two years
| CVE-2024-23917 | Feb 2024 | HTTP API | Path traversal auth bypass | 9.8 |
| CVE-2024-27198 | Mar 2024 | HTTP handler | Auth bypass via HTTP request handling | 9.8 |
| CVE-2026-63077 | Jul 2026 | Agent polling protocol | XStream deserialization, additive allowlist | 9.8 |
All three land on TeamCity’s externally-reachable surfaces — first the HTTP API and request-handling layer, now the agent polling protocol. Each prior fix was scoped to the specific reported endpoint rather than a systemic audit of every externally-reachable surface’s trust assumptions, which is the same failure mode this write-up’s Patch Diffing and Conclusion sections return to.
Patch diffing
JetBrains’ fix in 2025.11.7 and 2026.1.3 gates the missing permission call behind an isWhiteListForced flag rather than removing the additive calls.
The fix: NoTypePermission.NONE before allowTypes(), conditionally
Pre-patch: XStreamHolder.setupSecurityIfNeeded() calls allowTypes() twice — once for TeamCity’s static protocol-class allowlist, once for a per-instance additional-classes list — without ever calling addPermission(NoTypePermission.NONE) first. Both calls only add to XStream’s already-broad default permissions.
Post-patch: when isWhiteListForced is true, addPermission(NoTypePermission.NONE) runs before either allowTypes() call, clearing the default permissions so both calls together define the entire permitted set. Any class outside that explicit list — including HSQLMetadataStorage$SchemaMismatchException, TiedMapEntry, and the rest of the chain above — now raises ForbiddenClassException during deserialization, before the gadget chain has a chance to reach BasicDataSource.getConnection().
java
// BEFORE (vulnerable) // AFTER (patched)
xStream.allowTypes( if (isWhiteListForced) {
OUR_STATIC_CLASSES_WHITE_LIST xStream.addPermission(
.keySet().toArray(new String[0])); NoTypePermission.NONE);
xStream.allowTypes( }
this.myAdditionalClassesWhiteList xStream.allowTypes(
.toArray(new String[0])); OUR_STATIC_CLASSES_WHITE_LIST
// defaults still admit the gadget chain's classes .keySet().toArray(new String[0]));
xStream.allowTypes(
this.myAdditionalClassesWhiteList
.toArray(new String[0]));
// ONLY listed classes are admitted
// when isWhiteListForced is true
Because the change is a permission-model fix rather than an endpoint-authentication fix, it closes the vulnerability regardless of whether agent registration itself is ever locked down — the gadget chain simply has nothing left to deserialize into once the allowlist is exclusive. JetBrains’ security patch plugin, available for installations on 2017.1 and later, backports this same change without requiring a full version upgrade; installations on 2017.1 through 2018.1 specifically need a server restart after the patch plugin installs for it to take effect.

Conclusion
Impact
| Confidentiality | HIGH — read build secrets, source code, VCS and artifact-repository credentials |
| Integrity | HIGH — modify build configuration, inject code into build artifacts before they ship |
| Availability | HIGH — disrupt or disable the build server, use it as a pivot into the build-agent fleet |
| Privilege gained | TeamCity server process (SYSTEM on Windows, service user — often with sudo — on Linux) |
| Auth required | None |
| User interaction | None |
TeamCity sits at the center of the software supply chain for everything it builds. A compromised server is not a compromised endpoint — it is a supply chain entry point. An attacker who tampers with a build artifact before it is signed and published ships malicious code to every downstream consumer of that artifact, whether that is an internal service, an enterprise customer, or the public.
Affected versions
| 2026.1.x | 2026.1 through 2026.1.2 | 2026.1.3 |
| 2025.11.x and earlier | < 2025.11.7 | 2025.11.7, or the security patch plugin (2017.1+) |
TeamCity Cloud (JetBrains-hosted) is not affected.
Remediation
- Patch immediately to 2025.11.7 or 2026.1.3. If an immediate upgrade is not possible, apply JetBrains’ security patch plugin (2017.1+) — installations on 2017.1 through 2018.1 need a server restart afterward for it to take effect.
- Never expose the TeamCity server directly to the internet. Put it behind a VPN or restrict access to internal IP ranges at the network layer — the agent polling protocol has no business being reachable from an untrusted network.
- Treat all build secrets stored on an internet-reachable, unpatched server as compromised. Rotate cloud credentials, deploy keys, API tokens, VCS access tokens, and artifact-signing keys.
- Audit recent build artifacts against expected hashes. An attacker with server access before detection could have tampered with build output prior to the vulnerability being discovered.
- Check for persistence: unfamiliar registered build agents, unexpected .jsp/.jspws files under the webapp root, new user or service accounts, and modified TeamCity plugin directories.
Detection
Wire-level:
- Unexpected POST requests to /app/agents/v1/register or /app/agents/v1/commands/error from source IPs that are not known build agents.
- XML payloads on those endpoints referencing HSQLMetadataStorage, TiedMapEntry, BasicDataSource, or FreeMarker HashAdapter/BooleanModel class names.
- GET requests to newly-created .jsp/.jspws paths under the TeamCity webapp root.
Host-level:
- New .jsp/.jspws files appearing under the TeamCity installation’s webapps/ROOT/ directory.
- HSQLDB SCRIPT command execution in TeamCity’s database logs, particularly with a path containing ../.
- Child processes spawned by the TeamCity JVM that are not part of normal build activity: cmd.exe, powershell.exe, sh, bash, nc, curl, wget.
- Outbound connections from the TeamCity server to unexpected external destinations (C2 callbacks, reverse shells).
Timeline
| 2026-07-10 | Antoni Tremblay privately reports the vulnerability to JetBrains |
| 2026-07-27 | JetBrains advisory published; 2025.11.7 and 2026.1.3 released |
| 2026-08-05 | CISA adds CVE-2026-63077 to the KEV catalog; active in-the-wild exploitation confirmed |
| 2026-08-10 | DenizHalil publishes independent vulnerability analysis |
Takeaway
The bug is one missing (conditional) method call, but the pattern behind it is what matters: this is TeamCity’s third CVSS 9.8, unauthenticated, remotely-reachable vulnerability in a little over two years, following CVE-2024-23917 and CVE-2024-27198. Each of the three hit a different externally-facing surface — the HTTP API, the HTTP handler layer, and now the agent polling protocol — and each was fixed with a targeted patch scoped to the reported issue rather than a systemic review of every surface’s trust model.
The gadget chain itself is also a reminder that “additive allowlist” bugs aren’t uniformly dangerous — how dangerous one is depends entirely on what’s sitting on the classpath. This particular chain needed commons-dbcp2, FreeMarker, and commons-collections all present and reachable from the same classloader as XStream; remove any one of those dependencies and Rapid7’s specific chain stops working, even though the underlying permission bug is identical. For a CI/CD server specifically, that pattern is more consequential than it would be for an ordinary web application, because every externally-reachable surface on a build server is a potential entry point into the software supply chain it builds.
References
- NVD entry — https://nvd.nist.gov/vuln/detail/CVE-2026-63077
- MITRE CVE Record — https://www.cve.org/CVERecord?id=CVE-2026-63077