PHP2Shell — “실전에선 안 된다”던 그거, 우리 서버에선 됐습니다

지엔 연구팀의 PHP2Shell 실증 분석과 웹 서버 운영자를 위한 점검·대응 가이드

PHP 요청 사이에 남은 메모리 상태가 명령 실행 경로로 이어지는 모습을 표현한 일러스트
PHP 요청 사이에 남은 메모리 상태와 명령 실행 경로를 표현한 개념 이미지
실증 하이라이트
실증 하이라이트

01아무도 못 푼 문제

지난달 PHP에 터진 php2shell은 발표 직후부터 “심각한 건 맞는데 실제로 무기화하긴 어렵다”는 쪽으로 분위기가 정리됐습니다. CrowdStrike는 완전히 무기화된 익스플로잇을 관측하지 못했다며 당장의 대규모 공격 가능성을 낮게 봤고, 실제로 한동안 쓸 만한 공개 익스플로잇도 돌아다니지 않았습니다.

그 와중에 이번 HITCON CTF에 이 취약점이 그대로 문제로 나왔습니다. 구성이 아주 고약했어요.

  • 인스턴스 3개 — Nginx + PHP-FPM 둘, Apache 하나
  • 전부 그 위에 WordPress를 올려둠
  • PHP 버전은 7.x와 8.x를 랜덤으로 섞음

어떤 빌드가 걸릴지 모르게 해둔 겁니다. 출제자(@brokenpacifist)가 문제 의도를 이렇게 적었습니다.

“’exploit하기 어렵다’와 ’실제로 exploit이 안 된다’는 다르다. 현실에서 진짜 그런지 이 문제로 보자.”

결과는 — 대회 기간 내내, 아무도 풀지 못했습니다.

우리도 딱 그게 궁금했습니다. 정말 실전에선 안 되는 건가. 그래서 우리가 소유한 서버에 환경을 세우고, 메모리 유출부터 명령 실행까지 끝까지 직접 해봤습니다.(아 물론 해당 익스는 히트콘 대회 전 10월 22일 풀체인 익스 성공 하였습니다.)

02결론부터: 됐습니다

계정도, 로그인도, 사용자가 뭔가를 클릭하는 것도 없었습니다. 공인 IP로 HTTPS 요청 몇 번을 보낸 것만으로 우리 서버에서 www-data 권한의 명령이 실행됐습니다.

위 화면이 그 순간입니다. 왼쪽 터미널에서 유출 → 주소 역산 → 발사가 차례로 돌아가고, 다 끝나면 오른쪽 브라우저가 HTTPS로 결과 파일을 열어 uid=33(www-data)를 그대로 보여줍니다. 말로 “됩니다”가 아니라 화면으로 보여드리는 쪽을 택했습니다.

먼저 분명히 해둘 게 있습니다. 이 글에는 작동하는 익스플로잇 코드도, 페이로드 구조도, 메모리 오프셋 수치도 없습니다. 이건 “이게 진짜 되는 위협”이라는 걸 보이려는 글이지, 따라 하라고 쓰는 글이 아닙니다. 모든 시험은 우리가 소유하고 통제하는 서버에서만 했습니다.

03php2shell은 결함 하나가 아니다

php2shell이라고 한 덩어리로 부르지만, 실제로는 PHP 코어의 서로 다른 결함 두 개입니다. 둘 다 PHP 프로젝트가 원격 코드 실행 가능성을 공지했습니다.

CVE-2026-109618 — 요청을 처리하다 메모리 한도에 걸려 비정상적으로 빠져나갈 때, 아직 완성되지 않은 요청 데이터가 그대로 종료 처리 단계로 넘어갈 수 있습니다. 쉽게 말해 “만들다 만 상태”를 PHP가 멀쩡한 데이터처럼 정리하려 듭니다.

CVE-2026-109619 — 한 요청이 쓰던 상태가 워커(프로세스) 전역에 남아 있다가, 같은 워커가 처리하는 다음 요청이 정상 초기화보다 먼저 그걸 꺼내 쓸 수 있습니다.

