원문 캡처 · despairdrivendevelopment.com
원문 캡처 · despairdrivendevelopment.com

PHP 8.2 이상으로의 이전이 당장 어려운 시스템은 패치된 PHP 8.3이 HTTP 요청을 처리하고, 취약한 PHP 7.4는 CLI 프로세스로만 실행하도록 구성할 수 있다. 이 방식은 요청마다 프로세스를 띄우거나 장기 실행 워커를 운영하는 비용이 들지만, PHP 7.4가 취약한 웹 요청 경로를 실행하지 않게 한다.

이 글은 2026년 10월 PHP 릴리스의 취약점 CVE-2026-109618과 CVE-2026-109619를 다룬 연재의 두 번째 편이다. 두 취약점은 모두 CVSS 9.2이며 PHP 7.x용 수정 사항은 없다. 첫 번째 편은 즉시 적용할 수 있는 완화책을 설명한다.

취약한 경로를 우회하는 구성

두 버그는 PHP 전반이 아니라 웹 SAPI의 요청 처리 경로에 있다. main/rfc1867.c의 멀티파트 파서는 fpm·apache·cgi SAPI에서 POST 본문 처리기로 등록되고, session.upload_progress도 같은 파싱 과정에 연결된다. 두 번째 취약점은 웹 SAPI가 요청마다 수행하는 초기화·종료 작업에서 남은 상태를 이용한다.

CLI SAPI는 HTTP 본문을 읽거나 멀티파트 데이터에서 $_POST와 $_FILES를 채우지 않는다. 업로드 진행 상태 처리나 웹 요청 초기화도 하지 않는다. 따라서 PHP 8.3 앞단이 요청 본문과 업로드를 처리하고, 파싱한 데이터를 구조화해 PHP 7.4 CLI 프로세스에 전달하면 취약한 파서가 실행되지 않는다.

요청마다 새 CLI 프로세스를 실행하면 두 번째 취약점이 필요로 하는 워커 간 상태 재사용도 사라진다. 프로세스는 한 번 실행된 뒤 종료되므로 남은 상태가 다음 요청까지 이어지지 않는다. 취약한 인터프리터 자체를 없애는 것이 아니라 네트워크와 직접 맞닿지 않게 하는 방식이다.

웹 환경을 다시 만들어야 한다

비용은 연결 작업에 있다. Laravel 6과 CodeIgniter 3은 웹 SAPI를 전제로 하므로 채워진 슈퍼글로벌, header()와 setcookie()의 동작, 세션, 출력 버퍼, 웹 서버가 구성한 $_SERVER를 기대한다. CLI에서 앱을 실행하려면 앞단이 파싱한 요청으로 이 환경을 재구성하고, 앱의 출력을 HTTP 응답으로 바꾸는 어댑터가 필요하다. 전달해야 할 헤더나 $_FILES 형태가 조금이라도 맞지 않으면 애플리케이션 버그처럼 보이는 문제가 생길 수 있다.

PHP 코드로 요청 환경을 구성하는 어댑터의 개념은 다음과 같다. 앞단에서 직렬화한 작업을 표준 입력으로 읽는다.

<?php // legacy-worker shim — runs under the CLI SAPI on the vulnerable PHP. // assert(php_sapi_name() === 'cli'); // the entire security argument in one line $job = json_decode(stream_get_contents(STDIN), true); // Rebuild the request world the framework expects. The front already parsed // the body on patched 8.3; we receive structured data, never a multipart stream. $_GET = $job['query'] ?? []; $_POST = $job['post'] ?? []; $_COOKIE = $job['cookies'] ?? []; $_SERVER = $job['server'] ?? []; // method, path, headers, remote addr $_FILES = $job['files'] ?? []; // temp paths the front already wrote ob_start(); require __DIR__ . '/legacy-app/public/index.php'; // boots Laravel/CI as usual $body = ob_get_clean(); // Hand headers + body back to the front, which turns it into real HTTP. fwrite(STDOUT, json_encode([ 'status' => http_response_code(), 'headers' => headers_list(), 'body' => $body, ]));

php-cgi를 사용해 어댑터를 건너뛰는 방법은 안전하지 않다. php-cgi는 웹 SAPI이므로 표준 입력으로 원시 요청 본문을 받으면 rfc1867.c를 통해 multipart/form-data를 파싱한다. 격리를 유지하려면 실제 CLI SAPI를 써야 하며, PHP 7.4 쪽에 멀티파트 본문을 보내지 않아야 한다.

지연 시간을 줄이는 방법과 대가

프로세스를 띄우는 비용은 한 자릿수 밀리초 수준이다. 더 큰 부담은 프레임워크 부팅이다. Laravel 6은 요청마다 오토로더와 설정, 서비스 제공자를 다시 초기화하고, 메모리 내 opcode 캐시도 재사용하지 않아 수십 밀리초가 들 수 있다. 부하가 커져도 요청마다 이 비용을 치른다. CodeIgniter 3은 가볍지만 비용 양상은 같다.

opcache.file_cache를 지속 디렉터리에 지정하면 프로세스가 새로 시작해도 재컴파일을 줄일 수 있다. 메모리 내 캐시는 프로세스 종료와 함께 사라지지만, 파일 캐시는 남는다.

