vulnerabilities rfc1867 callback uaf

PHP 코어 UAF 내부 분석: RFC1867 업로드 콜백이 댕글링 포인터를 만드는 과정

php2shell.run Research Lab •

PHP 코어 UAF 내부 분석: RFC1867 업로드 콜백이 댕글링 포인터를 만드는 과정

<time datetime="2026-10-23">검증일: 2026년 10월 23일</time> · 대상: ext/session/session.c, main/rfc1867.c

PHP2Shell의 핵심 취약점인 CVE-2026-109619는 Zend Engine에서 가장 미묘한 Use-After-Free(UAF) 아키텍처 결함 중 하나입니다. 근본 원인은 워커 프로세스의 지속 수명(persistent lifetime) 과 요청 범위 힙 할당(request-scoped allocation) 사이의 수명 불일치입니다. PHP-FPM 워커는 수백~수천 개의 요청을 처리하며 살아남지만, 세션 해시 테이블 엔트리는 요청 또는 세션 재적재 시점에 해제됩니다. 이 두 수명 주기가 어긋나는 지점에서 전역 포인터가 해제된 메모리를 가리키게 됩니다.


1. RFC1867 추적 메커니즘의 해부

main/rfc1867.c의 멀티파트 파서는 PHP_SESSION_UPLOAD_PROGRESS 필드를 발견하면 ext/session/session.c에 등록된 콜백을 호출합니다. 콜백은 세션 해시 테이블에 진행률 엔트리를 만들고, 그 내부 zval 주소를 전역 정적 변수에 저장합니다.

/* ext/session/session.c — 개념 축약 모델 */
static zval *cached_post_bytes;   /* 처리된 전체 바이트 */
static zval *cached_file_bytes;   /* 처리된 파일 바이트 */

static int php_session_rfc1867_callback(unsigned int event,
                                        void *event_data,
                                        void **extra)
{
    switch (event) {
    case MULTIPART_EVENT_START: {
        /* 세션 HashTable 내 진행률 엔트리 생성 */
        zval *progress = php_session_get_progress_entry();
        ZVAL_LONG(&progress->value.bytes_processed, 0);

        /* [결함] 내부 zval의 원시 주소를 전역에 캐싱 */
        cached_post_bytes = &progress->value.bytes_processed;
        cached_file_bytes = &progress->value.files[0].bytes_processed;
        break;
    }
    case MULTIPART_EVENT_FILE_DATA:
        /* 스트리밍 중 매 청크마다 역참조 쓰기 */
        Z_LVAL_P(cached_post_bytes) += chunk_len;
        break;
    }
    return SUCCESS;
}

여기서 핵심은 zval* 원시 포인터가 프로세스 전역 변수에 직접 저장된다는 점입니다. 참조 카운트 증가(Z_TRY_ADDREF)도, 세대 번호(generation counter)도, 무효화 통지도 없습니다. C 언어의 관점에서 이는 소유권이 모호한 포인터이며, Zend Engine의 메모리 관리 규약을 위반합니다.


2. 메모리 상태 머신: 댕글링 포인터의 생애

  [ 1단계: 할당 및 포인터 캐싱 ]
  ┌─────────────────────────────────────────────────────────┐
  │ Zend MM 힙 버킷                                         │
  │ ┌──────────────────────┐                                │
  │ │ session_ht Entry     │ <─── cached_post_bytes (전역)  │
  │ │ [bytes_processed]    │      유효한 zval을 가리킴      │
  │ └──────────────────────┘                                │
  └─────────────────────────────────────────────────────────┘
                               │
                               │ 세션 리셋 / 새 요청 (session_reread)
                               ▼
  [ 2단계: 무효화 없는 파괴 ]
  ┌─────────────────────────────────────────────────────────┐
  │ Zend MM 힙 버킷                                         │
  │ ┌──────────────────────┐                                │
  │ │ [ 해제된 메모리 ]    │ <─── cached_post_bytes (STALE) │
  │ │ FreeBin으로 반환     │      댕글링 포인터 성립        │
  │ └──────────────────────┘                                │
  └─────────────────────────────────────────────────────────┘
                               │
                               │ 공격자가 소형 요청으로 버킷 재점유
                               ▼
  [ 3단계: 힙 점유 및 UAF 쓰기 ]
  ┌─────────────────────────────────────────────────────────┐
  │ Zend MM 힙 버킷                                         │
  │ ┌──────────────────────┐                                │
  │ │ 공격자 제어 데이터   │ <─── *cached_post_bytes += len │
  │ │ Fake zval 구조체     │      의도적 메모리 오염        │
  │ └──────────────────────┘                                │
  └─────────────────────────────────────────────────────────┘

ZMM은 요청 종료 시 일괄 해제가 아니라, 크기별 Bin으로 분리된 free-list를 운용합니다. 작은 할당은 small Bin, 큰 할당은 large/huge로 나뉘며, 해제된 청크는 LIFO 순서로 재사용됩니다. 공격자는 이 재사용 순서를 예측하여 해제된 슬롯을 자신의 문자열 또는 세션 값으로 즉시 덮어쓸 수 있습니다.


