PHP2Shell 대응으로 Docker PHP 7.2를 7.4로 전환

by

그제 어제 이슈가 있었던 PHP2Shell 보안취약점 문제에 대한 작업 처리와 그에대한 기록입니다.

단순히 최신 버전으로 올리면 해결되는문제긴해도 라이브서버의 운영체제 제한도있고, 그위에 구동되는 코드들도 php7버전에 맞춰져있어서 7.4-r1 버전으로 적용하기위한 작업을 진행했습니다.


그 과정을 기록형태로 정리해둡니다.

나중에 필요할때 다시 참고할수있도록...





2026년 10월 22일부터 23일까지 운영 중인 업무용 서버 두 대의 Docker PHP를 교체했다. PHP2Shell로 알려진 보안 이슈에 대응하면서, 기존 Laravel 서비스와 외부 연동을 계속 사용할 수 있는 구성을 만드는 작업이었다.


최종적으로 두 서버의 공용 PHP 컨테이너를 같은 보안 이미지로 교체했고, 대표 사이트가 정상 동작하는 것을 확인했다. 진행 과정에서는 패키지 변경 이력 파일이 빠져 빌드가 멈추기도 했고, 전환 후 외부 서비스의 시간 오류가 실제로는 PHP 시작 태그 문제였던 일도 있었다.


이 글에는 당시 적용한 설정과 확인 과정을 정리했다. 서버 식별정보와 실제 서비스 주소는 생략했으며, 설정과 명령은 설명에 필요한 부분을 발췌했다.



1. 기존 서비스 구조와 전환 목표

서비스는 앞단 Nginx와 사이트별 Nginx, 공용 PHP-FPM 컨테이너로 구성돼 있었다.


외부 웹 요청
    ↓
앞단 Nginx 컨테이너 80/443
    ↓
사이트별 Nginx 컨테이너
    ↓ FastCGI php:9000
공용 PHP-FPM 컨테이너
    ↓
기존 애플리케이션과 데이터베이스Copy



첫 번째 서버에서 확인한 기존 컨테이너의 PHP는 7.2.34였다. 대표 시험 대상으로 고른 서비스는 Laravel 6.20.44를 사용했고, PHP 확장에는 GD, BCMath, IMAP, PDO MySQL, SQLite, sodium, mcrypt 등이 포함돼 있었다.


첫 번째 서버에는 사이트용 backend Nginx 컨테이너가 96개, 두 번째에는 21개 있었다. 이 숫자는 컨테이너 수이며, 서로 다른 웹사이트 수나 기능 시험 건수를 의미하지는 않는다.


이번 목표는 기존 서비스와의 호환성을 확인하면서 두 취약점 대응 패치가 포함된 PHP 런타임으로 바꾸는 것이었다. PHP 8과 최신 Laravel로 옮기는 작업은 별도의 업그레이드 과제로 남겼다.



2. PHP 7.4를 선택한 이유와 보안 패치 확인

2026년 10월 21일 PHP 프로젝트가 공개한 권고는 인증 없이 원격 코드 실행으로 이어질 수 있는 두 취약점을 다뤘다. 웹 요청을 처리하는 PHP-FPM도 위험 대상에 포함됐다. [PHP 공식 보안 권고]


취약점핵심문제
CVE-2026-109618메모리 부족 처리 중 초기화가 끝나지 않은 요청 데이터가 잘못 사용될 수 있음
CVE-2026-109619이전 요청의 해제된 메모리를 가리키는 상태가 남아 후속 요청에서 사용될 수 있음


세부 원인은 PHP 프로젝트의 [OOM corruption 권고]와 [stale global state 권고]에 설명돼 있다.


PHP 7.4는 이미 정규 지원이 종료된 버전이다. 다만 이번 이슈에서는 PHP 프로젝트가 7.4용 최소 소스 백포트도 제공했다.

