Research & Technique
■ 서론
2026년 10월, PHP에서 인증 없이 원격으로 서버에 임의의 코드를 실행할 수 있는 두 취약점(CVE-2026-109618, CVE-2026-109619)이 공개되었으며, 통칭 PHP2Shell로 불린다.
PHP는 웹 서버에서 동작하는 프로그램을 만드는 데 가장 널리 쓰이는 언어로, 서버 사이드 언어가 확인된 웹사이트의 약 69%가 PHP를 사용한다. WordPress·Laravel·Drupal 등 널리 쓰이는 웹 서비스와 개발 도구가 PHP 위에서 동작한다.
그림 1. 서버 사이드 언어별 점유율 (W3Techs)
공격자는 인증 없이 조작된 multipart 업로드 요청 1만으로 웹 서버 권한의 코드를 실행할 수 있어, 데이터 유출이나 서버 장악으로 이어질 수 있다. PHP 8.2부터 8.5까지 지원 버전 전반이 영향을 받으므로, 사용 중인 버전을 확인하고 패치 버전으로 업그레이드해야 한다. 두 취약점은 같은 원인에서 출발하지만 각각 독립적으로 원격 코드 실행이 가능하며, 이 보고서는 실제로 재현한 CVE-2026-109619를 중심으로 분석하고 CVE-2026-109618은 차이점을 위주로 함께 다룬다.
■ 영향받는 소프트웨어 버전
PHP2Shell(CVE-2026-109618, CVE-2026-109619)에 취약한 소프트웨어는 다음과 같다.
| S/W 구분 | 취약 버전 |
|---|---|
| PHP 8.5 | 8.5.0 ≤ version ≤ 8.5.11 |
| PHP 8.4 | 8.4.0 ≤ version ≤ 8.4.26 |
| PHP 8.3 | 8.3.0 ≤ version ≤ 8.3.35 |
| PHP 8.2 | 8.2.0 ≤ version ≤ 8.2.34 |
| PHP 8.1 이하 | 모든 버전 |
■ 공격 시나리오
그림 2. 공격 시나리오
① 공격자가 조작된 multipart 업로드 요청으로 메모리 부족(OOM)을 유발한다. 인증은 필요하지 않다. ② OOM이 longjmp2 로 요청 처리를 정리 없이 중단해 손상 상태가 워커에 남고, 재사용된 워커의 다음 요청이 이를 사용하면서 use-after-free3 가 발생한다. ③ 공격자는 이 잔존 상태를 임의 코드 실행으로 발전시켜, 웹 서버 권한으로 리버스 셸을 획득한다. |
|---|
■ 테스트 환경 구성 정보
피해자 서버는 취약한 PHP 8.4.26을 Docker 컨테이너로 구동한다. 공격자는 피해자 서버에 네트워크로 접근 가능한 호스트라면 어디서든 공격할 수 있으며, 해당 테스트에서는 같은 네트워크의 Kali Linux 컨테이너를 사용한다.
| 이름 | 정보 |
|---|---|
| 피해자 | PHP 8.4.26 (Apache mod_php) (172.17.0.2:80) |
| 공격자 | Kali Linux (172.17.0.3) |
■ 취약점 테스트
Step 1. 취약 환경 구성
먼저 피해자 환경을 구성한다. 다음 명령어로 PHP2Shell에 취약한 PHP 8.4.26 이미지를 빌드하고, 해당 이미지로 Docker 컨테이너를 실행한다.
> git clone https://github.com/EQSTLab/CVE-2026-109619.git > cd CVE-2026-109619 > docker build --platform linux/amd64 -t cve-2026-109619 . > docker run --rm -d --name cve-2026-109619 -p 80:80 cve-2026-109619 |
|---|
Step 2. 공격 수행 및 결과 확인(CVE-2026-109619)
공격자는 Kali에서 172.17.0.2에 접근해 공격 대상이 Apache mod_php 기반의 PHP 8.4.26이며 세션 업로드 진행률 기능이 켜져 있는지 확인한다.
그림 3. 대상 PHP 서비스 확인
확인이 되면 다음 명령어로 CVE-2026-109619를 이용하는 공격 스크립트를 실행한다. 공격 스크립트는 서버에 메모리 오류를 일으켜 중단된 중단된 요청이 남긴 잘못된 메모리 참조가 다음 요청에서 재사용되도록 한다. 이때 응답으로 새어 나온 내부 주소를 이용해 서버가 공격자의 명령을 실행하도록 해 리버스 셸로 접속한다. 이때 스크립트는 대상 PHP 버전을 식별해 해당 빌드에 맞는 오프셋 정보를 사용한다.
> git clone https://github.com/EQSTLab/CVE-2026-109619.git > cd CVE-2026-109619 > python3 p2s_shell.py -u http://[target-ip]/index.php |
|---|
그 결과 공격자는 아무런 인증 없이 웹 서버 권한으로 리버스 셸을 획득하고, 서버에서 임의 명령을 실행할 수 있다.
그림 4. 인증 없이 획득한 웹 서버 권한의 원격 셸
■ 취약점 상세 분석
Step 1. 취약점 발생 배경
두 취약점을 이해하려면 손상 상태가 생기고 남는 조건(①·②)과, 남은 상태에 공격자의 요청이 닿는 경로(③)를 먼저 알아야 한다.
① 워커 재사용 구조
PHP는 요청마다 전역 상태를 초기화하고, 애플리케이션 코드를 실행한 뒤, 종료 단계에서 그 상태를 정리하고 메모리를 반환한다.
Apache mod_php나 PHP-FPM 같은 실행 환경은 요청을 처리하는 프로세스(이하 워커)를 상주시키고 여러 요청에 재사용한다. 따라서 한 요청이 남긴 전역 상태가 정리되지 않으면, 같은 워커가 처리하는 다음 요청에 그대로 넘어간다.
그림 5. PHP 워커의 요청 처리와 재사용
② 치명적 오류 시 longjmp 중단
PHP는 메모리 부족 같은 치명적 오류가 나면 longjmp로 현재 실행 지점을 벗어나 요청 처리를 중단한다. 이는 오류가 난 요청의 남은 처리를 건너뛰고 빠르게 끝내기 위한 설계이다. 이 중단은 호출 스택을 건너뛰므로, 정상 종료 경로에 있던 상태 정리 코드가 실행되지 않을 수 있다.
메모리 할당 실패는 요청이 완전히 초기화되기 전에도 일어날 수 있다. 이때 초기화가 끝나지 않은 요청 메모리가 그대로 남아, 이후 종료 절차나 다른 처리 과정에서 참조될 수 있다.
그림 6. 정상 종료 경로와 비정상 중단 경로 비교
③ 세션 업로드 진행률 기능
PHP는 session.upload_progress 기능으로 multipart 업로드의 진행 상황을 세션에 기록한다. 업로드가 오래 걸릴 때 진행률을 보여 주기 위한 기능이며, 기본적으로 켜져 있다.
이 처리는 요청 본문을 파싱하는 시점, 즉 애플리케이션 코드가 시작되기 전에 동작하며, 세션을 읽어 역직렬화4 하고 갱신한 뒤 다시 기록한다. 이때 갱신되는 세션 데이터와 세션 식별자는 모두 공격자가 요청으로 직접 넣을 수 있다.
따라서 이 기능은 공격자가 보낸 데이터로, 워커에 남은 잔존 상태를 정상 초기화보다 먼저 건드릴 수 있는 통로가 된다. 이 통로는 CVE-2026-109619에 해당하며, 같은 요청의 종료 절차가 손상 상태를 읽는 CVE-2026-109618은 이 기능 없이도 성립한다.
Step 2. 공격 흐름 분석
그림 7은 공격 요청이 코드 실행에 이르는 흐름이다. 공격은 하나의 손상 상태에서 출발해 두 갈래로 나뉘었다가, 같은 방식으로 코드 실행에 이른다. 두 갈래는 각각 독립된 공격 경로이며, 테스트에서 재현한 것은 CVE-2026-109619 경로이다.
그림 7. PHP2Shell 공격 흐름
① OOM 유발과 손상 상태 발생
공격자는 요청 크기 제한 검사는 통과하면서 메모리 한계는 넘기는 multipart 업로드 요청을 보내, 요청 처리 도중 OOM을 일으킨다. 콘텐츠 길이를 선언하지 않거나, 한 파트에 매우 긴 헤더를 넣거나, 깊게 중첩된 빈 파일 필드를 대량으로 보내는 방식이 쓰인다. 이때 longjmp 중단으로 정리되지 않은 손상 상태가 워커에 남는다.
② 손상 상태를 사용하는 두 경로
CVE-2026-109619는 중단된 요청이 남긴 잔존 상태를 같은 워커의 다음 요청이 사용하는 경로이다. 앞 요청이 전역 위치에 걸어 둔 메모리는 해제된 뒤에도 그 자리를 계속 가리키며, 다음 요청의 세션 업로드 진행률 처리가 이를 읽거나 해제하면서 이미 해제된 메모리를 다시 사용하는 use-after-free가 발생한다.
한편 CVE-2026-109618은 OOM이 발생한 바로 그 요청의 종료 절차가 손상 상태를 읽는 경로이다. Apache mod_php는 요청 시작이 실패해도 종료 절차를 실행하므로, 다음 요청 없이 단일 요청만으로 성립한다.
③ 코드 실행
두 경로 모두 해제되었거나 초기화되지 않은 메모리를 유효한 값으로 다시 사용한다는 점은 같다. PHP 할당자는 해제된 조각을 같은 크기의 다음 할당에 돌려주므로, 공격자는 같은 크기의 요청 데이터로 그 자리를 원하는 값으로 채운다. 이 값이 허상 포인터5 를 통해 PHP 내부 구조체로 해석되면, 인접 메모리의 주소를 유출해 주소 공간 배치 무작위화(ASLR)6 를 무력화하고, 위조한 포인터로 임의 주소 읽기·쓰기를 얻어 코드를 실행한다. 조각 크기와 구조체 형태는 공개된 PHP 소스에서 미리 파악할 수 있어, 공격자가 메모리 주소를 미리 알 필요는 없다.
이 과정은 다른 메모리 할당과 경쟁하므로 확률적이며 반복 시도가 필요하다. CVE-2026-109619는 워커가 여럿인 환경에서 두 요청이 같은 워커에 도달할 때까지 시도 횟수가 늘어나지만 조건 자체는 사라지지 않으며, 일반적인 다중 워커 구성에서도 원격 코드 실행이 확인되었다.
Step 3. 취약 코드 분석
① 메모리 관리자의 OOM 처리(공통 원인)
메모리 관리자는 할당이 한계를 초과하면 zend_mm_safe_error를 호출한다. 이 함수는 오류를 보고한 뒤 강조된 zend_bailout()으로 요청을 중단한다. 이 중단 함수는 longjmp 기반이라 호출 스택을 건너뛴다.
이 조기 이탈이 세션·역직렬화 같은 전역 상태를 정리 없이 남기는 공통 원인이다. 패치 이전 메모리 관리자의 오류 처리 함수는 복귀하지 않는 함수(ZEND_NORETURN)로 선언되어, OOM이 언제나 요청 중단으로 이어졌다.
그림 8. 패치 이전 OOM 처리
② 업로드 진행률 처리의 세션 역직렬화(CVE-2026-109619)
php_session_rfc1867_update는 업로드 진행 상황을 세션에 반영한다. php_session_initialize()가 세션을 읽어 역직렬화하는데, 이 함수는 요청 본문 파싱 단계에서, 즉 애플리케이션 코드가 시작되기 전에 실행된다. 중단된 앞 요청이 남긴 세션·역직렬화 상태가 바로 이 지점에서 사용될 수 있다.
그림 9. 업로드 진행률 처리의 세션 역직렬화
③ 전역 역직렬화 상태 재사용(CVE-2026-109619)
php_var_unserialize_init은 역직렬화 진행 상태를 프로세스 전역 BG(unserialize)에 저장한다. 강조된 분기는 기존 전역 데이터를 그대로 재사용하고 참조 수준만 올린다. 이는 역직렬화 도중 다시 역직렬화가 호출될 때 같은 상태를 공유하기 위한 정상 동작이다. 요청이 중단되어 이 전역이 정리되지 않으면, 전역은 이미 해제된 요청 메모리를 계속 가리킨다. 다음 요청이 같은 전역을 사용하면 use-after-free로 이어진다.
그림 10. 전역 역직렬화 상태 저장
④ 요청 시작 실패 후의 종료 절차 실행(CVE-2026-109618)
Apache mod_php의 요청 처리 함수는 요청 시작 단계에서 OOM이 발생하면 zend_bailout()으로 보호 블록을 빠져나온다. 그런데 이 블록이 끝난 직후 요청 종료 절차를 호출하는데, 이 호출은 하위 요청인지만 확인하고 요청 시작의 성공 여부는 확인하지 않는다. 따라서 초기화가 끝나지 않은 요청에서도 종료 절차가 그대로 실행된다. 이 절차가 손상 상태를 읽으면서 CVE-2026-109618이 성립한다.
그림 11. 요청 시작 실패 후에도 실행되는 mod_php 종료 절차
■ 대응 방안
두 취약점은 패치 버전으로 업그레이드하고 모든 PHP 워커를 재시작해야 완전히 해소된다. 각 지원 브랜치의 패치 버전은 다음과 같다.
| S/W 구분 | 패치 버전 |
|---|---|
| PHP 8.5 | version ≥ 8.5.12 |
| PHP 8.4 | version ≥ 8.4.27 |
| PHP 8.3 | version ≥ 8.3.36 |
| PHP 8.2 | version ≥ 8.2.35 |
| PHP 8.1 이하 | 지원 브랜치로 업그레이드 |
패치는 longjmp 기반 요청 중단(bailout)에 의한 상태 손상을 막는 것을 목표로, 두 축에서 동작을 바꾼다.
① OOM 처리 방식 변경
패치는 OOM 경로에서 longjmp를 제거한다. OOM이 발생해도 요청을 즉시 중단하지 않고, PHP 실행기가 상태를 안전하게 정리할 수 있는 다음 지점에서 오류를 처리한다(소프트 OOM). 메모리 사용이 더 높은 2차 임계를 넘으면 정리 과정 없이 _exit()로 워커를 즉시 종료한다(하드 OOM). 워커가 종료되므로 손상 상태가 다음 요청으로 넘어가지 않는다.
이로써 요청 종료 절차가 유효하지 않은 상태를 읽지 않으며, PHP의 메모리 한계 기능은 그대로 유지된다. 그림 12는 이 중 메모리 할당 경로의 변경으로, 기존에 zend_mm_safe_error로 요청을 중단하던 부분이 한계 검사 실패 시 NULL을 반환하는 복구 가능한 형태로 바뀌었다.
그림 12. OOM 처리에서 bailout 제거
② 업로드 진행률 역직렬화 제한(CVE-2026-109619)
세션 업로드 진행률 처리가 일반 초기화 전에 세션을 역직렬화할 때, 패치는 객체를 __PHP_Incomplete_Class로만 복원하도록 제한한다. 자동 로딩과 __wakeup·__unserialize가 실행되지 않고, 객체는 변경 없이 다시 기록된다.
그림 13은 업로드 진행률 처리 중임을 나타내는 PS(in_rfc1867_update) 플래그가 설정된 경우에만 불완전 클래스 복원을 적용하는 부분이다. 패치에는 이 외에 전역 상태를 조기에 초기화하는 변경도 포함되어, 이 처리 중에 손상 상태에 접근하는 것을 막는다.
그림 13. 업로드 진행률 역직렬화 제한
■ 참고 사이트
• GitLab
◦ https://github.com/php/php-src/security/advisories/GHSA-w8hw-w9fx-j79w
◦ https://github.com/php/php-src/security/advisories/GHSA-6wmf-4rv5-p3rp
◦ https://github.com/php/php-src/commit/01a40e89fbfc499936e3b05888d8f00bfde886df
◦ https://www.openwall.com/lists/oss-security/2026/10/21/6
• 취약점 정보
◦ https://nvd.nist.gov/vuln/detail/CVE-2026-109618
◦ https://nvd.nist.gov/vuln/detail/CVE-2026-109619
multipart 업로드 요청: 파일과 입력값을 여러 부분으로 나눠 한 번에 서버로 보내는 HTTP 요청 형식으로, 웹 페이지의 파일 업로드에 쓰인다↩︎
longjmp: 실행 중인 함수들을 하나씩 정상 종료하지 않고, 미리 지정해 둔 지점으로 실행 흐름을 즉시 되돌리는 C 언어의 실행 흐름 이동 방식↩︎
use-after-free: 이미 해제된 메모리를 프로그램이 계속 참조해 다시 사용하는 취약점↩︎
역직렬화: 문자열 형태로 저장된 데이터를 다시 PHP의 값과 객체로 되돌리는 과정↩︎
허상 포인터(dangling pointer): 가리키던 메모리가 해제된 뒤에도 그 주소를 계속 가리키고 있는 포인터이다↩︎
ASLR(Address Space Layout Randomization): 프로그램이 실행될 때마다 메모리 주소를 무작위로 배치해 공격자가 주소를 예측하지 못하게 하는 보호 기법이다↩︎