PHP 개발자라면 serialize()와 unserialize()를 캐싱이나 세션 저장 용도로 한두 번쯤은 써봤을 것이다. 그런데 대부분의 개발자들은 unserialize()가 얼마나 위험한지 모른 채 외부에서 들어오는 데이터를 그냥 역직렬화하고 있다. 공격자가 조작한 직렬화 문자열을 unserialize()에 넘기면 예상치 못한 객체 생성과 메서드 실행으로 서버가 해킹될 수 있다. 이번 글에서는 PHP Object Injection이 뭔지, 왜 일어나는지, 그리고 어떻게 완벽하게 방어하는지 실무 관점에서 정확히 정리하겠다.

 

serialize()와 unserialize()의 동작 원리
serialize()는 PHP 변수나 객체를 문자열로 변환하고, unserialize()는 그 문자열을 다시 원래 형태로 복원한다. 간단해 보이지만 여기서부터 문제가 시작된다.
<?php
// 정상적인 사용
$data = array('name' => 'John', 'age' => 30);
$serialized = serialize($data);
echo $serialized; // a:2:{s:4:"name";s:4:"John";s:3:"age";i:30;}

$unserialized = unserialize($serialized);
var_dump($unserialized); // array(2) { ["name"]=> string(4) "John" ["age"]=> int(30) }
?>

serialize() 출력값을 보면 데이터 타입 정보까지 포함된다. 정수는 'i:', 문자열은 's:'로 시작하고 길이 정보도 있다. unserialize()는 이 정보를 읽고 객체를 재구성할 때 클래스의 매직 메서드인 __wakeup()와 __destruct()를 자동으로 호출한다. 여기가 핵심 공격 지점이다.

 

Object Injection 공격의 구체적 사례

사용자가 업로드한 프로필 데이터를 직렬화해서 캐시에 저장하는 코드를 예시로 들어보자.

<?php
// 위험한 코드
class UserProfile {
    public $name;
    public $email;
    
    public function __wakeup() {
        echo "사용자 프로필 복원됨: " . $this->name;
    }
}

// 사용자 입력을 직렬화했다가 나중에 역직렬화
$user_data = $_GET['user_profile']; // 입력받은 직렬화 문자열
$profile = unserialize($user_data); // 위험!
?>

✗ 잘못된 방법: 공격자가 다음과 같은 악의적 직렬화 문자열을 보낼 수 있다.

<?php
// 공격자가 만든 payload
class EvilClass {
    public $command;
    
    public function __destruct() {
        // 객체가 파괴될 때 시스템 명령어 실행
        system($this->command);
    }
}

$evil = new EvilClass();
$evil->command = "rm -rf /var/www/html/*"; // 위험한 명령어
$payload = serialize($evil);
echo $payload; // O:9:"EvilClass":1:{s:7:"command";s:27:"rm -rf /var/www/html/*";}
?>

이 payload가 서버에 도달하고 unserialize()로 처리되면 __destruct() 메서드가 자동으로 호출되면서 시스템 명령어가 실행된다. 서버가 완전히 손상될 수 있다.

 

완벽한 방어 방법 1. JSON 사용(권장)

✓ 올바른 방법: serialize() 대신 json_encode()와 json_decode()를 사용하자. JSON은 데이터 구조만 포함하고 실행 가능한 코드를 절대 포함할 수 없다.

<?php
// 안전한 코드
$profile = array('name' => 'John', 'email' => 'john@example.com');

// 캐시에 저장
$json_data = json_encode($profile);
redis_set('user:123', $json_data);

// 캐시에서 가져오기
$retrieved = redis_get('user:123');
$profile = json_decode($retrieved, true); // 배열로 변환

var_dump($profile); // array(2) { ["name"]=> string(4) "John" }
?>

JSON을 사용하면 공격자가 보낸 어떤 문자열도 코드 실행으로 이어질 수 없다. json_decode()는 데이터 구조만 파싱할 뿐이다.

 

완벽한 방어 방법 2. unserialize() 사용 시 allowed_classes 제한

레거시 코드 때문에 unserialize()를 꼭 써야 한다면, 최소한 allowed_classes 옵션으로 역직렬화할 수 있는 클래스를 명시적으로 화이트리스트 지정하자.