따로 보면 각각 “좀 이상한 상태”입니다. 그런데 붙여 놓으면 얘기가 달라집니다. 앞 요청이 메모리에 쓰레기를 남기고, 뒤 요청이 그 쓰레기를 정상 데이터로 착각해 사용하는 흐름이 만들어지거든요. 공격자 입장에서는 남길 쓰레기의 모양과 그걸 꺼내 쓰는 타이밍을 둘 다 HTTP 요청만으로 조절할 수 있다는 뜻입니다. 로그인도, 파일 업로드 권한도 필요 없습니다.

세션 초기화가 빠진 지점
세션 초기화가 빠진 지점

취약한 빌드에서 세션 업로드 진행 콜백이 불리는 지점. 이 이른 단계에 세션 전역을 초기화하는 코드가 없습니다. 수정 버전은 정확히 여기에 초기화를 추가했습니다 — 패치 diff를 보면 이 결함의 성격이 분명해집니다.

04진짜 위험한 건 “요청이 끝날 때”다

많은 분들이 웹 취약점 하면 요청을 처리하는 동안을 떠올립니다. 그런데 php2shell에서 결정적인 순간은 요청이 끝나는 때입니다.

PHP는 요청 하나가 끝나면 그동안 쓴 자료구조를 정리합니다. 배열을 정리할 때는 “이 배열을 어떻게 치울지” 알려주는 함수 포인터를 구조체에서 읽어 그대로 호출합니다. 평소엔 당연히 PHP 내부의 정리 함수가 들어 있고요.

간접 호출 지점
간접 호출 지점

CALL qword ptr [RBX + 0x30] — 구조체에서 읽은 함수 포인터를 그대로 호출하는 기계어 한 줄. 디컴파일러로 보면 (**(code **)(param_1 + 0x30))(...) 입니다.

앞의 두 결함으로 이 자리의 내용을 흔들 수 있다면 어떻게 될까요. 정리 함수 대신 다른 함수가 불립니다. 그리고 PHP에는 하필 명령 실행용 내장 함수들이 있습니다.

명령 실행 내장 함수
명령 실행 내장 함수

PHP의 명령 실행 계열 내장 함수. 진입하자마자 필요한 인자 하나를 스스로 세팅하고 실행 루틴으로 넘어갑니다. 공격자 입장에선 “준비가 반쯤 끝나 있는” 표적인 셈입니다.

정리하면 이렇습니다. 요청을 처리하다 일부러 메모리를 터뜨려 상태를 망가뜨리고, 다음 요청이 그 망가진 상태를 받아 쓰게 만든 뒤, 요청이 끝나는 순간 정리 함수 자리에 올려둔 것이 대신 실행되게 하는 것. 전부 평범한 HTTP 요청으로 이뤄집니다.

05디버거로 본 그 순간

개념 설명만으로는 “정말 그렇게 되냐”가 안 풀립니다. 그래서 디버거를 붙이고 직접 봤습니다.

요청 종료 중 발생한 SIGSEGV
요청 종료 중 발생한 SIGSEGV

요청 종료 정리 경로에서 발생한 SIGSEGV. 호출 스택이 zend_array_destroy → php_request_shutdown → php_handler → apache2 순서로 또렷합니다. 즉 요청이 끝나면서 배열을 치우다가 조작된 포인터를 건드린 겁니다. 그리고 레지스터에 들어 있는 값은 우리가 HTTP로 계산해 보낸 값과 맞아떨어집니다 — 공격자가 보낸 데이터가 실제 CPU 레지스터까지 그대로 전달된다는 뜻입니다.

정상 동작 대조군
정상 동작 대조군

대조군. 평범한 PHP 코드가 명령 실행 함수를 정상적으로 호출했을 때의 모습입니다. 공격 경로에서 관측된 것과 같은 함수, 같은 준비 과정이 보입니다 — 취약점을 통해 도달하려던 지점이 어디인지 확인하는 기준선입니다.

여기서 솔직하게 짚고 넘어갈 게 있습니다. 위 크래시 화면은 “성공한” 실행이 아니라 실패한 시도를 잡은 것입니다. 디버거를 붙이면 프로세스 타이밍이 달라져 성공률이 뚝 떨어집니다. 실제로 명령 실행에 성공한 실행들에는 디버거가 붙어 있지 않았고, 그래서 “성공한 바로 그 요청의 레지스터”는 우리에게 없습니다.

