설명
PHP의 unserialize()은 serialize() 형식의 문자열에서 스칼라, 배열 및 객체를 복원합니다. 객체를 복원하면 자동 로딩이 수행될 수 있고 클래스에 정의된 __unserialize() 또는 __wakeup() 메서드가 자동으로 호출됩니다. 공격자가 HTTP 요청, 쿠키, 헤더 또는 다른 외부 입력으로 직렬화 바이트를 제어하면 애플리케이션에 로드된 클래스의 가젯 체인을 통해 상태 변경, 파일 작업 또는 코드 실행을 유발할 수 있습니다.
PHP 매뉴얼은 allowed_classes 값과 관계없이 신뢰되지 않은 사용자 입력을 unserialize()에 전달하지 말라고 명시합니다. allowed_classes => false나 좁은 클래스 허용 목록은 객체 복원을 제한하는 심층 방어 수단이지만, 적대적인 직렬화 형식을 안전한 데이터 교환 형식으로 바꾸지는 않습니다.
위험 원인
- PHP 직렬화 형식은 단순한 데이터뿐 아니라 객체의 클래스와 속성 상태도 표현합니다.
- 객체 복원 과정에서 자동 로딩과 매직 메서드가 실행되며, 사용 가능한 라이브러리 클래스가 공격 가젯이 될 수 있습니다.
- Base64 디코딩, 형식 검사, 역직렬화 후 검증은 위험한 객체 생성 전에 위험을 제거하지 못합니다.
max_depth는 중첩 깊이에 따른 자원 고갈을 줄이는 가용성 통제일 뿐 객체 주입 방지책이 아닙니다.- HMAC은 키를 모르는 주체의 변조를 탐지할 수 있지만, 악의적이거나 침해된 정식 생성자가 만든 페이로드를 안전하게 만들지는 않습니다.
잠재적 영향
- 애플리케이션 권한으로 임의 코드 또는 명령 실행
- 파일 생성·삭제·변조와 내부 서비스 접근
- 인증·권한 상태 또는 다른 애플리케이션 데이터 조작
- 예외, 깊은 구조 또는 큰 페이로드를 이용한 서비스 거부
해결 방법
- 신뢰되지 않은 입력이 도달할 수 있는 경로에서
unserialize()을 제거합니다. - 외부 데이터는 JSON 같은 데이터 전용 형식으로 디코딩합니다. 디코딩 전에 엔드포인트에 맞는 바이트 제한을 적용하고, 디코딩 후에는 정확한 최상위 자료형, 필수 필드, 추가 필드, 길이, 범위 및 도메인 제약 조건을 검사합니다.
- 검증된 필드만 새 배열이나 값 객체로 복사합니다. 디코딩하거나 검증한 결과를 다시
unserialize()에 전달하지 않습니다. - 기존 PHP 직렬화 레코드는 네트워크 요청과 분리된 통제된 오프라인 절차에서 데이터 전용 형식으로 마이그레이션합니다. 단기적으로 기존 레코드를 읽어야 한다면 디코딩 전에 서버가 보관한 키로 저장된 정확한 바이트의 HMAC을 검증하고 생성 주체를 인가합니다.
allowed_classes,max_depth, 최소 권한 및 격리는 이미 신뢰된 레거시 데이터 처리의 심층 방어로만 사용하고, 외부 입력에 대한 영구적인 해결책으로 사용하지 않습니다.
예시
변경 전
<?php
$payload = $_POST['payload'] ?? '';
$profile = unserialize($payload);
payload가 객체 직렬화 문자열이면 애플리케이션에 존재하는 클래스와 매직 메서드가 복원 과정에 관여할 수 있습니다.
변경 후
<?php
function parseProfile(string $body): array
{
if (strlen($body) > 65_536) {
throw new InvalidArgumentException('profile payload is too large');
}
$value = json_decode($body, true, 512, JSON_THROW_ON_ERROR);
$allowedKeys = ['displayName' => true, 'age' => true];
if (
!is_array($value) ||
array_is_list($value) ||
count($value) !== 2 ||
array_diff_key($value, $allowedKeys) !== [] ||
!array_key_exists('displayName', $value) ||
!array_key_exists('age', $value) ||
!is_string($value['displayName']) ||
strlen($value['displayName']) < 1 ||
strlen($value['displayName']) > 80 ||
!is_int($value['age']) ||
$value['age'] < 0 ||
$value['age'] > 130
) {
throw new InvalidArgumentException('invalid profile');
}
return [
'displayName' => $value['displayName'],
'age' => $value['age'],
];
}
$body = file_get_contents('php://input', false, null, 0, 65_537);
if ($body === false) {
throw new RuntimeException('request body read failed');
}
try {
$profile = parseProfile($body);
http_response_code(201);
} catch (JsonException | InvalidArgumentException $error) {
http_response_code(400);
$profile = ['error' => 'invalid profile'];
}
header('Content-Type: application/json');
echo json_encode($profile, JSON_THROW_ON_ERROR);
이 예시는 최대 허용 크기보다 한 바이트만 더 읽어 초과 입력을 탐지하고, JSON 객체의 정확한 필드와 값 범위를 검사한 뒤 새 배열을 만듭니다. 65_536바이트와 각 필드 제약은 예시이므로 실제 엔드포인트 계약에 맞게 조정해야 합니다.
적용 시 주의사항
입력을 받는 경로나 함수 호출 방식과 관계없이 외부 데이터는 데이터 전용 형식으로 처리하세요. 신뢰되지 않은 YAML을 처리할 때도 PHP 객체 복원을 허용하지 않도록 yaml.decode_php=0을 유지하세요. 위 JSON 예시의 array_is_list는 PHP 8.1 이상에서 사용할 수 있습니다.
참조
- PHP
unserialize()매뉴얼 - PHP 매직 메서드
- PHP
json_decode()매뉴얼 - PHP
filter_input_array()매뉴얼 - PHP
apache_request_headers()매뉴얼 - PHP
getallheaders()매뉴얼 - PHP
getenv()매뉴얼 - PECL YAML 런타임 설정
- OWASP ASVS 5.0.0 V1.5 Safe Deserialization
- OWASP Deserialization Cheat Sheet
- CWE-502: Deserialization of Untrusted Data
- OWASP Top 10:2025 A08 - Software or Data Integrity Failures
- OWASP Top 10:2021 A08 - Software and Data Integrity Failures