<?php
class SafeUserProfile {
    public $name;
    public $email;
}

// 위험한 방법
$data = unserialize($_GET['profile']); // 모든 클래스 허용

// 안전한 방법
$data = unserialize(
    $_GET['profile'],
    array('allowed_classes' => array('SafeUserProfile'))
);

// 더 안전한 방법: 클래스 생성 완전 금지
$data = unserialize(
    $_GET['profile'],
    array('allowed_classes' => false) // 객체 생성 불가, 배열만 허용
);
?>

allowed_classes를 false로 설정하면 모든 객체 생성이 차단되고 배열이나 스칼라 값만 역직렬화된다.

 

완벽한 방어 방법 3. 직렬화 데이터 검증 및 서명

unserialize()를 꼭 써야 한다면 HMAC 서명으로 데이터 무결성을 검증하자. 공격자가 직렬화 문자열을 조작하면 서명이 맞지 않아 거부된다.

<?php
$secret_key = 'your-secret-key';

// 데이터를 직렬화하고 서명 생성
function secure_serialize($data) {
    global $secret_key;
    $serialized = serialize($data);
    $signature = hash_hmac('sha256', $serialized, $secret_key);
    return $signature . '::' . $serialized;
}

// 서명을 검증한 후 역직렬화
function secure_unserialize($signed_data) {
    global $secret_key;
    list($signature, $serialized) = explode('::', $signed_data, 2);
    
    // 서명 검증
    $expected_signature = hash_hmac('sha256', $serialized, $secret_key);
    if (!hash_equals($signature, $expected_signature)) {
        throw new Exception('데이터가 위변조되었습니다.');
    }
    
    return unserialize($serialized, array('allowed_classes' => false));
}

// 사용
$profile = array('name' => 'John', 'email' => 'john@example.com');
$signed = secure_serialize($profile);
echo $signed; // 해시값::직렬화문자열

// 검증 후 복원
$restored = secure_unserialize($signed);
var_dump($restored);
?>

hash_equals()를 사용하는 것이 중요하다. == 연산자로 비교하면 타이밍 공격에 취약하지만, hash_equals()는 두 해시를 일정 시간에 비교해서 공격을 방어한다.

 

주의사항 및 흔한 실수

✗ 잘못된 코드: 사용자 입력을 바로 unserialize() 처리

<?php
// 위험
if (isset($_COOKIE['user_data'])) {
    $user = unserialize($_COOKIE['user_data']);
}
?>

쿠키도 클라이언트가 조작할 수 있다. 세션 저장 경로라도 안전하지 않다.

✓ 올바른 코드: JSON 사용 또는 서명 검증

<?php
// 안전
if (isset($_COOKIE['user_data'])) {
    $user = json_decode($_COOKIE['user_data'], true);
}

// 또는 서명 검증
if (isset($_COOKIE['user_data'])) {
    $user = secure_unserialize($_COOKIE['user_data']);
}
?>

 

실전 체크리스트
상황권장 방법이유
새로 개발하는 기능JSON 사용가장 안전하고 표준. 언어/플랫폼 간 호환성도 좋음
캐시 저장JSON 또는 암호화속도도 빠르고 보안도 강함
세션 저장JSON 또는 DBPHP 기본 세션 핸들러 충분
외부 API 응답JSON 파싱대부분의 API가 JSON 사용
레거시 unserialize()서명 검증 + allowed_classes 제한기존 코드 최소 변경으로 보안 강화

 

정리 및 다음 단계

PHP Object Injection은 unserialize()를 사용하는 순간 시작된다. 공격자가 __wakeup(), __destruct() 같은 매직 메서드를 이용해 객체 생성과 동시에 임의 코드를 실행할 수 있기 때문이다. 새로운 프로젝트는 반드시 JSON을 기본으로 삼고, 레거시 코드도 단계적으로 JSON으로 마이그레이션하자. 만약 unserialize()를 꼭 써야 한다면 HMAC 서명 검증과 allowed_classes 제한을 필수로 적용해야 한다. 이 글의 secure_serialize()와 secure_unserialize() 함수를 프로젝트의 유틸 라이브러리에 추가해놓고, 모든 unserialize() 호출을 이 함수로 대체하면 Object Injection 위협에서 벗어날 수 있을 것이다.