블로그로 돌아가기

php2shell 취약점으로부터 게임 서버 보호: 6시간 패치 런북

게시일 2026년 10월 23일
php2shell 취약점으로부터 게임 서버 보호: 6시간 패치 런북 AI의 도움으로 생성됨

핵심 요약

확인하세요: php2shell 취약점(CVE-2026-109618, CVE-2026-109619)으로부터 게임 서버를 보호하는 6시간 패치 런북으로, 게임 개발자를 위한 PHP 런타임 인벤토리, 패치, 워커 재활용, 침해 탐지까지의 단계별 대응법을 담았습니다.

게임의 PHP 티어는 아무도 쳐다보지 않는 스택의 일부일 가능성이 높다. 코스메틱을 판매하는 웹 스토어. 커뮤니티 매니저가 사용하는 관리자 패널. 2022년에 작성하고 다시는 건드리지 않은 레거시 리더보드 엔드포인트. 그것이 php2shell이 공격하는 표면이다.

2026년 10월 21일, Calif.io, Wiz, OpenAI, Quarkslab이 CVE-2026-109618 및 CVE-2026-109619로 추적되는 두 개의 PHP 런타임 취약점을 공개했으며, 이는 통칭 php2shell로 명명되었다. CVSS v4.0 기준 9.2점이다. 5.4 이후의 모든 PHP 설치에 영향을 미치며, 인증 없는 원격 코드 실행, 데이터 노출, 서비스 거부를 허용한다. Cloudflare는 공개 발표 이전에 이미 실제 고객을 대상으로 페이로드를 변형하는 라이브 공격 트래픽을 관찰했다.

이것은 WordPress 플러그인 문제가 아니다. PHP 런타임 자체의 메모리 안전성 버그다. 프레임워크, CMS, Composer 의존성을 업데이트해도 아무 효과가 없다. PHP가 포함된 게임 백엔드를 운영한다면 이 런북이 당신을 위한 것이다.

php2shell이 실제로 깨뜨리는 것

두 CVE 모두 요청 처리가 비정상적으로 진행될 때 PHP가 메모리를 처리하는 방식에 존재한다. 일반적으로 PHP는 요청에 메모리를 할당하고, 사용한 뒤 요청이 끝나면 해제한다. 이 버그는 정리 경로가 애초에 유효하지 않았던 상태를 대상으로 실행될 때 나타난다.

CVE-2026-109618: OOM 메모리 손상

PHP는 요청 처리 중 메모리 상한에 도달하면 일부 내부 구조가 초기화를 마치기 전에 중단(abort)된다. 그러면 정리 루틴이 반쯤 만들어진 데이터를 유효한 것으로 취급한다. 대용량 요청 본문, 깊게 중첩된 입력, 과도하게 큰 업로드 등으로 OOM 조건을 안정적으로 유발할 수 있는 공격자는 미완성 메모리에 포함된 내용을 제어할 기회를 얻게 된다.

게임 백엔드에서 실질적인 트리거는 명확하다. 크기 제한 없이 본문을 받는 모든 엔드포인트다. 매치 결과 제출, 스크린샷 업로드, 모드 메타데이터, 텔레메트리 배치 등이다.

CVE-2026-109619: 요청 간 스테일 전역 상태

치명적 오류가 요청을 중단시키면 PHP 프로세스는 요청 종료 시 해제되는 메모리에 대한 참조를 보유한 채 남게 된다. 동일한 워커 프로세스가 다음 요청을 처리할 때 이러한 댕글링 참조를 따라갈 수 있다. 이것이 데이터 노출이다. 한 플레이어의 세션 데이터나 데이터베이스 자격 증명이 다른 플레이어의 응답에 섞여 나가며, 특정 조건에서는 코드 실행으로 이어진다.

이것은 게임 서버에 더 교활한 버그다. PHP-FPM 워커는 설계상 오래 살아남기 때문이다. 오염된 워커 하나가 재활용되기 전에 수백 개의 후속 요청을 처리할 수 있다. pm.max_requests = 0(기본값, 즉 재활용 안 함)으로 실행 중이라면 손상된 워커가 컨테이너 수명 동안 지속될 수 있다.

1단계: 보유한 모든 PHP 런타임 인벤토리 작성

찾지 못한 것은 패치할 수 없다. 대부분의 스튜디오는 사고가 진행되는 중에야 서비스 세 개가 PHP를 실행 중이고 그중 하나가 2년 전에 빌드된 베이스 이미지의 8.1이라는 사실을 발견한다. 다른 무엇보다 먼저 이 작업을 실행하라.

#!/usr/bin/env bash
# php2shell exposure inventory (CVE-2026-109618 / CVE-2026-109619)
set -euo pipefail