그래도 이 화면이 말해주는 건 적지 않습니다. 우리가 HTTP로 계산해 보낸 값이 요청 종료 경로의 CPU 레지스터까지 실제로 도달한다는 것, 그리고 그 지점이 정적 분석으로 미리 지목했던 바로 그 자리라는 것. 성공 실행은 별도로 결과 파일을 독립 조회해 해시까지 대조하는 방식으로 확인했습니다.

06같은 8.3.35가, 같은 8.3.35가 아니다

이번 작업에서 실무적으로 가장 중요한 발견은 따로 있습니다.

우리는 같은 버전 표기를 가진 PHP 모듈 세 개를 각각 분석했습니다. 하나는 클라우드 서버에 실제로 올라가 있던 배포판 패키지, 둘은 우리가 소스에서 직접 빌드한 것(취약본 / 수정본)입니다.

같은 PHP 버전의 빌드별 모듈 비교
모듈SHA-256 앞자리출처내부 함수 배치
클라우드 운영 모듈ea069762…배포판 패키지기준값
직접 빌드 (취약)4545188f…소스 컴파일다름
직접 빌드 (수정 적용)b791924f…소스 컴파일또 다름

세 모듈 모두 HTTP 응답 헤더로는 똑같이 PHP/8.3.35를 내놓습니다. 그런데 내부 함수들이 놓인 위치는 전부 달랐습니다. (구체 수치는 공개하지 않습니다.)

여기서 두 가지가 따라 나옵니다.

하나. HITCON이 왜 PHP 버전을 랜덤으로 섞었는지 이해가 됩니다. 버전 문자열만 보고는 그 안의 바이너리가 어떻게 생겼는지 알 수 없으니까요. 공격자가 특정 빌드에 맞춰 준비를 끝내 놨어도, 대상이 다른 빌드면 그대로는 안 통합니다. 그만큼 아무 서버나 무차별로 긁는 대량 공격은 간단하지 않다는 뜻이기도 합니다.

둘, 그리고 이쪽이 훨씬 중요합니다. 방어하는 입장에서 버전 문자열은 믿을 근거가 못 됩니다. 배포판은 버전 번호를 그대로 두고 보안 패치만 백포트하는 경우가 흔합니다. 반대로 번호는 올라갔는데 실제로 로드되는 모듈은 옛것인 경우도 있고요. 점검은 반드시 지금 웹 서비스가 실제로 로드하고 있는 모듈 기준이어야 합니다.

07흔한 오해 네 가지

점검 방법으로 넘어가기 전에, 고객사에서 제일 자주 듣는 반응부터 정리하고 가겠습니다.

“저희는 HTTPS 씁니다.”
관계없습니다. 우리 실증도 전부 HTTPS로 했습니다. TLS는 네트워크 구간을 암호화할 뿐이고, 이 결함은 암호가 풀린 뒤 PHP 엔진이 요청을 처리하는 단계에서 터집니다. HTTPS는 공격을 막기는커녕, 중간 장비가 트래픽을 못 들여다보게 만들어 탐지를 더 어렵게 합니다.

“WAF가 앞에 있습니다.”
도움은 되지만 방패는 아닙니다. 이 공격의 입력은 평범한 멀티파트 POST 요청이라 정상 업로드와 모양이 크게 다르지 않습니다. 시그니처 기반 차단은 요청을 조금만 바꿔도 비켜 갑니다.

“WordPress(또는 쓰는 프레임워크)를 최신으로 올렸습니다.”
무관합니다. 결함은 애플리케이션이 아니라 PHP 런타임 자체에 있습니다. 앱을 아무리 올려도 그 아래 PHP가 취약하면 그대로입니다.

“php -v 찍어보니 최신인데요.”
그건 CLI 바이너리입니다. 웹 서비스가 실제로 로드하는 모듈은 다른 파일인 경우가 흔합니다. 반드시 웹 쪽 기준으로 확인해야 합니다.

08내 서버는 괜찮은가

영향받는 버전

