CVE-2026-109618 / CVE-2026-109619 (php2shell) 분석

CVE-2026-109618 / CVE-2026-109619 (php2shell) 분석

CVE · 웹·서버 · RCE · CVE-2026-109618 · CVE-2026-109619 · [심층]

덱. PHP는 요청마다 새로 뜨는 프로그램이 아닙니다. 워커(worker) 하나가 요청을 수백~수천 번 재사용합니다. 그래서 “이번 요청이 끝나면 전부 치운다”는 전제가 깨지는 순간, 지난 요청의 메모리가 다음 요청에 그대로 남을 수 있습니다. 이 글은 2026년 10월 21일 PHP가 긴급 패치한 두 취약점 — 메모리 부족(OOM) 처리 중 상태가 망가지는 CVE-2026-109618과, 요청이 끝나도 전역 상태가 리셋되지 않는 CVE-2026-109619 — 를 GHSA와 실제 패치 커밋을 직접 열어 분석합니다. 둘 다 애플리케이션 코드가 한 줄도 실행되기 전에 터지는 미인증 원격 코드 실행(RCE)입니다.

사용자 입력이 “데이터”에서 “코드”로 승격되는 순간이 위험하다는 건 익숙한 교훈입니다. 이번 사건은 한 겹 더 아래에 있습니다 — 문제는 입력이 아니라 **생명주기(lifecycle)**입니다. “이 메모리는 이번 요청이 끝나면 죽는다”와 “이 전역 변수는 워커가 사는 동안 산다”는 두 생명주기가 겹쳐 있는데, 예외(fatal error)나 메모리 부족 같은 비정상 종료 경로가 정리(cleanup)를 절반만 하고 넘어갔습니다. 이런 “구조체/상태의 미초기화·잔존값” 부류는 커널의 nf_tables(CVE-2024-1086)나 Dirty Pipe류에서도 본 적 있는 패턴이고, PHP 코어에서도 처음이 아닙니다.

개요

PHP는 2026년 10월 21일, 8.5.12 / 8.4.27 / 8.3.36 / 8.2.35를 동시에 내며 두 치명적 취약점을 고쳤습니다 [확인](php.net 공지). 공지는 “mod_php에서 애플리케이션 코드가 실행되기 전에 미인증 요청만으로 공격이 트리거될 수 있다”고 명시했고, PHP-FPM·php-cgi·FrankenPHP·LiteSpeed도 영향받는 SAPI(서버 연동 방식)로 함께 적시했습니다 [확인].

CVE-2026-109619CVE-2026-109618
GHSAGHSA-6wmf-4rv5-p3rpGHSA-w8hw-w9fx-j79w
제목stale global state에 의한 RCEOOM corruption에 의한 RCE
CVSS v4.09.2 AV:N/AC:H/AT:P/PR:N/UI:N/VC:H/VI:H/VA:H/SC:N/SI:N/SA:N9.2 (동일 벡터)
주 CWECWE-416(Use-After-Free) + CWE-755·824·1325·1419CWE-416 + CWE-755·824·1325·1419

[확인] (양쪽 GHSA 공통) — 두 CVE는 공격 벡터(네트워크·낮은 권한 요건·사용자 상호작용 불필요)는 같지만, 메모리가 망가지는 경로가 다릅니다. 이 구분이 이번 글의 핵심이고, 뒤에서 보듯 2차 분석(sansec)은 이 둘을 서로 바꿔 설명했습니다.

영향받는 버전

라인취약 버전안전(패치) 버전
8.2.x< 8.2.358.2.35
8.3.x< 8.3.368.3.36
8.4.x< 8.4.278.4.27
8.5.x< 8.5.128.5.12

[확인] (GHSA 공통 표). 7.4 / 8.0 / 8.1은 이미 공식 지원이 끝난 라인이라, php.net은 넘버링된 릴리스 대신 **소스 백포트(minimal source-backport)**만 링크로 제공했습니다 [확인]. “minimal”이라는 표현 자체가 8.2+ 라인의 전체 수정과 동등하지 않다는 뜻이며, 이 점은 뒤의 패치 완전성 평가에서 다시 다룹니다.

기술적 배경 — 워커는 기억을 지운다고 “약속”할 뿐이다

PHP-FPM이든 Apache mod_php든, 서버는 PHP를 요청마다 새로 켜지 않습니다. 워커 프로세스(또는 스레드) 하나가 요청을 연속으로 처리합니다. 요청 하나가 끝나면 PHP는 두 가지를 치워야 합니다:

  1. 요청 소유 메모리(request-owned heap) — 이번 요청에서 할당한 모든 변수·버퍼. 요청이 끝나면 전부 free됩니다.
  2. 워커 전역 상태(worker-global state) — 에러 핸들러, 세션 모듈 상태, 마지막 에러 백트레이스처럼 워커가 살아있는 동안 유지되는 전역 변수들. 다음 요청이 시작되기 전(request init) 리셋되는 게 정상입니다.

이 둘은 생명주기가 다릅니다. 정상 흐름에서는 “① 요청 처리 → ② 전역 상태 리셋 → ③ 요청 메모리 free → ④ 다음 요청 init”이 깨지지 않습니다. 문제는 비정상 종료가 이 순서를 건너뛸 때입니다. PHP는 치명적 오류(fatal error)나 메모리 부족(OOM)을 zend_bailout()이라는, C의 longjmp()를 쓰는 비지역 탈출(non-local exit)로 처리합니다 — 현재 실행 중인 함수 호출 스택을 그대로 건너뛰어 상위 핸들러로 점프합니다. 스택을 건너뛴다는 건, 그 스택 중간에 있던 “정리 코드”도 같이 건너뛴다는 뜻입니다. 이 개념이 아래 두 CVE의 공통 무대입니다.

쉽게 다시 정리 — 두 줄짜리 비유와 용어 풀이

비유는 “식당 테이블 정리”입니다 [분석](이해용 비유이며 출처 있는 사실이 아님). 손님(요청)이 나가면 종업원(워커)이 테이블을 치우고 다음 손님을 받습니다. 그런데 손님이 도중에 급히 나가면(비정상 종료) 치우기가 절반만 되고, 다음 손님이 앉기 전에 누군가 “치웠을 거라 믿고” 그 테이블의 메모를 읽어 버립니다. 이 글의 두 CVE는 이 상황의 서로 다른 두 가지 모양입니다.

용어이 글에서의 뜻
요청 소유 메모리요청 하나가 끝나면 통째로 반납되는 메모리 [확인](위 ①)
워커 전역 상태워커가 살아 있는 동안 남는 변수. 요청 메모리를 가리키는 값이 들어 있을 수 있음 [확인]
dangling pointer이미 반납된 메모리를 계속 가리키는 값. 이걸 읽으면 UAF(Use-After-Free) [확인](CWE-416)
bailout / longjmp오류 시 중간 단계를 건너뛰고 한 번에 빠져나가는 방식 [확인]
safe point실행을 안전하게 끊을 수 있도록 정해 둔 지점. 패치 후 소프트 OOM 처리가 여기로 미뤄짐 [확인]
  • CVE-2026-109619 한 줄 요약: 치우기(리셋)가 빠진 전역 값이 남고, 다음 요청이 초기화되기 전에 그 값을 읽습니다 [확인].
  • CVE-2026-109618 한 줄 요약: 아직 반쯤만 준비된 상태에서 메모리 부족이 나서 중간 정리를 건너뛰고, 뒤따르는 종료 루틴이 그 반쪽 상태를 정상으로 취급합니다 [확인].
  • 두 경우 모두 애플리케이션 코드가 실행되기 전 단계에서 일어난다는 점이 공통입니다 [확인].

취약점 발생 원인

CVE-2026-109619 — stale global state (UAF)