declare -A FIXED=( [8.5]="8.5.12" [8.4]="8.4.27" [8.3]="8.3.36" [8.2]="8.2.35" )
SERVICES=("game-api" "web-store" "admin-panel" "forums")

for svc in "${SERVICES[@]}"; do
  ver=$(docker compose exec -T "$svc" php -r 'echo PHP_VERSION;' 2>/dev/null | tr -d '[:space:]') || {
    echo "SKIP  $svc (no php binary)"; continue; }

  branch="${ver%.*}"          # "8.3.12" -> "8.3"
  min="${FIXED[$branch]:-}"

  if [[ -z "$min" ]]; then
    echo "EOL   $svc on $ver -> no upstream patch, upgrade required"
  elif php -r 'exit(version_compare($argv[1], $argv[2], ">=") ? 0 : 1);' "$ver" "$min"; then
    echo "OK    $svc on $ver"
  else
    echo "VULN  $svc on $ver (fixed in $min)"
  fi
done

이 스크립트가 의도적으로 수행하는 두 가지: EOL 브랜치를 별도로 표시하고, 호스트의 php -v가 컨테이너의 실제 런타임을 반영한다고 가정하지 않는다. Kubernetes를 사용 중이라면 docker compose exec를 kubectl exec로 바꾸고 서비스 이름 대신 디플로이먼트를 순회하라.

수정된 업스트림 버전은 8.5.12, 8.4.27, 8.3.36, 8.2.35이다. 8.1, 8.0, 7.x, 5.x는 수명 종료(EOL)이며 수정이 제공되지 않는다. 패치가 아니라 업그레이드다.

2단계: 애플리케이션이 아닌 런타임을 패치하라

이런 사고의 첫 1시간 동안 가장 흔한 실패 패턴은 애플리케이션 코드를 업데이트하고, 재배포하고, CI가 초록불인 것을 확인하고, 끝났다고 가정하는 팀이다. 끝난 것이 아니다.

  • PHP-FPM 및 mod_php: 베이스 이미지를 다시 빌드하거나 패키지를 업그레이드하라. Debian/Ubuntu: apt-get install --only-upgrade php8.3-fpm php8.3-cli php8.3-common. Alpine: apk upgrade php83. 빌드 호스트가 아니라 실행 중인 컨테이너 내부에서 php -r 'echo PHP_VERSION;'로 확인하라.
  • FrankenPHP / php-cgi: 런타임을 번들로 포함하므로 패키지 업그레이드가 아니라 새 릴리스 바이너리나 베이스 이미지가 필요하다.
  • 관리형 호스팅: 제공업체가 PHP를 관리한다면 즉시 티켓을 열고 권고 참조 번호를 요청하라. "인지하고 있습니다"를 해결책으로 받아들이지 마라.
  • Docker 베이스 이미지: 태그가 아닌 다이제스트로 고정하라. php:8.3-fpm은 다음 풀 때 다른 빌드로 해석될 수 있다. 패치에는 좋지만 재현성에는 치명적이다. 버전을 확인한 후 다이제스트를 고정하라.

패치 후 기존 워커를 모두 종료하라. 공유 소켓에서 이전 프로세스를 살려두는 롤링 재시작은 취약한 워커가 계속 트래픽을 처리하게 만든다. docker compose restart 또는 kubectl rollout restart를 실행하고 프로세스 시작 시간이 변경되었는지 확인하라.

3단계: 기다리는 동안 폭발 반경을 줄여라

패치는 시간이 걸린다. "우리가 알고 있음"과 "모든 환경이 재빌드됨" 사이에 시간창이 있다. 이 시간을 활용하라.

엣지에서 요청 본문 크기 제한

# Shrink the php2shell trigger surface
client_max_body_size 2m;          # game APIs rarely need more than this
client_body_timeout 10s;
client_header_timeout 10s;
large_client_header_buffers 4 8k;

location ~ \.php$ {
    include fastcgi_params;
    fastcgi_pass php-fpm:9000;
    fastcgi_read_timeout 15s;
    fastcgi_buffering on;
}

2MB 제한은 결심한 공격자를 막지 못한다. 작은 요청 여러 개로 OOM을 유발할 수 있기 때문이다. 그러나 단일 요청으로 발생하는 사소한 트리거를 제거하고 비용도 들지 않는다.

워커를 적극적으로 재활용하라

; /usr/local/etc/php-fpm.d/zz-php2shell.conf
php_admin_value[memory_limit] = 256M
php_admin_value[max_execution_time] = 20
pm.max_requests = 500
pm.max_children = 24
request_terminate_timeout = 20s

