신뢰할 수 없는 데이터의 역직렬화

Deserialization of Untrusted Data

설명

Java 네이티브 직렬화는 바이트 스트림에서 클래스 정보와 객체 그래프를 복원합니다. Java 25의 ObjectInputStream 문서는 신뢰되지 않은 데이터의 역직렬화가 본질적으로 위험하므로 피해야 한다고 경고합니다.

new ObjectInputStream(input) 생성자는 직렬화 스트림의 헤더와 버전을 읽고 확인하지만 아직 객체를 복원하지는 않습니다. 실제 객체 역직렬화는 readObject() 또는 readUnshared()에서 시작됩니다. 이 과정은 객체가 참조하는 전체 그래프를 순회하고 다음과 같은 클래스별 동작을 호출할 수 있습니다.

  • 직렬화 가능한 클래스의 readObject 및 readResolve
  • Externalizable 구현의 생성자와 readExternal
  • 동적 프록시 및 클래스 해석 로직
  • 그래프 안에 포함된 다른 객체의 동일한 역직렬화 동작

공격자가 스트림을 제어하고 런타임 클래스 경로에 악용 가능한 가젯 체인이 있으면 애플리케이션 권한으로 코드가 실행될 수 있습니다. 코드 실행 가젯이 없더라도 비정상적인 객체 상태, 데이터 변조, 예외 반복, 과도한 배열·참조·깊이로 인한 리소스 고갈이 발생할 수 있습니다.

잠재적 영향

  • 임의 코드 실행: 가젯 체인의 메서드나 프로세스 실행 API가 역직렬화 중 호출될 수 있습니다.
  • 데이터 및 상태 변조: 생성자를 거치지 않고 필드가 복원되면서 애플리케이션 불변 조건이 깨질 수 있습니다.
  • 정보 노출: 실행된 가젯이나 후속 처리 로직이 파일, 비밀 값 또는 내부 서비스에 접근할 수 있습니다.
  • 서비스 거부: 깊은 객체 그래프, 많은 참조, 큰 배열 또는 악의적인 클래스 동작이 CPU와 메모리를 소모할 수 있습니다.

해결 방법

1. 외부 입력에서 Java 네이티브 직렬화를 제거합니다

JSON처럼 클래스 실행 의미가 없는 데이터 전용 형식을 사용하고 명시적인 DTO에 바인딩합니다. 요청 크기를 먼저 제한하고 DTO 필드를 애플리케이션 도메인 제약에 따라 검증하세요. Java와 Kotlin의 구체적인 예시는 아래에 있습니다.

2. 즉시 제거할 수 없다면 컨텍스트별 필터를 적용합니다

호환성 때문에 ObjectInputStream을 일시적으로 유지해야 한다면 객체를 읽기 전에 스트림별 ObjectInputFilter를 설정합니다.

  • 프로토콜에 필요한 정확한 클래스와 모듈만 허용하고 나머지는 모두 거부합니다.
  • UNDECIDED 상태를 허용으로 취급하지 않습니다.
  • 그래프 깊이, 전체 참조 수, 배열 길이 및 소비 바이트 수를 제한합니다.
  • 제한적인 JVM 전역 필터도 보조 방어로 적용합니다.
  • 필터를 설정한 뒤에만 readObject() 또는 readUnshared()를 호출합니다.

Java 직렬화 필터링은 기본으로 활성화되지 않습니다. 단순한 클래스 이름 거부 목록이나 일부 클래스만 확인하는 resolveClass 재정의는 새로운 가젯, 배열, 프록시 및 누락된 클래스 경로를 모두 보장하지 못합니다. 필터는 마이그레이션 기간의 보조 통제이며 데이터 전용 형식으로 교체하는 조치를 대신하지 않습니다.

3. 신뢰 경계와 운영 환경을 함께 제한합니다

  • 신뢰할 수 있는 생성 주체만 직렬화 데이터를 만들 수 있어야 한다면 정확한 바이트를 역직렬화 전에 서명 또는 HMAC으로 검증합니다.
  • 서명이나 HMAC은 악의적인 권한 보유 생성 주체 또는 지나치게 넓은 클래스 허용 목록을 안전하게 만들지 않습니다.
  • 런타임 클래스 경로에서 불필요한 가젯 가능 의존성을 제거하고 JDK와 라이브러리를 현재 보안 패치로 유지합니다.
  • 남은 호환성 처리 경로를 최소 권한으로 실행하고 기존 레코드를 데이터 전용 형식으로 마이그레이션합니다.