어디: Zend/zend_execute_API.c의 zend_shutdown_executor_values() / shutdown_executor(), 그리고 Zend/zend.c의 zend_error_zstr_at() 등 전역 상태를 다루는 지점.

없는 검증(패치 전): 요청 종료(shutdown) 경로가 EG(user_error_handler), EG(user_exception_handler), EG(last_fatal_error_backtrace), EG(record_errors) / EG(num_errors) / EG(errors), EG(error_handling), EG(exception) 같은 실행기(executor) 전역을 명시적으로 리셋하지 않았습니다. 패치 커밋(31c0ea7d7ff1d1fccb6492e52dab544011628065)이 zend_shutdown_executor_values()에 이 필드들을 ZVAL_UNDEF/memset으로 초기화하는 블록을 새로 추가했고, 주석은 정확히 “may be used by zend_error_zstr_at before init_executor”라고 적어 놓았습니다 [확인](커밋 diff 직접 확인) — 즉 개발자들도 “이 전역이 다음 요청의 초기화(init_executor) 전에 읽힐 수 있다”는 바로 그 틈을 고친 겁니다.

그래서 가능해지는 것(producer): GHSA는 이 틈을 만드는 쪽(“producer”)을 두 종류로 분류합니다 [확인]:

  • fatal error가 정리 전에 연산을 중단시키는 경로 — parse/compile 실패, 처리되지 않은 에러, require 실패, Maximum execution time exceeded 등.
  • OOM이 상태를 발행(publish)한 뒤 연산을 중단시키는 경로, 혹은 shutdown 후반부의 비치명적(nonfatal) 진단이 이미 지워진 에러 상태를 다시 채워 넣는 경로.

consumer(이 잔존 상태를 다음 요청에서 읽는 쪽): GHSA가 “확인된 사례”로 명시한 두 가지는 [확인]:

  • PHP 8.5의 fatal_error_backtraces — 백트레이스 수집 경로(Zend/zend_builtin_functions.c의 debug_backtrace_get_args() 계열)가 정상 요청 초기화 전에 실행될 수 있습니다. 패치 diff는 이 함수 안에 ZEND_HAS_OOM() 체크와 goto oom 분기를 새로 넣어, OOM이 걸린 상태에서 더는 진행하지 않고 안전하게 빠지도록 바꿨습니다 [확인].
  • 세션 업로드 진행률(session upload progress) — ext/session/session.c의 php_session_rfc1867_update()는 정상 세션 초기화(RINIT)보다 먼저 실행됩니다. 패치는 이 경로가 도는 동안 PS(in_rfc1867_update) 플래그를 세우고, 그 사이의 unserialize를 __PHP_Incomplete_Class만 생성하도록 강제(php_var_unserialize_set_incomplete_classes_only)해 사용자 코드(__wakeup/__unserialize/오토로딩)가 이 시점에 호출되는 것 자체를 막습니다 [확인].
  • 요구 조건: producer와 consumer가 같은 워커에서 순서대로 발생해야 합니다. 워커가 여러 개거나 재활용(recycle)되면 성공률은 떨어지지만 조건 자체가 없어지지는 않습니다 [확인].

CVE-2026-109618 — OOM corruption (longjmp 기반 설계 결함)

어디: Zend/zend_alloc.c의 메모리 한도 체크 경로(패치 전 zend_mm_safe_error()).

없는 검증(패치 전): 소프트 메모리 한도를 넘기면, 패치 전 코드는 곧바로 에러를 내고 zend_bailout()으로 longjmp했습니다. 문제는 이 시점이 request-startup 중, 일부 구조체가 아직 완전히 초기화되지 않은 때일 수 있다는 것입니다. 할당 실패가 바로 “그 자리에서” 실행을 끊어버리니, 뒤이은 request-shutdown 루틴이 아직 반쪽만 만들어진 상태를 ‘정상’인 것처럼 처리합니다 — 그 할당 실패가 손상된 메모리와 직접 관련 없어도 마찬가지입니다 [확인](GHSA·php.net 공통 서술).

그래서 가능해지는 것: 멀티파트(multipart) 업로드 처리 — 길이 제한 없는 디코딩된 본문, part별 헤더 개수 무제한, 빈 파일 필드나 중첩 필드명으로 만든 대형 배열 — 로 OOM을 트리거할 수 있습니다 [확인]. mod_php는 기본 설정값만으로 HTTP를 통한 완전한 RCE가 실증(demonstrated) 됐고, FrankenPHP·LiteSpeed는 “유사 가능성 높음(likely susceptible)”, PHP-FPM은 “소스 분석상 취약 가능성이 있으나 완전한 익스플로잇은 실증되지 않음”으로 GHSA가 등급을 구분했습니다 [확인] — 이 구분은 뒤의 “영향·SAPI 위험면”에서 그대로 씁니다.

패치의 구조적 변화: 패치는 longjmp 기반 즉시 탈출을 없애고, 새로운 상태 기계(zend_oom_state: NONE → PENDING → HANDLING → HANDLED)로 교체했습니다. 소프트 한도를 넘기면 zend_mm_delayed_error()가 에러를 즉시 던지지 않고 EG(delayed_oom)에 지연 기록한 뒤 인터럽트 플래그만 세웁니다 — 실제 에러 처리는 다음 “VM safe point”(실행이 안전하게 멈출 수 있는 지점)까지 미뤄집니다. 하드 한도(더 높은 2차 임계값)를 넘기면 zend_mm_hard_limit_error()가 메시지를 stderr에 남기고 _exit(137)로 프로세스를 즉시 종료합니다 — 복구를 시도하지 않고 워커 자체를 버립니다 [확인](커밋 diff Zend/zend_alloc.c/.h 직접 확인). 호출부가 “할당 실패 시 NULL을 돌려줄 수 있는” 새 femalloc/fecalloc 계열 fallible 할당 함수로 바뀐 지점이 코어와 약 30여 개 확장(ext/json, ext/pcre, ext/mbstring, ext/gd 등)에 걸쳐 있습니다 [확인].

미확정. sansec의 2차 분석은 이 두 CVE의 메커니즘을 GHSA와 반대로 매핑합니다 — sansec은 109618을 “업로드 진행률 unserializer UAF”, 109619를 “fatal-error backtrace UAF + OPcache type confusion”으로 서술합니다. 이 글은 GHSA/php.net/실제 패치 커밋을 1차출처로 교정해 **109618=OOM/longjmp, 109619=stale global state(업로드 진행률·INI 진단 모두 이쪽 consumer)**로 썼습니다. OPcache type confusion 연계는 GHSA 본문·패치 diff 어디에도 명시되지 않았습니다 — sansec만의 주장이며 미확정입니다. 마찬가지로 “PHP 5.4(2012)부터 취약 코드가 존재했다”는 sansec의 주장도 GHSA가 공식 영향범위로 못박은 건 **8.2 이상(+ 7.4/8.0/8.1 minimal backport)**뿐이라, 5.4~7.3의 실제 영향 여부는 공개 범위 밖·미확정입니다.

공격 흐름 (메커니즘 수준)

이 절은 발화 순서를 개념적으로만 서술합니다. 작동 페이로드·완성형 익스플로잇은 싣지 않습니다(랩 공개 윤리).