트레이드오프 계산을 해보자. 24개 워커에 초당 200개 요청이 들어오면 각 워커는 약 8.3 req/s를 처리한다. pm.max_requests = 500이면 모든 워커가 약 60초마다 재활용된다. 이는 CVE-2026-109619로 오염된 프로세스가 트래픽을 처리할 수 있는 시간을 제한하지만, 분당 24회의 프로세스 생성도 의미한다. 콜드 스타트가 무거운 프레임워크에서는 측정 가능한 오버헤드다.

memory_limit을 128M에서 256M으로 올리면 OOM 발생 빈도가 줄어들지만(CVE-2026-109618 트리거 감소) 최악의 메모리 사용량은 두 배가 된다. 24 children × 256M = 6GB의 헤드룸이 노드에 필요하다. 노드에 4GB만 있다면 메모리가 존재하는 척하지 말고 pm.max_children을 낮춰라.

이 중 어느 것도 해결책이 아니다. 몇 시간을 벌어줄 뿐이다. 분류(triage)로 취급하라.

4단계: 이미 공격을 받았는지 탐지하라

공개가 첫 악용과 같은 것은 아니다. 공식 발표일 이전에 이미 공격 트래픽이 관찰되었다. 이미 정찰을 받았다고 가정하라.

# 1. OOM aborts — strongest single signal for CVE-2026-109618
grep -c "Allowed memory size" /var/log/php-fpm/error.log

# 2. Worker crashes — signal for both CVEs
journalctl -u php8.3-fpm --since "48 hours ago" \
  | grep -Ei "segfault|SIGSEGV|exited on signal 11"

# 3. Which paths are generating 500s
awk '$9 == 500 {print $7}' /var/log/nginx/access.log \
  | sort | uniq -c | sort -rn | head -20

# 4. Same source IP hitting the same 500-ing path repeatedly
awk '$9 == 500 {print $1, $7}' /var/log/nginx/access.log \
  | sort | uniq -c | sort -rn | head -20

먼저 기준선(baseline)을 확보하라. PHP-FPM 로그에 이미 메모리를 많이 먹는 export 엔드포인트 때문에 하루 40건의 OOM 중단이 있었다면 그 숫자는 노이즈다. 찾아야 할 것은 비율의 변화다. signal 11 종료의 급증, 500 목록에 나타난 새로운 경로, 또는 단일 IP가 하나의 엔드포인트에 수십 건의 실패를 발생시키는 경우다.

성공적인 악용 증거를 찾았다면 완전한 침해로 취급하라. 데이터베이스 자격 증명, 세션 서명 키, 결제 제공업체 시크릿, PHP 프로세스에서 접근 가능한 모든 API 키를 교체하라. 이에 대한 플레이북은 PHP 전용이 아니다. 스튜디오가 침해에서 살아남는 게임 백엔드를 설계하는 데 사용하는 동일한 원칙이 여기에 적용되며, 공격자가 읽기 접근만이 아니라 코드 실행 권한을 가졌다고 가정하는 것에서 시작한다.

백엔드가 이미 요청을 드롭하고 있고 부하 때문인지 공격 때문인지 확실하지 않다면, 이 서버 프리즈 진단 프로토콜의 크래시 분류 순서를 따르라. 설정을 건드리기 전에 실패하는 티어를 격리하라.

요청만 보는 WAF 규칙이 실패한 이유

Cloudflare의 분석 글에는 이번 사건 전체에서 가장 이식 가능한 엔지니어링 교훈이 담겨 있다. 초기 보호 조치는 요청 기반이었고, 공격자들은 페이로드를 변형하며 대응했다. 어떤 변형이 매치되고 어떤 것이 통과하는지 탐색하면서 매치/노매치 신호를 오라클로 사용한 것이다.

요청 데이터만으로는 악용 트래픽과 정상 트래픽을 구분할 수 없었다. 중첩 배열이 포함된 대용량 POST는 정상 게임 클라이언트가 매치 요약을 제출할 때 보내는 것이기도 하기 때문이다. 모호함을 해소한 신호는 응답이었다. 오리진이 PHP 치명적 오류 추적과 함께 500을 반환했는가? 의심스러운 요청과 진단 응답의 조합은 신뢰도가 높다. 요청만으로는 부족하다.

Cloudflare는 이를 "전체 트랜잭션 탐지(full transaction detection)"로 출시했다. 동일한 HTTP 트랜잭션 내에서 요청과 응답을 상호 연관시키고 응답이 요청자에게 도달하기 전에 차단하는 방식이다. 자체 엣지나 리버스 프록시를 운영한다면 이 패턴을 복제할 가치가 있다.

def is_probable_php2shell(resp) -> bool:
    """Correlate request shape with origin response, not request alone."""
    body = resp.body[:4096]
    return (
        resp.status == 500
        and b"Fatal error" in body
        and resp.origin_latency_ms < 50      # exploit aborts fast
        and b"Allowed memory size" in body   # OOM path = 109618 signature
    )