영향받는 PHP 버전과 수정 버전
브랜치영향받는 버전수정 버전
8.58.5.0 – 8.5.118.5.12
8.48.4.26 이하8.4.27
8.38.3.35 이하8.3.36
8.28.2.34 이하8.2.35
8.1 이하전 버전공식 수정본 없음 (지원 종료)

PHP 8.1 이하는 이미 지원이 끝나 공식 패치가 나오지 않습니다. 상위 브랜치로 올리거나, 쓰는 배포판이 따로 백포트를 제공하는지 확인해야 합니다.

⚠️ 버전 번호만으로 판단하지 마세요. 배포판은 버전 표기를 그대로 둔 채 보안 수정만 백포트하는 일이 잦습니다. 앞에서 본 것처럼 같은 8.3.35 표기를 달고도 내부가 서로 다른 바이너리가 실제로 존재합니다. 판단 기준은 지금 웹이 로드하고 있는 모듈입니다.

구성별 위험도

PHP 서비스 구성별 위험도
구성위험도비고
Apache + mod_php (prefork)🔴 가장 높음워커가 요청 사이에 상태를 들고 가는 구조. 우리 실증 환경
Apache + mod_php (worker/event)🟠 높음prefork보다 까다롭지만 차단되는 건 아님
PHP-FPM (Nginx/Apache)🟡 영향 있음HITCON 챌린지도 FPM 인스턴스를 포함했습니다. 난도는 높지만 안전지대가 아닙니다
FrankenPHP / LSAPI 등❓ 미검증본 분석 범위 밖. PHP 공식 권고를 따르세요
CLI / 배치 전용⚪ 해당 없음웹 요청 경로가 아님

확인 절차

# 1) 웹서버가 mod_php를 쓰는가
apache2ctl -M 2>/dev/null | grep -i php
# Nginx라면 FPM 쪽
systemctl status 'php*-fpm' --no-pager 2>/dev/null | head

# 2) 웹이 실제로 로드하는 모듈 파일과 그 버전
#    (expose_php=Off 면 X-Powered-By 헤더가 안 나옵니다 — 아래처럼 파일 기준으로)
ls -l /usr/lib/apache2/modules/libphp*.so 2>/dev/null
dpkg -l | grep -E 'libapache2-mod-php|php[0-9.]+-fpm'

# 3) 배포판 백포트 여부 (데비안·우분투 계열)
apt changelog php8.3 2>/dev/null | grep -i "CVE-2026-1096"

# 3-1) RHEL·Rocky·Alma 계열
rpm -q --changelog php | grep -i "CVE-2026-1096" | head

임시로 info.php 같은 페이지를 만들어 확인했다면 확인 직후 반드시 삭제하세요. 그 자체가 정보 노출 경로입니다.

09조치

1순위 — 패치하고 재시작

위 표의 수정 버전 이상으로 올립니다.

sudo apt-get update
sudo apt-get install --only-upgrade php8.3 libapache2-mod-php8.3

# 재시작은 선택이 아닙니다
sudo systemctl restart apache2        # mod_php
sudo systemctl restart php8.3-fpm     # FPM 구성이라면 이쪽도

재시작을 빠뜨리면 패치한 의미가 없습니다. 이 취약점은 워커가 요청 사이에 들고 가는 상태를 노립니다. 기존 워커가 그대로 떠 있으면 옛 코드가 계속 돌아갑니다. 컨테이너라면 이미지를 다시 빌드해 교체해야 합니다.

2순위 — 당장 패치가 어렵다면

공격 난도를 올려 시간을 버는 설정입니다. 해결이 아니라 완화입니다. PHP 프로젝트도 설정만으로는 위험을 완전히 없앨 수 없다고 명시했습니다.

; 요청 간 상태가 남는 경로를 줄임
session.upload_progress.enabled=0
session.upload_progress.cleanup=1

; 메모리 고갈을 의도적으로 일으키기 어렵게
memory_limit= 256M

; 한 요청으로 밀어 넣을 수 있는 입력량 축소
post_max_size= 8M
max_input_vars=100
max_file_uploads=20

적용 위치를 헷갈리지 마세요. mod_php는 /etc/php/8.3/apache2/php.ini, FPM은 /etc/php/8.3/fpm/php.ini 또는 풀 설정의 php_admin_value[...]입니다. CLI용 php.ini를 고쳐 놓고 조치했다고 생각하는 사고가 정말 많습니다.

