태그 보관물: vulnerability

php2shell on a Containerized WordPress: Why the PHP Patches Never Arrived

php2shell 취약점 공지와 운영 중이던 WordPress의 상태

2026년 10월 21일 PHP 프로젝트가 인증 없이 원격 코드 실행이 가능한 php2shell 취약점 두 건(CVE-2026-109618, CVE-2026-109619)을 고친 긴급 보안 릴리스를 냈습니다. Kubernetes에서 운영하는 WordPress가 영향을 받는지 확인해 보니, 파드 안의 PHP는 8.4.24였고 실행 방식은 Apache mod_php였습니다. 패치 대상인 데다, PHP가 위험도를 가장 높게 매긴 구성이었습니다.

더 불편한 사실은 따로 있었습니다. 이 이미지는 8월 20일에 빌드된 뒤 그대로였고, 그사이 나온 8.4.25와 8.4.26 보안 릴리스도 들어 있지 않았습니다. 베이스 이미지는 상위에서 계속 갱신되고 있었지만 우리 이미지는 빌드한 순간의 PHP에 멈춰 있었습니다. 이 글은 취약 여부 판단, 침해 흔적 점검, 이미지 교체 전 이력 검토, 재빌드와 무중단 배포까지의 기록입니다.

환경

구성 요소 내용
Kubernetes v1.36.3 (kubeadm HA, control plane 3대 + worker 3대)
WordPress 7.1.3, 파드 2개. 웹 루트 전체가 CephFS(RWX) 공유 볼륨
이미지 베이스 공식 wordpress:php8.4-apache (Debian 13)
웹 서버 / SAPI Apache 2.4 + mod_php (mpm_prefork)
PHP 8.4.24 → 8.4.27
이미지 빌드 클러스터 안 Kaniko Job (v1.23.2), 사설 GitLab Container Registry
앞단 CloudFront → HAProxy → Traefik Ingress
워커 노드 allocatable 약 7.8 GiB, swap 없음

취약 여부를 판단한 근거

공지에서 확인한 두 가지 결함

PHP 보안 공지에 따르면 두 결함은 성격이 다릅니다. CVE-2026-109618은 메모리 부족(OOM) 처리 도중 반쯤 초기화된 요청 데이터를 정리 루틴이 유효한 값으로 다루는 문제입니다. CVE-2026-109619는 해제된 요청 상태가 남아 있다가 같은 워커가 처리하는 다음 요청에서 다시 쓰이는 문제입니다. 둘 다 CVSS v4.0 9.2이고, 수정 버전은 8.5.12, 8.4.27, 8.3.36, 8.2.35입니다.

판단에 결정적이었던 부분은 실행 방식별 위험도였습니다. 공지는 PHP-FPM, CGI, LiteSpeed 등 웹 요청을 처리하는 설치를 모두 영향 대상으로 보면서, mod_php를 가장 위험한 경우로 꼽았습니다. mod_php에서는 애플리케이션 코드가 실행되기 전에 요청 하나로 공격이 성립할 수 있어서, WordPress 코어와 플러그인을 최신으로 유지해도 막을 수 없습니다. 공개 나흘 전인 10월 17일부터 공격 시도가 관측됐다는 보고도 있었습니다.

실행 중인 파드에서 확인한 값

이미지 태그나 Dockerfile이 아니라 실제로 떠 있는 파드에서 확인했습니다. 태그 이름은 빌드 시점의 의도일 뿐이고, 공격을 받는 것은 지금 돌고 있는 바이너리이기 때문입니다.

kubectl exec -n wordpress deploy/wordpress -- php -v
kubectl exec -n wordpress deploy/wordpress -- ls /etc/apache2/mods-enabled | grep -E 'php|mpm'
kubectl exec -n wordpress deploy/wordpress -- \
  php -r 'echo ini_get("session.upload_progress.enabled"), PHP_EOL;'
항목 확인 값 판정
PHP 8.4.24 (2026-08-05 빌드) 취약. 8.4.27 필요
SAPI Apache mod_php (mpm_prefork + php.load) 공지상 가장 위험한 구성
session.upload_progress.enabled 1 (기본값) 임시 완화책 미적용
WordPress 코어 7.1.3 (최신 안정판) WordPress 쪽 조치는 불필요