그래서 기존 앱의 PHP 요구사항을 확인하고, 두 CVE 대응 패치가 포함된 공급업체 패키지를 사용해 7.4로 전환하기로 했다. 이는 이번 취약점에 대한 대응이며, PHP 7.4가 다시 일반 보안 지원을 받는다는 의미는 아니다. [PHP 지원 종료 목록], [이전 버전 백포트 안내]


여기서 확인할 것은 `php -v`에 표시되는 `7.4.33`만이 아니었다. 보안 수정은 패키지 리비전에 반영되므로 전체 버전을 확인했다.


1:7.4.33-31+0~20261021.121+debian12~1.gbpe9c428Copy



사용한 배포 경로는 [Sury PHP 저장소]였다. 저장소 메타데이터와 내려받은 주요 패키지의 해시를 대조하고, 공급업체 소스의 패치 목록과 변경 이력에서 두 CVE를 확인했다. 실제 이미지 설치 단계에서도 고정한 패키지 버전과 changelog의 CVE 항목을 검사했다.


패키지와 소스의 해시·패치 검토는 로컬에서 수행했고, 이미지 빌드와 런타임 검사는 운영 서버에 마련한 별도 시험 환경에서 진행했다. 사전 정적 검토와 서버에서의 실제 실행 결과를 나눠 확인했다.


이 근거를 통해 공급업체 백포트를 포함한 패키지를 선택했다. 별도로 공격 코드를 실행해 취약점 재현 시험을 한 것은 아니다.



3. 패치 준비 중 적용한 임시 완화

새 이미지를 준비하는 동안에는 기존 PHP 설정을 먼저 조정했다.


memory_limit = 128M
post_max_size = 32M
upload_max_filesize = 30M
session.upload_progress.enabled = 0Copy



POST 전체 크기를 32M, 개별 업로드 파일을 30M으로 제한하고 세션 기반 업로드 진행률 기능을 껐다. 설정을 바꾼 뒤에는 FPM을 재로딩하고 기존 워커가 새 워커로 교체됐는지, 새 프로세스의 설정값이 목표값과 일치하는지 확인했다.


공식 OOM 완화에는 `memory_limit=-1`도 포함돼 있었지만, 당시 약 4GB RAM의 공유 호스트에서는 메모리 고갈 위험을 고려해 128M을 유지했다. 따라서 이 단계는 공식 완화를 모두 적용한 상태가 아니라 일부 공격 경로의 노출을 줄이는 부분 완화였다. 최종 대응은 패치가 포함된 런타임으로 교체하는 것이었다. [PHP 임시 완화 안내]



4.  운영과 분리한 시험 이미지 만들기

새 런타임은 Debian 12 기반 이미지에 버전을 고정한 PHP 패키지를 설치하는 방식으로 준비했다. 기존 공식 PHP 이미지와는 설치 경로와 패키지 구성이 달라지므로, 기존 Dockerfile의 버전 문자열만 바꾸는 방식으로 진행하지 않았다.


구성 요소시험 및 운영 이미지에서 사용한 버전
기반 배포판Debian 12 bookworm
PHP7.4.33과 지정한 보안 패키지 리비전
mcrypt 확장1.0.9
Node.js18.12.1
npm9.1.3



Node와 npm도 기존 환경에 맞춰 고정했다. PHP 보안 대응 중 `n lts` 같은 명령으로 다른 런타임까지 함께 바뀌는 일을 피하기 위해서였다. 이 버전 고정은 호환성을 위한 선택이며, 해당 도구 전체의 최신 보안 상태를 보증하는 것은 아니다.


필요한 확장을 확인하고 기존 환경에서 사용하지 않던 OPcache는 비활성화했다. mcrypt도 시험 대상에 포함했지만, 이번 두 CVE의 패치는 PHP 코어에 대한 것이므로 mcrypt 버전만으로 대응 여부를 판단하지 않았다.



5. changelog가 없어 빌드가 멈춘 문제

첫 빌드에서는 PHP 패키지의 변경 이력을 확인하는 단계가 실패했다. 패키지에 패치가 없어서가 아니라, slim 이미지의 문서 제외 설정 때문에 changelog 파일이 설치 후 남지 않았던 문제였다.


