이 글에 등장하는 코드는 금융권 보안 정책상 실제 프로젝트 코드를 그대로 공개할 수 없어, 동일한 취약 패턴과 해결 흐름을 재현한 샘플로 대체했다. 클래스명·테이블명·경로 같은 식별자는 가상이지만, SonarQube가 잡는 데이터 플로우와 통과 조건은 실제 환경과 같다.
📑 목차
JDK 21 업그레이드 작업 중에 SonarQube Deserialization 룰 S5135가 7건이 떴다. 외부 정산 시스템과 Java 객체 직렬화로 통신하던 레거시 모듈이 문제였다. 10년 넘게 돌아가던 코드인데, 당시 “Java 객체 직렬화가 편하니까”라는 이유로 설계된 게 그대로 내려온 거다. SonarQube Deserialization 메시지는 “Deserialization should not be vulnerable to injection attacks”였고, CWE-502 분류다.
S5135가 얼마나 심각한 룰인지는 ysoserial이라는 도구가 유명해지면서 알려졌다. Apache Commons Collections, Spring, Groovy 같은 라이브러리가 클래스패스에 있으면, ObjectInputStream.readObject()만 있어도 임의 코드 실행이 가능하다. 2015년 FoxGlove Security 보고서 이후로 금융권에서 이 패턴은 사실상 퇴출 대상인데, 레거시 모듈에는 그대로 살아있는 경우가 많다. S2755 XXE나 S2076 Command Injection과 마찬가지로, 이 룰도 “그냥 경고 무시”할 수 있는 수준이 아니었다.
4단계를 거치면서 최종적으로 ObjectInputStream 자체를 걷어냈다. 그 과정을 기록한다.