PHP 패치가 두 달 동안 들어오지 않은 이유

공식 WordPress 이미지의 php8.4-apache 태그는 PHP 패치가 나올 때마다 상위에서 다시 빌드됩니다. 그래서 태그만 보면 늘 최신 PHP를 쓰는 것처럼 보입니다. 하지만 그 태그를 베이스로 우리가 빌드한 이미지는 빌드한 순간의 PHP를 담은 채 고정됩니다. 상위 태그가 몇 번 갱신되든, 다시 빌드하지 않으면 운영 파드에는 아무것도 들어오지 않습니다.

레지스트리에서 이미지 설정을 직접 읽어 보니 상황이 분명해졌습니다. 운영 중이던 php8.4-7은 8월 20일 빌드였고 환경 변수는 PHP_VERSION=8.4.24였습니다. 그 뒤 8월 27일 8.4.25, 9월 24일 8.4.26, 10월 21일 8.4.27이 나왔으니 보안 릴리스 세 번을 연달아 놓친 셈입니다. php2shell 취약점 공지가 아니었다면 이 차이는 계속 드러나지 않았을 것입니다.

php2shell 취약점 대응 전 PHP 버전 추이: 8월 20일 빌드한 WordPress 이미지가 PHP 8.4.24에 머무는 동안 8.4.25, 8.4.26, 8.4.27 보안 릴리스가 나온 타임라인
운영 이미지의 PHP와 php.net 최신 8.4 패치의 차이. 음영 구간이 놓친 보안 릴리스이고, 빨간 선은 첫 공격 시도가 보고된 10월 17일입니다.

docker가 없는 환경에서도 이미지를 내려받지 않고 PHP 버전을 확인할 수 있습니다. 레지스트리 API로 매니페스트에서 이미지 설정(config) 블롭의 다이제스트를 찾고, 그 블롭 안의 환경 변수를 읽는 방식입니다. 상위 베이스가 패치를 담았는지와 우리가 푸시한 이미지에 무엇이 들어갔는지를 모두 이 방법으로 확인했습니다. 아래는 Docker Hub의 공식 이미지를 조회하는 예입니다.

REPO=library/wordpress; TAG=7.1.3-php8.4-apache; REG=https://registry-1.docker.io/v2/$REPO
TOKEN=$(curl -s "https://auth.docker.io/token?service=registry.docker.io&scope=repository:$REPO:pull" \
  | python3 -c 'import json,sys; print(json.load(sys.stdin)["token"])')
MANIFEST=$(curl -s -H "Authorization: Bearer $TOKEN" \
  -H "Accept: application/vnd.oci.image.index.v1+json" $REG/manifests/$TAG \
  | python3 -c 'import json,sys; print(next(m["digest"] for m in json.load(sys.stdin)["manifests"] if m["platform"]["architecture"]=="amd64"))')
CONFIG=$(curl -s -H "Authorization: Bearer $TOKEN" \
  -H "Accept: application/vnd.oci.image.manifest.v1+json" $REG/manifests/$MANIFEST \
  | python3 -c 'import json,sys; print(json.load(sys.stdin)["config"]["digest"])')
curl -sL -H "Authorization: Bearer $TOKEN" $REG/blobs/$CONFIG \
  | python3 -c 'import json,sys; c=json.load(sys.stdin); print(c["created"]); print(*[e for e in c["config"]["Env"] if e.startswith("PHP_VERSION")])'

# 2026-10-21T16:46:55.959706349Z
# PHP_VERSION=8.4.27

패치 전에 침해 흔적부터 확인했습니다

공격 시도가 공개 전부터 관측됐으므로, 패치만 하고 끝내면 이미 들어온 것을 놓칠 수 있습니다. 첫 공격 시도가 보고된 10월 17일을 기준점으로 잡고 세 가지를 확인했습니다.

메모리 손상의 부산물이 로그에 남았는지

두 결함 모두 메모리 손상 계열이라, 공격이 실패하더라도 워커가 비정상 종료하거나 메모리 한도 초과 오류가 남을 가능성이 큽니다. 재기동 이전 컨테이너의 로그까지 포함해 10월 17일 이후 Apache와 PHP 로그를 검색했습니다.

for P in $(kubectl get pods -n wordpress -l app=wordpress -o name); do
  kubectl logs -n wordpress $P --previous 2>/dev/null
  kubectl logs -n wordpress $P