예외 규칙은 다음과 같았다.


path-include=/usr/share/doc/php7.4-common/changelog*Copy



처음에는 이 규칙을 `99-php-security-evidence`에 넣었지만, 뒤에서 읽히는 문서 제외 규칙에 다시 덮였다. dpkg의 경로 필터는 적용 순서에 따라 처리되며, 마지막으로 일치하는 규칙이 파일 포함 여부를 결정한다. [dpkg 경로 필터 문서]


설정 파일 이름을 다음과 같이 바꿔 포함 규칙이 뒤에서 적용되게 했다.


/etc/dpkg/dpkg.cfg.d/zz-php-security-evidenceCopy



설치 스크립트의 체크섬도 갱신하고 배포 파일의 해시 검사를 통과한 뒤 다시 빌드했다. 이후에는 changelog에서 두 CVE를 확인하는 단계까지 통과해 이미지가 완성됐다.


검사에 필요한 근거 파일을 보존하도록 수정해 기존 CVE 검사를 유지했다.



6. PHP 버전 확인에서 실제 FastCGI 요청까지

시험 컨테이너는 운영 앱을 연결하지 않은 상태로 실행했다. 네트워크를 차단하고 외부 포트를 게시하지 않았으며, 루트 파일시스템을 읽기 전용으로 두고 필요한 임시 경로만 별도로 제공했다.


시험 컨테이너에는 RAM 256MiB, CPU 0.25개 상당, PID 64개의 제한을 설정했다. 그 상태에서 다음을 확인했다.


- 고정한 PHP 패키지 버전과 changelog의 두 CVE

- PHP-FPM 설정 검사와 CLI 버전

- 필수 PHP 확장과 mcrypt 1.0.9

- Node 18.12.1과 npm 9.1.3

- 내부 FastCGI 요청을 통한 실제 FPM 응답

- GD JPEG·FreeType, BCMath, 메모리 SQLite, sodium·mcrypt 시험 데이터 왕복



실제 FPM 응답의 일부는 다음과 같았다.


{
  "ok": true,
  "php_version": "7.4.33",
  "sapi": "fpm-fcgi",
  "mcrypt_version": "1.0.9",
  "missing_modules": []
}Copy



시험 데이터의 암호화·복호화가 성공한 것과 기존 운영 암호문을 읽을 수 있다는 것은 구분했다. 후자는 실제 서비스의 데이터와 사용 방식에 대한 확인이 필요하다.


실행 중에는 swap limit 관련 경고도 나왔다. 확인 결과 호스트의 `SwapLimit=false` 조건 때문이었다. RAM 제한과 swap 상한은 별개이므로, 이 환경에서 RAM과 swap을 합친 총량까지 제한했다고 해석하지 않았다. [Docker 자원 제한 문서]


패키지 설치 로그의 `policy-rc.d denied execution`은 빌드 중 서비스 자동 기동을 막은 결과였고, npm의 `--force` 경고는 캐시 정리 명령에서 발생했다. 경고 문구만으로 실패를 판단하지 않고 실제 실행한 명령과 최종 검사 결과를 함께 확인했다.



7. Laravel 애플리케이션 호환성 확인

런타임 시험 다음에는 대표 Laravel 프로젝트를 확인했다. 먼저 다음 메타데이터를 읽어 현재 의존성 구성을 파악했다.


파일확인한 내용
composer.jsonPHP·확장 요구사항과 autoload 설정
composer.lock의 선별 정보잠긴 패키지 버전과 플랫폼 조건
vendor/composer/installed.json현재 설치된 패키지의 버전과 참조



잠긴 정보와 설치 메타데이터 111개의 이름·버전이 일치했고, 개발 브랜치 두 개의 참조도 대조했다. `composer.lock`이 정확한 의존성 버전을 기록한다는 점을 기준으로 삼았으며, 이 작업에서는 운영 의존성 자체를 업데이트하지 않았다. [Composer lock 파일 설명]