CVE-2026-109619 (stale global state) 발화 순서:

  1. 공격자가 보낸 요청 A가 같은 워커에서 producer 경로를 탄다 — 예: 세션 업로드 진행률 처리 중 OOM이 발생하거나, PHP 8.5에서 백트레이스 수집 중 치명적 오류가 난다. [확인](GHSA)
  2. 패치 전 코드는 이 경로의 정리를 절반만 수행한 채 요청 A를 끝낸다 — 일부 실행기 전역(에러 핸들러·백트레이스·예외 포인터 등)이 요청 A의 메모리를 여전히 가리킨 채 남는다. [확인]
  3. 요청 A의 힙은 free된다. 이제 전역이 가리키는 메모리는 해제된 메모리다(dangling pointer). [분석](1·2단계로부터의 논리적 귀결 — GHSA가 UAF로 분류한 근거와 일치)
  4. 같은 워커가 요청 B를 받는다. 요청 B가 “정상 초기화보다 먼저 도는” consumer 경로(세션 업로드 진행률·INI 진단 등)를 타면, 그 경로는 아직 리셋되지 않은 전역을 읽거나, free하거나, 그 값에 따라 분기한다. [확인]
  5. 이 시점에서 애플리케이션 코드(라우터·컨트롤러 등)는 아직 한 줄도 실행되지 않았다 — 요청 B가 request init을 마치기 전이기 때문이다. 메모리 손상은 WordPress·Magento 같은 앱의 설정과 무관하게 일어난다. [확인](GHSA·php.net 공통 서술)
  6. 공격자가 요청 A/B의 내용과 타이밍을 조정해 힙 배치를 다듬으면, 이 use-after-free를 메모리 노출(다른 요청의 세션 토큰·API 키 등) 또는 코드 실행으로 격상시킬 수 있다. 격상의 구체적 성공 조건은 공개 범위 밖이다. [분석]

CVE-2026-109618 (OOM corruption) 발화 순서:

  1. 공격자가 multipart 본문(무제한 길이의 디코딩 데이터, 과다한 헤더, 혹은 중첩 필드명으로 만든 대형 배열)을 보낸다. [확인]
  2. 이 처리가 request-startup 중, 메모리 구조체가 완전히 초기화되지 않은 지점에서 할당을 시도한다. [확인]
  3. 그 할당이 (패치 전 설계상) 메모리 한도를 넘겨 실패하면, longjmp 기반 즉시 탈출이 현재 컨텍스트를 건너뛴다 — 그 컨텍스트 안에 있던 “일관성 유지용 정리”도 함께 건너뛴다. [확인]
  4. request-shutdown 루틴이 이 반쪽짜리 상태를 정상인 것처럼 처리한다 — 이 손상은 애초의 할당 실패와 직접 관련 없는 영역까지 번질 수 있다. [확인]
  5. mod_php에서는 이 경로가 기본 설정값만으로, 애플리케이션 코드 실행 전에 완전한 RCE로 실증됐다. [확인](GHSA)

영향·SAPI 위험면

GHSA는 SAPI(서버 연동 방식)별로 확증 수준을 다르게 적었습니다 — 이 구분을 그대로 옮기는 게 과장을 피하는 길입니다.

SAPICVE-2026-109618CVE-2026-109619
Apache mod_php확인 — 기본 설정만으로 HTTP 완전 RCE 실증확인 — PHP 8.5 fatal_error_backtraces 경로로 RCE 실증
PHP-FPM추정 — 소스 분석상 취약 가능성, 완전한 익스플로잇은 미실증확인 — 세션 업로드 진행률 경로로 실증
FrankenPHP / LiteSpeed(LSAPI)추정 — “유사 가능성 높음”으로만 서술LSAPI: 조건부 가능성 — GHSA-6wmf-4rv5-p3rp는 LSAPI를 “조건부 후보(conditional candidate)“로 명시([확인], “LSAPI can apply special environment INI values early and is a conditional candidate”). FrankenPHP: 기본 설정상 조기 INI consumer가 확인되지 않음(“An equivalent early configuration consumer was not established in the default configuration of FrankenPHP”)이지만, 면제는 아님 — advisory가 바로 이어서 “This does not make this environment immune”라고 명시 [확인].
php-cgiphp.net 공지가 영향 SAPI로 명시, GHSA는 세부 등급 미서술동일

[확인](표의 각 등급은 GHSA 원문 표현을 그대로 반영) — 결론적으로 mod_php가 두 CVE 모두에서 가장 위험합니다. 요청마다 프로세스를 새로 띄우는 구조가 아니라 같은 워커가 요청을 재사용하는 모델이라 producer→consumer가 한 워커 안에서 맞아떨어지기 쉽고, 두 CVE 다 여기서 “실증(demonstrated)“까지 갔습니다. PHP-FPM도 109619는 실증됐지만 109618은 아직 실증 사례가 공개되지 않았다는 비대칭이 있습니다 — 이건 “FPM이 안전하다”는 뜻이 아니라 “공개된 실증이 아직 없다”는 뜻으로 읽어야 합니다. 109619의 FrankenPHP·LSAPI 칸도 같은 독법입니다 — “확인된 실증이 없다”가 “안전하다”로 읽혀서는 안 됩니다. GHSA 본문도 FrankenPHP에 대해 “이 환경이 면제되는 건 아니다(not immune)“라고 못박았고, LSAPI는 FPM과 유사한 조기 INI 소비 경로를 가질 “조건부 후보”로만 분류했습니다.

GHSA·php.net 공통으로 강조하는 지점 하나 더: 이 버그는 애플리케이션 코드 실행 전에 발화합니다. WordPress·Drupal·Joomla·Laravel·Magento 같은 프레임워크/CMS 자체가 완벽하게 안전하게 짜여 있어도 영향에서 벗어나지 못합니다 [확인]. 애플리케이션 방화벽·유지보수 모드처럼 “앱 코드가 실행된 뒤” 작동하는 방어는 이 틈에서 전부 무력합니다.

완화(대응방안)

1) 안전 버전으로 업데이트 (최우선, 유일한 완전 해법). php.net 공지 원문을 직접 확인했습니다(https://www.php.net/archive/2026.php, 2026-10-21-5) — 공지는 “업데이트만이 완전한 해결책이며, 아래 완화책들은 특정 공격 경로의 노출을 줄일 뿐 설치를 안전하게 만들지는 못한다”는 취지로 못박았습니다(직접 인용은 “do not make an installation safe” [확인]). 즉 아래 완화는 전부 특정 공격 경로의 노출을 줄이는 부분 조치일 뿐 설치 자체를 안전하게 만들지는 않는다고 벤더가 직접 못박았습니다. 위 표의 패치 버전(8.2.35 / 8.3.36 / 8.4.27 / 8.5.12) 이상으로, 또는 7.4/8.0/8.1이면 공식 소스 백포트로 올리십시오. 8.1 이하는 지원 종료 라인이라 디스트로/LTS 벤더 자체 백포트를 확인해야 합니다.

2) 업데이트가 즉시 어려울 때의 차선(부분 완화 — 패치의 대체는 아님).

  • CVE-2026-109618(OOM): post_max_size를 줄이고(upload_max_filesize는 그보다 작게), 컨트롤 가능한 memory_limit을 제거(php.net 공지는 memory_limit = -1을 FPM 풀/Apache 설정에서 php_admin_value로 고정하라고 권고 — 애플리케이션이 이를 덮어쓰지 못하게). multipart 업로드 자체를 프런트엔드에서 차단할 수 있다면 그것도 대안.
  • CVE-2026-109619(stale global state): session.upload_progress.enabled = 0을 php.ini/FPM 풀/서버 설정 수준에서(런타임 ini_set()이나 .user.ini가 아니라) 비활성화. PHP 8.5라면 fatal_error_backtraces = 0도 확인된 경로 하나를 막습니다.
  • 공통 주의: RLIMIT_AS나 RLIMIT_DATA로 메모리를 제한하는 방식은 쓰지 마십시오 — php.net 공지가 명시적으로 “이 취약 경로를 트리거할 수 있다”고 경고합니다 [확인]. 반대로 메모리 상한을 “해제”하는 쪽(memory_limit=-1)이 109618 한정으로 공식 권고임을 주의 깊게 읽어야 합니다(DoS 트레이드오프는 [분석]이지만, 벤더가 직접 권고한 완화입니다 [확인]).
  • 모든 설정 변경 뒤에는 모든 워커를 재시작해야 적용됩니다.
  • [주석] memory_limit=-1의 위치: 이는 벤더 권고 임시 완화이나 패치의 대체는 아니며, 공지도 근본 할당 실패가 취약 경로에 닿을 수 있다고 단서를 답니다 [확인]. 무제한 설정의 DoS 트레이드오프는 저희 분석입니다 [분석]. RLIMIT_AS/RLIMIT_DATA는 공지가 금지하며, 가용성 경계는 cgroup/컨테이너 한도로 두라는 것이 공지의 취지입니다 [확인]. 일부 2차 가이드(Xint)가 말하는 OS 수준 메모리 제한 병행도 같은 취지일 수 있습니다 [2차]. 어느 쪽이든 업데이트가 유일한 완전 해법입니다 [확인].