done | grep -iE "segmentation|exit signal|Allowed memory size|zend_mm|heap corruption"

결과는 0건이었습니다. 같은 기간의 PHP Fatal 오류는 wp-includes 아래 PHP 파일을 직접 호출하는 스캐너 요청에서만 나왔습니다. WordPress를 거치지 않고 내부 파일을 부르면 당연히 나는 오류이고, 메모리 계열과는 무관했습니다.

웹 루트에 새로 생긴 실행 파일이 있는지

웹 루트 전체가 공유 볼륨이라 한 파드에서 확인하면 두 파드에 같은 결과가 나옵니다. 처음에는 find -newermt로 최근 변경된 PHP 파일을 나열했는데, 플러그인 자동 업데이트로 바뀐 수천 개 파일에 묻혀 판단할 수 없었습니다. 디렉터리와 시간 단위로 묶어 세는 쪽이 훨씬 빨랐습니다.

kubectl exec -n wordpress deploy/wordpress -- sh -c '
  cd /var/www/html
  find . -type f -newermt "2026-10-15" ! -path "./wp-content/cache/*" \
       -printf "%TY-%Tm-%Td %TH:%TM|%h\n" \
  | awk -F"|" "{split(\$2,a,\"/\"); print substr(\$1,1,13), a[2]\"/\"a[3]\"/\"a[4]}" \
  | sort | uniq -c'

   2293 2026-10-16 00 wp-content/plugins/<plugin-a>
      2 2026-10-17 13 wp-content/<waf-data>/
     37 2026-10-19 00 wp-content/plugins/<plugin-b>
    454 2026-10-19 00 wp-content/plugins/<plugin-c>
   2009 2026-10-20 12 wp-content/plugins/<plugin-d>
     16 2026-10-21 16 wp-content/uploads/2026
      1 2026-10-22 00 wp-content/<waf-data>/
      1 2026-10-22 01 wp-content/<waf-data>/
      1 2026-10-22 04 wp-content/<waf-data>/
      2 2026-10-22 07 wp-content/<waf-data>/

플러그인 디렉터리의 묶음은 각 플러그인의 자동 업데이트 시각과 일치했고, WAF 디렉터리는 보안 플러그인이 규칙과 차단 기록을 저장하는 데이터 파일이었습니다. uploads의 16개는 같은 날 직접 올린 블로그 이미지와 그 썸네일로, file 명령으로 모두 PNG임을 확인했습니다. uploads 아래 PHP 확장자 파일은 플러그인이 디렉터리 목록 노출을 막으려고 만든 빈 index.php 두 개뿐이었습니다.

컨테이너 안에 남은 것이 있는지

마지막으로 볼륨 밖의 컨테이너 파일시스템과 /tmp, /dev/shm, 프로세스 목록을 확인했습니다. 10월 15일 이후 바뀐 파일은 컨테이너 기동 때 생기는 파일과 메일 스풀뿐이었고, 프로세스도 Apache와 Postfix 외에는 없었습니다.

이 점검은 로그와 파일 상태로 확인할 수 있는 범위에 한정됩니다. 컨테이너 로그는 재기동 이전 한 세대까지만 남아 있어 10월 17일 이전은 볼 수 없었고, 디스크에 흔적을 남기지 않는 침입까지 배제하지는 못합니다. 그래서 점검 결과와 관계없이 패치를 서둘렀습니다.

이미지를 바꾸기 전에 확인한 이력

이 WordPress는 독립 서버에서 Kubernetes로 옮기면서 몇 군데를 손으로 고쳐 둔 상태입니다. 이미지를 교체할 때 그중 무엇이 사라질 수 있는지 먼저 따져 봤습니다. 기준은 단순합니다. 이미지 안에 있는 것은 재빌드로 바뀌고, 공유 볼륨과 ConfigMap에 있는 것은 그대로 남습니다.

