PHP2Shell 분석: multipart 임시 버퍼가 해제된 세션 메모리를 재사용하는 과정
@djad04는 PHP 8.4.26 Alpine CLI에서 OOM 뒤 남은 세션 ID 포인터를 multipart 파서의 임시 버퍼가 재사용하는 과정을 추적했습니다. 조작된 길이 필드로 쿠키 생성 과정에서 힙 정보를 읽는 흐름을 설명했으며, 구체적인 할당 배치는 Steal My Bitcoin CTF 익스플로잇과 해당 빌드의 실험에 한정됩니다.
GitHub 계정 @djad04가 PHP2Shell의 메모리 동작을 추적한 기술 분석을 공개했습니다. 작성자는 PHP 소스코드 검토와 실험을 바탕으로, OOM 뒤 남은 세션 ID 포인터가 후속 요청의 multipart 임시 버퍼에 재사용되는 과정을 설명했습니다. 이 분석은 작성자 개인의 이해와 실험에 기반한 것으로, 공식 분석은 아니라고 밝혔습니다.
OOM 뒤 worker에 남는 세션 포인터
PHP worker는 한 번의 HTTP 요청을 처리하고 끝나는 프로세스가 아니라 여러 요청을 이어서 처리할 수 있습니다. 따라서 요청 메모리가 해제된 뒤에도 C 코드의 전역 상태에 이전 요청의 포인터가 남으면, 같은 worker가 처리하는 후속 요청에서 그 주소를 다시 참조할 수 있습니다.
@djad04의 설명에 따르면 조작된 multipart 요청이 세션 처리 중 메모리 부족을 일으키면 Zend Engine의 zend_bailout()이 정상 실행 경로를 건너뜁니다. 그 결과 세션 비직렬화 과정에서 전역 상태를 초기화하는 코드와 요청 종료 때 세션 모듈이 포인터를 정리하는 코드가 실행되지 않을 수 있습니다. 요청 메모리는 회수돼도 BG(unserialize).data와 PS(id)는 이전에 할당된 주소를 가리킨 채 남습니다.
PS(id)는 세션 ID 문자열을 가리키는 포인터입니다. PHP가 응답의 세션 쿠키를 만들 때 이 포인터가 가리키는 내용을 읽으므로, 후속 요청에서 해당 주소의 메모리를 공격자 입력으로 채우면 해제된 메모리를 읽는 경로가 생깁니다. 같은 worker가 다음 요청을 처리하고 allocator가 해당 주소를 재사용해야 하는 과정입니다.
페이지를 채운 뒤 임시 버퍼로 슬롯을 재사용
작성자의 PHP 8.4.26 Alpine CLI 실험에서는 OOM 이후 zend_mm_shutdown()이 힙 청크를 해제해 allocator의 bin과 freelist 상태를 초기화했습니다. 실험에서 후속 요청은 같은 2MiB 청크를 다시 할당받았지만, allocator는 새 위치부터 페이지를 사용하기 시작했습니다. 따라서 작은 데이터를 한 번 할당하는 것만으로는 이전 포인터가 가리키던 주소에 도달하지 못합니다.
이를 위해 익스플로잇은 패딩 필드를 보내 allocator가 페이지를 더 사용하도록 만듭니다. 분석에 나온 51개 필드의 값은 각각 48바이트입니다. PHP가 이를 $_POST에 저장할 때 만드는 zend_string은 헤더와 널 종료 문자를 포함해 73바이트가 되고, allocator에서는 80바이트 크기 등급에 들어갑니다. 키와 HashTable 배열, PHP 내부 할당도 페이지 사용에 더해집니다. 작성자의 실험에서는 이 할당들이 진행되면서 51번째 값이 PS(id)가 가리키던 주소에 놓였습니다.
그다음 익스플로잇은 이미 존재하는 키 1050에 더 큰 값을 넣습니다. 기존 48바이트 값은 해제되고 새 값은 더 큰 크기 등급에 할당되므로, 목표 주소의 80바이트 슬롯이 비게 됩니다. 이때 슬롯을 차지하는 것은 최종 $_POST용 문자열 복사본이 아니라 multipart_buffer_read_body()가 원시 요청 값을 읽으며 만드는 임시 버퍼입니다. 79바이트의 조작값에 널 종료 문자 하나를 더하면 정확히 80바이트가 되어 그 슬롯을 재사용합니다.
PHP는 임시 버퍼를 $_POST 값으로 복사한 뒤 해제합니다. 해제 과정에서 슬롯의 앞뒤 일부에는 allocator 관리 정보가 기록되지만, 가운데에 둔 조작 데이터는 남습니다. 이 데이터는 zend_string의 길이 필드가 있는 위치에 256을, 문자열 시작 위치에 ‘L’을 기록하도록 구성돼 있습니다. 이후 쿠키 생성 코드가 PS(id)를 zend_string으로 읽으면 원래 세션 ID보다 긴 256바이트를 읽어 주변 힙의 포인터와 구조체 일부를 응답에 담습니다. 글은 이 힙 정보 누출과 별도의 코드 포인터 누출을 익스플로잇의 후속 단계로 설명합니다.
실험이 보여주는 범위
작성자는 대상 PHP 빌드를 컨테이너에서 실행하고 GDB 서버와 Ghidra 디버거로 할당·해제 시점의 메모리를 살폈습니다. 단계별 힙 덤프와 메모리 시각화도 저장소에 공개했습니다. 최적화 빌드에서는 할당과 복사 함수가 인라인돼 중단점을 잡기 어려웠고, 디버그 빌드는 계측으로 할당 크기와 bin 배치가 달라져 같은 힙 구성을 재현하지 못했다고 설명합니다. 디버그 빌드는 익스플로잇 배치를 재현하는 대신 소스 코드의 할당 경로를 확인하는 데 사용했습니다.
51개 패딩 필드와 79바이트 값의 배치는 PHP 8.4.26 Alpine CLI에서 관찰한 결과입니다. 글은 이 과정을 Steal My Bitcoin CTF 익스플로잇 코드와 연결해 설명하며, 구체적인 할당 위치와 크기는 해당 빌드의 실험 조건에 묶여 있습니다.
참고 자료
- PHP2Shell — Deep Dive
- GitHub - djad04/php2shell-deep-dive: A deep technical walkthrough of PHP2Shell from memory visualization of a real exploit against PHP 8.4.26's Steal My Bitcoin challenge, to PHP's source code internals and engine, to the exploit code itself. How two CVEs in PHP's core (CVE-2026-109618 & CVE-2026-109619) chain an out-of-memory crash into remote code execution · GitHub