3) 탐지 관점. 두 CVE 모두 “성공한 공격은 조용하고, 실패한 시도만 크래시·타임아웃을 남긴다”는 성질을 공유할 가능성이 높습니다(패치가 state corruption을 막는 방향이라, 패치 전 공격 성공 여부를 운영 로그만으로 사후 확정하기는 어렵습니다 [분석]). 실무적으로는 ① 패치 배포 시점(2026-10-21) 이전/이후의 비정상 워커 재시작·세그폴트·OOM 로그 급증을 1차 신호로, ② fatal_error_backtraces 활성 상태에서의 비정상 에러 패턴, ③ 비정상적으로 크거나 중첩이 깊은 multipart 업로드 요청을 보강 신호로 보는 접근이 합리적입니다. 이는 우리 탐지 제안이며 벤더 공지에 포함된 내용은 아닙니다 [분석].

호스트측 탐지 지표 (운영 관찰 — 확증 아님)

한계 먼저. 아래는 [2차·운영 관찰] 입니다. 보안 대응 가이드 등(Xint/Theori 등 2차 출처)에서 운영 관찰로 보고되는 지표를 우리 1차출처 메커니즘과 연결해 본 것이며, 저희가 재현하거나 실측하지 않았습니다. 이들은 트리거 시도나 메모리 손상의 “부수 현상 후보”일 뿐, 공격 성공의 확증이 아닙니다. 정상 장애(트래픽 폭주, 일반 OOM, 다른 버그)에서도 같은 흔적이 나올 수 있어 오탐이 큽니다. 앞서 말했듯 성공한 공격은 조용할 수 있어 지표가 없다고 안전하다는 뜻도 아닙니다 [분석].

관찰 지표배지1차출처 기반 연결 설명
PHP 에러 로그에 파일 unknown, 줄 0으로 찍힌 “Allowed memory size … exhausted” 류 메모리 소진[2차·운영 관찰]109618의 OOM은 request-startup 중 실행 파일 문맥이 아직 없는 시점에 날 수 있습니다 [확인](GHSA 서술). 문맥이 없으면 파일·줄이 비어 보일 수 있다는 것은 [분석](추론). 이 로그가 “unknown:0”인 이유를 PHP가 공식 설명한 것은 아닙니다
워커 프로세스 SIGSEGV, 커널 로그(dmesg)의 segfault[2차·운영 관찰]해제된 메모리 접근(UAF) 또는 반쪽 상태 처리는 비정상 종료를 낳을 수 있습니다 [분석]. 실패한 시도가 크래시를 남기고 성공은 조용할 수 있다는 앞 절의 가설과 같은 맥락입니다
평소보다 큰 세션 파일, 비정상적으로 긴 세션 쿠키[2차·운영 관찰]109619의 consumer 중 하나가 세션 업로드 진행률 경로입니다 [확인](GHSA). 세션 데이터가 이 경로로 읽히고 다시 쓰이므로 세션 저장소가 관찰 지점이 될 수 있다는 것은 [분석]
대용량 세션 파일이 약 24분 뒤 정리되는 패턴(GC)[2차·운영 관찰]session.gc_maxlifetime 기본 1440초(24분)는 1차출처(GHSA·php.net 공지·패치 커밋·PHP 매뉴얼) 중 PHP 매뉴얼 기준 사실입니다 [확인]. 다만 이 패턴을 공격 지표로 쓰는 것은 [2차·운영 관찰]이며, 설정에 따라 달라 오탐이 큽니다
워커 재시작 급증(FPM이면 child exited, Apache면 child 종료)[2차·운영 관찰]위 SIGSEGV의 결과로 나타날 수 있습니다 [분석]. 또한 패치된 빌드에서는 하드 메모리 한도 초과 시 stderr 메시지와 함께 _exit(137)로 워커가 즉시 종료되므로 [확인](커밋 diff, 위 (a)), 패치 이후엔 “재시작 급증 = 시도 흔적”으로 읽을 여지가 달라집니다 [분석]

활용 요령은 단순합니다. 패치 배포 시점(2026-10-21) 전후의 기준선과 비교하고, 위 지표 중 둘 이상이 같은 시간대에 모이거나 앞서 설명한 네트워크측 신호(multipart 요청 로그 등)와 겹칠 때만 조사 대상으로 올리십시오 [분석]. 지표 단독으로 침해를 선언하지 마십시오. 침해가 의심되면 로그를 보존하고 시크릿 회전 등 사고대응으로 넘어가는 것이 맞습니다 [분석].

패치 diff — 실제 소스

