떠들썩했던 PHP2Shell

PHP2Shell은 10월 21일 공개된 PHP 보안 취약점 두 건을 RCE 취약점(CVE-2026-109618 및 CVE-2026-109619) 입니다.
메모리 부족으로 초기화가 중단된 상태를 정리 과정에서 사용하는 문제와 이전 요청에서 해제한 메모리를 이후 요청에서 다시 사용하는 문제에요. PHP2Shell로 최근 세상이 시끌시끌했는데요. 개발자 커뮤니티에서뿐만 아니라 미국이나 일본 정부 등에서 국가 단위의 공식 보도나 SNS로도 즉각 대응이 필요하고 불가능하다면 관련 시스템들을 중단까지 해야한다며 난리가 났었습니다.
이 글에서는 주니어 개발자가 학습을 위한 용도로 이번 PHP2Shell 사례에서 볼 수 있는 PHP의 요청 처리 구조와 메모리 관리와 실패 처리까지 내려가보면서 원인을 분석해보기 위한 글입니다.
PHP2Shell의 영향과 발생 조건
PHP2Shell은 2026년 10월 21일 공개된 PHP 원격 코드 실행 취약점 두 건을 가리킵니다. 원격 코드 실행(RCE)은 외부에서 보낸 요청으로 서버에서 공격자가 원하는 코드를 실행할 수 있다는 뜻이에요.
하나는 메모리 부족으로 중단된 작업의 미완성 데이터를 사용하는 문제이고, 다른 하나는 이전 요청에서 해제한 메모리를 다음 요청에서 다시 사용하는 문제입니다.
공식 공지는 PHP-FPM, Apache의 mod_php 등을 포함해 웹 요청을 처리하는 PHP 환경 전반을 영향 범위로 안내합니다. 다만 환경마다 공격이 가능한 경로와 조건은 다릅니다.
저는 PHP로 서비스를 운영해본 적이 없어, 애플리케이션 코드가 실행되기 전에도 공격이 가능하다는 설명이 먼저 눈에 들어왔습니다. 서버는 이미 실행 중일 텐데, 여기서 말하는 앱 실행 전은 어느 시점일까요?
PHP의 request lifecycle
/profile로 name=Kim이라는 폼 데이터가 들어왔다고 해보겠습니다. 이 요청을 처리할 파일이 profile.php라면, PHP는 그 파일을 실행하기 전에 입력을 해석하고 $_POST['name']으로 Kim을 읽을 수 있도록 배열과 문자열을 준비합니다.

그림의 앱 실행 전이 이 구간입니다. 서버와 PHP 실행 환경은 이미 동작하지만 우리가 작성한 로그인 검사나 입력 검증 코드는 아직 실행되지 않았어요. 이때 실행되는 것은 PHP 자체를 구현한 코드입니다. PHP 소스를 실행하는 Zend Engine과 입력 처리 코드도 프로그램이므로, 요청을 읽고 내부 데이터를 만드는 동안 메모리를 사용합니다.
요청을 처리한 뒤에는 요청에 사용한 자원을 정리합니다. 하지만 PHP-FPM처럼 처리 프로세스인 워커를 재사용하는 구성에서는 프로세스가 종료되지 않고 다음 요청을 받아요. 그래서 같은 워커 안에 요청이 끝나면 없어져야 하는 데이터와 다음 요청에도 남는 상태가 함께 있습니다. 이 두 가지를 기억하고 실제 문제가 생긴 지점으로 들어가보겠습니다.
메모리 부족으로 중단된 초기화 (CVE-2026-109618)
첫 번째 취약점은 CVE-2026-109618입니다. 잘 아시다시피 OOM(Out Of Memory)은 필요한 메모리를 더 확보하지 못한 상태를 말해요. 이 OOM은 서버의 RAM이 전부 소진돼야만 발생하는 것은 아닙니다. PHP가 설정된 memory_limit에 도달해도 OOM을 처리합니다. 수정 전 소스에서는 이 오류 처리 중 zend_bailout()을 호출하고, 그 안에서 LONGJMP를 실행하는 경로를 볼 수 있습니다.
// PHP 8.4.26
// Zend/zend_alloc.c
zend_bailout();
// Zend/zend.c: _zend_bailout() 내부
LONGJMP(*EG(bailout), FAILURE);
longjmp는 미리 저장해둔 실행 지점으로 돌아가는 C의 기능입니다. 함수가 하나씩 return하는 대신 중간 실행 과정을 건너뛰므로, 그 뒤에 예정되어 있던 초기화 코드도 실행되지 않을 수 있어요.