그다음 운영 `.env`, 저장 데이터, 로그, 캐시, `node_modules` 등을 제외한 코드 사본을 만들었다. `vendor`뿐 아니라 Composer가 참조하는 seeds, factories, tests의 PHP 파일과 helper도 필요한 범위에 포함했다.


검사는 구문, autoload, Laravel 부팅 순서로 진행했다.


검사결과
대상 PHP 파일 구문 검사129개, 실패 0개
Composer autoload성공
앱 HTTP Kernel 클래스와 helper 로딩성공
Laravel 부팅6.20.44, testing 환경에서 성공
라우트 등록261개



부팅 시험에서는 쓰기 경로를 `/tmp`로 분리하고 빈 SQLite, 임시 캐시·세션 설정을 사용했다. 운영 DB를 복제하거나 마이그레이션한 단계가 아니었다.


라우트 261개 등록은 261개 URL을 모두 실행했다는 의미가 아니다. 또한 이 구문 검사에는 Blade 뷰 전체가 포함되지 않았다. 기본 호환성을 확인한 뒤 실제 대표 사이트로 넘어갔다.



8. 대표 사이트만 먼저 PHP 7.4에 연결

공용 PHP를 바로 바꾸기 전에 대표 사이트 하나만 새 PHP에 연결했다.


사이트별 Nginx 컨테이너들은 같은 설정 디렉터리를 공유하고 있었다. 공유 파일을 수정하면 다른 서비스에도 영향을 줄 수 있어, 대표 사이트 컨테이너 안에 별도 설정 사본을 만들고 해당 서버 블록만 연결을 바꿨다.


# 기존 공용 PHP
fastcgi_pass php:9000;Copy
# 대표 사이트에서 먼저 확인한 새 PHP
fastcgi_pass php74-demo:9000;Copy



기존 Nginx 설정을 백업하고 새 설정의 문법 검사를 통과한 뒤 reload했다. 이후 대표 사이트의 HTTP 응답과 웹에서 사용 중인 PHP 버전, 실제 사용 흐름을 확인했다.


대표 사이트에 연결할 때는 시험 설정에서 서비스용 설정으로 바꿨다. 격리 시험용 FPM의 loopback 주소와 워커 설정을 조정해 Docker 네트워크로 요청을 받고 기존 환경에 맞는 풀을 사용하도록 했다.


항목격리 시험서비스용 구성
FPM listen127.0.0.1:90000.0.0.0:9000
FPM 프로세스 관리ondemand, 최대 1개dynamic, 최대 5개
PHP memory_limit64M128M
앱 연결운영 앱 마운트 없음대표 사이트 전환은 해당 앱 경로, 공용 전환은 /var/www 연결 



서비스용 이미지를 만들 때는 시험용 ini와 검사 페이지를 제거했다.



9. 기존 공용 PHP를 교체하고 원복 경로 남기기

대표 사이트를 확인한 다음에는 공용 `php` 서비스를 새 이미지로 대체했다. 예전 PHP 컨테이너를 별도로 계속 운영하는 방식 대신, 기존 이미지와 설정을 보관해 필요하면 다시 생성할 수 있도록 준비했다.


백업 대상은 기존 PHP 이미지, Compose 파일, Dockerfile, host php.ini와 Nginx 설정이었다. 설정 백업 tar는 root만 읽을 수 있는 권한으로 보관했다.


두 서버의 기존 이미지 ID는 서로 달랐다. 첫 서버의 ID를 다른 서버에 그대로 사용하면 `No such image`가 발생하므로, 각 서버에서 현재 컨테이너가 사용하는 ID를 기준으로 태그를 남겼다.


old_image_id=$(sudo docker inspect --format '{{.Image}}' docker_php_1)
sudo docker tag "$old_image_id" docker_php:before-php74-20261022Copy



Dockerfile이 남아 있어도 나중에 같은 결과물을 다시 만들 수 있다고 가정하지 않았다. 기반 이미지와 저장소의 패키지가 달라질 수 있어, 당시의 완성 이미지를 보관하는 편이 복구에 유리했다.