예시

Java

변경 전

java
import java.io.ObjectInputStream;

import jakarta.servlet.http.HttpServletRequest;

final class ProfileEndpoint {
    Object readProfile(HttpServletRequest request) throws Exception {
        try (ObjectInputStream input =
                new ObjectInputStream(request.getInputStream())) {
            return input.readObject();
        }
    }
}

이 코드에서 ObjectInputStream 생성은 헤더만 읽습니다. 취약한 객체 그래프를 실제로 복원하고 클래스별 동작을 호출하는 지점은 input.readObject()입니다.

변경 후

새 Java 프로젝트에는 지원되는 Jackson 계열의 최신 보안 패치를 사용하세요. 아래 예시는 Jackson 3 API를 사용합니다. 이 문서를 검토한 2026년 9월 2일 기준 최신 jackson-databind 3.1 태그는 3.1.6입니다.

Jackson의 클래스 이름 기반 기본 다형성 타입 기능을 활성화하지 마세요. 다형성이 꼭 필요하면 서버가 정의한 논리적 타입 이름과 좁은 하위 타입 허용 목록을 사용하세요.

java
import java.io.IOException;

import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import tools.jackson.databind.json.JsonMapper;

final class ProfileEndpoint {
    private static final int MAX_BODY_BYTES = 64 * 1024;
    private static final JsonMapper JSON = JsonMapper.builder().build();

    record Profile(String name, int age) {}

    Profile readProfile(HttpServletRequest request, HttpServletResponse response)
            throws IOException {
        if (request.getContentLengthLong() > MAX_BODY_BYTES) {
            response.sendError(413);
            return null;
        }

        byte[] body = request.getInputStream().readNBytes(MAX_BODY_BYTES + 1);
        if (body.length > MAX_BODY_BYTES) {
            response.sendError(413);
            return null;
        }

        Profile profile = JSON.readValue(body, Profile.class);
        if (profile.name() == null
                || profile.name().isBlank()
                || profile.name().length() > 100
                || profile.age() < 0
                || profile.age() > 130) {
            response.sendError(400);
            return null;
        }
        return profile;
    }
}

DTO 바인딩 뒤의 검증은 JSON 값의 의미를 제한합니다. 안전하지 않은 네이티브 역직렬화가 끝난 뒤 반환 객체만 검증하는 방식은 이미 실행된 가젯 동작을 되돌릴 수 없습니다.

Kotlin

변경 전

kotlin
import java.io.ObjectInputStream

import jakarta.servlet.http.HttpServletRequest

fun readProfile(request: HttpServletRequest): Any =
    ObjectInputStream(request.inputStream).use { it.readObject() }

Kotlin의 use는 스트림을 닫아 주지만 신뢰되지 않은 객체 그래프의 복원을 안전하게 만들지는 않습니다. 이 예시의 취약한 지점은 it.readObject()입니다.

변경 후

Kotlin에서는 공식 kotlinx.serialization의 안정된 JSON 형식과 컴파일러가 생성한 serializer를 사용할 수 있습니다. 이 문서를 검토한 2026년 9월 2일 기준 공식 시작 가이드는 Kotlin 2.4.10과 kotlinx-serialization-json 1.11.0을 사용합니다. 다형성이 필요하면 서버가 명시적으로 등록한 하위 타입으로 제한하세요.

kotlin
import jakarta.servlet.http.HttpServletRequest
import jakarta.servlet.http.HttpServletResponse
import kotlinx.serialization.Serializable
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.json.Json

private const val MAX_BODY_BYTES = 64 * 1024

@Serializable
data class Profile(val name: String, val age: Int)

fun readProfile(
    request: HttpServletRequest,
    response: HttpServletResponse,
): Profile? {
    if (request.contentLengthLong > MAX_BODY_BYTES) {
        response.sendError(413)
        return null
    }

    val body = request.inputStream.readNBytes(MAX_BODY_BYTES + 1)
    if (body.size > MAX_BODY_BYTES) {
        response.sendError(413)
        return null
    }

    val profile = Json.decodeFromString<Profile>(body.decodeToString())
    if (
        profile.name.isBlank() ||
        profile.name.length > 100 ||
        profile.age !in 0..130
    ) {
        response.sendError(400)
        return null
    }
    return profile
}

이 예시는 입력 크기를 먼저 제한하고 JSON을 명시적인 Profile 타입으로 변환한 뒤 도메인 제약을 검사합니다. 네이티브 JVM 클래스 이름을 입력에서 선택하지 않습니다.

참조