아래 4개 블록은 패치 커밋 31c0ea7d7ff1d1fccb6492e52dab544011628065의 실제 unified diff(https://github.com/php/php-src/commit/31c0ea7d7ff1d1fccb6492e52dab544011628065.diff, 423개 파일 전체 중 핵심 4개 파일)에서 발췌한 것입니다. 출처는 모두 php-src, PHP License(퍼미시브 — 출처 명시 하 재게시 가능)이고, 큰 리팩터(특히 (a))는 개념용으로 핵심만 발췌했습니다. 이건 저희가 재현한 공격 코드가 아니라 수비측 패치 소스입니다.

(a) Zend/zend_alloc.c — longjmp 기반 zend_mm_safe_error() 제거 → zend_oom_state 상태기계 출처: php-src 31c0ea7d7ff1d1fccb6492e52dab544011628065, Zend/zend_alloc.c(+Zend/zend_alloc.h) · PHP License · 개념용 발췌(원본 diff는 +527/−124줄, 아래는 핵심 함수만 축약)

 // 패치 전 — 한도 초과 즉시 longjmp (zend_bailout), 복구 불가
-static ZEND_COLD ZEND_NORETURN void zend_mm_safe_error(zend_mm_heap *heap,
-	const char *format, size_t limit, size_t size)
-{
-	heap->overflow = 1;
-	zend_try {
-		zend_error_noreturn(E_ERROR, format, limit, size);
-	} zend_catch {
-	} zend_end_try();
-	heap->overflow = 0;
-	zend_bailout();      // ← 현재 컨텍스트를 건너뛰고 점프. 정리 코드도 같이 건너뜀
-	exit(1);
-}

 // 패치 후 — 소프트 한도는 "지연", 하드 한도만 즉시 종료(_exit, 복구 시도 안 함)
+typedef enum _zend_oom_state {
+	ZEND_OOM_STATE_NONE = 0,
+	ZEND_OOM_STATE_PENDING,
+	ZEND_OOM_STATE_HANDLING,
+	ZEND_OOM_STATE_HANDLED
+} zend_oom_state;
+
+#define ZEND_HAS_OOM() (UNEXPECTED(EG(delayed_oom).state != ZEND_OOM_STATE_NONE))

+static ZEND_COLD void zend_mm_delayed_verror(const char *format, va_list args)
+{
+	if (ZEND_HAS_OOM()) {
+		return;                       // 이미 OOM 처리 중이면 중복 기록 안 함
+	}
+	/* ... 현재 실행/컴파일 중인 파일·라인 캡처 ... */
+	EG(delayed_oom).message = zend_string_init(buf, strlen(buf), /* persistent */ true);
+	EG(delayed_oom).state = ZEND_OOM_STATE_PENDING;   // ← 즉시 던지지 않고 "지연 기록"만
+	zend_atomic_bool_store_ex(&EG(vm_interrupt), true); // 다음 VM safe point에서 처리
+}

+#ifdef ZEND_MM_LIMIT
+static ZEND_COLD ZEND_NORETURN void zend_mm_hard_limit_error(zend_mm_heap *heap, size_t add_size ZEND_FILE_LINE_DC)
+{
+	fprintf(stderr, "Hard memory limit of %zu bytes exhausted ...\n", heap->hard_limit, add_size, ...);
+	_exit(137);                        // ← 워커 자체를 즉시 버림. 복구 시도 없음
+}
+#endif

 static zend_result zend_mm_check_limit(zend_mm_heap *heap, size_t add_size, ...) {
+	if (!heap->fallible && add_size > heap->hard_limit - heap->real_size) {
+		zend_mm_hard_limit_error(heap, add_size ZEND_FILE_LINE_RELAY_CC);  // 하드 한도: 즉시 종료
+	}
+	if (!gc || (heap->limit < heap->real_size || add_size > heap->limit - heap->real_size)) {
+		if (!ZEND_HAS_OOM()) {
+			zend_mm_delayed_error("Allowed memory size of %zu bytes exhausted ...", heap->limit, add_size); // 소프트 한도: 지연
+		}
+	}
 }

(b) Zend/zend_execute_API.c — zend_shutdown_executor_values()에 전역 리셋 블록 신규 추가 (109619 근본원인 수정) 출처: php-src 31c0ea7d7ff1d1fccb6492e52dab544011628065, Zend/zend_execute_API.c 424행 부근 · PHP License · 실제 hunk(요약 없이 그대로)

 ZEND_API void zend_shutdown_executor_values(bool fast_shutdown)
 {
 	} else {
+		/* may be used by zend_error_zstr_at before init_executor */
+		ZVAL_UNDEF(&EG(user_error_handler));
+		ZVAL_UNDEF(&EG(user_exception_handler));
+		memset(&EG(user_error_handlers_error_reporting), 0, sizeof(EG(user_error_handlers_error_reporting)));
+		memset(&EG(user_error_handlers), 0, sizeof(EG(user_error_handlers)));
+		memset(&EG(user_exception_handlers), 0, sizeof(EG(user_exception_handlers)));
+
 		zend_hash_discard(EG(zend_constants), EG(persistent_constants_count));
 	}

 	zend_objects_store_free_object_storage(&EG(objects_store), fast_shutdown);
+
+	/* may be used by zend_error_zstr_at before init_executor */
+	ZVAL_UNDEF(&EG(last_fatal_error_backtrace));
+	EG(record_errors) = false;
+	EG(num_errors) = 0;
+	EG(errors) = NULL;
+	EG(error_handling) = EH_NORMAL;
+	EG(exception) = NULL;
 }

이 주석(“may be used by zend_error_zstr_at before init_executor”)이 그대로 109619의 근본원인입니다 — 개발자들이 직접 “다음 요청의 초기화 전에 이 전역이 읽힐 수 있다”는 틈을 적어 놓고 고쳤습니다.

(c) ext/session/session.c / ext/session/php_session.h — in_rfc1867_update 가드로 __PHP_Incomplete_Class 강제 (consumer 2차 방어선) 출처: php-src 31c0ea7d7ff1d1fccb6492e52dab544011628065, ext/session/php_session.h 204행 · ext/session/session.c 954행·3177행 부근 · PHP License · 실제 hunk(요약 없이 그대로, 일부 연속 라인 생략은 ...로 표기)

--- a/ext/session/php_session.h
+++ b/ext/session/php_session.h
 typedef struct _php_ps_globals {
 	zend_string *session_vars; /* serialized original session data */
+
+	bool in_rfc1867_update;    /* php_session_rfc1867_update() is running */
 } php_ps_globals;

--- a/ext/session/session.c
+++ b/ext/session/session.c
+/* The upload progress handler (php_session_rfc1867_update()) decodes and
+ * re-encodes the session during request startup, before RINIT, when no user
+ * code may run. Objects are then kept as __PHP_Incomplete_Class: no
+ * autoloading, no unserialize_callback_func, no __wakeup() or __unserialize(),
+ * and they are written back unchanged. */
+static void php_session_unserialize_init(php_unserialize_data_t *var_hash)
+{
+	PHP_VAR_UNSERIALIZE_INIT(*var_hash);
+	if (PS(in_rfc1867_update)) {
+		php_var_unserialize_set_incomplete_classes_only(*var_hash, true);
+	}
+}
 ...
 static void php_session_rfc1867_update(php_session_rfc1867_progress *progress, bool ...)
 {
-	php_session_initialize();
-	PS(session_status) = php_session_active;
+	/* Runs before RINIT: no user code may be invoked, see php_session_unserialize_init() */
+	PS(in_rfc1867_update) = true;
+	if (php_session_initialize() == FAILURE || PS(session_status) != php_session_active) {
+		PS(in_rfc1867_update) = false;
+		return;
+	}
 	...
 	php_session_flush(1);
+	PS(in_rfc1867_update) = false;
 }

여기가 정확히 §7의 “세션 업로드 진행률” consumer입니다 — in_rfc1867_update 플래그가 세워진 구간에서는 unserialize가 __wakeup/__unserialize/오토로딩을 절대 호출하지 못하고 __PHP_Incomplete_Class만 만들도록 강제됩니다. producer를 막는 (b)와 별개로, consumer 쪽에 독립적인 하드닝을 넣은 것입니다.

(d) Zend/zend_builtin_functions.c — debug_backtrace_get_args()에 ZEND_HAS_OOM()/goto oom 분기 신규 (PHP 8.5 fatal_error_backtraces consumer 하드닝) 출처: php-src 31c0ea7d7ff1d1fccb6492e52dab544011628065, Zend/zend_builtin_functions.c 1722행 부근 · PHP License · 개념용 발췌(반복되는 3개 유사 분기 중 1개만, 반환형도 void→bool로 바뀜)

-static void debug_backtrace_get_args(zend_execute_data *call, zval *arg_array)
+static bool debug_backtrace_get_args(zend_execute_data *call, zval *arg_array)
 {
 	...
 			object_init_ex(&redacted_arg, zend_ce_sensitive_parameter_value);
-			zend_call_known_function(Z_OBJCE_P(&redacted_arg)->constructor, ...);
+			zend_sensitive_parameter_value_init(Z_OBJ(redacted_arg), &original_arg);
 			ZEND_HASH_FILL_SET(&redacted_arg);
+			if (ZEND_HAS_OOM()) {
+				zval_ptr_dtor(&redacted_arg);
+				ZEND_HASH_FILL_FINISH();
+				goto oom;              // ← OOM이 걸리면 더 진행하지 않고 안전하게 빠짐
+			}
 	...
+	return true;
+
+oom:
+	zval_ptr_dtor(arg_array);
+	ZVAL_EMPTY_ARRAY(arg_array);
+	return false;              // ← 호출자(zend_fetch_debug_backtrace)가 false를 받으면 중단
 }

패치 완전성 평가

이 평가는 정적 신호(패치 diff·GHSA 서술)에 기반한 가설입니다. 동적 확증(실제 악용 재현)은 하지 않았고, 할 계획도 랩 공개 윤리상 없습니다.

Q1 — 근본원인을 완전히 막았나, 증상만 막았나?

