같은 워커에 다음 요청이 들어오면
업로드 처리가 중간에 끝났는데 세션 포인터는 워커에 남는다. 요청의 메모리는 해제했어도, 다음 요청이 그 포인터를 먼저 읽으면 문제가 된다. php2shell에서 눈여겨본 부분은 업로드 파일의 내용보다 이 초기화 순서다.
여기서 php2shell은 Calif·OpenAI·Wiz가 공개한 PHP 코어 취약점 연구를 뜻한다. 동명의 제3자 Python 도구 하나를 원본으로 삼지 않았다. 원문은 자료 1, 실제로 읽고 비교한 원본 저장소는 php/php-src다.
분석 기준은 수정 전 php-8.5.11 (678778973bb4d4185c06ef1320de50f1a975d3c8)과 수정 후 php-8.5.12 (136e049ac10b032c83018e65b63b6df858943750)다. 2026-10-23에 공식 태그를 GitHub API로 조회해 커밋을 고정했다. 아래 파일 위치와 줄 번호는 이 두 커밋을 기준으로 한다.
이번에는 소스 비교만 했음. 취약한 PHP 서버를 띄우거나 공격 요청을 보내지 않았고 셸 획득, 메모리 누출, 크래시를 직접 재현하지 않았다. 공식 보고서의 재현 결과와 여기서 확인한 코드 흐름을 나누어 적는다.
OOM과 stale state는 서로 다른 문제다
CVE-2026-109618은 OOM(out of memory) 처리 도중 불완전한 내부 상태를 남기는 문제다. 메모리 부족에서 longjmp로 현재 처리를 빠져나오면, 정리 코드가 아직 유효하지 않은 값을 정상 상태로 다룰 수 있다. 업로드는 메모리를 소모하는 진입점 중 하나다. 취약점 전체가 세션 업로드 진행률 기능에만 한정되는 것은 아니다. 근거는 자료 2다.
CVE-2026-109619는 요청이 소유한 메모리를 가리키는 전역 상태가 워커에 남는 문제다. 값을 남기는 producer와 다음 요청에서 그 값을 사용하는 consumer가 연결돼야 한다. 업로드 진행률은 그 연결의 한 사례이며, PHP 8.5의 fatal backtrace 등 다른 상태도 공식 권고에 포함된다. 근거는 자료 3이다.
따라서 “세션 파일에 문자열을 넣을 수 있음”, “OOM을 만들었음”, “RCE를 재현했음”은 같은 결과가 아니다. 공개 PoC가 세션 오염 뒤 애플리케이션의 LFI에 의존한다면 그 추가 조건까지 따로 읽어야 한다. 여기서는 공식 연구의 세션 수명 경로를 따라간다.
일반 RINIT보다 업로드 파싱이 먼저다
PHP 8.5.11의 main/main.c에서 php_request_startup()을 보면 sapi_activate()가 zend_activate_modules()보다 먼저 호출된다. SAPI는 웹 서버와 PHP를 연결하는 실행 인터페이스이고, RINIT은 확장의 요청별 초기화 단계다. 자동 요청 본문 파싱은 SAPI 활성화 중에 일어날 수 있으므로, 업로드 콜백이 세션 확장의 일반 RINIT보다 먼저 실행된다. 근거는 자료 4의 1900~1944행과 자료 1이다.
업로드 진행률은 파일 전송이 끝나기 전에도 세션에 상태를 써야 한다. 그래서 php_session_rfc1867_callback()은 특정 파일 파트에 진입하면 세션을 먼저 초기화한다. 앱 코드에서 session_start()를 호출하지 않았다는 사실만으로 이 경로를 배제할 수 없는 이유다.
아래는 호출 순서만 옮긴 의사코드다. 실제 함수 전체나 재현용 요청은 아니다.
php_request_startup
modules_activated = 0
zend_activate
sapi_activate
multipart 파싱 → 세션 업로드 진행률 콜백
zend_activate_modules → 확장 RINIT
modules_activated = 1FILE_START에서 요청 소유 포인터를 전역에 놓는다
8.5.11의 ext/session/session.c에서 첫 MULTIPART_EVENT_FILE_START는 세션 ID와 진행률 키가 있는지 확인한다. 아직 progress->data가 초기화되지 않았다면 php_rinit_session(0)을 호출하고 요청의 세션 ID를 PS(id)에 복사한다. 아래 두 줄은 원본 3264~3265행 그대로다. 근거는 자료 5다.
이어 php_session_rfc1867_update()가 php_session_initialize()로 들어간다. 이 과정에서 PS(http_session_vars)도 요청 힙의 값을 가리킨다. PS는 세션 확장의 전역 상태를 접근하는 매크로다. 이 상태가 요청 사이에 살아 있는 워커 안에 있다는 점이 중요하다.
정상 흐름에서는 multipart의 END 콜백과 세션 종료 처리가 값을 정리한다. 그런데 나중의 파싱에서 fatal이 발생해 END에 도달하지 못하면 이 짝이 깨진다. 메모리를 할당한 곳과 초기화·정리를 보장하는 곳이 서로 다른 단계에 있다.
php_rinit_session(0);
PS(id) = zend_string_copy(Z_STR(progress->sid));정리는 건너뛰는데 요청 힙은 해제한다
8.5.11의 php_request_shutdown()은 PG(modules_activated)가 참일 때 zend_deactivate_modules()를 호출한다. 앞 단계에서 아직 일반 모듈 활성화가 끝나지 않았다면 이 조건은 거짓이다. 반면 뒤의 shutdown_memory_manager()는 그 조건문 밖에 있다. 자료 4의 1996~2000행과 2050행에서 확인했다.
이 흐름으로 보면, multipart END를 건너뛰고 세션 RSHUTDOWN까지 실행하지 못한 요청은 요청 메모리만 해제한 채 세션 전역의 참조를 남길 수 있다. 주소가 남았다는 사실은 그 주소의 객체가 여전히 유효하다는 뜻이 아니다.
다만 다음 요청으로 이어지려면 워커가 살아 있어야 한다. mod_php에서 요청 시작 실패 뒤에도 shutdown과 워커 재사용이 이어지는 경로는 발견자 원문과 공식 권고가 확인한 내용이다. 이번 소스 비교에서 Apache를 직접 실행한 것은 아니다.
if (PG(modules_activated)) {
zend_deactivate_modules();
}
/* 다른 shutdown 단계 생략 */
shutdown_memory_manager(CG(unclean_shutdown) || !report_memleaks, 0);다음 요청은 FILE_START 없이 END로 갈 수 있다
같은 워커가 다음 multipart 요청을 받았다고 보자. 세션 ID와 진행률 필드는 있지만 파일 파트는 없다면 FILE_START의 초기화는 지나지 않는다. 그런데 8.5.11의 MULTIPART_EVENT_END는 progress->sid와 progress->key.s를 확인하고 cleanup 설정이 켜져 있으면 php_session_rfc1867_cleanup()을 호출한다. 근거는 자료 5의 3326행 이후다.
cleanup은 php_session_initialize()를 호출한다. 이 함수는 PS(id)를 확인하고, php_session_track_init()은 PS(http_session_vars)가 정의돼 있으면 zval_ptr_dtor()로 기존 값을 파괴한다. 이전 요청이 남긴 해제된 값을 새 요청이 읽거나 파괴하는 consumer가 되는 셈이다. 참조가 남는 경로와 참조를 사용하는 경로를 같이 봐야 UAF(use after free) 설명이 완성된다.
아래는 원문과 코드에서 확인한 연결을 정리한 의사코드다. 이 흐름이 존재한다는 정적 확인과, 힙 배치를 맞춰 명령 실행까지 도달했다는 동적 결과는 구분한다.
요청 A / 같은 워커
FILE_START → 세션 상태 게시
이후 파싱 중 fatal → END 및 세션 RSHUTDOWN 누락
요청 힙 해제 → 전역 참조가 남음
요청 B / 같은 워커
파일 파트 없음 → FILE_START 초기화 없음
END → cleanup → 세션 초기화
이전 요청의 세션 ID·값에 접근FPM에는 시작 실패 뒤 워커 종료가 있다
FPM에 mod_php의 요청 A·B 설명을 그대로 적용하면 틀린다. 8.5.11의 sapi/fpm/fpm/fpm_main.c는 메인 요청 루프에서 php_request_startup() 실패 시 FPM_EXIT_SOFTWARE를 반환한다. 시작 단계에서 실패한 자식이 같은 상태로 다음 요청을 처리한다는 전제가 여기서 끊긴다. 근거는 자료 6의 1838~1842행이다.
그렇다고 FPM 전체가 안전한 것은 아니다. 공식 stale-state 권고는 정상 시작을 마친 뒤 앱 실행 중 상태가 게시되고 fatal이 발생한 다음, 후속 업로드 진행률 처리가 이를 소비하는 FPM 경로를 검증했다고 적는다. 이때는 적합한 앱 동작이 필요하다. 반면 OOM corruption 권고의 FPM 설명은 소스 분석상 영향 가능성과 완성된 FPM exploit 미입증을 구분한다. 두 CVE의 검증 수준을 섞으면 안 된다. 근거는 자료 2·3이다.
이번 글에서 확인한 것은 이 종료 분기와 세션 콜백의 순서다. 워커 수, 확장, 빌드 옵션, 메모리 할당기 배치에 따른 성공률은 측정하지 않았다.
8.5.12는 세션을 더 일찍 비우고 OOM 처리도 바꾼다
세션 쪽에서 가장 눈에 띄는 차이는 MULTIPART_EVENT_START다. 8.5.12는 progress 구조체를 할당하기 전에 php_rinit_session_globals()를 호출한다. 이 함수는 PS(id)를 NULL로, PS(http_session_vars)를 UNDEF로 초기화한다. 파일 파트가 없어 FILE_START를 지나지 않는 요청도 이제 이 초기화를 먼저 거친다. 자료 7의 3238~3246행과 초기화 함수를 비교했다.
같은 수정본에는 일반 모듈 활성화 뒤의 콜백을 피하는 조건, 업로드 진행률 처리 중 직렬화·역직렬화에서 사용자 코드를 호출하지 않도록 하는 분기, 세션 초기화 실패 검사도 보인다. 한 줄 수정만 떼어 “전체 취약점 패치”라고 부르기 어려운 이유다. 근거는 자료 7의 php_session_unserialize_init() 및 update·cleanup 함수다.
OOM 쪽은 Zend/zend_alloc.c의 변화가 크다. 8.5.11의 zend_mm_safe_error()는 오류 처리 뒤 zend_bailout()으로 빠진다. 8.5.12는 delayed OOM 상태와 VM interrupt를 설정하는 경로, hard limit에서 _exit(137)로 끝내는 경로를 둔다. 공식 권고는 이를 안전한 VM 지점까지 지연하는 soft OOM과 즉시 종료하는 hard OOM으로 설명한다. 근거는 자료 2·8·9다.
내 해석은 이렇다. 세션 패치는 다음 요청이 오래된 상태를 먼저 소비하는 틈을 줄이고, OOM 패치는 불완전한 상태에서 강제로 제어 흐름을 끊는 원인을 바꾼다. 둘은 서로 다른 층을 고친다. 이 해석은 코드 비교에 근거하며 패치의 모든 경로를 동적으로 검증했다는 뜻은 아니다.
php_rinit_session_globals();
progress = ecalloc(1, sizeof(php_session_rfc1867_progress));버전과 임시 완화에서 확인할 것
2026-10-23 기준 PHP 공식 수정 릴리스는 8.2.35·8.3.36·8.4.27·8.5.12다. 각 지원 브랜치에서는 그 이전 릴리스가 대상이다. 발견자 원문은 가장 오래된 확인 경로를 PHP 5.4.0까지 추적하지만, 이 글의 직접 코드 비교는 8.5.11과 8.5.12에 한정한다. 지원 종료 버전은 배포판의 backport 여부를 확인하거나 지원 버전으로 옮겨야 한다. 근거는 자료 1·10이다.
업데이트가 완전한 해결책이다. session.upload_progress.enabled=0은 확인된 세션 경로를 줄일 뿐 다른 상태 경로까지 막지 않는다. 요청 파싱 전에 적용되는 서버 설정에서 바꾸고 모든 워커를 재시작해야 한다. 앱 안의 ini_set()에 맡기기에는 이미 늦다.
memory_limit=-1도 그대로 복사할 만한 보편적인 해결책은 아니다. PHP의 예측 가능한 메모리 한도 실패를 없애는 부분 완화지만, 하위 할당 실패와 자원 고갈 문제는 남는다. 단순 업로드 크기 제한이나 조용한 로그 역시 안전의 증명이 되지 않는다. 운영 변경은 공식 권고와 실제 풀·프런트엔드 설정을 함께 확인해야 한다. 근거는 자료 2·3·10이다.
이번에 확인한 범위와 남은 확인
직접 수행한 일은 공식 릴리스 태그의 커밋 조회, 두 버전의 session.c·main.c·zend_alloc.c·fpm_main.c 비교, 초기화·종료 분기 확인이다. 원본 파일의 해시와 확인 절차는 프로젝트 artifacts/php2shell에 기록했다. 이 정적 확인으로 세션 상태를 남기고 소비하는 흐름과 수정 지점은 설명할 수 있다.
HTTP로 RCE를 재현했다는 결과는 없다. 특정 서비스의 침해 여부나 버전 문자열만으로 판별한 안전 여부도 주장하지 않는다. 공식 보고서의 RCE 결과를 이번 작업의 실험 결과로 옮겨 적지 않았다.
동적 검증을 이어간다면 격리된 동일 빌드에서 워커가 실제로 재사용되는지, 실패 지점에서 어떤 전역이 남는지, 수정본에서는 언제 초기화되는지부터 관찰해야 한다. 지금 남은 질문은 그 실행 시점의 상태 변화다.
참고 자료
- [1] Calif Research · php2shell 발견자 원문
- [2] PHP 공식 권고 · CVE-2026-109618 / OOM corruption
- [3] PHP 공식 권고 · CVE-2026-109619 / stale global state
- [4] php-src 8.5.11 · main/main.c · 시작 및 종료 순서
- [5] php-src 8.5.11 · ext/session/session.c · 세션 초기화와 multipart 콜백
- [6] php-src 8.5.11 · FPM 시작 실패 분기
- [7] php-src 8.5.12 · ext/session/session.c · 조기 초기화와 디코딩 변경
- [8] php-src 8.5.11 · Zend/zend_alloc.c · 기존 OOM 처리
- [9] php-src 8.5.12 · Zend/zend_alloc.c · delayed OOM과 hard limit
- [10] PHP 공식 보안 공지 · 2026-10-21 · 수정 버전과 완화 한계
자료 확인 · 2026.10.23