; php.ini on the legacy CLI runtime opcache.enable_cli=1 opcache.file_cache=/var/cache/opcache opcache.file_cache_only=1 ; CLI has no shared memory segment to reuse

더 큰 폭으로 줄이려면 요청마다 프로세스를 만드는 대신 장기 실행 CLI 워커 풀을 둘 수 있다. 워커는 소켓에서 작업을 받아 실행하고 상태를 초기화한 뒤 다음 작업을 기다린다. 이는 레거시 런타임을 위한 자체 구현 Octane이나 RoadRunner와 비슷하며, 프레임워크를 워커마다 한 번만 부팅한다.

<?php // persistent legacy worker — still the CLI SAPI, so still none of the vulnerable path. $sock = stream_socket_server("unix:///run/legacy/worker.sock", $errno, $errstr); while ($conn = stream_socket_accept($sock, -1)) { $job = json_decode(fgets($conn), true); reset_request_state(); // $_GET/$_POST/$_FILES/$_SERVER, session, output buffers $resp = handle($job); // run the booted app against this job fwrite($conn, json_encode($resp)); fclose($conn); }

장기 실행 워커도 CLI SAPI를 사용하므로 rfc1867.c나 두 번째 취약점이 읽는 웹 요청 초기화 경로를 실행하지 않는다. 다만 여러 요청이 같은 프로세스를 쓰므로 reset_request_state()에서 요청 상태를 매번 의도적으로 초기화해야 한다.

남는 위험과 적용 대상

격리는 공격 표면을 크게 줄이지만 PHP 7.4를 안전한 인터프리터로 만들지는 않는다. CVE-2026-109618의 배경인 메모리 부족(OOM) 손상은 멀티파트보다 넓은 문제다. 알려진 공격 경로인 멀티파트 파싱은 제거되지만, 큰 입력으로 CLI 프로세스의 메모리를 고갈시킬 위험이 완전히 없다고 증명할 수는 없다. 따라서 레거시 CLI에 memory_limit=-1을 설정하고 cgroup으로 상한을 둬야 한다. 이렇게 하면 PHP의 OOM 처리 대신 커널이 프로세스를 종료한다. 컨테이너 설정 계산은 첫 번째 편의 3절에 설명돼 있다.

장기 실행 워커는 요청 간 상태를 재사용하므로 방어적인 초기화가 필요하다. 웹 SAPI 전용 취약점 경로가 다시 열리는 것은 아니지만, 신뢰할 수 없는 요청을 처리하는 인터프리터가 메모리를 공유한다는 점은 별도로 관리해야 한다.

대부분의 서비스에는 이 구성까지 필요하지 않다. 글은 엣지에서 multipart/form-data를 차단하고 session.upload_progress를 끄는 방법이 노력의 약 10%로 보호 효과의 약 90%를 얻는 대안이라고 설명한다. 업로드 차단을 안정적으로 적용하기 어렵거나, 규제·고가치·인터넷 노출 환경에서 더 강한 방어가 필요하거나, 어차피 장기 실행 워커로 이전할 계획이 있는 경우에는 격리 구성이 유용할 수 있다. 특히 Octane, RoadRunner, FrankenPHP 같은 런타임으로 옮길 예정이라면 패치된 PHP 8.3 앞단과 워커 풀을 먼저 구성하고 기존 앱을 그 안에서 운영하는 방식이 이전 작업과 격리를 함께 진행할 수 있다.

데모

php-cve-lab 저장소에는 패치된 PHP 8.3 fpm 앞단과 PHP 7.4 CLI 워커를 연결하는 네 번째 데모가 추가됐다. 요청마다 프로세스를 실행하는 방식과 장기 실행 워커 방식이 모두 포함되며, 레거시 쪽에서 php_sapi_name() === "cli"인지 확인하고 멀티파트 본문을 파싱하지 않는지 점검한다. 두 방식의 지연 시간 차이도 출력한다. 저장소는 취약점이 아니라 격리 경계를 보여주는 데모이며, 익스플로잇 코드는 포함하지 않는다.

장기 실행 워커 런타임이 어차피 PHP의 다음 단계라면, 격리 구성과 이전은 같은 프로젝트다. Modernization Roadmap은 팀에 전달할 수 있는 이전 전략과 목표 아키텍처를 제공하며, 주말 내내 서비스 장애를 겪지 않고 출시할 수 있도록 범위를 정한다.

매슈 피츠패트릭(Matthew FitzPatrick)은 AI 보조 개발 가드레일을 활용해 에이전트의 생산성을 유지하면서 운영 환경을 안전하게 지키는 방식으로 팀의 레거시 시스템 현대화를 돕는다. fitzpatricksoftware.com

매슈 피츠패트릭은 Go, AWS, 인프라, 개발자 도구에 관해 글을 쓰며, 서비스 장애 없이 현대화를 진행하도록 팀을 돕는다. GitHub · fitzpatricksoftware.com

이전 글: PHP 7.4를 계속 쓰고 업그레이드할 수 없다면? 2026년 10월 원격 코드 실행 취약점 완화하기