받은 바이트를 객체로 되돌리는 일이 위험해지는 것은 내용이 잘못됐기 때문이 아니다. 일부 형식에서는 어떤 타입의 객체를 만들지를 외부 데이터가 정한다.
입력 검증이 늦는 이유
순수 데이터 형식(JSON / XML)
외부 데이터는 값만 정한다 → 받은 뒤 자기 타입에 옮겨 담는다 → 검증이 통한다
객체를 복원하는 형식
외부 데이터가 값과 타입을 정한다 → 고른 타입에 속한 코드가 복원 중에 실행된다 → 실행이 검사까지 도달하지 않는다
대부분의 입력 검증은 값이 허용되는지를 묻는다. 그러나 객체를 복원하는 형식에서는 복원 자체, 즉 어떤 클래스가 만들어지고 그 과정에서 어떤 코드가 도는지가 외부 데이터에 의해 좌우된다. 검증이 호출되기도 전에 무언가가 실행을 마칠 수 있다.
프로토타입 오염과 같은 형태다. 외부 데이터가 값이 아니라 구조를 정한다. 이름도 언어도 다르지만, 방어의 감각은 같다.
무엇이 잘못되는가(OWASP의 세 가지)
- 서비스 거부
- 복원 자체를 무겁게 하거나 깨뜨려 애플리케이션을 멈춘다
- 접근 제어 우회
- 권한이나 역할을 나타내는 객체가 공격자에게 유리하게 만들어진다
- 원격 코드 실행
- 복원 중 호출되는 루틴을 이어 붙여 임의 코드 실행에 도달한다. 가장 심각한 결과다
'우리 프레임워크는 그런 걸 안 한다'는 안전한 전제가 아니다
이것은 오래된 언어만의 이야기가 아니다. NVD 데이터에서 Next.js 취약점을 세어 보니 역직렬화(CWE-502)가 CWE 분포의 상위에 있었다. 현대 프레임워크에는 Server Actions처럼 요청 본문을 역직렬화하는 경로가 들어 있기 때문이다(Next.js와 React는 취약점이 많은가?). '직접 쓰지 않았다'가 '없다'는 뜻은 아니다.
두 가지 방어
1. 무엇도 타입을 고르게 두지 않는다(첫 번째 선택)
OWASP의 표현: JSON이나 XML 같은 순수 데이터 형식으로 바꾸면, 사용자 정의 역직렬화 로직이 악의적인 목적에 쓰일 가능성이 줄어든다.
외부 입력은 값만 싣는 형식으로 받고, 자기 코드에서 자기 타입에 옮겨 담는다. 이것만으로 공격자가 무엇을 만들지 고를 수 없게 된다. 대부분의 코드베이스에서 이것으로 문제가 해결된다.
2. 서명하고, 서명 없는 것은 거부한다
사용자 정의 형식을 피할 수 없는 경우, 지침은 직렬화 과정에서 서명하고 인증된 서명이 없는 메시지는 역직렬화하지 않는 방법을 제시한다.
핵심은 복원하기 전에 검증하는 것이다. 먼저 복원하고 나중에 검사하면 위에서 말한 이유로 이미 늦을 수 있다. 순서가 통제의 전부다.
설정으로 안전하게 만들 수 없는 장치가 있다
.NET의 BinaryFormatter에 대해 OWASP는 BinaryFormatter 타입은 위험하며 안전하게 만들 수 없다고 단언한다.
이 문장은 운영에서 중요하다. '제대로 쓰면 괜찮다'가 그냥 성립하지 않는 도구가 있다는 뜻이다. 강화 옵션, 검증 추가, 허용 목록 작성, 어느 것도 안전하게 만들지 못한다. 유일한 통제는 쓰지 않는 것이다.
그래서 점검할 때는 먼저 설정으로 지킬 수 있는 것과 없는 것을 나눈다. 두 번째 범주에 노력을 쏟는 것은 완화가 아니라 미루기다.
OWASP가 언어별로 지목한 것
- PHP
unserialize()를 피하고 JSON을 쓴다- Python
pickle/c_pickle, PyYAML의load,jsonpickle이 위험하다고 나열된다- Java
ObjectInputStream#resolveClass()를 재정의해 복원할 수 있는 클래스를 제한한다- .NET
BinaryFormatter는 위험하며 안전하게 만들 수 없다. 쓰지 않는다
공통점: 언어에 내장된 편리한 영속화 장치가 바로 외부 데이터를 마주해서는 안 되는 것이다. 서로 신뢰하는 당사자 사이에서 객체를 통째로 옮기려고 만든 것이며, 악의적인 송신자를 전제하지 않는다.
언어별 안전한 API와 위험한 API
이 표는 각 언어의 공식 문서가 외부 데이터에 쓰지 말라고 경고하는 것과, 대신 권하는 것을 나란히 놓은 것이다. 왼쪽 열의 무언가가 코드에 있다면, 그 데이터가 어디서 오는지 확인하라.
| 언어 | 외부 데이터에 쓰지 말 것 | 대신 쓸 것 | 공식 문서의 설명 |
|---|---|---|---|
| Python | pickle.load / pickle.loads, marshal, yaml.load(Loader=yaml.Loader 지정)와 yaml.unsafe_load, jsonpickle.decode | json.loads, yaml.safe_load | pickle은 '안전하지 않다', 변조 감지가 필요하면 hmac으로 서명 |
| PHP | unserialize()(allowed_classes를 지정해도) | json_decode() / json_encode() | allowed_classes와 상관없이 신뢰할 수 없는 입력을 넘기지 말 것 |
| Ruby | Marshal.load | JSON, 또는 기본 타입만 읽는 다른 형식 | 신뢰할 수 없는 출처에서 읽으면 원격 코드 실행으로 이어질 수 있다 |
| Java | 외부 데이터에 대한 ObjectInputStream#readObject, XMLDecoder, XStream#fromXML | JSON을 자기 클래스(DTO)에 옮겨 담기 | 피할 수 없으면 ObjectInputFilter로 허용 클래스를 제한 |
| .NET | BinaryFormatter, SoapFormatter, NetDataContractSerializer, LosFormatter, ObjectStateFormatter, None 이외의 Json.NET TypeNameHandling | System.Text.Json, XmlSerializer, DataContractSerializer | .NET 9부터 BinaryFormatter는 사용 시 예외를 던진다 |
나란히 놓으면 차이는 이렇다. 위험한 쪽은 도착한 것을 그대로 객체로 되돌리고, 안전한 쪽은 값으로 읽은 뒤 필요한 필드만 자기 타입으로 옮긴다.
# 피할 것: 받은 바이트를 그대로 객체로 되돌린다
order = pickle.loads(request_body)
# 쓸 것: 값을 읽고, 필요한 필드만 자기 타입으로 옮긴다
data = json.loads(request_body)
order = Order(id=int(data["id"]), quantity=int(data["quantity"]))안전한 쪽에서는 어떤 클래스를 만들지 당신의 코드(Order)가 정한다. 들어오는 데이터는 id와 quantity의 값만 정하며, 그 값에는 평범한 입력 검증이 통한다.
자기 코드에서 확인할 것
외부 데이터가 처음 도착하는 곳을 모두 나열한다
요청 본문, 쿠키, 세션, 큐, 업로드, 웹훅, 캐시 등 무언가가 저장됐다가 나중에 복원되는 모든 지점이다. 세션과 캐시는 놓치기 쉽다. 자기 것이라고 생각하는 데이터도 경로에 따라 외부에서 도달할 수 있다.
그중 타입을 복원하는 형식을 쓰는 곳을 찾는다
위 목록에 나온 함수와 클래스를 검색한다. 그것이 가장 먼저 없앨 것이다. 안전하게 만들 수 없는 장치(BinaryFormatter 등)를 쓰고 있다면 그것이 최우선이다. 이전 말고는 해결책이 없다.
순수 데이터 형식으로 옮긴다
JSON이나 XML로 받아 자기 코드에서 자기 타입으로 옮겨 담는다. 그 과정에서 스키마 검증을 적용하면 예상치 못한 키도 함께 막힌다(프로토타입 오염과 같은 처방).
옮길 수 없는 곳은 서명으로 보호한다
형식을 바꿀 수 없다면 직렬화할 때 서명하고 복원하기 전에 검증한다. **'서명 없으면 복원하지 않는다'**를 기본으로 한다. 키는 .env와 API 키의 무엇이 위험한가에서처럼 다루고, 절대 코드와 같은 곳에 두지 않는다.
의존성이 하는 복원을 센다
직접 쓴 것이 없더라도 의존성이 객체를 복원하고 있을 수 있다. CWE-502로 분류되는 CVE는 계속 나오므로 기계 감시 아래 둔다(osv-scanner 시작하기 / 실무용 CVE 대응 플레이북).
자기 환경을 확인하는 방법
위 절차의 '찾기' 부분을 구체적인 명령과 설정으로 바꾼 것이다. 모든 확인은 자기 저장소와 자기 서버를 읽기만 한다.
소스 코드를 텍스트 검색한다
먼저 후보를 모두 나열한다. ripgrep(rg)으로는 이렇게 검색한다(grep -rnE도 같은 패턴을 쓸 수 있다).
# Python
rg -n "pickle\.loads?\(|marshal\.loads?\(|yaml\.load\(|yaml\.unsafe_load|jsonpickle\.decode"
# PHP
rg -n "unserialize\("
# Ruby
rg -n "Marshal\.load"
# Java
rg -n "ObjectInputStream|readObject\(|XMLDecoder|fromXML\("
# .NET
rg -n "BinaryFormatter|SoapFormatter|NetDataContractSerializer|LosFormatter|ObjectStateFormatter|TypeNameHandling"검색 결과가 모두 위험한 것은 아니다. 각각 받는 데이터가 어디서 오는지 적고, 요청, 쿠키, 업로드, 공유 스토리지처럼 외부인이 도달할 수 있는 경로에 연결된 것을 먼저 다룬다.
꺼 둔 경고를 찾는다
.NET에서 BinaryFormatter를 쓰면 SYSLIB0011 경고나 오류가 난다. 그것을 끄는 설정은 위험한 호출이 아직 코드에 남아 있는 곳을 가리킨다.
rg -n "SYSLIB0011|CA23[0-3][0-9]" --glob "*.cs" --glob "*.csproj" --glob ".editorconfig" --glob "*.props"#pragma warning disable SYSLIB0011이나 <NoWarn> 안의 ID를 찾으면, 왜 있는지와 언제 이전할지를 확인한다.
정적 분석 규칙을 켠다
Python에서 관련된 Bandit 검사는 B301(pickle 등), B302(marshal), B506(yaml.load)이며, bandit -r . -t B301,B302,B506으로 이 세 가지만 실행할 수 있다. .NET에서 관련된 코드 분석 규칙은 CA2300 계열이다(예: Json.NET의 TypeNameHandling을 잡는 CA2326). CA2326은 .NET 10에서도 기본으로 켜져 있지 않으므로, .editorconfig에 dotnet_diagnostic.CA2326.severity = warning 같은 줄을 넣어 켠다. CI에서 돌리면 새로 생기는 호출도 막힌다.
Java는 런타임 필터 설정을 확인한다
Java는 역직렬화가 만들 수 있는 클래스를 런타임에 제한할 수 있다(직렬화 필터). 이 장치는 JDK 9(JEP 290)에서 들어왔고, Java 17(JEP 415)에서 컨텍스트별로 바꿀 수 있게 됐다. 실행 중인 프로세스라면 jcmd <PID> VM.system_properties 출력에서 jdk.serialFilter를 찾는다. java.security 파일에 보안 속성으로 설정할 수도 있으므로 둘 다 확인한다. 어느 쪽에도 없고 코드가 ObjectInputFilter.Config.setSerialFilter를 호출하지 않는다면, 프로세스 전체에 걸친 필터는 없는 것이다.
의존성의 알려진 취약점을 확인한다
자기 코드에 그런 호출이 없어도 의존성이 객체를 복원할 수 있다. osv-scanner 같은 도구로 의존성의 알려진 취약점을 나열하고, CWE-502(신뢰할 수 없는 데이터의 역직렬화)로 분류된 것부터 업데이트한다.
흔한 실수와 고치는 법
| 실수 | 문제인 이유 | 고치는 법 |
|---|---|---|
| '내부 데이터라서', '직접 저장한 파일이라서' 믿는다 | Microsoft는 개발자가 모르는 사이 데이터가 신뢰 경계를 넘는 예로 공유 저장 파일, 클라우드 동기화, 침해된 직원 PC를 든다 | 외부에서 도달하는 경로가 하나라도 있으면 외부 데이터로 다룬다. 서명 없는 것은 복원하지 않는다 |
| 위험한 클래스를 거부 목록에 하나씩 추가한다 | 목록에 없거나 아직 알려지지 않은 조합에는 아무 효과가 없다. PHP 매뉴얼은 allowed_classes를 써도 신뢰할 수 없는 입력을 넘기지 말라고 한다 | 형식을 JSON 등으로 바꾼다. 피할 수 없는 곳에서만 허용할 클래스를 나열한다 |
| 먼저 복원하고 나서 내용을 검증한다 | 복원 중에 코드가 돌므로 검증은 너무 늦다 | 서명 검증을 복원 앞에 둔다 |
| 경고를 끄고 고쳤다고 한다 | SYSLIB0011이나 CA2326을 꺼도 위험한 호출은 그대로 남는다 | 경고를 끈 곳을 검색하고 이전 기한을 정한다 |
| 'JSON이니까 안전하다'고 가정한다 | Json.NET의 TypeNameHandling처럼 JSON이 스스로 타입을 지정하게 하는 설정은 그것을 타입을 고르는 형식으로 바꾼다 | TypeNameHandling을 None(기본값)으로 둔다 |
PyYAML에서 yaml.load를 쓴다 | PyYAML 문서는 yaml.load가 pickle만큼 강력해 어떤 Python 함수든 호출할 수 있다고 한다 | yaml.safe_load로 바꾼다 |
실제 사례: 확장 기능 안의 복원
이 사이트의 CVE-2026-45247 경보는 Magento 2 확장 기능 'Mirasvit Full Page Cache Warmer' 1.11.12 이전 버전의 PHP 객체 주입(CWE-502)을 다룬다. 인증 없이 원격 코드 실행이 가능하고, CVSS 점수는 9.3이며, CISA의 알려진 악용 취약점 목록(KEV)에 추가됐다.
교훈은 복원 코드를 사이트 운영자가 쓴 것이 아니라 설치한 확장 기능 안에 있었다는 점이다. 위의 '의존성을 확인한다' 단계가 중요한 이유가 이것이다. 해결은 확장 기능 업데이트였고, 자기 코드를 검색해서는 찾을 수 없었을 것이다.
이 사이트의 관점: 값이 맞는지가 아니라 입력이 무엇을 정해도 되는지를 묻는다
'입력 검증'이라고 하면 대부분 길이, 형식, 범위 같은 값 검사를 떠올린다. 이 글의 위협은 그보다 앞에서 결판이 난다. 우리의 관점은 이렇다. 입력에 대한 진짜 질문은 '이 값이 맞는가'가 아니라 '이 입력에 얼마나 많은 판단을 맡겼는가'이다.
값만인가? 구조까지인가(프로토타입 오염)? 타입까지인가(이 글)? 놓이는 위치까지인가(파일 업로드 취약점)? 외부에 맡긴 판단을 세어 보면 위험한 곳이 저절로 드러난다.
출처(1차 자료)
- OWASP Cheat Sheet Series, "Deserialization Cheat Sheet" — cheatsheetseries.owasp.org(세 가지 영향, 순수 데이터 형식으로의 전환, 무결성을 위한 서명, 언어별 주의 사항,
BinaryFormatter에 관한 서술은 모두 이 문서에서 가져왔다. 2026년 9월 5일 확인) - Python 문서, "pickle" — docs.python.org('안전하지 않다'는 경고,
hmac서명과 JSON 권장) - PyYAML Documentation — pyyaml.org(
yaml.load와yaml.safe_load의 차이) - PHP Manual, "unserialize" — php.net(
allowed_classes와 상관없이 신뢰할 수 없는 입력을 넘기지 말 것, JSON 사용) - Ruby 문서, "Marshal" — docs.ruby-lang.org
- Microsoft Learn, "Deserialization risks in use of BinaryFormatter and related types" — learn.microsoft.com(똑같이 위험한 타입, 권장 대안, .NET 9부터의 동작, 신뢰 경계를 넘는 예)
- Microsoft Learn, "SYSLIB0011"과 "CA2326" — SYSLIB0011 / CA2326
- OpenJDK, "JEP 290: Filter Incoming Serialization Data"와 "JEP 415: Context-Specific Deserialization Filters" — JEP 290 / JEP 415
- Bandit 문서(B301, B302, B506) — bandit.readthedocs.io
- 언어별 표, 검색 절차, 흔한 실수는 2026년 10월 6일에 위 문서들과 대조해 확인했다
다음에 읽을 글
- 같은 형태: Node.js의 프로토타입 오염(외부 데이터가 구조를 정한다) / 파일 업로드 취약점(외부 데이터가 놓이는 위치를 정한다)
- 용어: 원격 코드 실행(RCE)이란
- 실제 사례: CVE-2026-45247(Magento 확장 기능의 PHP 객체 주입)
- 프레임워크별: ASP.NET Core / Spring Boot / Django 보안
- 의존성: osv-scanner 시작하기 / 실무용 CVE 대응 플레이북
FAQ
Q역직렬화가 왜 그렇게 위험한가요? 그냥 데이터 변환 아닌가요?
일부 형식에서는 데이터 변환이 아니라 객체 재구성이기 때문입니다. 어떤 클래스를 만들지에 대한 정보가 데이터 안에 실려 오면, 무엇을 만들지를 공격자가 고르게 됩니다. OWASP는 역직렬화기에 대한 공격으로 서비스 거부, 접근 제어 우회, 원격 코드 실행이 가능했다고 밝힙니다. 막기 어려운 이유는 값 검사가 돌기 전에 공격이 성공하기 때문입니다.
Q가장 확실한 방어는 무엇인가요?
외부에서 오는 데이터를 타입을 고를 수 없는 형식으로 한정하는 것입니다. OWASP는 JSON이나 XML 같은 순수 데이터 형식으로 바꾸면 사용자 정의 역직렬화 로직이 악용될 가능성이 줄어든다고 설명합니다. 사용자 정의 형식을 피할 수 없다면, 같은 지침은 직렬화 과정에서 메시지에 서명하고 인증된 서명이 없는 메시지는 역직렬화하지 않는 방법을 제시합니다. 먼저 형식을 바꾸고, 바꿀 수 없다면 서명으로 묶으세요.
Q설정을 강화하면 안전해지나요?
장치에 따라 다릅니다. OWASP는 .NET의 BinaryFormatter 타입이 위험하며 안전하게 만들 수 없다고 분명히 말합니다. 어떤 장치는 설정이나 추가 검증으로 안전해지지 않으며, 유일한 해결은 쓰지 않는 것입니다. 설정으로 지킬 수 있는 것과 없는 것을 구분하세요. '제대로 쓰면 괜찮다'가 성립하지 않는 도구가 실제로 있습니다.
Q제가 쓰는 언어에서는 무엇을 조심해야 하나요?
OWASP가 직접 언급한 것으로는, PHP에서는 unserialize()를 피하고 JSON을 쓰고, Python에서는 pickle / c_pickle, PyYAML의 load, jsonpickle이 위험하고, Java에서는 ObjectInputStream#resolveClass()를 재정의해 복원할 수 있는 클래스를 제한하고, .NET에서는 BinaryFormatter를 쓰지 않는 것입니다. 공통점은 언어가 제공하는 편리한 영속화 장치가 바로 외부 데이터에 써서는 안 되는 것이라는 점입니다.
Q제 코드에서 안전하지 않은 역직렬화를 어떻게 찾나요?
세 단계로 합니다. 첫째, ripgrep이나 grep으로 pickle.loads, unserialize, Marshal.load, ObjectInputStream, BinaryFormatter 같은 호출을 텍스트 검색합니다. 둘째, 정적 분석 규칙을 켭니다(Python은 Bandit의 B301, B302, B506, .NET은 CA2300 계열 코드 분석 규칙). 셋째, osv-scanner 같은 도구로 의존성의 알려진 취약점을 검사하고 CWE-502에 해당하는 것부터 업데이트합니다. 찾은 호출마다 받는 데이터가 어디서 오는지 적고, 외부에서 도달할 수 있는 것부터 고치세요.
QJSON을 쓰면 항상 안전한가요?
데이터가 스스로 타입을 지정하게 하는 설정을 켜면 그렇지 않습니다. 대표적인 예가 .NET용 Json.NET의 TypeNameHandling이며, Microsoft의 코드 분석 규칙 CA2326은 None 이외의 값을 쓰지 말라고 합니다. JSON의 장점은 값만 실어 오므로 어떤 클래스를 만들지 받는 쪽 코드가 정한다는 것입니다. 그 장점을 없애는 설정을 켜지 않았는지 확인하세요.