[보안] PHP2Shell 새벽 대응기: 공식 이미지 없이 PHP 8.3.36 워드프레스 빌드 (with claude)
육아를 마치고 자려고 누워서 잠시 나무위키를 켰는데 실시간 인기 검색어에 PHP가 있었다.
무슨 일인가 하고 눌러 보니 PHP 원격 코드 실행 취약점이었다.


최근에 만든 https://simplelife.ai.kr 이 PHP 기반 워드프레스다. 그래서 새벽에 Claude Code와 같이 바로 조치했다.
요약
- 운영 서버는 Docker 공식
wordpress:php8.3-apache, PHP 8.3.35였다. 취약 버전이고, 구성이 가장 위험하다는 Apache mod_php였다 - PHP 8.3.36은 10/21 23:00(KST)에 나왔지만 Docker Hub 공식 이미지는 아직 8.3.35였다
- 패치 전까지는 Cloudflare 규칙과 PHP 설정으로 공격 경로를 줄였다
- 공식 빌드 파일 그대로 PHP 8.3.36을 직접 빌드해서 워드프레스 이미지를 만들고 운영에 갈아 끼웠다
- 대응 시작부터 운영 반영까지 1시간 31분 걸렸다. 그중 52분은 맥에서 amd64 이미지가 빌드되기를 기다린 시간이고, 손으로 한 작업은 40분 정도다. 예전 같으면 공식 이미지가 나올 때까지 기다렸을 것이다
1. 취약점과 내 서버 상황
| 항목 | 내용 |
|---|---|
| CVE | CVE-2026-109618, CVE-2026-109619 (CVSS v4 9.2) |
| 수정 버전 | 8.5.12, 8.4.27, 8.3.36, 8.2.35 (2026-10-21 14:00 UTC) |
| 109618 | multipart 파싱 중 메모리 고갈(OOM) 뒤 정리 과정의 메모리 오염 |
| 109619 | 요청이 끝난 뒤 남은 전역 상태를 같은 워커의 다음 요청이 사용 |
| 위험한 구성 | Apache mod_php. 로그인 없이, 워드프레스 코드가 돌기 전에 터진다 |
서버에서 확인한 값이다.
$ docker compose exec wordpress php -v
PHP 8.3.35 (cli) (built: Oct 6 2026 01:28:49) (NTS)
$ docker pull wordpress:php8.3-apache && docker run --rm wordpress:php8.3-apache php -v
PHP 8.3.35 (cli) (built: Oct 21 2026 00:33:53) (NTS) # 공식 이미지도 아직 8.3.35
PHP 개발자들은 설정으로는 못 막고 업데이트만이 답이라고 했다.
그래서 패치 전까지는 위험을 줄이고, 패치는 최대한 빨리 하는 두 갈래로 갔다.
판단은 Claude가 정리하고 Gemini·Codex로 교차 검증했다. 세 모델 모두 "패치가 유일한 해결"이라는 데 동의했다. 다만 Gemini는 "php-fpm으로 바꾸면 무력화된다"고 했는데, 공식 권고상 FPM도 영향 범위라서 틀린 답이었다. 교차 검증을 하는 이유다.
2. 패치 전 임시 조치
1) 침해 흔적 점검
조치 전에 이미 뚫렸는지부터 봤다.
- 최근 3일 사이 바뀐
.php4개: 저장소 파일과 SHA256 해시가 같았다 (내가 배포한 테마·mu-plugin) wp-content/uploads안.php: 없음/tmp: 비어 있음- 최근 48시간 로그의 segfault·메모리 초과: 0건
- POST 요청:
wp-cron.php와 REST API뿐
2) Cloudflare 기본 보호 확인
원본 서버는 Cloudflare Tunnel로만 공개되어 있어 모든 웹 요청이 Cloudflare를 거친다.
Cloudflare는 PHP2Shell 관리형 규칙을 자동으로 배포했다.


Cloudflare도 배너에 "위험은 줄이지만 PHP 취약점을 고치지는 않는다"고 적어 두었다.
3) multipart 요청 차단 (CVE-2026-109618)
109618의 주 경로는 multipart/form-data 파싱이다. 공식 권고도 "앱이 안 쓰면 앞단에서 막으라"고 한다.
내 사이트에서 multipart를 쓰는 건 사진 업로드뿐이라, 집 IP가 아닌 곳의 multipart 요청을 막는 사용자 규칙을 넣었다.
(any(lower(http.request.headers["content-type"][*])[*] contains "multipart")
and not ip.src eq <집 IP>)
→ Block
업로드 경로만 막으면 되지 않나 싶지만, PHP는 인증 확인 전에 요청 본문부터 해석한다. 그래서 경로가 아니라 IP로 걸었다.
집에서 multipart POST → 200
다른 IP(Lightsail)에서 multipart → 403
다른 IP에서 일반 GET → 200
4) 세션 업로드 진행률 끄기 (CVE-2026-109619 일부)