이 교훈은 이 CVE를 넘어 일반화된다. 인바운드 트래픽만 보는 탐지 시스템은 아웃바운드 결과를 볼 수 있는 공격자에게 우회된다. 응답을 로깅하고 상호 연관시키라. 이것이 유지되는 규칙과 퍼즐이 되는 규칙의 차이다.

모범 사례

  1. 공개 시점이 아니라 일정에 따라 패치하라. "CVE 공개"와 "실제 악용" 사이의 간격은 이제 시간 단위로 측정되며 때로는 음수다. 발표된 것이 없어도 PHP 베이스 이미지를 매주 재빌드하고 롤백 경로를 유지하라.
  2. 프로덕션에서 pm.max_requests = 0을 절대 실행하지 마라. 무제한 워커 수명은 모든 메모리 안전성 버그를 지속적인 발판으로 만든다. 게임 API에는 500~1000이 합리적인 기본값이다.
  3. 모든 요청 본문을 엣지에서 제한하라. JSON API는 2MB, 업로드는 10MB, 콘텐츠 타입이 일치하지 않는 것은 거부하라. 이렇게 하면 메모리 압박 악용의 전체 클래스를 PHP에 도달하기 전에 차단한다.
  4. 5xx 오류에 대한 응답 본문을 샘플링하여 로깅하라. 오리진 응답이 옵저버빌리티 스택에 보이지 않으면 전체 트랜잭션 탐지를 구축할 수 없다. 10%를 샘플링하고 7일간 보관하라.
  5. 운영 중인 모든 런타임의 인벤토리를 유지하라. PHP만이 아니다. Node, Python, Go 등 웹 티어를 실행하는 모든 것이다. php2shell에 1시간 이내로 대응한 스튜디오에는 서비스 카탈로그가 있었다. 하루가 걸린 스튜디오는 Dockerfile을 grep하고 있었다.

더 큰 교훈: 웹 티어가 공격 표면이다

php2shell은 게임 백엔드에 크러프트(cruft)가 쌓인다는 사실을 상기시킨다. 1년 차에 빨리 만들 수 있다는 이유로 PHP 관리자 패널을 출시했다. 3년 차에도 여전히 거기에 있고, 여전히 8.1이고, 여전히 플레이어 테이블에 쓰기 권한이 있는 데이터베이스 자격 증명을 보유하고 있다. 그리고 뚫리는 것은 강화된 게임 서버 바이너리가 아니라 바로 이것이다.

구조적 해결책은 보유한 런타임의 양을 줄이는 것이다. PHP 웹 티어를 운영하는 유일한 이유가 플레이어 계정, 클라우드 세이브, 리더보드를 처리하기 위해서라면, 그것들은 정확히 horizOn이 관리형 API로 제공하는 기능이다. 계정 바인딩 세션을 통한 최종 사용자 인증, 클라이언트가 충돌을 해결하는 리비전 인식 읽기/쓰기 방식의 Cloud Save, 리더보드, 원격 구성 등이다. 새벽 3시에 패치할 PHP-FPM 풀이 없다. PHP-FPM 풀이 없기 때문이다.

이것은 관리형 서비스가 취약점에 면역이라는 주장이 아니다. 운영하는 모든 런타임은 인벤토리, 패치, 모니터링이 필요한 런타임이며 대부분의 스튜디오가 생각보다 더 많은 런타임을 보유하고 있다는 주장이다.

지금 실행하라

이 글에서 하나만 실행한다면 1단계의 인벤토리 스크립트로 하라. 모든 환경에 대해 실행하라. 프로덕션, 스테이징, 퍼블리셔 피치 때 만들고 잊어버린 데모 서버까지. 잊고 있던 런타임을 거의 확실히 발견하게 될 것이고, 그것은 거의 확실히 EOL 브랜치에 있을 것이다.

그런 다음 패치하고, 워커를 재활용하고, 엣지 규칙을 유지하라. WAF 규칙과 본문 크기 제한은 심층 방어이지 벤더 수정의 대체재가 아니다. PHP 권고가 진실의 원천이며 실제로 구멍을 막는 것이다.

인벤토리에서 앞으로 소유하고 싶은 것보다 더 많은 PHP가 발견된다면, horizOn이 계정, 세이브, 리더보드 티어를 관리형 서비스로 제공한다. 패치 책임이 있는 표면을 줄이는 가장 빠른 방법이다. 어느 쪽이든: 인벤토리가 먼저, 패치가 두 번째다. 그리고 스택에서 지루한 부분이 안전한 부분이라고 가정하는 것을 멈춰라.


출처: Cloudflare, php2shell 취약점에 대한 보호 조치 배포