php2shell 취약점 패치를 위한 WordPress 이미지 재빌드 시 교체되는 항목(PHP, mod_php, php.ini, vhost 규칙)과 유지되는 항목(공유 볼륨의 코어와 .htaccess, ConfigMap의 wp-config) 구조도
WordPress 이미지 재빌드로 바뀌는 것(왼쪽)과 바뀌지 않는 것(가운데, 오른쪽).
이력 항목 위치 재빌드 영향
.htaccess의 HTTPS 리다이렉트 조건 수정 (X-Forwarded-Proto 기준) 공유 볼륨 없음
.htaccess의 보안 플러그인 WAF 연결 (<IfModule mod_php.c> 안의 auto_prepend_file) 공유 볼륨 없음. 같은 PHP 8 mod_php라 모듈 이름이 그대로 맞음
헬스체크 경로를 리다이렉트에서 빼는 vhost 규칙 이미지 (Dockerfile의 sed 주입) 있음. 새 베이스의 설정 파일 형식이 바뀌면 조용히 빠질 수 있어 배포 후 확인 필요
프록시 뒤 HTTPS 판별과 접속 IP 보정 ConfigMap (wp-config.php) 없음
php.ini 재정의 (memory_limit 256M 등) 이미지 같은 파일을 다시 넣으므로 유지
빌드 컨텍스트 ConfigMap 클러스터 저장소 파일과 비교해 다섯 파일 모두 일치

위험한 항목은 vhost 규칙 하나였습니다. Dockerfile이 기본 사이트 설정의 DocumentRoot 줄 뒤에 RewriteRule을 끼워 넣는 방식이라, 상위 이미지가 그 줄의 형식을 바꾸면 빌드는 성공하는데 규칙만 빠집니다. 다행히 readiness probe가 바로 이 경로를 보므로, 규칙이 빠지면 새 파드가 Ready가 되지 못하고 롤아웃이 멈춥니다.

확인 과정에서 빌드 Job 매니페스트가 저장소에 없다는 것도 드러났습니다. 이전 작업 때 한 번 만들어 쓰고 남기지 않은 탓에 이번에 다시 작성해야 했습니다. 이번 패치와 함께 저장소에 넣어, 다음 보안 릴리스 때는 태그만 올려 바로 쓸 수 있게 했습니다.

Kaniko로 다시 빌드하고 무중단으로 교체한 과정

1. 베이스 이미지를 버전 태그로 고정했습니다

기존 Dockerfile은 FROM wordpress:php8.4-apache였습니다. 버전이 드러나지 않는 태그라 어떤 베이스로 빌드했는지가 기록에 남지 않으므로, WordPress 버전까지 들어간 태그로 바꿨습니다. 푸시하는 쪽도 기존 관례였던 latest 동시 푸시를 없애고 버전 태그 하나만 남겼습니다.

# docker/wordpress/Dockerfile
FROM wordpress:7.1.3-php8.4-apache

바꾸기 전에 두 태그의 다이제스트를 비교했고, 둘 다 PHP 8.4.27을 담은 같은 amd64 매니페스트를 가리켰습니다. 다만 고정한다고 PHP 패치가 저절로 들어오는 것은 아닙니다. 이 태그 역시 PHP 패치 때마다 상위에서 다시 빌드되므로, 우리가 재빌드해야 반영된다는 점은 같습니다. 태그에 들어간 7.1.3도 런타임에는 영향이 없습니다. WordPress 코어는 공유 볼륨에 있고 WordPress가 스스로 업데이트하기 때문입니다.

2. 임시 완화 설정을 이미지에 함께 넣었습니다

공지는 두 가지 완화책을 제시합니다. CVE-2026-109619에는 session.upload_progress.enabled=0, CVE-2026-109618에는 memory_limit=-1과 낮은 post_max_size입니다. 업로드 진행률 기능은 WordPress가 쓰지 않으므로 끄는 데 부담이 없어, 패치와 별개로 계속 유지하기로 했습니다. 공지대로 ini_set()이나 .user.ini가 아니라 php.ini 단계에서 꺼야 효과가 있습니다.

; docker/wordpress/php-custom.ini
session.upload_progress.enabled = 0

memory_limit=-1은 적용하지 않았습니다. 이 클러스터의 워커는 swap 없이 allocatable 약 7.8 GiB로 운영되고, 노드 메모리 고갈로 kubelet이 멈춘 장애를 이미 두 번 겪었습니다. PHP의 메모리 상한을 없애면 요청 하나가 컨테이너 한도까지 메모리를 끌어갈 수 있으므로, 패치로 원인을 없애는 쪽을 택했습니다. post_max_size는 이미 공지 권고값인 8M이었습니다.

