AI

[보안] PHP2Shell 새벽 대응기: 공식 이미지 없이 PHP 8.3.36 워드프레스 빌드 (with claude)

섭코딩 2026. 10. 22. 08:54

육아를 마치고 자려고 누워서 잠시 나무위키를 켰는데 실시간 인기 검색어에 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일 사이 바뀐 .php 4개: 저장소 파일과 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와 같이 따져 본 결론은 "아니다"였다.

런타임을 바꿔도 공개 서버에서 코드가 돌고 패치를 남의 이미지에 기다리는 구조는 그대로라서다.

바꾼다면 서버 없는 정적 사이트 쪽이 맞다. 이건 따로 정리할 생각이다.

 

참고

반응형