워드프레스는 이 기능을 쓰지 않는다. 109619의 한 경로만 막고 109618에는 효과가 없어서, 어디까지나 보조 조치다.
session.upload_progress.enabled => Off => Off
3. PHP 8.3.36 워드프레스 이미지 직접 빌드
공식 wordpress 이미지는 결국 php:8.3-apache 위에 확장 모듈과 워드프레스를 얹은 것이다. Docker 공식 빌드 저장소(docker-library)의 Dockerfile은 이미 8.3.36으로 바뀌어 있었다. Docker Hub에 이미지가 아직 안 올라왔을 뿐이다.
wordpress-patched/
├── php/ # docker-library/php 8.3/trixie/apache 그대로
└── wordpress/ # docker-library/wordpress latest/php8.3/apache, FROM 한 줄만 수정
# wordpress/Dockerfile
FROM simplelife/php:8.3.36-apache # 원래: FROM php:8.3-apache
php Dockerfile은 php.net 원본을 SHA256과 GPG 서명으로 검증한 뒤 컴파일한다. 직접 빌드라도 출처 검증은 공식 이미지와 같다.
1) 로컬(맥, arm64) 빌드와 테스트
docker build -t simplelife/php:8.3.36-apache php # 272초
docker build -t simplelife/wordpress:php8.3.36-apache wordpress # 97초
PHP 8.3.36 (cli) (built: Oct 21 2026 16:31:20) (NTS)
로컬 스택을 이 이미지로 띄워서 확인했다.
- PHP 모듈 목록: 운영과 같음 (gd, imagick, intl, mysqli, zip, opcache 등)
/,/wp-login.php,/sitemap_index.xml,/wp-json/: 200/wp-admin/: 302,/xmlrpc.php: 403 (기존 차단 유지)- 비로그인 미디어 업로드: 401 (multipart 정상 파싱)
- 에러 로그 fatal·segfault: 0
2) 운영(amd64) 빌드와 교체
운영 서버는 1GB·2코어라 거기서 PHP를 컴파일하면 사이트가 느려진다. 그래서 맥에서 buildx --platform linux/amd64로 빌드했다. Rosetta 없는 QEMU 에뮬레이션이라 php 단계만 31분 걸렸다. 네이티브로는 4.5분이던 작업이다.
wordpress 단계에서 한 번 더 막혔다. imagick을 받는 pecl install이 pecl.php.net 504로 실패했다. 보안 릴리스 직후라 PHP 쪽 서버가 몰린 것 같다. 같은 3.8.1을 공식 GitHub 저장소 태그에서 받아 빌드하도록 그 줄만 바꿨다.
# pecl install imagick-3.8.1 대신
mkdir -p /usr/src/imagick; \
curl -fsSL https://github.com/Imagick/imagick/archive/refs/tags/3.8.1.tar.gz | tar -xz -C /usr/src/imagick --strip-components=1; \
docker-php-ext-install -j "$(nproc)" /usr/src/imagick; \
다시 빌드하는 데 10분 걸렸다. 이미지는 790MB였고, 서버로 보내는 데 31초 걸렸다.
docker save simplelife/wordpress:php8.3.36-apache-amd64 | gzip | ssh simplelife 'gunzip | docker load'
compose는 이미지를 .env에서 고를 수 있게 한 줄 바꿨다. 공식 이미지가 나오면 .env에서 한 줄만 지우면 원래대로 돌아간다.
image: ${WP_IMAGE:-wordpress:php8.3-apache}
$ echo "WP_IMAGE=simplelife/wordpress:php8.3.36-apache-amd64" >> .env
$ docker compose up -d --no-deps wordpress
$ docker compose exec wordpress php -v
PHP 8.3.36 (cli) (built: Oct 21 2026 16:59:28) (NTS)
교체하는 동안 사이트가 끊긴 시간은 컨테이너 재시작 몇 초였다. 교체 뒤 확인한 결과다.
- PHP 모듈 목록: 교체 전과 같음
/,/sitemap_index.xml,/wp-json/,/robots.txt: 200/wp-login.php,/wp-admin/: Cloudflare Access 로그인으로 302 (기존 설정 그대로), 원본에서 직접 열면 200/xmlrpc.php: 403- 비로그인 미디어 업로드: 401
- 에러 로그 fatal·segfault: 0
- 메모리: 909MB 중 사용 502MB (교체 전과 비슷)
4. 걸린 시간
| 시각(KST) | 한 일 |
|---|---|
| 10/21 23:00 | PHP 8.3.36 공개 |
| 10/22 00:57 | 대응 시작: 서버 버전 확인, Gemini·Codex 교차 검증 |
| 01:01 | 침해 흔적 점검 끝 - 이상 없음 |
| 01:05 | session.upload_progress 끄기 반영 |
| ~01:15 | Cloudflare 기본 보호 확인 |
| ~01:25 | multipart 차단 규칙 반영 |
| 01:26~01:34 | 로컬 빌드(6분)·테스트 |
| 01:35~02:15 | 맥에서 amd64 php 빌드 (31분, 에뮬레이션) |
| ~01:50 | 충전기 꽂고 잠. 운영 반영 승인만 해 두고 나머지는 Claude에게 맡김 |
| 02:15~02:27 | pecl 504로 wordpress 단계 실패, GitHub 소스로 바꿔 다시 빌드 (10분) |
| 02:28 | 운영 이미지 교체, PHP 8.3.36 확인 |
| 02:30 | 초안 정리·push 뒤 맥 잠자기 |
PHP 8.3.36이 나오고 3시간 28분, 대응을 시작하고 1시간 31분 뒤에 운영에 반영했다. 빌드를 기다린 시간을 빼면 손으로 한 작업은 40분 정도였다. 공식 wordpress:php8.3-apache는 이 시각까지도 8.3.35였다.
5. 남은 일
- 공식
wordpress:php8.3-apache가 8.3.36 이상이 되면.env의WP_IMAGE줄을 지우고 공식 이미지로 되돌린다 - 그때 Cloudflare 임시 규칙(multipart 차단)도 지운다. 남겨 두면 집 밖에서 사진을 못 올린다
session.upload_progress.enabled = 0은 그대로 둔다
이번에 Node.js로 갈아탈까도 잠깐 고민했다. Gemini·Codex와 같이 따져 본 결론은 "아니다"였다.
런타임을 바꿔도 공개 서버에서 코드가 돌고 패치를 남의 이미지에 기다리는 구조는 그대로라서다.
바꾼다면 서버 없는 정적 사이트 쪽이 맞다. 이건 따로 정리할 생각이다.