3. 메모리 여유가 가장 큰 노드에서 빌드했습니다

로컬에 docker가 없어 이미지는 클러스터 안의 Kaniko Job으로 빌드합니다. 빌드도 노드 메모리를 쓰므로 스케줄러의 requests 계산 대신 실제 여유를 먼저 봤습니다. Prometheus의 node_memory_MemAvailable_bytes 기준으로 워커 세 대의 여유는 2.43, 3.37, 4.70 GiB였고, 가장 넉넉한 노드에 Job을 고정했습니다.

apiVersion: batch/v1
kind: Job
metadata:
  name: wordpress-image-build
  namespace: wordpress
spec:
  backoffLimit: 0
  ttlSecondsAfterFinished: 86400
  template:
    spec:
      restartPolicy: Never
      nodeSelector:
        kubernetes.io/hostname: <worker-node-3>
      initContainers:
        - name: copy-context
          image: busybox:1.38.0
          command: ["sh", "-c", "cp -L /context-src/* /workspace/"]
          volumeMounts:
            - { name: context-src, mountPath: /context-src }
            - { name: workspace, mountPath: /workspace }
      containers:
        - name: kaniko
          image: gcr.io/kaniko-project/executor:v1.23.2
          args:
            - --context=dir:///workspace
            - --dockerfile=/workspace/Dockerfile
            - --destination=registry.example.com/infra/wordpress:php8.4-8
            - --digest-file=/dev/termination-log
          resources:
            requests: { cpu: 500m, memory: 1Gi }
            limits: { cpu: "2", memory: 2Gi }
          volumeMounts:
            - { name: workspace, mountPath: /workspace }
            - { name: registry-cred, mountPath: /kaniko/.docker, readOnly: true }
      volumes:
        - name: context-src
          configMap: { name: wordpress-build-context }
        - name: workspace
          emptyDir: {}
        - name: registry-cred
          secret:
            secretName: registry-cred
            items: [{ key: .dockerconfigjson, path: config.json }]

initContainer를 따로 둔 이유는 ConfigMap 볼륨의 파일이 ..data/를 가리키는 심볼릭 링크이기 때문입니다. 이를 그대로 빌드 컨텍스트로 쓰면 Kaniko의 COPY가 깨진 링크를 이미지에 넣으므로, 실파일로 복사한 emptyDir을 넘깁니다. 빌드는 47초 만에 끝났고, 푸시된 이미지 설정에서 PHP_VERSION=8.4.27을 확인한 뒤 배포로 넘어갔습니다.

4. 이미지 태그 한 줄만 바꿔 롤링 업데이트했습니다

Deployment는 maxUnavailable: 0, maxSurge: 1이고 readiness probe가 헬스체크 경로를 봅니다. 앞서 말한 vhost 규칙이 빠졌다면 새 파드가 Ready가 되지 못하고 기존 파드가 계속 서비스하므로, 배포 방식 자체가 안전장치 역할을 합니다. kubectl diff로 변경이 이미지 태그 한 줄뿐인 것을 확인하고 적용했고, 두 파드 교체는 약 1분 만에 끝났습니다.

kubectl diff -f manifests/wordpress/wordpress.yaml
-      - image: registry.example.com/infra/wordpress:php8.4-7
+      - image: registry.example.com/infra/wordpress:php8.4-8

kubectl apply -f manifests/wordpress/wordpress.yaml
kubectl rollout status deploy/wordpress -n wordpress

작업 중 만난 확인 거리

diff에 없던 리소스가 configured로 나왔습니다

apply 출력에 Deployment뿐 아니라 wp-config가 든 ConfigMap과 PodDisruptionBudget도 configured로 찍혔습니다. 사전 diff에는 이미지 태그만 있었으므로, 의도하지 않은 변경이 들어갔는지 바로 확인했습니다.

원인은 last-applied-configuration 주석이었습니다. 해당 ConfigMap은 10월 초 DB 서버 이전 때 kubectl patch로 값을 바꿨는데, patch는 이 주석을 갱신하지 않습니다. 저장소 파일도 같은 값으로 고쳐 두었으므로 실제 데이터는 이미 일치했고, apply는 낡은 주석만 갱신했습니다. managedFields를 보면 이 사실이 그대로 드러납니다.