10. 기존 이미지와 달라진 php.ini 경로

기존 공식 PHP 이미지에서는 host ini를 다음 위치에 연결했다.


/usr/local/etc/php/conf.d/php.iniCopy



새 Debian 패키지 기반 구성에서는 CLI와 FPM의 ini 경로가 나뉜다. PHP 서비스의 Compose 설정은 다음처럼 바꿨다.


php:
  image: php74-security:20261022-r1
  restart: on-failure
  volumes:
    - ./php/php.ini:/etc/php/7.4/cli/conf.d/zz-host.ini:ro
    - ./php/php.ini:/etc/php/7.4/fpm/conf.d/zz-host.ini:ro
    - /var/www:/var/wwwCopy



기존의 `build:` 설정은 완성한 운영 이미지 참조로 바꿨다. host ini는 두 실행 환경에 읽기 전용으로 연결하고, CLI와 FPM 각각에서 로딩 경로와 시간대·업로드·메모리 설정을 확인했다.


원복 구성에는 예전 이미지뿐 아니라 예전 ini 경로도 함께 넣었다. 이미지와 설정 경로를 한 쌍으로 복구하도록 준비한 것이다.



11. PHP 서비스만 재생성하고 Nginx 재로딩

실제 전환은 사전 검사와 실패 시 복구를 묶은 스크립트로 진행했다. 핵심 동작은 Compose의 `up -d --no-deps --no-build php`로 PHP 서비스만 재생성하는 것이었다.


새 PHP가 예상한 이미지로 실행되고 9000번 포트에서 준비되면 실제 컨테이너 IP를 다시 읽었다. 이어 각 backend Nginx에서 `php` 이름이 새 IP를 가리키는지 대조하고, `nginx -t`를 통과한 뒤 reload했다.


첫 번째 서버는 backend 96개에 이 과정을 적용했다. 이후 대표 사이트들이 정상 동작하는 것을 확인했다. 이 기록은 실제 전환과 대표 기능 확인 결과이며, 전체 요청의 무중단을 측정한 결과는 아니다.


대표 사이트도 새 공용 PHP로 돌려놓은 뒤, 임시 `php74-demo` 컨테이너와 전용 Nginx 설정 사본만 정리했다. 고객용 사이트 컨테이너, 프로젝트, DB와 업로드 파일은 유지했다.



12. 시간 오류로 보였던 외부 연동 장애

첫 서버 전환 후 외부 서비스 연동에서 오류가 발생했다.