양쪽 CVE 모두 설계 수준의 수정으로 보입니다 [확인](diff 구조 기반). 109618은 “OOM을 즉시 longjmp로 처리한다”는 설계 자체를 “지연된 상태 기계(delayed OOM state) + 다음 safe point까지 미루는 소프트 OOM + 즉시 _exit()하는 하드 OOM”으로 바꿨습니다 — 특정 함수 하나에 null 체크를 끼워 넣는 수준이 아니라, 코어 메모리 관리자(zend_alloc.c, +527/−124줄)와 그 위에서 도는 30여 개 확장의 할당 호출부를 fallible 할당(femalloc/fecalloc) 관용구로 전환한 전면 개편입니다. 109619도 “전역을 리셋하지 않는다”는 근본 문제를 zend_shutdown_executor_values()에 명시적 리셋 블록을 추가해 막았고, 추가로 세션 업로드 진행률 경로(consumer 쪽)에는 “이 구간에서는 __PHP_Incomplete_Class만 만든다”는 2차 방어선까지 넣었습니다. producer를 막고 consumer도 하드닝한 양면 수정이라는 점에서 근본원인 대응의 신호가 강합니다.

다만 이 커밋은 423개 파일, +11,556/−1,017줄 규모입니다. 이 글은 핵심 코어 파일(zend_alloc.c/.h, zend.c, zend_execute_API.c, zend_globals.h, zend_builtin_functions.c, ext/session/session.c·php_session.h)만 직접 열어 대조했고, 나머지 ext 확장 전부를 라인 단위로 보지는 못했습니다 — “모든 확장의 모든 할당 호출부가 새 관용구로 정확히 전환됐는가”는 공개 범위 밖입니다.

Q2 — 같은 뿌리의 다른 입구(형제 call site·variant)가 남았나?

있을 수 있다는 걸 GHSA 자신이 인정합니다. 109619 설명은 확인된 producer/consumer 쌍을 “confirmed cases”로 명시한 뒤, 별도로 **“conditional cases: 애플리케이션·확장·콜백 동작에 따라 달라지는 다른 에러/예외/오브젝트/확장 상태”**를 적어 놨습니다 [확인]. 이는 벤더가 “지금 패치된 건 확인된 경로들이고, 서드파티 확장이나 특정 애플리케이션 조합에서 같은 뿌리의 다른 producer/consumer 쌍이 있을 수 있다”는 걸 스스로 열어 둔 것입니다. 109618 쪽은 php-src 자체 확장은 폭넓게 전환됐지만, php-src 바깥의 PECL/서드파티 확장이 여전히 구식 emalloc/ecalloc를 쓰면서 자체 전역 상태를 들고 있다면 같은 longjmp 취약 패턴에 노출될 수 있습니다 — 이건 저희 추론입니다. [추정]

Q3 — 백포트(7.4/8.0/8.1 minimal)가 동등한가?

아니오, 동등하지 않습니다 — 그리고 이건 벤더가 직접 명시한 사실입니다. php.net 공지는 EOL 라인에 넘버링된 릴리스 대신 **“minimal source-backport”**만 제공한다고 적었습니다 [확인]. “minimal”이라는 단어 자체가 8.2+ 라인의 전체 개편(코어 메모리 관리자 재설계 + 30여 개 확장 전환)과 같은 수준이 아니라는 뜻입니다. 이 CVE 쌍에 대해 “조용한 재패치가 있었다”는 전례(예: 다른 벤더에서 본 ‘패치가 패치를 뚫린’ 사례)는 아직 공개된 바 없습니다 — 이건 첫 패치이지 재패치가 아닙니다. 하지만 백포트가 명시적으로 비동등하다는 사실 자체가, 7.4/8.0/8.1을 쓰는 운영 환경에는 별도의 검증이 필요하다는 신호입니다.

왜 이게 반복되는가 — 논평

이 두 CVE의 진짜 공통 뿌리는 “메모리 손상”이 아니라 **“요청 소유 자원과 워커 전역 자원의 생명주기가 코드 한 줄 차이로 겹친다”**는 설계입니다. 정상 종료 경로만 보면 문제가 없습니다. 깨지는 건 비정상 종료(fatal error, OOM, longjmp 탈출)가 “정리”를 선택적으로 건너뛸 때입니다. 이건 저희가 전에도 본 형태입니다 — 커널 nf_tables(CVE-2024-1086)나 Dirty Pipe류의 “구조체 필드가 초기화 안 된 채 재사용된다”는 패턴과 동형(isomorphic)입니다. 차이는 무대뿐입니다: 거기는 커널 구조체, 여기는 PHP 실행기 전역입니다.

여기서 한 가지 설계 교훈이 또렷합니다 — 예외 경로(exception path)는 정상 경로보다 테스트가 덜 됩니다. longjmp로 “어디서든 빠져나올 수 있다”는 편의성은, 동시에 “그 어디든 빠져나온 자리 뒤에 뭘 안 치웠는지”를 전부 추적해야 한다는 부담을 떠안깁니다. 패치가 longjmp 즉시 탈출을 지연된 상태 기계로 바꾼 선택은 그래서 의미가 있습니다 — “언제 끊을지”를 코드 어디서나가 아니라 **정해진 안전 지점(safe point)**으로 좁혔습니다. 이건 같은 뿌리의 문제를 “일단 막는다”가 아니라 “애초에 덜 생기게 만든다”는 방향입니다. 다만 Q2에서 봤듯, 벤더 스스로도 “조건에 따라 달라지는 다른 사례”가 남아 있을 수 있다고 인정한 만큼, 이 뿌리가 php-src 전체와 서드파티 확장 생태계에서 완전히 뽑혔다고 단정할 근거는 아직 없습니다.

긴급도 갱신 (2026-10-23)

2026-10-23 기준, 78ResearchLab이 이 두 CVE에 대한 공개 full-chain 익스플로잇 PoC를 공개했다는 제보가 있습니다(사용자 제공 URL 기준 — 저희가 저장소 내용을 열어보지 않았고, 이 글에도 그 내용을 싣지 않습니다). 이는 위협모델이 “랩 재현 또는 비공개 역량이 있어야 가능한 단계”에서 공개 아티팩트로 악용이 실증·대중화되는 단계로 이동했다는 신호이므로, 긴급도를 한 단계 높여 읽어야 합니다. 대응은 그대로입니다 — 즉시 업데이트(8.2.35 / 8.3.36 / 8.4.27 / 8.5.12, 7.4/8.0/8.1은 공식 소스 백포트 또는 벤더 백포트)가 유일한 완전 해법이며, 위 완화 절의 부분 조치(session.upload_progress.enabled=0 등)는 패치 적용 전 노출을 줄이는 임시 수단일 뿐입니다. 공개 익스플로잇의 실제 효능(성공률·대상 SAPI·버전 범위)은 저희가 검증하지 않았습니다. 공개 익스플로잇을 실행하거나 재현하지 않았으므로, 작성자의 주장은 미검증으로 취급해 주십시오 [미확정].

ITW(실환경 악용) 보고 — 긴급도 갱신 (2026-10-25)

Palo Alto Networks Unit 42는 2026-10-23에 갱신한 Threat Brief에서, 이 두 CVE와 관련해 “에이전틱(agentic) AI 공격 플랫폼을 이용한 후속 공격(post-exploitation) 활동을 관측했다”고 밝혔습니다 [확인=Unit42]. 공개된 사례 하나는 유럽 소재 정부기관을 대상으로, 초기 침해 이후 2시간 내 패스워드 해시와 다중인증(MFA) 비밀정보가 탈취됐다고 서술돼 있습니다 [확인=Unit42](Unit 42, “Threat Brief: Critical Vulnerabilities in PHP: CVE-2026-109618, CVE-2026-109619”, 2026-10-23 갱신). 저희는 이 페이지의 raw HTML을 직접 받아 byte 단위로 해당 문장을 대조했습니다.