kubectl get cm wordpress-config -n wordpress -o json --show-managed-fields \
  | python3 -c 'import json,sys; [print(m["manager"], m["time"], json.dumps(m["fieldsV1"], separators=(",",":"))) for m in json.load(sys.stdin)["metadata"]["managedFields"]]'

kubectl-patch 2026-10-10T16:08:36Z {"f:data":{"f:wp-config.php":{}}}
kubectl-client-side-apply 2026-10-22T08:21:50Z {"f:data":{},"f:metadata":{"f:annotations":{".":{},"f:kubectl.kubernetes.io/last-applied-configuration":{}}}}

wp-config.php 필드의 소유자는 여전히 10월 10일의 kubectl-patch이고, 이번 apply가 가져간 것은 주석 필드뿐입니다. 저장소와 운영 중인 wp-config.php의 내용도 비교해 동일함을 확인했습니다. PDB 쪽은 spec이 8월 이후 그대로였고, 파드 교체에 따라 컨트롤러가 status를 갱신한 것이었습니다.

빌드 로그의 zip 경고

빌드 로그에 warning: zip (zip) is already loaded!가 찍혔습니다. 마이그레이션 때 원본 서버에 없던 zip 확장을 Dockerfile에서 직접 설치하도록 했는데, 공식 베이스 이미지에 zip이 이미 들어 있어서 나는 경고입니다. docker-php-ext-enable이 중복을 감지해 설정 파일을 하나만 남기므로 새 파드의 php -m에서도 경고가 없었고, 동작에는 영향이 없습니다. 다음 정리 때 Dockerfile에서 빼도 되는 단계입니다.

php2shell 취약점 조치 결과

항목 조치 전 조치 후
운영 파드의 PHP 8.4.24 (8월 20일 빌드 이미지) 8.4.27 (10월 22일 빌드 이미지)
session.upload_progress.enabled 1 0
베이스 이미지 태그 php8.4-apache 7.1.3-php8.4-apache
푸시 태그 php8.4-N + latest php8.4-N만
빌드 Job 매니페스트 저장소에 없음 저장소에 보관

배포 후에는 두 파드 각각에서 PHP 버전, ini 값, vhost 규칙, Apache 모듈을 확인했습니다. 헬스체크, 메인 페이지, REST API, 로그인 페이지 모두 내부 경로와 외부 경로에서 200을 반환했고, 인증된 REST 조회로 플러그인이 모두 활성 상태인 것도 확인했습니다. 롤아웃 이후 PHP 오류 로그는 없었습니다.

kubectl exec -n wordpress deploy/wordpress -- php -v | head -1
kubectl exec -n wordpress deploy/wordpress -- \
  php -r 'echo ini_get("session.upload_progress.enabled"), PHP_EOL;'
kubectl exec -n wordpress deploy/wordpress -- \
  grep -n healthz /etc/apache2/sites-enabled/000-default.conf
curl -s -o /dev/null -w "%{http_code}\n" https://www.example.com/healthz.html

PHP 8.4.27 (cli) (built: Oct 21 2026 15:53:29) (NTS)
0
15:	RewriteRule ^/healthz\.html$ - [L,END]
200

검증하지 않은 것도 분명히 해 둡니다. 공개된 공격 코드를 이 사이트에 직접 실행해 보지는 않았고, 침해 흔적 점검은 10월 17일 이후 로그와 현재 파일 상태에 한정됩니다. 관리자 화면은 인증된 REST 조회까지만 확인했습니다.

이번 일에서 얻은 교훈은 php2shell 취약점 자체보다 운영 방식에 있었습니다. 컨테이너 이미지는 빌드한 순간의 런타임을 고정하므로, 베이스 태그가 아무리 부지런히 갱신돼도 재빌드하지 않으면 패치는 들어오지 않습니다. 이제 PHP 보안 공지가 나오면 레지스트리에서 베이스의 PHP_VERSION을 확인하고, 저장소에 둔 빌드 Job의 태그를 하나 올려 재빌드하는 것을 정해진 절차로 두었습니다.

참고

관련 포스트:

참고 문서: PHP Security Advisory (2026-10-21) · PHP 8.4.27 ChangeLog · Tenable php2shell FAQ · WordPress Docker Official Image · Kaniko · Kubernetes Field Management