중단 이후에 남은 데이터
공식 보고서에 따르면, 일부 경로에서는 아직 초기화가 끝나지 않은 데이터에 다른 코드가 접근할 수 있었습니다. 그 사이 할당이 실패해 실행을 건너뛰면, 요청 정리 코드가 이 미완성 데이터를 읽거나 조건 판단에 사용하게 됩니다.
문제는 OOM이 발생했다는 사실만으로 끝나지 않습니다. 중단된 시점에는 아직 사용할 수 없던 데이터를, 정리 코드가 사용 가능한 데이터로 다뤘다는 데 있어요. 보고서는 multipart 입력을 처리하면서 내부 자료구조의 메모리 사용량이 커지는 경로를 설명합니다. 애플리케이션 실행 중 할당 실패도 원인이 될 수 있고, 실패한 할당이 미완성 데이터 자체를 위한 할당일 필요도 없습니다.
mod_php는 요청 시작에 실패해도 정리 절차를 호출합니다. 이 경로에서 인증 없이 앱 코드 실행 전에 웹 워커 계정 권한으로 코드를 실행하는 공격이 확인됐습니다.
PHP-FPM은 요청 시작에 실패하면 해당 워커를 종료해 이 경로를 막습니다. 다만 시작 이후의 OOM 경로는 별개이며, 보고서는 첫 번째 취약점의 완전한 FPM 공격까지 입증한 것은 아니라고 밝힙니다.
해제한 메모리를 다음 요청에서 사용 (CVE-2026-109619)
요청 A에서 남긴 참조
두 번째 취약점인 CVE-2026-109619는 실제로 어떤 데이터가 남는지 확인할 수 있습니다. PHP 8.5의 fatal error backtrace, 치명적인 오류가 발생했을 때 남기는 함수 호출 기록이 그 사례입니다. 호출 기록 기능이 켜져 있으면 PHP는 오류가 발생했을 때 EG(last_fatal_error_backtrace)에 기록을 저장합니다. 이름이 길지만 여기서는 엔진이 보관하는 마지막 치명적 오류의 호출 기록이라고 읽으면 됩니다. 이 기록을 담은 메모리는 요청에 속해 있습니다. 요청 A가 끝나면 그 메모리를 해제해야 하고, 엔진에 남은 참조도 더 이상 사용되지 않도록 초기화해야 합니다.