한 가지 짚어둘 점 — 같은 글의 Executive Summary 문단은 “Unit 42 has seen proof of concept of these vulnerabilities(PoC를 확인했다)“는 더 보수적인 표현을 그대로 남겨 두고 있어, 같은 글 안에서 두 서술이 완전히 정합하지는 않습니다(상충 소지, 저희가 직접 확인). 다만 뒤에 추가된 구체 사례 서술(피해기관·탈취 정보 종류·공격 행위의 특징)이 더 자세하고 가장 최근 갱신분이라, 이 글은 “Unit 42가 실환경 악용(후속 공격) 사례를 보고했다”는 쪽을 신뢰도 높은 사실로 채택합니다.

CISA KEV(Known Exploited Vulnerabilities) 카탈로그는 2026-10-25 기준(catalogVersion 2026.10.23, 공식 JSON 피드 직접 확인) 이 두 CVE를 아직 등재하지 않았습니다 [확인]. 미국 연방기관 긴급패치 의무화(BOD 22-01) 단계까지는 아직 가지 않았다는 뜻이며, Unit 42의 ITW 보고와는 별개 트랙으로 읽어야 합니다.

읽는 법(과소·과대평가 둘 다 피하기). 공개 PoC 다수가 특수 설정에서만 동작한다거나 범용성이 낮다는 관찰만 보고 “실제 공격 가능성이 낮다”고 단정하면 안 됩니다 — Unit 42의 ITW 보고는 적어도 한 건의 실제 피해 사례가 존재함을 보여줍니다. 동시에 저희는 그 공격 기법이나 재현 절차를 검증하거나 게재하지 않습니다 [미검증] — 이 글의 원칙(완성형 익스플로잇·공격 절차 미게재)은 그대로 유지합니다. 결론은 바뀌지 않습니다: 패치가 유일한 완전 해법이며, 위 완화 조치는 패치 전 노출을 줄이는 임시 수단일 뿐입니다.

결론

워커가 요청을 재사용하는 구조 자체는 PHP 성능의 핵심입니다. 문제는 그 재사용이 “이번 요청의 기억은 다 지웠다”는 약속을 전제로 한다는 데 있습니다. 2026년 10월 21일 패치는 그 약속을 두 군데서 다시 손봤습니다 — OOM이 약속을 어기는 경로(109618)와, 전역 상태가 약속을 어기는 경로(109619)입니다. 둘 다 고쳐졌지만, “비정상 종료 경로가 정리를 절반만 한다”는 사고방식은 이 코드베이스 밖에서도, 심지어 이 코드베이스 안의 다른 자리에서도 또 나올 수 있습니다. 그래서 우리는 도구가 아니라 방법을 남깁니다: 정상 종료만 보지 말고, 비정상 종료 경로가 “어디까지 치웠는지”를 끝까지 추적할 것.

타임라인 / 크레딧

  • 보고자: sagitzadik-wiz, tomash-areion, lcfr-hax, amyb-oai [확인](GHSA 공통)
  • 수정(remediation) 개발자: Arnaud Le Blanc(arnaud-lb, 커밋 author), Ilija Tovilo(iluuu1994, 커밋 committer 겸 co-author), Jakub Zelenka(bukka), Nora Dossche(핸들 ndossche, 커밋 co-author 라인 “Nora Dossche [email protected]”로 1차출처 교정 — 종래 표기 “Niels Dossche”는 오기), Nicolas Grekas(co-author) [확인](GitHub API commits/31c0ea7d7ff1d1fccb6492e52dab544011628065의 커밋 메시지 co-author 트레일러 직접 확인; php.net ChangeLog-8.php의 8.5.12 Core 항목은 GHSA 번호만 적고 개별 기여자 실명을 적지 않아 교차대조 불가 — 이름의 1차출처는 커밋 메시지뿐)
  • 2026-10-21: php.net 공지 발행 + 8.2.35/8.3.36/8.4.27/8.5.12 동시 릴리스 [확인]
  • 패치 커밋(공통 반영분): php-src 31c0ea7d7ff1d1fccb6492e52dab544011628065(부모 dd830558ffb1ac8378e812b4db97626e6087de3f), 메시지 “Avoid state corruption via bailout” — “Fix GHSA-w8hw-w9fx-j79w (CVE-2026-109618)” / “Fix GHSA-6wmf-4rv5-p3rp (CVE-2026-109619)“를 한 커밋에 명시 [확인]

패치 출시 전후 악용 타임라인(발견·보고·유출·조기 공격 관측 등)은 sansec의 2차 분석에서만 서술되며, 이 글의 1차출처(GHSA/php.net)에는 포함돼 있지 않습니다 — 그래서 본문에는 반영하지 않았고, 별도 검증 없이 날짜를 단정하지 않았습니다. [미확정]

References / 참고자료

  1. (재구성한 원 분석) PHP, “PHP 8.5.12, 8.4.27, 8.3.36, and 8.2.35 released” 보안 공지 — https://www.php.net/archive/2026.php#2026-10-21-5
  2. php/php-src, GHSA-w8hw-w9fx-j79w “RCE via OOM corruption” (CVE-2026-109618) — https://github.com/php/php-src/security/advisories/GHSA-w8hw-w9fx-j79w
  3. php/php-src, GHSA-6wmf-4rv5-p3rp “RCE via stale global state” (CVE-2026-109619) — https://github.com/php/php-src/security/advisories/GHSA-6wmf-4rv5-p3rp
  4. php/php-src, 패치 커밋 “Avoid state corruption via bailout” (31c0ea7d7ff1d1fccb6492e52dab544011628065) — https://github.com/php/php-src/commit/31c0ea7d7ff1d1fccb6492e52dab544011628065
  5. PHP ChangeLog — https://www.php.net/ChangeLog-8.php#8.5.12 / #8.4.27 / #8.3.36 / #8.2.35
  6. (2차 분석, 흡수 정본을 통해서만 참고— 원문 미직접인용) sansec, “php2shell” 분석. 메커니즘 라벨은 본문에서 1차출처 기준으로 교정했습니다.
  7. Palo Alto Networks Unit 42, “Threat Brief: Critical Vulnerabilities in PHP: CVE-2026-109618, CVE-2026-109619” (2026-10-23 갱신) — https://unit42.paloaltonetworks.com/cve-2026-109618-cve-2026-109619/ (ITW 보고 단락의 근거, raw HTML 직접 대조)
  8. ENKI WhiteHat(엔키화이트햇), “PHP2Shell(CVE-2026-109618 & CVE-2026-109619) 취약점 완전 분석” (2026-10-23) — https://www.enki.co.kr/media-center/blog/vulnerability-analysis-of-php2shell (경쟁사의 독립 메커니즘 분석 — 저희 1차출처 기반 메커니즘 서술과 교차확인되는 2차 자료로 참고. 원문 인용 없음)

이 글은 위 1차출처(php.net 공지·GHSA·실제 패치 커밋)를 직접 열어 우리 관점에서 다시 쓰고 논평을 더한 것입니다. 특정 문장·코드를 그대로 옮기지 않았으며, 작동 가능한 익스플로잇·페이로드는 싣지 않습니다(랩 공개 윤리). 자세한 재현 절차는 원문을 참고하세요.

배지: [확인] = 1차출처(php.net 공지·GHSA·php-src 패치 커밋)로 직접 확인된 사실 · [추정]/[분석] = 우리 추론(1차출처가 명시하지 않은 논리적 귀결 또는 우리 판단) · [미확정] = 1차출처로 확증되지 않아 격리한 서술(2차분석 주장·공개 범위 밖). 이 글은 우리 랩에서 재현하지 않았습니다(재현 배지 없음).


부록 — 주장 ↔ 1차출처 대조표 (독립 팩트체크용)