그리고 한계를 분명히 알아두셔야 합니다. memory_limit을 올리면 메모리를 터뜨리기가 번거로워질 뿐, 더 큰 요청을 보내면 그만입니다. 반대로 한도를 무작정 키우면 이번엔 자원 고갈로 인한 서비스 장애 위험이 생깁니다. session.upload_progress를 끄는 건 요청 간 상태가 남는 경로 하나를 줄이는 것이지 결함 자체를 없애지 않습니다. 완화는 패치할 때까지 버티는 용도입니다.

📌 후속 공지도 함께 확인하세요. PHP 프로젝트는 10월 21일 공지 이후 특정 직렬화 핸들러 조건과 관련한 별도 사안(CVE-2026-115560)을 추가로 안내했습니다. 이 글의 재현 범위 밖이라 여기서는 다루지 않지만, 패치 계획을 세울 때는 PHP 공식 공지를 한 번 더 확인하시는 편이 안전합니다.

10침해 흔적부터 확인하세요

패치는 앞으로를 막는 것이고, 이미 당했는지는 따로 봐야 합니다. 이 공격은 성격상 흔적이 남습니다.

# 메모리 한도 초과 — 공격의 전제 조건
grep -c "Allowed memory size" /var/log/apache2/error.log

# 자식 프로세스 비정상 종료 — 실패한 시도의 잔해
grep -ciE "segmentation fault|exit signal" /var/log/apache2/error.log

# 비정상적으로 큰 POST 응답 — 메모리 유출 흔적
awk '$6=="POST" && $10 > 1000000' /var/log/apache2/access.log | wc -l

# 웹서버 계정으로 최근 생성된 파일 — 웹셸 의심
find /var/www -uid 33 -mtime -7 -type f 2>/dev/null | grep -vE '\.sess|cache'

정상 운영 중인 서버에서 메모리 한도 초과와 자식 프로세스 크래시가 같은 시간대에 반복된다면 그냥 넘기지 마세요. 의심되면 서비스를 내리고 로그와 웹루트를 통째로 보존한 다음 분석하는 게 순서입니다.

11다음 편 예고

그리고 그다음. 우리는 이 취약점이 특정 환경(SAPI)과 특정 빌드에 얼마나 묶여 있는지, 그 의존도를 어디까지 떨어뜨릴 수 있는지를 계속 연구했고 최대한 의존도 없이 되는것을 성공 했습니다. 한개만 말씀드리면, 유출과 주소 산출 단계도 서로 다른 빌드에서도 같은 방식으로 성립했습니다.

HITCON이 던진 질문 — “버전을 모르는 상태에서도 되느냐” — 도 할수 있습니다.

다만 그 내용은 지금 공개하지 않습니다. 패치하지 않은 서버가 아직 널려 있는 상황에서 공격 쪽 디테일을 푸는 건 연구가 아니라 피해 유발입니다. 이번 글에 재현 정보를 하나도 넣지 않은 이유도 같습니다. 상황이 정리되고 공개해도 되겠다는 판단이 서면, 그때 기술적으로 제대로 쓰겠습니다.

기대하셔도 좋습니다.

12정리

  • php2shell은 특정 플러그인이나 테마의 문제가 아니라 PHP 엔진 자체의 결함입니다. WordPress든 Laravel이든 직접 만든 사이트든, 코드를 아무리 잘 짰든 취약 버전 PHP 위에서 돌아가면 대상입니다.
  • 업계가 “무기화하기 어렵다”고 본 것도, HITCON에서 아무도 못 푼 것도 사실입니다. 그런데 어렵다는 게 안 된다는 뜻은 아니었습니다. 우리 서버에서는 됐습니다.
  • 할 일은 하나입니다. 패치하고, 재시작하고, 흔적을 확인하세요.
홍지원지엔 - 연구팀
PHP2ShellCVE-2026-109618CVE-2026-109619PHP
Newsletter

다음 긴급 CVE는 메일로 먼저

수정 버전과 영향 자산 판단 기준을 한 통으로 보냅니다.