
PHP2Shell에 대한 심층 기술 해설: 메모리 부족 크래시와 use-after-free에서 비롯된 두 개의 PHP 코어 CVE를 힙 그루밍, ASLR 유출, 원격 코드 실행으로 연쇄시키는 과정을 다룹니다.

저는 2026년 10월 26일에 이 연구를 쓰기 시작했으며, 언제 완성할 수 있을지는 아직 확실하지 않습니다.
글을 쓰는 시점에도 PHP2Shell에 대해 이용 가능한 정보는 여전히 매우 적으며, 특히 취약점과 익스플로잇 과정에 대한 상세한 기술 분석에 관해서는 더욱 그렇습니다.
솔직히 말하면, 내부에서 실제로 무슨 일이 일어나는지 보여주지 않고 겉만 살짝 훑는 설명을 찾는 데 지쳐가고 있었습니다. 저는 뭔가 다른 것을 하고 싶었습니다. 전체 과정을 탐구하고, 모든 중요한 세부 사항을 검토하며, 단지 '무엇'이 일어나는지만이 아니라 '왜' 그리고 '어떻게' 일어나는지를 설명하는, 제대로 된 심층 기술 해설 말입니다.
이 연구는 PHP 소스 코드에 대한 저 자신의 이해, 제 실험, 그리고 이 취약점들을 이해하려고 노력하면서 수행한 조사들을 바탕으로 합니다. 저는 이 모든 조각들을 모아 제 발견을 공유하고, 이를 통해 기저에 깔린 개념들 중 일부를 다른 사람들이 더 쉽게 이해할 수 있게 되기를 바랐습니다.
제 주요 목표 중 하나는 기술적 깊이를 희생하지 않으면서도 이 내용을 접근 가능하게 만드는 것이었습니다. 기본적인 C 지식과 일반적인 프로그래밍 경험이 있는 사람이라면, 저수준 메모리 익스플로잇에 대한 사전 경험이 전혀 없더라도 따라올 수 있기를 바랍니다.
또한 이 연구가 현재 제대로 설명되지 않았거나 오해받고 있는 부분들 중 일부를 명확히 하는 데 도움이 되기를 바랍니다. 다만 분명히 밝히고 싶습니다. 이것은 취약점들에 대한 저 자신의 기술적 이해이며, 공식적인 분석이나 절대 틀리지 않는 참고 자료가 아닙니다. 저는 여전히 배우고, 조사하고, 스스로 점들을 연결해 나가는 중입니다. 과정 중에 실수나 부정확한 점, 혹은 제가 잘못 이해한 부분이 있을 수 있습니다.
잘못된 점을 발견하셨거나, 다른 해석을 가지고 계시거나, 더 조사할 가치가 있다고 생각되는 부분이 있다면 주저하지 말고 저에게 연락해 주십시오. 이 연구를 더 정확하고 모두에게 유용하게 만드는 데 도움이 될 수정, 논의, 기여를 진심으로 감사히 여기겠습니다.
결국, 이 글을 공유하는 이유는 함께 배우고 더 많은 사람들이 내부에서 실제로 무슨 일이 일어나는지 이해하도록 돕는 것입니다.
기본 개념은 이미 잘 알고 계신가요?? 관심 있는 부분으로 바로 이동하세요:
힙 익스플로잇이 처음이거나 전체 경험을 원한다면, 그냥 계속 스크롤하세요. 저는 이 글을 처음부터 끝까지 읽도록 작성했습니다.
본격적으로 들어가기 전에 모두가 명심해야 할 몇 가지가 있습니다. 이는 연구를 진행하면서 특히 유용해질 것입니다. 앞서 언급했듯이, 저는 이 워크스루가 모든 사람이 접근할 수 있기를 원하므로, 더 기초적인 개념조차 설명하는 시간을 가질 것입니다. 이미 익숙하다면 건너뛰셔도 됩니다!
일반적인 프로그램에서 메모리는 스택과 힙을 포함한 여러 영역으로 구성됩니다.
예를 들어, C++에서는:
int main() {
int a = 30; // Typically stored on the stack
int* b = new int(30); // b is typically on the stack,
// but the integer it points to is on the heap
}
여기서 a는 값 30을 담고 있는 지역 변수입니다. 포인터 b 역시 지역 변수이지만, 힙에 할당된 정수의 주소를 담고 있습니다.
PHP는 주로 C로 작성되었기 때문에, 우리는 메모리 할당이 어떻게 동작하는지 이해해야 합니다.
운영체제는 프로세스에 고유한 가상 주소 공간을 제공합니다. 그런 다음 프로세스의 메모리 할당자가 사용 가능한 메모리 내에서 할당을 관리합니다.
예를 들어, C++에서는 new, malloc() 및 기타 메모리 할당 방법이 있습니다.
할당자는 매번 운영체제에 새 메모리를 요청하는 대신 해제된 메모리를 재사용하려고 시도하는 경우가 많습니다.
또한 객체의 수명이 끝나더라도 그 메모리에 저장되어 있던 데이터가 반드시 즉시 지워지는 것은 아닙니다. 해당 메모리는 단순히 재사용 가능한 것으로 표시될 수 있습니다.
이를 더 잘 이해하기 위해, 다음 코드를 잠깐 살펴보겠습니다:
#include <cstdlib>
#include <iostream>
int main() {
void* a = malloc(64);
free(a);
void* b = malloc(64);
std::cout << a << "\n" << b << std::endl;
free(b);
return 0;
}
여기서 a와 b를 출력하면 동일한 숫자 메모리 주소를 포함하고 있음을 발견할 수 있습니다.
왜일까요? free(a)를 호출한 후 malloc()이 이전에 a에 할당되었던 메모리를 재사용하여 또 다른 메모리 블록을 얻을 필요가 없기 때문입니다.
이것이 항상 발생한다고 보장되지는 않지만, 흔한 동작입니다.
이것이 문제의 핵심으로 이어집니다. 우리는 메모리가 재활용된다는 것을 알고 있지만, 그 메모리 주소를 담고 있던 변수들은 어떻게 될까요?
free(a)를 호출하면 메모리 할당 시스템은 단순히 내부 기록을 갱신하여 해당 메모리 청크를 재사용 가능으로 표시합니다. 그러나 그 메모리 내부의 데이터는 깨끗이 지워지지 않으며, 포인터 변수 자체도 마법처럼 NULL로 초기화되지 않습니다. 프로그래머가 명시적으로 지우지 않는 한, 포인터는 이전과 동일한 메모리 주소를 계속 담고 있습니다.
이렇게 남겨진 변수를 우리는 오래된 포인터(stale pointer) 또는 댕글링 포인터(dangling pointer)라고 부릅니다.
오래된 포인터를 보유하는 것은 여러 면에서 위험할 수 있습니다. 가장 심각한 두 가지는 더 이상 원래 객체에 속하지 않는 데이터를 읽는 것과, 이전 포인터를 통해 쓰기를 수행하여 새로운 객체를 손상시키는 것입니다.
사용자 계정을 위해 메모리를 할당한다고 상상해 봅시다:
struct User {
char username[24];
int is_admin;
};
int main() {
// 1. Allocate memory for a user
User* a = (User*)malloc(sizeof(User));
// Initialize the original user
strcpy(a->username, "alice");
a->is_admin = 0;
// We keep a copy of the pointer to demonstrate the bug
User* a_stale = a;
// 2. Free the memory. The object is gone, but a_stale still holds the address!
free(a);
이제 바로 이어서, 프로그램이 다른 곳에서 또 다른 할당을 수행하고 할당자가 동일한 주소를 재사용한다고 가정해 보겠습니다:
User* b = (User*)malloc(sizeof(User));
strcpy(b->username, "jack");
b->is_admin = 1;
따라서 b는 이제 정확히 a가 있던 자리에 위치합니다. 하지만 a를 해제했고 a_stale을 갱신한 것이 아무것도 없기 때문에 a_stale은 여전히 그곳을 가리킵니다.
즉, 여전히 a_stale을 사용하는 코드는 Alice의 데이터를 보고 있다고 생각하지만, 실제로는 Jack의 데이터에 접근하고 있을 수 있습니다:
if (a_stale->is_admin) {
// This may run, even though Alice was never an admin
printf("Welcome admin %s\n", a_stale->username);
}
이를 통해 우리는 Jack의 사용자 이름처럼 보아서는 안 되는 데이터에 접근할 수 있게 되며, 프로그램은 잘못된 권한 부여 결정을 내릴 수 있습니다.
기술적으로 해제된 메모리에 접근하는 것은 C/C++에서 "정의되지 않은 동작(undefined behavior)"이므로, 컴파일러와 시스템에 따라 정확한 결과가 보장되지 않습니다. 하지만 이는 근본적인 문제를 보여줍니다.
그러나 실제로는 그렇게 단순하지 않습니다. 아마 이렇게 생각하고 계실 겁니다: "그래, 이건 좋네. 그런데..."
이것이 작동하려면 우선 댕글링 포인터(stale pointer)가 필요합니다. 실제 공격에서는 우리가 피해자의 프로그램을 작성하는 것이 아니므로, a_stale에서 했던 것처럼 의도적으로 변수에 대한 포인터를 저장할 수는 없습니다. 버그는 이미 코드 안에 존재해야 합니다...
공격자는 해제된 메모리 슬롯에 무엇이 들어갈지만 영향을 미칠 수 있습니다.
지금은 이 내용을 깊이 이해하는 것만 확실히 하세요. 왜냐하면 그 질문에 대한 답이 우리가 논의하는 PHP CVE의 가장 중요한 부분이 될 것이기 때문입니다.
이제 반대의 경우를 상상해 보세요: 프로그램이 메모리가 해제된 후에도 이전 포인터를 사용하여 메모리에 쓰기를 계속하는 경우입니다.
#include <cstdlib>
#include <cstdio>
int main() {
int* a = (int*)malloc(sizeof(int));
*a = 42;