3. 댕글링 포인터 트리거 순서

  1. 세션 무효화: session_reread() 또는 zend_hash_update()가 기존 엔트리를 교체합니다.
  2. 파괴 통지 부재: zval_ptr_dtor가 실행되어 원본 zval 청크를 ZMM에 반환합니다.
  3. 전역 포인터 잔존: cached_post_bytes, cached_file_bytes는 초기화되지 않고 해제된 주소를 유지합니다.
  4. UAF 쓰기: 다음 멀티파트 청크가 도착하면 콜백이 해제된 주소에 정수를 더합니다.
/* 다음 청크 도착 시 */
*cached_post_bytes += chunk_len;   /* 해제된 메모리에 대한 쓰기 */

이 쓰기는 정수 증가 연산이지만, 공격자가 해당 16바이트 슬롯을 조작된 zval로 채웠다면 type_info와 value.ptr 필드를 덮어쓸 수 있습니다. 이후 Zend VM이 그 zval을 객체로 역참조하면 vtable 함수 포인터가 공격자 통제하에 놓입니다.


4. CVE-2026-109618: OOM 경로의 평행선

CVE-2026-109618은 전역 포인터 대신 OOM 예외 처리 경로를 통해 같은 목적지에 도달합니다.

  • PHP는 memory_limit 초과 시 zend_error_noreturn 대신 longjmp() 기반 bailout을 사용합니다.
  • RFC1867 멀티파트 스트리밍 중에는 중간 구조체가 완전히 초기화되기 전에 다음 할당이 발생합니다.
  • OOM이 중간에 발생하면 정리 루틴이 부분 초기화된 구조체를 해제하면서 힙 블록의 크기 메타데이터가 실제 할당과 불일치하게 됩니다.
  • 이후 동일 Bin에 새 할당이 들어오면 겹치는(overlapping) 청크가 형성됩니다.

두 취약점의 공통점은 "해제된 메모리에 대한 유효한 쓰기 경로" 를 남긴다는 것입니다. 차이는 트리거 조건이 세션 리셋(UAF)이냐 메모리 고갈(OOM)이냐입니다.


5. 왜 session_start()가 없어도 터지는가

많은 운영자가 "우리 앱은 세션을 안 쓴다"고 안심합니다. 그러나 콜백은 애플리케이션의 세션 사용 여부와 무관하게 SAPI가 요청 본문을 파싱하는 시점에 등록됩니다. session.upload_progress.enabled=On이면 PHP는 요청 시작 시 자동으로 세션을 초기화하고 진행률 엔트리를 만들 준비를 합니다. 즉, 정적 페이지나 API 엔드포인트도 공격면에 포함됩니다.


6. 안전한 코드 패턴과 방어적 수정

upstream 수정은 전역 캐시를 제거하고, 엔트리 수명을 요청 컨텍스트에 묶는 방향으로 이루어졌습니다.

/* 수정 방향 개념도 */
typedef struct {
    zend_string *session_id;
    zend_ulong   generation;   /* 세션 세대 번호 */
    zval        *progress;     /* 요청 범위 포인터 */
} php_rfc1867_ctx;

static void php_rfc1867_write(php_rfc1867_ctx *ctx, size_t chunk_len)
{
    if (!ctx || !ctx->progress ||
        ctx->generation != php_session_generation()) {
        return;   /* 무효화된 컨텍스트는 쓰기 금지 */
    }
    Z_LVAL(ctx->progress->value.bytes_processed) += chunk_len;
}

운영 환경에서 즉시 적용 가능한 방어는 다음과 같습니다.

session.upload_progress.enabled = Off
# 패치 버전 확인 (8.5.12 / 8.4.27 / 8.3.36 / 8.2.35 이상)
php -v
php-fpm -t

7. 탐지 관점: 코드 결함을 어떻게 관측하나

UAF는 사용자 공간 로그에 흔적을 거의 남기지 않습니다. 대신 다음 신호를 결합해야 합니다.

# 비정상적으로 짧은 간격의 multipart POST 반복
sudo tail -f /var/log/nginx/access.log | \
  grep -E '"POST .*HTTP/(1\.1|2)" (200|404|413)' | \
  awk '{print $1, $4, $7}' | sort | uniq -c | sort -rn | head

# FPM 워커의 비정상 종료 (SIGSEGV, SIGABRT)
journalctl -u php8.3-fpm --since "1 hour ago" | \
  grep -E 'segfault|signal|SIGSEGV|SIGABRT'

# 코어 덤프 존재 여부
coredumpctl list php-fpm --since "1 hour ago" 2>/dev/null

Suricata와 Falco 규칙은 별도 문서에서 다루지만, 핵심은 "짧은 시간 내 다수의 multipart 요청 + 비정상 워커 종료 + 외부로 향하는 새 프로세스" 조합을 상관 분석하는 것입니다.


8. 요약

  • 취약점의 본질은 전역 정적 zval*가 세션 엔트리 해제 후에도 유지되는 수명 불일치입니다.
  • longjmp 기반 bailout은 OOM 경로에서 부분 초기화 구조체를 정리하여 힙 메타데이터를 깨뜨립니다.
  • 두 CVE 모두 사용자 PHP 코드 실행 이전 단계에서 발생하므로 애플리케이션 레벨 방어가 무력합니다.
  • 확실한 방어는 8.5.12 / 8.4.27 / 8.3.36 / 8.2.35 이상으로의 업그레이드, 또는 session.upload_progress.enabled=Off입니다.