PHP 보안
PHP 보안: 코드, Composer 종속성, 빌드에서 PHP 취약점을 막는 방법
PHP를 보호하려면 세 계층을 다뤄야 합니다. 직접 작성하는 코드에서는 입력을 검증하고, 매개변수화된 쿼리와 프레임워크의 이스케이프를 사용하며, 신뢰할 수 없는 데이터는 절대 unserialize하지 않습니다. 의존하는 Composer 패키지에서는 composer audit를 실행하고, composer.lock을 커밋하고, 안전하지 않은 버전을 차단합니다. 이를 설치하는 CI에서는 실제로 설치된 것을 기록하고 빌드가 연결할 수 있는 곳을 제한합니다. 대부분의 PHP 보안 가이드는 첫 번째 계층에서 멈춥니다. 이 가이드는 세 계층을 모두 다룹니다.
업데이트
- 직접 작성한 코드 인젝션, XSS, 역직렬화, 파일 처리, 세션 프레임워크 기본값, 코드 리뷰, PHPStan, Psalm, Semgrep
- Composer 종속성 알려진 취약점, 방치되거나 악성인 패키지, lockfile 드리프트, 설치 시 실행되는 코드 composer audit, Composer 정책, Dependabot 또는 Renovate
- 빌드 CI가 실제로 설치하고 실행한 것, 가져온 출처, 접근할 수 있었던 곳 고정된 액션, 최소 권한 토큰, 이그레스 정책, 빌드 시점 SBOM
이 페이지의 내용: PHP2Shell, 여전히 중요한 PHP 취약점, 설정과 지원 버전, 정적 분석, Composer 종속성 보안, PHP 공급망 공격, CI 빌드, SBOM, 계층별 도구.
PHP2Shell: 지금 바로 패치해야 할 PHP 취약점
2026년 10월 21일 PHP 프로젝트는 PHP 8.5.12, 8.4.27, 8.3.36, 8.2.35를 출시해 인증 없이 원격 코드 실행을 허용할 수 있는 PHP 자체의 치명적인 취약점 두 건을 수정했습니다. 두 취약점 모두 CVSS v4.0 기준 9.2점이며, 함께 PHP2Shell로 불립니다.
- CVE-2026-109618: 메모리 부족 처리가 요청 데이터가 일부만 초기화된 상태에서 PHP를 중단시킬 수 있고, 이후 요청 정리 과정이 그 잘못된 데이터를 올바른 데이터로 취급할 수 있습니다.
- CVE-2026-109619: 요청이 소유한 상태가 그 메모리가 해제된 뒤에도 남을 수 있고, 같은 워커가 처리하는 다음 요청이 이를 사용할 수 있습니다.
PHP의 보안 공지는 웹 요청을 처리하는 모든 설치 환경이 위험하다고 밝히며, 여기에는 PHP-FPM, mod_php를 사용하는 Apache, php-cgi, FrankenPHP, LiteSpeed가 포함됩니다. 그중 mod_php의 위험이 가장 높다고 봅니다. PHP 8.1 이하 버전은 PHP 프로젝트로부터 새 릴리스를 받지 못합니다. 다만 심각도를 고려해 PHP 보안 팀은 소스에서 PHP를 빌드하거나 LTS 공급업체가 없는 사용자를 위해 PHP 7.4, 8.0, 8.1용 최소한의 소스 백포트를 공개했고, 일부 배포판과 LTS 공급업체도 자체 백포트 수정을 제공합니다. 해야 할 일은 다음과 같습니다.
- 수정된 릴리스로 업그레이드하세요. 8.1 이하라면 지원되는 브랜치로 옮기거나, 공급업체의 백포트 패키지를 사용하거나, PHP를 직접 빌드한다면 공식 소스 백포트를 적용하세요.
-
PHP가 포함된 모든 컨테이너 이미지를 다시 빌드하고 다시 배포하세요. 오래된 이미지 태그는 교체될 때까지 이전 PHP를 계속
실행하므로, 저장소에 있는 것만이 아니라 실제로 배포된 것을 확인하세요:
docker run --rm IMAGE php -v. -
오늘 업그레이드할 수 없다면 PHP 보안 공지의 임시 완화 조치를 적용하세요. 예를 들어
session.upload_progress.enabled=0입니다. 이 값은php.ini, FPM 풀 또는 요청을 파싱하기 전에 적용되는 서버 설정에 넣고 모든 워커를 재시작하세요.ini_set()이나 FPM의.user.ini에 의존하지 마세요. 공지는 두 가지 모두 쓰지 말라고 경고합니다. PHP는 이 조치가 노출을 줄여 줄 뿐 설치 환경을 안전하게 만들지는 않는다고 분명히 밝힙니다.
여기서 composer audit가 알려 주지 못하는 점에 주의하세요. PHP2Shell은 Composer 패키지가 아니라 런타임에
있으므로 composer.lock에 나타나지 않습니다. 실행 중인 PHP는 베이스 이미지나 OS 패키지에서 오며, 이것이 이
가이드의 세 번째 계층입니다. 버전 표, 타임라인, 모든 이미지 태그를 확인하는 스크립트는
PHP2Shell: 무엇을 패치하고 취약한 PHP를 모두 찾는 방법을 참고하세요.
여전히 중요한 PHP 취약점
PHP 애플리케이션의 취약점 유형은 지난 10년 동안 거의 바뀌지 않았습니다. 바뀐 것은 Laravel, Symfony, 그리고 최신 PHP가 대부분을 기본값으로 막아 준다는 점입니다. 그래서 실제 버그는 이제 코드가 그 기본값을 우회하는 곳에 몰려 있습니다. OWASP Top 10:2025에서 인젝션은 A05이며, Software Supply Chain Failures가 A03에 새로 들어왔습니다. 후자는 이 가이드 후반부의 주제입니다.
| 취약점 | PHP에서 나타나는 방식 | 해결 방법 | Laravel과 Symfony |
|---|---|---|---|
| SQL 인젝션 | 요청 데이터를 SQL에 이어 붙임. 원시 쿼리를 쓰는 우회 경로 포함 | 매개변수를 바인딩하는 PDO 준비된 문 | Eloquent와 Doctrine은 매개변수를 바인딩함. whereRaw(), DB::raw(), 이어 붙인 DQL은 그렇지 않음 |
| 크로스 사이트 스크립팅(XSS) | 사용자 입력을 이스케이프 없이 HTML에 출력 | 출력 시 htmlspecialchars() 또는 템플릿 엔진으로 이스케이프 |
Blade {{ }}와 Twig는 기본적으로 이스케이프함. {!! !!}와 |raw는 그렇지 않음 |
| 크로스 사이트 요청 위조(CSRF) | 상태를 바꾸는 폼이나 AJAX 엔드포인트가 세션 쿠키가 담긴 요청이면 무엇이든 받아들임 | 세션별 토큰을 모든 POST, PUT, PATCH, DELETE에서 검증하고 SameSite 쿠키 사용 |
Laravel은 web 라우트에서 토큰을 검증(폼에는 @csrf). Symfony Forms는 기본으로 토큰을 넣고 검증. 검증에서 제외한 라우트는 보호받지 못함 |
eval()을 통한 코드 인젝션 |
요청 데이터나 사용자가 편집할 수 있는 템플릿, 수식이 eval()에 도달 |
eval()을 쓰지 않음. 입력을 정해진 작업 집합에 대응시키거나, 필요한 식에는 파서 사용 |
직접 호출하는 eval()을 보호하는 프레임워크는 없음 |
| 객체 인젝션 | 공격자가 쓸 수 있는 쿠키, 요청 데이터, 캐시 항목에 unserialize() 사용 |
외부 데이터에는 JSON 사용. unserialize가 꼭 필요하면 객체를 거부 | 직접 호출하는 unserialize()를 보호하는 프레임워크는 없음 |
| 파일 포함과 업로드 | 입력으로 만든 경로를 include에 전달. 업로드 파일을 클라이언트 파일 이름 그대로 웹 루트 아래에 저장 |
포함할 파일을 허용 목록으로 제한하고, 업로드는 생성한 이름으로 웹 루트 밖에 저장 | Laravel의 store()는 파일 이름을 생성함. 유형과 크기는 서버에서 검증 |
| 타입 저글링 | 토큰이나 해시를 ==로 비교해 "0e123" == "0e456"이 참이 됨 |
===, 비밀 값에는 hash_equals(), 비밀번호에는 password_verify() |
프레임워크 헬퍼는 안전하게 비교함. 직접 작성한 비교는 스스로 책임져야 함 |
| 취약한 세션과 쿠키 | JavaScript로 읽을 수 있거나, HTTP로 전송되거나, 로그인 후에도 유지되는 세션 ID | Secure, HttpOnly, SameSite 쿠키, strict 모드, 로그인 후 새 세션 ID | 둘 다 로그인 시 세션 ID를 재생성함 |
| 서버 측 요청 위조(SSRF) | 사용자가 준 URL에 file_get_contents()나 cURL 사용. file://, phar:// 같은 래퍼 포함 |
스킴과 호스트를 허용 목록으로 제한하고 내부 주소를 차단하며, 쓰지 않으면 allow_url_fopen을 끔 |
기본 보호 없음 |
| 명령 인젝션 | 입력을 exec(), shell_exec(), system(), proc_open()에 전달 |
셸을 피하고 인수를 배열로 넘기거나, 각 인수를 escapeshellarg()로 이스케이프 |
Symfony Process와 Laravel의 Process 파사드는 인수 배열을 받음 |
| 대량 할당 | User::create($request->all())로 요청이 is_admin을 설정할 수 있음 |
채울 수 있는 필드를 나열하거나 검증된 입력만 전달 | Eloquent 모델은 기본적으로 보호됨. $guarded = []는 이 보호를 끔 |
SQL 인젝션: 취약한 코드와 수정한 코드
// Vulnerable: the id goes straight into the SQL string
$rows = $pdo->query("SELECT * FROM orders WHERE id = " . $_GET['id']);
// Fixed: a prepared statement with a bound parameter
$stmt = $pdo->prepare('SELECT * FROM orders WHERE id = ?');
$stmt->execute([$_GET['id']]);
$rows = $stmt->fetchAll();
// Laravel: whereRaw() takes SQL as written, where() binds the value
Order::whereRaw("status = '" . $request->input('status') . "'")->get(); // vulnerable
Order::where('status', $request->input('status'))->get(); // fixed
XSS: 취약한 코드와 수정한 코드
<?php // Plain PHP: escape on output
echo '<p>' . $comment . '</p>'; // vulnerable
echo '<p>' . htmlspecialchars($comment, ENT_QUOTES, 'UTF-8') . '</p>'; // fixed ?>
{{-- Blade: {{ }} escapes, {!! !!} prints raw HTML --}}
<p>{!! $comment !!}</p> {{-- vulnerable --}}
<p>{{ $comment }}</p> {{-- fixed --}}
{# Twig: autoescape is on, |raw turns it off #}
<p>{{ comment|raw }}</p> {# vulnerable #}
<p>{{ comment }}</p> {# fixed #}
객체 인젝션: 취약한 코드와 수정한 코드
// Vulnerable: an attacker-controlled cookie becomes PHP objects, and any
// class with a __wakeup() or __destruct() method in vendor/ is a gadget
$prefs = unserialize($_COOKIE['prefs']);
// Fixed: JSON carries data, not objects
$prefs = json_decode($_COOKIE['prefs'], true, 512, JSON_THROW_ON_ERROR);
// If you must read serialized data, refuse objects
$prefs = unserialize($data, ['allowed_classes' => false]);
phar 역직렬화, 즉 phar:// 경로에 대한 파일 작업이 아카이브 메타데이터를 unserialize하던 변종은 PHP 8.0에서
범위가 좁아졌습니다. phar 메타데이터는 phar를 열 때
더 이상 unserialize되지 않고, 코드가
getMetadata()를 호출할 때만 처리됩니다. PHP 7을 떠나야 할 이유가 하나 더 생긴 셈입니다.
파일 포함: 취약한 코드와 수정한 코드
// Vulnerable: ?page=../admin/reset reaches any .php file on the disk
include __DIR__ . '/pages/' . $_GET['page'] . '.php';
// Fixed: only names on an allow-list can be included
$pages = ['home' => 'home.php', 'pricing' => 'pricing.php'];
$file = $pages[$_GET['page'] ?? 'home'] ?? 'home.php';
include __DIR__ . '/pages/' . $file;
설정과 런타임 강화
안전한 애플리케이션이라도 런타임을 통해 정보가 새어 나갈 수 있습니다. 아래 php.ini 설정은
OWASP PHP
Configuration Cheat Sheet를 따릅니다.
| 설정 | 운영 환경 값 | 이유 |
|---|---|---|
display_errors |
Off(log_errors = On과 함께) |
스택 트레이스는 경로, 쿼리, 때로는 비밀 값까지 보여 줌 |
expose_php |
Off |
응답 헤더에 정확한 PHP 버전을 알리지 않음 |
allow_url_include |
Off |
원격 URL의 include를 막음. PHP 7.4부터 사용 중단 |
allow_url_fopen |
애플리케이션이 파일 함수로 URL을 가져오지 않는다면 Off |
file_get_contents()를 통한 SSRF를 줄임 |
disable_functions |
예: exec, shell_exec, system, passthru, proc_open, popen |
코드 실행에 성공한 공격자에게서 셸을 빼앗음. 애플리케이션이 절대 호출하지 않는 함수만 나열 |
open_basedir |
애플리케이션 디렉터리 | PHP 파일 함수가 열 수 있는 경로를 제한함. 격리 경계가 아니라 안전장치 |
| 세션 쿠키 | session.cookie_secure = 1, session.cookie_httponly = 1, session.cookie_samesite = Lax 또는 Strict, session.use_strict_mode = 1 |
세션 ID를 평문 HTTP와 JavaScript에서 멀리 두고, 서버가 발급하지 않은 ID를 거부함 |
file_uploads |
애플리케이션이 업로드를 받지 않는다면 Off |
입력 경로 하나를 통째로 없앰 |
지원되는 PHP 버전 유지하기
php.net에 따르면 PHP는 각 브랜치에 2년의 활성 지원과 그 뒤 2년의 보안 수정 전용 기간을 제공합니다. PHP 8.1은 2025년 12월 31일에 지원이 종료되었고, PHP 8.2는 2026년 12월 31일에 종료됩니다. 지원이 끝난 브랜치는 PHP 프로젝트로부터 새 릴리스를 받지 못하며, PHP2Shell이 8.1 사용자를 몰아넣은 상황이 바로 이것입니다. 이들을 위한 공식 수정은 소스 백포트뿐이었습니다.
- PHP 8.1 지원 종료, 2025년 12월 31일 종료
- PHP 8.2 보안 수정만, 2026년 12월 31일까지
- PHP 8.3 보안 수정만, 2027년 12월 31일까지
- PHP 8.4 활성 지원, 2026년 12월 31일까지
- PHP 8.5 활성 지원, 2027년 12월 31일까지
Laravel과 Symfony의 운영 환경 설정
-
디버그 끄기. Laravel에서는
APP_DEBUG=false, Symfony에서는APP_ENV=prod와APP_DEBUG=0입니다. 운영 환경의 디버그 페이지는 설정과 환경 변수 값을 보여 줍니다. -
키와 비밀 값.
APP_KEY(Laravel)와APP_SECRET(Symfony)은 저장소에 두지 말고, 유출되면 교체하세요. Laravel의APP_PREVIOUS_KEYS를 사용하면 모든 사용자를 로그아웃시키지 않고 키를 교체할 수 있습니다. -
신뢰할 수 있는 프록시. 로드 밸런서 뒤에서는 신뢰하는 프록시를 나열하세요(Laravel의 trusted proxies
미들웨어, Symfony의
framework.trusted_proxies). 그래야 클라이언트가X-Forwarded-*헤더로 IP 주소나 HTTPS를 위조할 수 없습니다.
PHP 정적 분석(SAST)
정적 분석은 코드를 실행하지 않고 읽습니다. 대부분의 PHP 팀은 다음 세 가지 오픈소스 도구로 충분합니다.
-
PHPStan은 점점 엄격해지는 수준에서 타입 오류와 버그를 찾습니다. 보안 스캐너는 아니지만, 많은 보안
버그는 타입 혼동이나 확인하지 않은
null에서 시작됩니다. -
Psalm은 오염 분석(taint
analysis)을 제공합니다.
$_GET,$_COOKIE같은 소스에서echo,include, SQL 쿼리 같은 싱크까지 데이터를 추적하고,TaintedSql,TaintedHtml같은 문제를 보고합니다. 현재 Psalm은 타입 검사와 함께 이를 실행합니다.--taint-analysis는 한 번의 실행에만 이를 켜고,--report=results.sarif는 GitHub code scanning용 SARIF를 생성합니다. - Semgrep은 PHP를 정식 지원 언어로 제공하며, 흔한 PHP 실수를 잡는 커뮤니티 규칙과 상용 버전의 함수 간 데이터 흐름 분석을 갖추고 있습니다.
레거시 PHP에서 SAST가 시끄러운 데는 구조적인 이유가 있습니다. 동적 include, 가변 변수, __get()과
__call() 같은 매직 메서드, 그리고 분석기가 따라갈 수 없는 컨테이너와 파사드로 데이터를 흘려보내는 프레임워크
관례입니다. 도구는 추측해서 오탐을 내거나, 포기하고 실제 흐름을 놓치게 됩니다.
오래된 코드베이스에는 래칫처럼 단계적으로 도입하는 것이 가장 좋습니다. 오늘의 결과를 기준선으로 기록하고
(vendor/bin/phpstan analyse --generate-baseline은 phpstan-baseline.neon을 생성하며, Psalm에는
--set-baseline이 있습니다), 새로운 결과에서만 빌드를 실패시키고, 기준선을 줄여 가면서 수준을 높입니다.
오래 운영된 PHP 코드베이스를 가진 팀들은 AI 자동 수정이 종속성 업데이트에는 잘 작동하지만 코드 관련 결과에는 품질이
고르지 않다고 말합니다. 따라서 SAST 결과에 대해 생성된 수정은 다른 변경과 똑같이 꼼꼼하게 검토하세요. SAST와 SCA는 서로
다른 질문에 답합니다. SAST와 SCA 비교를 참고하세요.
Composer 종속성 보안
Laravel이나 Symfony 애플리케이션은 수십 개의 Composer 패키지를 설치하며, 그 대부분은 전이적 종속성입니다. Composer는 최근 몇 차례의 릴리스에서 본격적인 보안 기능을 갖추게 되었습니다. 이 섹션은 현재 릴리스인 Composer 2.11을 기준으로 합니다.
composer audit
composer audit는 설치된 패키지를 Packagist.org
API의 보안 권고와 대조합니다. 이 API는 GitHub Advisory Database, FriendsOfPHP/security-advisories 같은 출처에서 권고를
모읍니다. 방치된 패키지도 보고하며, Composer 2.10부터는 멀웨어로 표시된 패키지 버전도 보고합니다. 모든 옵션은
Composer 문서를 참고하세요.
composer audit # audit what is installed in vendor/
composer audit --locked # audit composer.lock, whatever is in vendor/
composer audit --no-dev # skip require-dev packages
composer audit --direct # direct dependencies only (Composer 2.11)
composer audit --format=json # table, plain, json or summary
composer audit --abandoned=report # ignore, report or fail on abandoned packages
composer audit --ignore-severity=low
Composer 2.10부터 종료 코드는 문제가 없으면 0, 감사에 통과하지 못한 항목이 있으면 1이므로,
출력을 파싱하지 않고도 CI 게이트로 쓸 수 있습니다. 방치된 패키지는 기본적으로 감사를 실패시킵니다.
수용한 결과는 아무도 기억하지 못하는 CI 플래그가 아니라 이유와 함께 composer.json에 두어야 합니다. Composer
2.10은 기존 audit 설정의 대부분을 policy 블록으로 대체했습니다.
{
"config": {
"policy": {
"advisories": {
"ignore-id": {
"CVE-1234": "Not affected: we never call the vulnerable parser."
}
},
"abandoned": {
"audit": "report"
}
}
}
}
기존 키(audit.ignore, audit.abandoned, audit.block-insecure)는 해당
policy 섹션이 없는 동안 대체 수단으로 계속 작동하지만, 사용 중단되었습니다. 권고 관련 설정은 한꺼번에
옮기세요. policy.advisories가 존재하는 순간 권고 관련 audit.* 키는 모두 무시됩니다.
안전하지 않은 버전 차단하기
Composer 2.9(2025년 11월)부터 composer update와 composer require는 보안 권고가 있는 패키지
버전을 거부합니다. 예전에는 audit.block-insecure 설정이었고 지금은 policy.advisories.block이며,
기본으로 켜져 있습니다. Composer 2.10은 Packagist 멀웨어 피드를 바탕으로 한 멀웨어 차단을 추가했고, 이는
composer install에도 적용됩니다. Composer 2.11은 선택 사항인 cooldown 정책을 추가해, 새로
출시된 버전을 "7 days"처럼 직접 정한 기간 동안 보류할 수 있게 했습니다. 많은 악성 버전이 몇 시간에서 며칠
사이에 발견되어 삭제되기 때문입니다.
CI에서는 두 가지가 중요합니다. 첫째, 권고 기반 차단은 버전을 해석할 때 적용됩니다. 이미 composer.lock에 있는
버전은 권고가 게시된 뒤에도 composer install로 설치되므로, 게이트로서 composer audit가 여전히
필요합니다. 둘째, --no-blocking은 한 명령에 대해 모든 정책을 끕니다. 파이프라인에서 찾아볼 가치가 있습니다.
Composer 2.9 이전에는 안전하지 않은 버전을 차단하는 일반적인 방법이
roave/security-advisories였습니다. GitHub Advisory
Database와 FriendsOfPHP를 바탕으로 알려진 모든 취약 버전과 conflict를 선언하는 패키지입니다. 지금도
작동하며 오래된 Composer 버전에서는 여전히 도움이 됩니다. Composer 2.9 이상에서는 내장 차단 기능이 추가 패키지 없이 같은
권고를 처리합니다.
lockfile 관리 원칙
- 애플리케이션에서는
composer.lock을 커밋해 모든 환경이 같은 버전을 설치하게 하세요. -
CI와 배포에서는
composer install을 사용하고, 즉석에서 새 버전을 해석하는composer update는 절대 사용하지 마세요. - 운영 빌드는
--no-dev로 설치해 테스트 도구가 출시되지 않게 하세요. -
composer validate --strict를 실행하세요.composer.lock이composer.json에 비해 오래되었으면 실패합니다.
lockfile은 Composer가 해석한 내용을 기록합니다. lockfile이 기록하지 않는 것을 참고하세요.
설치 시 실행되는 코드: 플러그인, 스크립트, 자동 로드되는 파일
Composer는 대부분의 팀이 예상하는 것보다 많은 곳에서 종속성의 코드를 실행합니다. 그래서 Composer는 npm 설치 스크립트에 해당하는 PHP의 장치라고 할 수 있습니다.
-
플러그인은 Composer 내부에서 실행됩니다. Composer 2.2부터
allow-plugins설정에 실행을 허용할 플러그인을 모두 나열해야 하며, 기본적으로는 아무것도 허용하지 않습니다. 이곳에 추가되는 새 항목은 셸 접근 권한이 있는 새 종속성처럼 다루세요. -
스크립트는 루트
composer.json에서만 실행되며 종속성에서는 실행되지 않습니다. 하지만 애플리케이션을 부팅하는 경우가 많습니다. -
자동 로드되는 파일은 오토로더가 로드될 때마다 실행됩니다. 표준 Laravel 애플리케이션에서는
composer install이php artisan package:discover로 끝나고, 이것이 오토로더를 로드하므로 종속성이 자동 로드하는files가 설치 중에 CI 잡 안에서 실행됩니다.
잡에 플러그인이나 스크립트가 필요 없다면 --no-plugins --no-scripts로 설치 중에 종속성 코드가 실행되지 않게
하고, 필요한 단계는 이후에 명시적으로 실행하세요.
저장소 출처와 종속성 혼동
Composer는 Packagist.org, Private Packagist 같은 비공개 Composer 저장소, VCS 저장소(Git URL), 로컬 경로에서 설치할 수
있습니다. 비공개 출처와 공개 출처를 섞는 곳에서 종속성 혼동(dependency confusion)이
생깁니다. 비공개 패키지 이름이 Packagist.org에도 더 높은 버전으로 존재한다면 어느 쪽이 설치될까요? Composer 2에서는
기본적으로 모든 저장소가
정식(canonical)입니다. Composer는 한 저장소에서 패키지를 찾으면 목록 아래쪽에서는 더 찾지 않으며, Packagist.org는
항상 마지막입니다. 저장소에 "canonical": false를 설정하면 이 보호가 사라지므로 의도한 경우에만 설정하세요.
VCS 저장소는 Git 호스트가 태그에 대해 제공하는 것을 그대로 가져오므로, lockfile에서 커밋 단위로 고정하고 변경 사항을
검토하세요.
방치되거나 유지 관리되지 않는 패키지
레거시 PHP 환경에는 메인테이너가 떠난 패키지가 남아 있습니다. Packagist는 메인테이너가 밝히면 패키지를 방치됨으로
표시하고, 대체 패키지를 함께 안내하는 경우가 많습니다. composer audit는 기본적으로 이를 실패로 처리합니다.
하지만 유지 관리되지 않는 패키지 중 상당수는 공식적으로 방치 표시가 되지 않습니다. 입력, 인증, 암호화를 다루는 패키지는
마지막 릴리스 날짜를 확인하고, 취약점 때문에 어쩔 수 없이 바꾸기 전에 교체를 계획하세요.
테스트가 부족할 때 안전하게 업데이트하기
가장 어려운 경우는 취약한 종속성이 있고 테스트가 거의 없는 대규모 레거시 애플리케이션입니다. 효과적인 방법은 다음과 같습니다.
-
취약한 패키지를 끌어오는 패키지를 업데이트하세요. 대부분의 결과는 전이적입니다.
composer why vendor/package를 실행해 연결 고리를 확인하고, 먼저 상위 패키지를 제약 범위 안에서 업데이트하세요. -
한 번에 하나씩 업데이트하세요.
composer update vendor/package --with-dependencies를 쓰면composer.lockdiff로 검토할 수 있을 만큼 변경을 작게 유지할 수 있습니다. - 핵심 경로부터 스모크 테스트하세요. 로그인, 결제, 주요 API 엔드포인트처럼 몇 가지 엔드투엔드 점검만으로도 업그레이드로 인한 문제 대부분을 잡을 수 있습니다.
- 업그레이드가 건드리는 부분에 특성화 테스트를 추가하세요. 코드가 지금 어떻게 동작하는지 기록해 두면 업그레이드가 그 동작을 바꿀 때 알아챌 수 있습니다.
- 단계적으로 롤아웃하고 모든 고객에게 닿기 전에 오류율을 지켜보세요.
composer audit가 놓치는 것
lockfile은 계획일 뿐 빌드의 기록이 아닙니다. composer audit는 vendor/ 또는
composer.lock을 읽으므로 다음은 보지 못합니다.
- PHP 자체와 그 확장. 이들은 베이스 이미지나 OS 패키지에서 옵니다(PHP2Shell이 한 예입니다).
- Docker 이미지에 설치된 PECL 확장과 시스템 라이브러리.
- CI가
curl로 가져오는 도구, 실행하는 액션, 플러그인이나 스크립트가 내려받는 바이너리. - 아직 권고나 멀웨어 표시가 없는 악성 코드.
같은 한계는 각 생태계의 npm audit와 pip-audit에도 적용됩니다.
PHP 공급망 공격
네 건의 사고는 PHP를 노리는 공급망 공격이 실제로 어디를 파고드는지, 그리고 각각을 무엇이 잡아낼 수 있었는지 보여 줍니다.
2021년 3월: php-src의 악성 커밋
2021년 3월 28일, 핵심 개발자 두 명의 이름으로 php-src 저장소에 악성 커밋 두 건이 푸시되었습니다. PHP 팀은 자체 Git 서버 운영을 중단하고 GitHub 저장소를 정식 저장소로 삼겠다고 발표했습니다. 이후의 후속 보고에서는 커밋이 서버 침해가 아니라 HTTPS와 비밀번호 인증으로 푸시되었으며, 유출된 사용자 데이터베이스의 자격 증명이 쓰였을 가능성이 있다고 밝혔습니다. 무엇이 잡아낼 수 있었나: 여러분의 파이프라인으로는 아무것도 막을 수 없었습니다. 표적은 언어 자체였으며, PHP 런타임도 종속성이라는 사실을 일깨워 줍니다.
2021년 4월: Composer 명령 인젝션(CVE-2021-29472)
Composer가 Mercurial 저장소 URL을 처리하는 방식의 결함으로 명령 인젝션이 가능했습니다. Packagist의 보고에 따르면 Packagist.org와 Private Packagist는 조작된 패키지 URL을 통해 이론적으로 노출되어 있었지만 악용되지는 않은 것으로 보았고, Composer 2.0.13과 1.10.22에서 수정했습니다. 무엇이 잡아낼 수 있었나: 개발자 머신과 CI 이미지의 Composer 자체를 최신으로 유지하는 것입니다. 패키지 관리자도 공급망의 일부입니다.
2023년 5월: Packagist 계정 탈취
2023년 5월 1일, 공격자는 다른 곳에서 유출된 비밀번호로 Packagist.org의 휴면 계정 네 개에 로그인했습니다. 이 계정들은
모두 합쳐 14개 패키지를 관리하고 있었고, 공격자는 그 패키지들이 포크한 저장소를 가리키게 했습니다. 포크에는 바뀐 설명만
있었고 악성 코드는 없었습니다.
Packagist는 다음 날
아침까지 이를 복구했습니다. 무엇이 잡아낼 수 있었나: 커밋된 composer.lock입니다.
composer install은 lockfile에 기록된 출처를 사용하기 때문입니다. 그리고 Packagist 스스로 권장하듯 예상치
못한 URL이 없는지 lockfile diff를 검토하는 것입니다. 메인테이너는 고유한 비밀번호와 2단계 인증을 써야 합니다.
2026년: laravel-lang과 intercom-php의 탈취된 태그
2026년, 탈취한 접근 권한을 가진 공격자들이 기존 패키지의 악성 릴리스를 게시했습니다. Packagist는
공급망 보안에
관한 업데이트에서 intercom/intercom-php(4월 30일)와 laravel-lang 패키지(5월 22일)를
언급합니다. laravel-lang 사례에서는 버전 태그가 악성 포크의 커밋을 가리켰고, 페이로드인 src/helpers.php
파일이 Composer
오토로더로 로드되어 클라우드 키, SSH 키, .env 파일을 노리는 자격 증명 탈취 도구를 내려받았습니다.
Aikido는 침해된 버전을 233개로 집계했습니다. 무엇이 잡아낼 수 있었나: 표시가 붙은 뒤에는 Composer
2.10의 멀웨어 정책이 composer install에서도 해당 버전을 차단합니다. 그 전에는 cooldown 기간이 갓 나온
버전을 막고, 이그레스 정책이 페이로드가 서버에 닿지 못하게 하며, 빌드마다 남긴 기록이 어떤 빌드에 악성 버전이
설치되었는지 보여 줍니다.
이후 Packagist는 태그를 다시 단 안정 버전을 거부하고, 소유권과 태그 변경에 대한 투명성 로그를 공개하며, 2026년 3월부터 Packagist.org에서 멀웨어를 표시하고 있다고 발표했습니다.
CI에서 PHP 빌드 보호하기
빌드는 코드, 종속성, 비밀 값이 만나는 곳이므로 악성 패키지가 가장 큰 피해를 주는 곳이기도 합니다. 운영 빌드를 위한 GitHub Actions 워크플로 예시입니다.
name: build
on: [push, pull_request]
permissions:
contents: read # least privilege for GITHUB_TOKEN
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: shivammathur/setup-php@eb7c497e18156a6bbabfef1d3a82760b9eda3962 # 2.40.0
with:
php-version: "8.4"
tools: composer:v2
coverage: none
- run: composer validate --strict
- id: composer-cache
run: echo "dir=$(composer config cache-files-dir)" >> "$GITHUB_OUTPUT"
- uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: ${{ steps.composer-cache.outputs.dir }}
key: composer-${{ hashFiles('composer.lock') }}
- run: composer install --no-dev --no-interaction --no-progress --optimize-autoloader
- run: composer audit --no-dev
-
최소 권한 토큰.
permissions: contents: read는 침해된 단계가GITHUB_TOKEN으로 코드나 릴리스를 푸시하지 못하게 합니다. GitHub 토큰 권한을 참고하세요. - 고정된 액션. 전체 커밋 SHA는 태그처럼 옮길 수 없습니다. 주석 덕분에 Dependabot이나 Renovate도 버전을 읽을 수 있습니다.
-
vendor/가 아니라 다운로드를 캐시하세요. Composer의 다운로드 캐시를 캐시하면 모든 잡에서composer install이 lockfile을 기준으로 실행됩니다. 복원된vendor/는 그대로 신뢰되고, 캐시는 워크플로 사이에서 오염될 수 있습니다. GitHub Actions 캐시를 참고하세요. - 설치된 것을 기록하세요. lockfile은 무엇이 설치되어야 하는지를 말합니다. 빌드에서 기록한 SBOM은 PHP 런타임, OS 패키지, Composer 밖의 다운로드까지 포함해 실제로 무엇이 설치되었는지를 말합니다. 빌드 시점 SBOM을 참고하세요.
- 빌드가 연결할 수 있는 곳을 제한하세요. Composer 빌드에는 Packagist, GitHub, 그리고 비공개 저장소만 필요합니다. 이들만 허용하는 이그레스 정책은 자격 증명 탈취 도구가 찾아낸 것을 보내지 못하게 합니다.
PHP 프로젝트의 SBOM 생성하기
표준적인 오픈소스 선택지는 CycloneDX
Composer 플러그인입니다. 플러그인이므로 allow-plugins 항목이 필요합니다.
composer require --dev cyclonedx/cyclonedx-php-composer
composer CycloneDX:make-sbom --output-format=JSON --output-file=sbom.json
이 플러그인은 composer.json을 설치된 패키지 또는 composer.lock과 함께 읽으므로, Composer 종속성은
정확하게 나열하지만 그 밖의 것은 담지 않습니다. PHP 런타임, 그 확장, 베이스 이미지의 apt 또는 apk 패키지, 잡이 내려받은
도구는 들어가지 않습니다. 빌드 시점 SBOM은 Composer 파일이 아니라 잡이 실제로 가져온 것에서 만들어지므로 이들도
기록합니다. 형식과 SBOM에 담아야 할 내용은 SBOM이란을 참고하세요.
계층별 PHP 보안 도구
| 계층 | 도구 | 다루는 것 | 놓치는 것 |
|---|---|---|---|
| 코드 | PHPStan, Psalm, Semgrep | 버그와 타입 오류, 오염된 데이터 흐름, 알려진 안전하지 않은 패턴 | 취약한 종속성, 런타임, 빌드 |
| 종속성 | composer audit, Composer 정책, roave/security-advisories, Dependabot 또는 Renovate |
게시된 권고, 방치되거나 표시된 패키지, 업데이트 풀 리퀘스트 | 직접 작성한 코드, 그리고 composer.lock 밖의 모든 것 |
| 이미지 | 컨테이너 스캐너 | 빌드된 이미지 안의 OS 패키지와 언어 패키지 | 빌드가 가져왔지만 남기지 않은 것, 아무도 스캔하지 않은 이미지 |
| SBOM | CycloneDX Composer 플러그인 | CycloneDX SBOM 형식의 Composer 패키지 | Composer 밖의 모든 것 |
| 빌드 | CRACI | 각 GitHub Actions 잡이 Composer와 그 밖의 모든 출처에서 실제로 설치한 것, 지속적으로 모니터링 | 직접 작성한 PHP 코드(SAST 없음), GitHub Actions 이외의 CI 시스템 |
이미지에 대해 더 알아보려면 컨테이너 스캐닝을, 모든 계층에서 결과를 더 적고 더 정확하게 만들려면 취약점 노이즈를 줄이는 방법을 참고하세요.
PHP 보안과 CRACI
CRACI는 각 잡이 실제로 설치한 것을 기록하는 GitHub Actions 러너입니다. runs-on을 craci로
바꾸면 잡이 내려받는 모든 Composer 패키지가 패키지 URL(pkg:composer/vendor/package)과 함께 기록됩니다. 같은
잡의 OS 패키지 관리자 패키지, 컨테이너 이미지, Git 소스, 기타 다운로드, 그리고 CI 캐시에서 복원된 패키지도 함께
기록됩니다. SBOM의 모든 구성 요소는 실제로 빌드에 들어 있던 것입니다.
CRACI는 이 구성 요소를 알려진 취약점과 대조하고, 새 권고가 게시될 때마다 출시한 것의 SBOM을 계속 다시 확인합니다. 그래서 내년에 공개되는 CVE라도 그것을 포함한 빌드로 바로 이어집니다. 정책 게이트는 특정 CVE를 포함한 빌드를 비롯해 결과에 따라 빌드를 멈출 수 있고, 이그레스 정책은 잡이 연결할 수 있는 곳을 제한합니다.
CRACI는 PHP 코드를 스캔하지 않습니다. 설계상 SAST는 없습니다. 코드 계층에는 PHPStan, Psalm, Semgrep을 계속 사용하세요. PHP 빌드가 설치하는 모든 패키지를 확인하세요.
CRACI가 답이 아닌 경우
-
FTP로 배포하거나 CI 없는 공유 호스팅을 쓰는 경우. 기록할 빌드가 없습니다.
composer audit와 커밋된composer.lock부터 시작하세요. - 위험이 주로 직접 작성한 코드에 있는 경우. 예를 들어 원시 SQL로 가득한 레거시 애플리케이션입니다. SAST, 리뷰, 테스트가 먼저입니다.
- GitHub Actions 밖에서 빌드하는 경우. GitLab CI나 Bitbucket Pipelines가 그렇습니다. CRACI는 현재 GitHub Actions를 지원하며, GitLab CI와 Jenkins는 로드맵에 있습니다.
PHP 보안: 자주 묻는 질문
PHP 코드는 어떻게 보호하나요?
세 계층에서 작업하세요. 코드에서는 입력을 검증하고, 준비된 문을 사용하고, 프레임워크의 출력 이스케이프에 의존하며, 신뢰할 수 없는 데이터는 절대 unserialize하지 마세요. 종속성에서는 composer.lock을 커밋하고, CI에서 composer audit를 실행하고, Composer의 안전하지 않은 버전 차단을 켜 두세요. 빌드에서는 액션을 고정하고, 토큰을 제한하고, 네트워크 접근을 좁히고, 잡이 설치한 것을 기록하세요.
가장 흔한 PHP 취약점은 무엇인가요?
SQL 인젝션, 크로스 사이트 스크립팅, 크로스 사이트 요청 위조, eval()을 통한 코드 인젝션, unserialize()를 통한 객체 인젝션, 파일 포함과 안전하지 않은 업로드, ==를 이용한 타입 저글링, 취약한 세션 설정, URL 래퍼를 통한 SSRF, 명령 인젝션, 대량 할당입니다. 취약한 Composer 종속성과 PHP2Shell 버그 같은 패치되지 않은 PHP 버전도 그만큼 흔한 사고 원인입니다.
가장 좋은 PHP 보안 스캐너는 무엇인가요?
PHP 보안 전체를 혼자 다루는 도구는 없습니다. 코드에는 PHPStan, Psalm, Semgrep 같은 정적 분석기를, Composer 종속성에는 composer audit를, 이미지에는 컨테이너 스캐너를, 그리고 CI가 실제로 설치한 것에는 빌드 시점의 기록을 사용하세요.
composer audit는 어떻게 작동하고 무엇을 놓치나요?
설치된 패키지, 또는 --locked를 붙이면 composer.lock을 Packagist.org API의 권고와 대조하고, 방치된 패키지와 멀웨어로 표시된 패키지를 보고합니다. 감사에 통과하지 못한 항목이 있으면 1로 종료합니다. PHP 자체, PECL 확장, 이미지 안의 OS 패키지, CI가 Composer 밖에서 내려받는 것은 보지 못합니다.
CI에서 안전하지 않은 Composer 패키지를 어떻게 차단하나요?
Composer 2.9 이상을 사용하세요. update와 require 중에 권고가 있는 버전을 기본적으로 차단합니다(policy.advisories.block). lockfile에 고정된 취약 버전은 그대로 설치되므로, composer install 뒤에 composer audit도 실행하고 그 종료 코드로 잡을 실패시키세요. CI에서는 --no-blocking을 절대 사용하지 마세요.
Laravel은 기본적으로 안전한가요?
대체로 그렇습니다. Blade는 출력을 이스케이프하고, Eloquent는 쿼리 매개변수를 바인딩하며, 모델은 대량 할당으로부터 보호되고, 로그인 시 세션 ID가 재생성됩니다. 하지만 코드가 {!! !!}, 입력을 넘긴 whereRaw(), $guarded = [], 운영 환경의 APP_DEBUG=true를 쓰거나 APP_KEY가 유출되면 이 기본 보호는 더 이상 작동하지 않습니다.
테스트가 적은 레거시 PHP 앱의 종속성은 어떻게 업데이트하나요?
취약한 패키지를 끌어오는 상위 패키지를 composer update vendor/package --with-dependencies로 한 번에 하나씩 업데이트하세요. 핵심 경로를 스모크 테스트하고, 업그레이드가 건드리는 부분에 특성화 테스트를 추가하고, 단계적으로 롤아웃하세요.
PHP 프로젝트의 SBOM은 어떻게 생성하나요?
CycloneDX Composer 플러그인(cyclonedx/cyclonedx-php-composer)이 composer CycloneDX:make-sbom으로 Composer 패키지에서 SBOM을 생성합니다. Composer 패키지만 나열합니다. 빌드 시점 SBOM은 PHP 런타임, OS 패키지, 빌드 중의 다른 다운로드도 기록합니다.
PHP2Shell이란 무엇인가요?
PHP2Shell은 CVE-2026-109618과 CVE-2026-109619를 부르는 이름입니다. 인증 없이 원격 코드 실행을 허용할 수 있는 PHP 자체의 치명적인 취약점 두 건으로, 2026년 10월 21일에 출시된 PHP 8.5.12, 8.4.27, 8.3.36, 8.2.35에서 수정되었습니다. PHP2Shell을 참고하세요.
PHP 빌드가 설치하는 모든 패키지를 확인하세요
CRACI에서 GitHub Actions 워크플로 하나를 실행하고, 기록된 SBOM을 composer.lock과 비교해 보세요.
데모 예약