8.5.11에서 이 값을 지우는 코드는 if (!fast_shutdown) 블록 안에 있습니다. 빠른 종료 경로에서는 이 블록을 거치지 않아 호출 기록을 가리키는 값이 남을 수 있었어요. 이후 요청 메모리를 해제해도 남은 참조가 자동으로 지워지지는 않습니다.
요청 B에서 다시 읽는 코드
같은 워커가 요청 B를 받았을 때, 정상적인 초기화보다 먼저 설정 관련 경고를 처리하는 경우가 있습니다. 이때 오류 처리 코드가 이전 호출 기록을 다시 사용합니다.
// PHP 8.5.11, main/main.c: 1389L
if (!Z_ISUNDEF(EG(last_fatal_error_backtrace))) {
backtrace = zend_trace_to_string(
Z_ARRVAL(EG(last_fatal_error_backtrace)),
/* include_main */ true);
}
이 조건문은 기록이 없음 상태인지 확인합니다. 남아 있는 값이 가리키는 메모리가 이미 해제됐는지까지 검사하는 코드는 아니에요. 그 결과, 이미 해제한 메모리를 다시 사용하는 Use-after-free가 발생합니다. 다음 요청에서 초기화하는 코드가 있더라도 그보다 먼저 읽는 코드가 있다면 늦습니다. 메모리를 해제했다고 해서 모든 참조가 자동으로 지워지거나, 그 주소를 읽는 즉시 프로세스가 종료되는 것은 아닙니다. 해당 공간이 다른 데이터에 재사용될 수도 있어요. 공격자가 메모리 배치를 조절해 이 접근을 악용하면 코드 실행으로 이어질 수 있습니다.
호출 기록은 확인된 사례 중 하나입니다. 보고서는 세션 처리 상태 등 다른 경로도 다루며, 두 번째 취약점은 OOM 없이 다른 치명적 오류나 종료 중 경고로도 발생할 수 있다고 설명합니다.
PHP의 정리와 프로세스 종료
두 문제의 수정 방향도 원인과 연결됩니다. 첫 번째 패치는 OOM을 감지하자마자 초기화 중간을 빠져나가는 대신, 여유가 있으면 엔진의 안전 지점까지 중단을 미룹니다. 그 여유까지 소진해 하드 한도에 도달하면 _exit()로 프로세스를 즉시 종료합니다. 이 경로에서는 PHP의 정상적인 요청 정리 절차를 실행하지 않습니다. 두 번째 패치에서는 다음 요청 전에 남은 상태를 지우는 처리를 보강했습니다. 앞에서 본 호출 기록도 종료 과정에서 ZVAL_UNDEF로 없음 상태를 설정하는 코드를 확인할 수 있습니다. 여기서 Linux가 등장하는 이유도 분명해집니다. PHP 안에서 요청 하나를 정리하는 것과, OS가 프로세스 자체를 종료하는 것은 다른 동작이기 때문입니다. Linux의 cgroup은 프로세스들이 쓸 수 있는 메모리를 제한합니다. 한도에 도달한 뒤 회수로 해결하지 못하면 OOM killer가 프로세스를 종료할 수 있지만 이것이 PHP의 내부 참조 오류를 고쳐주는 것은 아닙니다.
다른 언어에서의 메모리 관리
다른 언어였다면 괜찮았을까요? 이번 결함은 PHP 구현에서 발견됐지만 OOM 처리와 데이터 수명 관리는 다른 런타임에서도 필요합니다. Node.js의 V8 OOM 처리 경로는 오류를 기록한 뒤 프로세스를 중단합니다. 이 경로에 들어간 프로세스가 다음 요청을 받지는 않지만 서비스가 중단되는 문제는 남아요. JVM은 힙 메모리가 부족하면 OutOfMemoryError를 발생시킵니다. Java 객체의 메모리는 GC가 관리하므로, 일반적인 Java 코드에서 객체를 직접 해제하고 남은 포인터로 다시 읽는 방식과는 차이가 있습니다. Rust는 소유권과 빌림 규칙으로 해제된 값을 사용하는 코드를 컴파일 단계에서 막습니다. 하지만 unsafe나 외부 C 코드가 잘못 구현됐다면 그 안전성도 깨질 수 있습니다. PHP 개발자도 보통 메모리를 직접 해제하지 않습니다. 이번에 살펴본 곳은 애플리케이션을 실행하고 메모리를 관리하는 PHP 내부의 C 코드였어요.
실패한 뒤에도 실행되는 코드
처음에는 메모리가 부족하면 요청이 실패하고 끝날 거라고 생각했습니다. 그런데 요청을 끝내기 위한 코드도 데이터를 읽고, 남은 상태는 다음 요청의 코드에서 다시 사용될 수 있었습니다. 제가 이번 사례에서 가져가고 싶은 질문은 두 가지입니다. 작업이 중간에 실패해도 정리 코드가 다룰 수 있는 상태인가? 그리고 데이터를 해제한 뒤에도 그것을 가리키는 참조가 남는가?입니다. 앞으로 초기화나 정리 코드를 볼 때도 정상적으로 끝나는 순서만 따라가지는 않으려고 합니다. 중간 단계에서 실패했을 때 무엇이 남고, 누가 그것을 다음에 읽는지까지 확인해봐야겠어요.
마치며
PHP 사건이 터지고 거의 10일 뒤에 글을 쓰는 것 같은데요... AI가 발전함에 따라 더 많은 보안 결함들이 발견되고 있습니다.
Node, JDK를 비롯해서 세계가 시끌시끌해지고 있습니다. 기존에 다들 모르고 지나갔던 것들이 정말 많았겠구나 싶네요. 적어도 한 조직의 개발자로서 제가 만들어나가고 있는 제품은 더 관심을 가지고 보안적인 부분들도 처리하며 공부하는게 필수 요소로 자리잡지 않을까? 라는 생각입니다.
References.
https://www.php.net/archive/2026.php#2026-10-21-5
https://www.php.net/manual/en/reserved.variables.post.php
https://docs.kernel.org/admin-guide/cgroup-v2.html#memory-interface-files
https://github.com/nodejs/node/blob/v24.0.0/src/node_errors.cc#L596
https://docs.oracle.com/javase/specs/jvms/se25/html/jvms-2.html#jvms-2.5.3
https://doc.rust-lang.org/book/ch04-01-what-is-ownership.html
https://doc.rust-lang.org/nomicon/working-with-unsafe.html
https://www.php.net/archive/2026.php#2026-10-21-5





