본문 주장근거 출처출처 내 위치/표현
109619 CVSS 9.2 / 벡터 / CWE 5종GHSA-6wmf-4rv5-p3rp”CVSS v4” 섹션, “CWE List” 섹션
109618 CVSS 9.2 / 벡터 / CWE 5종GHSA-w8hw-w9fx-j79w”CVSS v4.0 base score” 섹션, CWE 목록
영향 버전 <8.2.35/<8.3.36/<8.4.27/<8.5.12, 패치 동일 버전GHSA 양쪽 공통”Affected and Patched Versions” 표
7.4/8.0/8.1 “minimal source-backport”php.net archive 2026-10-21 공지공지 본문, EOL 라인 서술부
mod_php/PHP-FPM/php-cgi/FrankenPHP/LiteSpeed가 영향 SAPIphp.net 공지공지 본문 risk 서술부
109619 producer = fatal error 중단·OOM 중단·shutdown 비치명 진단 재populateGHSA-6wmf-4rv5-p3rp”Producers” 목록
109619 consumer = INI 진단(executor init 전) · 세션 업로드 진행률GHSA-6wmf-4rv5-p3rp”Consumers” 목록
109619 confirmed case = PHP 8.5 fatal_error_backtraces(mod_php RCE), 세션 업로드 진행률(mod_php+FPM)GHSA-6wmf-4rv5-p3rp”Confirmed cases” 목록
109619 conditional cases(다른 에러/예외/오브젝트/확장 상태) 잔존 가능GHSA-6wmf-4rv5-p3rp”Conditional cases” 목록
109619 producer/consumer가 “같은 워커”여야 함GHSA-6wmf-4rv5-p3rp”Requirement” 문장
109618 메커니즘(longjmp 기반 OOM 처리, request-startup 중 미초기화 메모리, shutdown이 반쪽 상태를 정상처리)GHSA-w8hw-w9fx-j79w”Mechanism” 섹션
109618 multipart 트리거(무제한 본문/헤더/중첩 필드)GHSA-w8hw-w9fx-j79w”Mechanism” 섹션
109618 mod_php=확인/실증, FrankenPHP·LSAPI=likely, FPM=소스분석상 가능·미실증GHSA-w8hw-w9fx-j79w”Mechanism” 섹션 SAPI별 서술
109618 패치 = longjmp 제거 + soft OOM(다음 safe point) + hard OOM(_exit())GHSA-w8hw-w9fx-j79w + 패치 커밋 Zend/zend_alloc.c/.hGHSA “Mechanism” + 커밋 diff(zend_mm_hard_limit_error, zend_mm_delayed_error)
RLIMIT_AS/RLIMIT_DATA를 메모리 한도로 쓰면 취약 경로 트리거php.net 공지완화 섹션 경고문
memory_limit = -1(관리자 강제) / post_max_size 축소가 109618 완화php.net 공지완화 섹션 ini 예시
session.upload_progress.enabled=0(php.ini/풀/서버 레벨) / fatal_error_backtraces=0(8.5)가 109619 완화php.net 공지완화 섹션 ini 예시
패치 커밋 31c0ea7d7ff1d1fccb6492e52dab544011628065이 두 CVE를 한 커밋에서 수정(“Fix GHSA-w8hw-w9fx-j79w”, “Fix GHSA-6wmf-4rv5-p3rp”)php-src 커밋 31c0ea7d7ff1d1fccb6492e52dab544011628065커밋 메시지 전문
커밋 규모 423파일 / +11,556·−1,017줄423파일 ← .diff 엔드포인트(diff --git 라인 카운트) / +11,556·−1,017 ← GitHub API commits/31c0ea7d7ff1d1fccb6492e52dab544011628065 응답 stats 필드전자: .diff 텍스트 응답 직접 확인(stats 필드엔 파일수 필드 없음, files 배열은 300개 캡이라 파일수 출처로 쓸 수 없음) / 후자: API 응답 stats 필드 직접 확인
zend_shutdown_executor_values()에 전역 리셋 블록 신규 추가(user_error_handler 등)커밋 diff Zend/zend_execute_API.c해당 hunk(주석 “may be used by zend_error_zstr_at before init_executor”)
php_session_rfc1867_update()가 RINIT 전 실행되며, 그 구간은 unserialize를 incomplete-class-only로 강제커밋 diff ext/session/session.c, php_session.hPS(in_rfc1867_update) 플래그·php_session_unserialize_init() 신규 함수
debug_backtrace_get_args()에 ZEND_HAS_OOM()/goto oom 분기 신규 추가커밋 diff Zend/zend_builtin_functions.c해당 hunk
sansec 메커니즘 매핑(109618=업로드 unserializer, 109619=backtrace+OPcache)이 GHSA와 상충 → GHSA로 교정ABSORB_cve_2026_109618_109619_php2shell_2026-10-22.md §3 + 본 글 GHSA 직접 대조ABSORB 흡수노트 “2차분석 불일치 기록”
OPcache type confusion 연계는 GHSA 미서술(미확정)GHSA 양쪽 전체본문에 해당 서술 없음(부재 확인)
PHP 5.4(2012)부터 취약코드 존재설은 미확정GHSA 양쪽(“Affected” 8.2+만 명시)GHSA에 5.4~7.x 서술 부재
109619 SAPI 표 — LSAPI=“조건부 후보(conditional candidate)”, FrankenPHP=“기본 설정상 조기 consumer 미확인 + 면제 아님(not immune)“GHSA-6wmf-4rv5-p3rp”Reachability”/SAPI 서술부(LSAPI·FrankenPHP 문단)
php.net 완화 문구 취지(간접서술) + 핵심 직접 인용 = “do not make an installation safe”(전문은 “while only an update is a complete solution, the following measures reduce exposure to specific attack paths but do not make an installation safe”)php.net archive 2026-10-21 공지”If you cannot update immediately:” 문단(공지 원문 curl로 직접 확인)
패치 커밋 co-author 실명 = “Nora Dossche”(핸들 ndossche), “Niels Dossche” 아님커밋 31c0ea7d7ff1d1fccb6492e52dab544011628065 메시지Co-authored-by: Nora Dossche <[email protected]> 트레일러(GitHub API 직접 확인)
패치 diff 코드블록(a)~(d) — zend_mm_safe_error 제거/zend_oom_state, zend_shutdown_executor_values() 리셋, in_rfc1867_update 가드, debug_backtrace_get_args() OOM 분기커밋 31c0ea7d7ff1d1fccb6492e52dab544011628065의 실제 unified diff(.diff 엔드포인트로 직접 받음)각 코드블록 상단 캡션에 파일·행 번호·실제/발췌 여부 명시
(2026-10-25 추가) Unit 42가 에이전틱 AI 공격 플랫폼 기반 후속공격을 관측, 유럽 정부기관 사례(초기침해 2시간 내 패스워드 해시·MFA 비밀 탈취)unit42.paloaltonetworks.com/cve-2026-109618-cve-2026-109619/ (2026-10-23 갱신)본문 중 “Unit 42 researchers also observed attackers using agentic AI attack platforms for post-exploitation activity. In one case, the attackers targeted a government entity in Europe…” 단락 (raw HTML curl 직접 대조)
(2026-10-25 추가) 같은 글 Executive Summary는 “PoC를 확인했다”는 더 보수적 표현을 유지 — 상충 소지동 글 Executive Summary”Unit 42 has seen proof of concept of these vulnerabilities.” 문장 (raw HTML 직접 대조)
(2026-10-25 추가) CVE-2026-109618/109619는 2026-10-25 기준 CISA KEV 미등재CISA KEV 공식 JSON 피드(known_exploited_vulnerabilities.json)catalogVersion 2026.10.23 · count 1756 · 두 CVE ID 매치 0건 (직접 다운로드·파싱 확인)