실제 응답을 확인하니 iframe 안에 실행되지 않은 PHP 코드가 남아 있었다. 외부 서비스로 넘기는 값에도 함수 실행 결과 대신 `encCode()` 같은 코드 조각이 들어갔다. 해당 뷰의 시작 태그는 `


<?Copy


PHP 7.4에서도 이 태그를 사용할 수 있지만 `short_open_tag` 설정에 영향을 받는다.


그래서 다음으로 기존 코드를 유지하기로 하고 host php.ini에 다음 옵션을 추가했다.


short_open_tag = OnCopy



이후 해당 외부 연동이 정상화됐다. 코드의 시작 태그를 `<?php`로 바꾸는 대안도 있었지만, 최종 적용은 공용 ini의 호환성 설정이었다.



이 설정은 host에서 bind mount하는 파일에 들어간다. 따라서 Docker 이미지만 다른 서버로 옮겨서는 함께 전달되지 않는다. 두 번째 서버에서는 전환 전에 이 옵션을 별도로 반영하고 CLI와 FPM 양쪽에서 `On`을 확인했다.


이번 사례에서는 외부 서비스의 오류 문구보다 실제 생성된 요청값과 응답 본문이 원인 판단에 더 직접적인 단서였다. HTTP 200 응답도 PHP 코드가 의도대로 실행됐다는 보장은 아니었다.



13. 두 번째 서버에는 완성 이미지를 전달

두 번째 서버에서는 같은 소스로 다시 빌드하는 대신 첫 서버에서 검증한 완성 이미지를 전달했다.


운영 이미지 태그는 다음과 같았다.


php74-security:20261022-r1Copy



이미지를 save하고 gzip으로 압축한 뒤, 소스와 식별정보를 함께 묶었다.


php74-security-20261022-r1.tar.gz
source.tar.gz
image-id
base-image-id
runtime-requirements.txt
SHA256SUMSCopy



파일은 평소 사용하던 SFTP 프로그램으로 PC를 거쳐 전달했다. 대상 서버에서는 `SHA256SUMS`에 기록된 다섯 파일의 해시를 확인하고 이미지를 load했다. 이어 full image ID를 대조해 같은 결과물이 들어왔는지 확인했다. 애플리케이션 `.env`나 DB 데이터를 이 전송 묶음에 넣지는 않았다.



두 번째 서버 자체의 Compose와 host ini를 기준으로 새 구성과 원복 구성을 만들었다. 이 서버는 backend Nginx가 21개였으므로 첫 서버의 96개 가정을 그대로 사용하지 않았다. PHP IP도 동적으로 다시 조회했다.


실제 전환 로그에는 다음 완료 항목이 남았다.


PRECHECK_OK: 21 backend Nginx
PHP_RECREATE_REQUESTED
NGINX_RELOAD_REQUESTED: 21
SHARED_PHP74_READY: HTTP checks pendingCopy



스크립트 완료 후에는 실제 대표 사이트를 열어 정상 동작을 확인했다. 컨테이너 준비와 설정 검사 완료를 서비스 기능 확인과 구분한 것이다.



14. Ubuntu에 남아 있는 PHP 7.2 확인

Docker PHP를 교체한 뒤에도 Ubuntu 호스트의 PHP는 별도로 남아 있었다. 컨테이너 교체가 호스트의 CLI나 FPM까지 업데이트하는 것은 아니다.


첫 번째 서버를 조회했을 때 호스트 `php7.2-fpm`은 실행 중이었지만, 호스트 Nginx와 Apache 서비스는 없었다. 80/443은 Docker가 게시하고 있었고, 호스트 PHP-FPM은 로컬 Unix 소켓에서 대기하고 있었다.


이 결과를 기존 Nginx의 `php:9000` 연결과 함께 보면, 확인된 외부 웹 요청 경로는 패치된 Docker PHP로 이어졌다. 이번 웹서비스 대응을 위해 호스트 PHP까지 추가로 7.4로 올리는 작업은 필수 범위에 넣지 않았다.


다만 호스트 PHP 패치까지 완료한 것으로 처리하지는 않았다. 크론이나 별도 도구에서 호스트 CLI를 사용하는지는 따로 확인할 항목이며, 호스트 FPM의 중지·삭제도 이번 완료 항목에 포함하지 않았다.



15. 적용 결과와 이후 작업

대상적용일backend Nginx결과
업무용 첫 번째 서버
2026년 10월 22일
96개
공용 PHP 교체와 대표 사이트 정상 확인
업무용 두 번째 서버
2026년 10월 23일
21개
같은 운영 이미지 적용과 대표 사이트 정상 확인



두 서버는 같은 보안 이미지를 사용하도록 바뀌었고, 복구에 필요한 예전 이미지와 설정 백업을 남겼다. 앱 의존성 업데이트나 DB 마이그레이션을 이번 전환에 함께 넣지 않아 변경 범위를 PHP 실행 환경 중심으로 유지할 수 있었다.



이번에 남긴 운영 기준은 패치가 들어간 정확한 이미지와 설정을 기록하고, 기본 런타임 시험에서 대표 앱 확인으로 단계를 나누며, 서버마다 실제 구성값을 다시 확인하는 것이다. 특히 ini 경로와 짧은 PHP 태그처럼 버전 숫자만으로는 드러나지 않는 차이가 실제 장애로 이어질 수 있었다.


이번 두 CVE 대응을 마친 것과 별개로, 지원 중인 PHP와 Laravel로 옮기는 장기 업그레이드는 계속 필요한 과제로 남아 있다.


#php #문제해결