레거시에서 발견된 패턴
7건을 분류하니 두 패턴으로 나뉘었다.
패턴 1. 외부 요청에서 직접 역직렬화
정산 시스템이 HTTP POST로 직렬화된 Java 객체를 보내고, 수신 서블릿에서 그대로 readObject()를 호출하는 구조였다. 레거시 코드가 딱 아래 형태였다.
// SettlementReceiveServlet.java (취약)
@WebServlet("/api/settlement/receive")
public class SettlementReceiveServlet extends HttpServlet {
@Override
protected void doPost(HttpServletRequest req, HttpServletResponse res)
throws ServletException, IOException {
// S5135 여기서 검출 — req.getInputStream()을 그대로 역직렬화
try (ObjectInputStream ois = new ObjectInputStream(req.getInputStream())) {
SettlementPayload payload = (SettlementPayload) ois.readObject();
processSettlement(payload);
} catch (ClassNotFoundException e) {
res.sendError(HttpServletResponse.SC_BAD_REQUEST);
}
}
}
SonarQube가 잡는 데이터 플로우는 명확하다. req.getInputStream()은 외부 입력이고, 그걸 ObjectInputStream에 직접 넘기면 신뢰할 수 없는 데이터가 역직렬화된다. SonarQube Deserialization 룰은 이 연결 고리를 본다.
패턴 2. Jackson 폴리모픽 역직렬화
Jackson을 쓰는 REST API 쪽에서도 2건이 잡혔다. 원인은 @JsonTypeInfo(use = Id.CLASS)였다.
// TransactionRequest.java (취약)
@JsonTypeInfo(use = JsonTypeInfo.Id.CLASS, include = JsonTypeInfo.As.PROPERTY,
property = "@class")
public abstract class TransactionRequest {
private String txId;
private BigDecimal amount;
// ...
}
Id.CLASS는 JSON 필드 @class에 임의의 클래스 이름을 넣으면 Jackson이 그 클래스를 인스턴스화한다. 클래스패스에 있는 아무 클래스나 생성할 수 있으니 ObjectInputStream만큼 위험하다. S5135가 이 패턴도 잡는다. S2083 Path Traversal처럼 입력 검증이 없는 게 문제인데, Jackson 폴리모픽은 “입력 검증”으로 해결되는 게 아니라 설계 자체를 바꿔야 한다.
시도 1. readObject 후 검증 — 실패
제일 먼저 떠올린 건 “역직렬화한 다음에 instanceof 체크를 하면 되지 않나”였다. 이게 직관적으로는 말이 되는 것 같지만, 역직렬화 취약점의 본질을 잘못 이해한 거다.
// 시도 1 — readObject 후 타입 검증 (여전히 취약)
try (ObjectInputStream ois = new ObjectInputStream(req.getInputStream())) {
Object obj = ois.readObject(); // 이미 여기서 가젯 체인 실행됨
if (!(obj instanceof SettlementPayload)) {
res.sendError(HttpServletResponse.SC_BAD_REQUEST, "Invalid type");
return;
}
SettlementPayload payload = (SettlementPayload) obj;
processSettlement(payload);
}
실패다. readObject()가 호출되는 순간 이미 역직렬화가 일어난다. 가젯 체인(gadget chain)은 역직렬화 과정에서 트리거된다 — readObject()가 리턴되기 전에 이미 Runtime.exec()가 실행될 수 있다. instanceof 체크는 그 이후 얘기다. SonarQube Deserialization 룰은 이 코드에서 계속 S5135를 잡았다. “타입 체크를 뒤에서 해봤자 역직렬화는 이미 완료됐다”는 걸 SonarQube 메시지에서 설명해주진 않지만, 메커니즘을 알면 당연한 결론이다.
시도 2. ObjectInputFilter (JDK 9+) — 부분 통과
JDK 9부터 ObjectInputFilter가 도입됐다. 역직렬화 전에 클래스를 필터링하는 API다. 실제로 SonarQube가 인정하는 패턴 중 하나다. JDK 21 환경이니까 바로 쓸 수 있었다.
// 시도 2 — ObjectInputFilter.Config.createFilter 사용
import java.io.ObjectInputFilter;
@WebServlet("/api/settlement/receive")
public class SettlementReceiveServlet extends HttpServlet {
// 패키지 기반 필터 — 우리 패키지 클래스만 허용
private static final ObjectInputFilter SETTLEMENT_FILTER =
ObjectInputFilter.Config.createFilter(
"com.finco.settlement.*;java.math.*;java.lang.*;!*"
);
@Override
protected void doPost(HttpServletRequest req, HttpServletResponse res)
throws ServletException, IOException {
try (ObjectInputStream ois = new ObjectInputStream(req.getInputStream())) {
ois.setObjectInputFilter(SETTLEMENT_FILTER);
SettlementPayload payload = (SettlementPayload) ois.readObject();
processSettlement(payload);
} catch (ClassNotFoundException | InvalidClassException e) {
res.sendError(HttpServletResponse.SC_BAD_REQUEST);
}
}
}
ObjectInputFilter.Config.createFilter()에 넘기는 패턴 문법은 세미콜론으로 구분하고, !로 거부, *로 와일드카드다. 패키지 계층은 .**(하위 포함)과 .*(바로 아래만)을 구분한다. 위 코드에서 com.finco.settlement.*;java.math.*;java.lang.*는 이 세 패키지만 허용하고 나머지(!*)는 전부 차단한다.
SonarQube는 이 시도에서 경고를 하나 줄였다. 그러나 S5135가 완전히 사라지지 않았다. SonarQube가 “패턴 매칭 기반 필터는 완전한 화이트리스트가 아닐 수 있다”는 논리로 아직 Hotspot으로 남겨두는 케이스가 있었다. 특히 패키지 패턴이 정밀하지 않으면 서브클래스나 패키지 구조가 바뀔 때 우회 가능성이 생긴다. 부분 통과였다.
시도 3. ValidatingObjectInputStream — 통과
Apache Commons IO 2.5+ 에서 제공하는 ValidatingObjectInputStream이 이 문제를 위해 나온 클래스다. 클래스 화이트리스트를 명시적으로 등록하고, 등록되지 않은 클래스가 역직렬화 대상으로 나타나면 InvalidClassException을 던진다.
<!-- pom.xml — commons-io 2.5+ -->
<dependency>
<groupId>commons-io</groupId>
<artifactId>commons-io</artifactId>
<version>2.15.1</version>
</dependency>
// 시도 3 — ValidatingObjectInputStream 화이트리스트
import org.apache.commons.io.serialization.ValidatingObjectInputStream;
@WebServlet("/api/settlement/receive")
public class SettlementReceiveServlet extends HttpServlet {
@Override
protected void doPost(HttpServletRequest req, HttpServletResponse res)
throws ServletException, IOException {
try (ValidatingObjectInputStream vois =
new ValidatingObjectInputStream(req.getInputStream())) {
// 허용할 클래스 명시적 등록 — 이 목록 외 클래스는 역직렬화 차단
vois.accept(SettlementPayload.class);
vois.accept(java.math.BigDecimal.class);
vois.accept(java.util.ArrayList.class);
vois.accept(java.lang.String.class);
SettlementPayload payload = (SettlementPayload) vois.readObject();
processSettlement(payload);
} catch (ClassNotFoundException | java.io.InvalidClassException e) {
// 화이트리스트에 없는 클래스 시도 → 여기서 잡힘
res.sendError(HttpServletResponse.SC_FORBIDDEN, "Deserialization blocked");
log.warn("Deserialization blocked: {}", e.getMessage());
}
}
}
vois.accept()에 등록된 클래스만 역직렬화 가능하다. 나머지는 역직렬화 단계에서 즉시 차단된다 — readObject()가 가젯 체인을 실행할 기회가 없다. SonarQube S5135가 이 시도에서 전부 통과됐다. ObjectInputStream 대신 ValidatingObjectInputStream을 사용하고, 화이트리스트를 명시적으로 선언하는 게 SonarQube가 인정하는 패턴이다.
단, 이 방법도 유지보수 부담이 있다. SettlementPayload에 새 필드가 추가되고 그 타입이 화이트리스트에 없으면 역직렬화가 갑자기 깨진다. 운영 중 장애로 이어질 수 있다. “오늘 배포했는데 정산 수신이 안 된다”는 상황이 생기기 딱 좋은 구조다. 그래서 이게 통과 솔루션이긴 해도 장기적으로는 추천하지 않는다.
최종 — JSON 전환 (권장)
팀 내 논의에서 결론은 “ObjectInputStream 자체를 걷어내자”였다. 외부 시스템과의 통신 포맷을 JSON으로 바꾸는 게 근본 해결이다. 상대방 시스템이 협력 가능한 상황이었고, 마침 JDK 21 업그레이드와 맞물려 API 재설계를 진행할 수 있었다.
Jackson 폴리모픽 — Id.CLASS 제거
Jackson 취약 패턴(@JsonTypeInfo(use = Id.CLASS))은 먼저 처리했다. Id.CLASS는 임의 클래스를 인스턴스화할 수 있으니 폐기하고, 명시적 subtype 등록으로 전환했다.
// 수정 전 — Id.CLASS (취약)
@JsonTypeInfo(use = JsonTypeInfo.Id.CLASS, include = JsonTypeInfo.As.PROPERTY,
property = "@class")
public abstract class TransactionRequest { }
// 수정 후 — Id.NAME + 명시적 subtype 등록 (안전)
@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, include = JsonTypeInfo.As.PROPERTY,
property = "type")
@JsonSubTypes({
@JsonSubTypes.Type(value = CreditTransactionRequest.class, name = "credit"),
@JsonSubTypes.Type(value = DebitTransactionRequest.class, name = "debit"),
@JsonSubTypes.Type(value = RefundTransactionRequest.class, name = "refund")
})
public abstract class TransactionRequest {
private String txId;
private BigDecimal amount;
}
Id.NAME을 쓰면 JSON의 type 필드가 "credit", "debit", "refund" 같은 등록된 이름만 받는다. 클래스 이름을 직접 지정하는 것 자체가 불가능해진다. SonarQube S5135가 잡던 2건이 여기서 해소됐다.
ObjectInputStream → Jackson JSON
정산 수신 서블릿을 JSON 방식으로 전환했다. 외부 시스템이 HTTP POST로 JSON을 보내도록 인터페이스를 변경하고, 수신 측에서는 Jackson ObjectMapper로 역직렬화했다.
// SettlementReceiveServlet.java (최종 — JSON 전환)
@WebServlet("/api/settlement/receive")
public class SettlementReceiveServlet extends HttpServlet {
private static final ObjectMapper MAPPER = new ObjectMapper()
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, true)
.setVisibility(PropertyAccessor.ALL, Visibility.NONE)
.setVisibility(PropertyAccessor.FIELD, Visibility.ANY);
@Override
protected void doPost(HttpServletRequest req, HttpServletResponse res)
throws ServletException, IOException {
// Content-Type 검증
String contentType = req.getContentType();
if (contentType == null || !contentType.startsWith("application/json")) {
res.sendError(HttpServletResponse.SC_UNSUPPORTED_MEDIA_TYPE);
return;
}
// 요청 크기 제한 (10MB)
if (req.getContentLengthLong() > 10 * 1024 * 1024) {
res.sendError(HttpServletResponse.SC_REQUEST_ENTITY_TOO_LARGE);
return;
}
try (InputStream body = req.getInputStream()) {
SettlementPayload payload = MAPPER.readValue(body, SettlementPayload.class);
processSettlement(payload);
res.setStatus(HttpServletResponse.SC_OK);
} catch (JsonProcessingException e) {
res.sendError(HttpServletResponse.SC_BAD_REQUEST, "Invalid JSON");
}
}
}
// SettlementPayload.java — 단순 POJO (직렬화 마커 없음)
public class SettlementPayload {
private String settlementId;
private String accountNo; // 가상 필드
private BigDecimal amount;
private String currency;
private String txDatetime;
// getter / setter 생략
}
Serializable을 구현하지 않아도 된다. ObjectMapper.readValue()는 JSON → Java POJO 매핑이고, 클래스패스의 가젯 체인이 트리거될 여지가 없다. FAIL_ON_UNKNOWN_PROPERTIES(true)는 JSON에 정의되지 않은 필드가 오면 예외를 던지도록 해서 예상치 못한 데이터를 거른다.
핵심 3가지
- ObjectInputStream은 신뢰할 수 없는 입력에 절대 쓰지 않는다 — 클래스패스에 Commons Collections, Spring, Groovy 중 하나라도 있으면 RCE 가능
- Jackson 폴리모픽은
Id.NAME+@JsonSubTypes조합으로 —Id.CLASS는 폐기, subtype이 고정되지 않으면 폴리모픽 자체를 쓰지 않는 게 낫다 - ValidatingObjectInputStream은 임시 조치로만 — 화이트리스트 관리 부담이 있고, 근본적으로 ObjectInputStream을 쓴다는 사실은 변하지 않는다. 외부 시스템 변경이 가능하면 JSON 전환이 우선이다
정리
SonarQube Deserialization(S5135)의 본질은 신뢰할 수 없는 입력을 ObjectInputStream으로 역직렬화하면 가젯 체인을 통해 임의 코드가 실행될 수 있다는 점이다. readObject 후 타입 검사로는 이미 늦고, 가장 확실한 해결은 ObjectInputStream 자체를 걷어내는 것이다. 단기적으로는 ValidatingObjectInputStream 화이트리스트로 통과시키고, 장기적으로는 JSON 전환이 정답이다.
이전 편 #48 CORS(S5122)에서는 Access-Control-Allow-Origin: * + credentials 조합을 다뤘다. 다음 편은 SonarQube Insecure Random 룰 S2245 — Math.random()으로 토큰을 생성하던 코드가 Security Hotspot 폭탄을 맞은 케이스다.
룰 본문은 SonarSource 공식 룰 페이지(S5135)에서 확인할 수 있다.