fix(security): 예약 작업·언어팩·인스톨러의 차단목록을 허용목록으로 전환

KISA 신고포상제 접수 3건(KVE-2026-1677/1678/1679)의 공통 원인은 하나다 —
"위험한 것을 골라 막았고, 목록 밖으로 우회됐다". 세 지점 모두 판정을 뒤집는다.

예약 작업 Artisan (KVE-2026-1679)
차단 9개 / 허용 229개였던 구조를 허용목록으로 바꾸고 명령마다 허용 옵션을 선언한다.
검증 과정에서 보고서보다 짧은 경로를 추가로 발견했다 — 검증기는 preg_split 의 첫
토큰을 명령명으로 봤지만 실행부는 문자열째 넘겼고, parameters 가 비면 Laravel 이
StringInput 으로 재파싱한다. 따옴표 한 쌍이면 차단목록의 tinker 가 그대로 실행됐다
(마커 파일로 확인). 파서를 엄격 형태 하나로 고정하고 실행은 (명령명, 인자배열)로 넘겨
재해석 지점 자체를 없앴다.

언어팩 (KVE-2026-1678)
위험 함수 11개를 정규식으로 나열하던 검사를 토큰 상태기계로 교체해 "번역 배열만"
통과시킨다. 목록에 없는 함수도, 함수 이름을 아예 쓰지 않는 형태도 원리적으로 막힌다.
파일 형식·PHP 위치·심볼릭 링크도 화이트리스트로 뒤집었다. 활성 승격이 require 배선의
필수 조건이므로 설치와 활성화의 권한 경계를 네 설치 경로 전부에 같은 강도로 건다 —
경로별 면제는 곧 우회로가 되기 때문이다. 화면은 운영자의 활성화 권한을 보고
auto_activate 를 실을지 결정하므로 재설치 흐름은 그대로 동작한다.

인스톨러 (KVE-2026-1677)
실행 바이너리 자리에 인자가 들어가는 것을 형태 규칙으로 차단한다. escapeshellarg 는
"하나의 인자" 임만 보장할 뿐 그 인자가 실행 파일인지 스크립트인지는 보장하지 않는다.
판정을 의존성 0 공용 정책으로 모아 진단 API·설치 워커·저장 시점이 같은 규칙을 쓴다.
stat 은 호출하지 않는다 — 접근이 제한된 서버에서 정상 경로가 거부되던 회귀를
재발시키지 않기 위함이다.

검증: 백엔드 392건 + 프론트 7건 green(skip 1, symlink 미지원 환경). PoC 마커로 red 를
먼저 확보하고, 수정 후 stash 역회귀로 다시 red 가 되는 것까지 확인했다.
This commit is contained in:
HeuJung
2026-08-04 17:48:05 +09:00
parent 30867e0e0b
commit 4b6adf4788
45 changed files with 4382 additions and 301 deletions
+22 -37
View File
@@ -13,6 +13,10 @@ use App\Support\PrivilegedDatabaseAccounts;
* - POST ?action=test-db : 데이터베이스 연결 테스트
*/
// 실행 바이너리 경로 허용 형태 정책 — 인스톨러 API 와 설치 워커가 같은 규칙을 공유한다.
// 한쪽만 고치면 다른 쪽이 우회로가 되므로 의존성 없는 공용 파일로 두고 양쪽에서 로드한다.
require_once __DIR__.'/../includes/binary-path-policy.php';
/**
* 검증 API 클래스
*/
@@ -874,11 +878,7 @@ class ValidationApi
*/
private function isInstallerSafePathArg(string $path): bool
{
if ($path === '') {
return false;
}
return ! preg_match('/[\s;`$|<>"\'&\x00-\x1F]/', $path);
return installer_binary_path_shape_ok($path);
}
/**
@@ -892,21 +892,7 @@ class ValidationApi
*/
private function splitPhpComposerTokens(string $path): ?array
{
if (! str_contains($path, ' ')) {
return null;
}
$tokens = preg_split('/\s+/', trim($path), 2);
if (! is_array($tokens) || count($tokens) !== 2) {
return null;
}
[$php, $composer] = $tokens;
if ($php === '' || $composer === '') {
return null;
}
return ['php' => $php, 'composer' => $composer];
return installer_resolve_php_composer_pair($path);
}
/**
@@ -921,11 +907,12 @@ class ValidationApi
return ['valid' => false, 'version' => null, 'message' => lang('error_php_path_empty')];
}
// 'php' 기본값이 아니면 셸 메타문자 차단. 파일 존재/실행 가능 검사는
// open_basedir 같은 PHP 런타임 제약 환경의 false negative 를 피하기 위해
// 생략하고, exec 결과로 최종 판정한다.
if ($path !== 'php' && ! $this->isInstallerSafePathArg($path)) {
return ['valid' => false, 'version' => null, 'message' => lang('error_php_exec_failed', ['path' => $path])];
// 'php' 기본값이 아니면 실행 경로 형태 규칙을 적용한다(이름은 제한하지 않는다 —
// 이 자리는 인자가 `--version` 으로 고정되어 있고 설치 환경마다 이름이 다르다).
// 파일 존재/실행 가능 검사는 open_basedir 같은 PHP 런타임 제약 환경의
// false negative 를 피하기 위해 생략하고, exec 결과로 최종 판정한다.
if ($path !== 'php' && ! installer_binary_path_shape_ok($path)) {
return ['valid' => false, 'version' => null, 'message' => lang('error_php_binary_path_not_allowed', ['path' => $path])];
}
$command = escapeshellarg($path).' --version 2>&1';
@@ -974,15 +961,13 @@ class ValidationApi
// 두 토큰으로 분해 후 각 토큰별 메타문자 차단 + 각각 escapeshellarg 적용한다.
// 옛 raw shell 전달(escape 없는 분기) 은 복원하지 않음.
if ($effectivePath !== 'composer' && str_contains($effectivePath, ' ')) {
// 자리별 규칙(PHP 자리=형태만, Composer 자리=이름 형태까지)은 공용 정책이 담당한다.
$tokens = $this->splitPhpComposerTokens($effectivePath);
if ($tokens === null
|| ! $this->isInstallerSafePathArg($tokens['php'])
|| ! $this->isInstallerSafePathArg($tokens['composer'])
) {
if ($tokens === null) {
return [
'valid' => false,
'version' => null,
'message' => lang('error_composer_exec_failed', ['path' => $effectivePath]),
'message' => lang('error_composer_binary_path_not_allowed', ['path' => $effectivePath]),
];
}
@@ -1011,24 +996,24 @@ class ValidationApi
return ['valid' => false, 'version' => null, 'message' => lang('error_composer_version_parse_failed')];
}
// 단일 토큰 — 시스템 기본('composer') 가 아니면 셸 메타문자 차단.
// 단일 토큰 — 시스템 기본('composer') 가 아니면 Composer 자리 규칙을 적용한다.
// 파일 존재/실행 가능 검사는 open_basedir 환경의 false negative 회피를 위해 생략.
if ($effectivePath !== 'composer' && ! $this->isInstallerSafePathArg($effectivePath)) {
if ($effectivePath !== 'composer' && ! installer_is_composer_binary_path($effectivePath)) {
return [
'valid' => false,
'version' => null,
'message' => lang('error_composer_exec_failed', ['path' => $effectivePath]),
'message' => lang('error_composer_binary_path_not_allowed', ['path' => $effectivePath]),
];
}
// .phar 파일이면 PHP 바이너리와 결합
if (str_ends_with($effectivePath, '.phar')) {
// phpPath 도 동일한 가드 — 'php' 기본값이 아니면 메타문자 없는 단일 토큰이어야 함
if ($phpPath !== 'php' && ! $this->isInstallerSafePathArg($phpPath)) {
if (str_ends_with(strtolower($effectivePath), '.phar')) {
// phpPath 는 실행 파일 자리 — 이름은 제한하지 않고 형태 규칙만 적용한다.
if ($phpPath !== 'php' && ! installer_binary_path_shape_ok($phpPath)) {
return [
'valid' => false,
'version' => null,
'message' => lang('error_php_exec_failed', ['path' => $phpPath]),
'message' => lang('error_php_binary_path_not_allowed', ['path' => $phpPath]),
];
}
$command = escapeshellarg($phpPath).' '.escapeshellarg($effectivePath).' --version 2>&1';
@@ -0,0 +1,159 @@
<?php
/**
* 인스톨러가 실행할 바이너리 경로의 허용 형태를 정의하는 공용 정책.
*
* 인스톨러는 운영자가 입력한 PHP/Composer 경로를 `exec()` 로 실행한다. `escapeshellarg()`
* 는 "이 문자열이 하나의 인자" 임만 보장할 뿐, 그 인자가 실행 파일인지 코드 실행 플래그인지
* 인터프리터가 실행할 스크립트인지는 보장하지 않는다. 실제로 Composer 칸에
* `PHP경로 <임의스크립트>` 를 넣으면 그 스크립트가 그대로 실행된다(명령 끝의 `--version`
* 은 스크립트 인자로 넘어갈 뿐이다).
*
* 따라서 셸 메타문자 차단 위에 **인자 자리를 닫는 순수 문자열 규칙**을 얹는다.
*
* 1. 하이픈 선두 토큰 거부 (양쪽 토큰) — `-r` `-c` `--define` 등 코드 실행 플래그 차단
* 2. Composer 자리(둘째 토큰·단일 토큰)만 이름 형태 제한 — composer 계열 또는 `.phar`
* 3. PHP 자리는 이름을 제한하지 않음 — 래퍼 스크립트·버전 붙은 이름·커스텀 이름 허용
* 4. 상대경로·`..` 세그먼트 거부 — CWD 에 의존한 실행 차단
*
* 파일 시스템 stat(`is_file`/`is_executable`)은 호출하지 않는다. 시놀로지 DSM 등
* `open_basedir` 가 시스템 binary 영역을 차단하는 환경에서 정상 절대경로가 false negative
* 로 거부되는 회귀가 있었고(#361), 그 회귀를 구조적으로 재발 불가하게 두기 위함이다.
* 실제 실행 가능 여부는 exec 결과로 최종 판정한다.
*
* 프레임워크·설정에 의존하지 않는다 — 인스톨러 API(라이브러리 모드)와 설치 워커
* (`task-runner.php`) 양쪽에서 단독으로 `require_once` 할 수 있어야 한다.
*/
if (! function_exists('installer_binary_path_shape_ok')) {
/**
* 실행 경로 토큰이 허용 형태인지 판정합니다 (자리 공통 규칙).
*
* @param string $token 단일 경로 토큰
* @return bool 허용 형태면 true
*/
function installer_binary_path_shape_ok(string $token): bool
{
if ($token === '') {
return false;
}
// 셸 메타문자·제어문자 차단. 백슬래시는 Windows 경로 구분자이므로 허용한다.
if (preg_match('/[\s;`$|<>"\'&\x00-\x1F]/', $token)) {
return false;
}
// 하이픈 선두는 실행 파일 경로가 아니라 옵션이다 — 이번 취약점을 닫는 핵심 규칙.
if ($token[0] === '-') {
return false;
}
// `#` 이후를 주석으로 흘려 `.phar` 접미사를 위장하는 트릭 차단.
if (str_contains($token, '#')) {
return false;
}
// `..` 세그먼트 차단 (CWD 상대 이동).
if (preg_match('#(^|[/\\\\])\.\.([/\\\\]|$)#', $token)) {
return false;
}
// bare 명령(PATH 탐색)이 아니면 절대경로여야 한다.
if ($token === 'php' || $token === 'composer') {
return true;
}
return installer_binary_path_is_absolute($token);
}
}
if (! function_exists('installer_binary_path_is_absolute')) {
/**
* 절대경로인지 판정합니다 (POSIX / Windows 드라이브 / UNC).
*
* @param string $token 경로 토큰
* @return bool 절대경로면 true
*/
function installer_binary_path_is_absolute(string $token): bool
{
if ($token[0] === '/') {
return true;
}
if (preg_match('#^[A-Za-z]:[\\\\/]#', $token)) {
return true;
}
return str_starts_with($token, '\\\\');
}
}
if (! function_exists('installer_is_composer_binary_path')) {
/**
* Composer 자리에 올 수 있는 경로인지 판정합니다.
*
* 이 자리는 인터프리터가 실행하는 스크립트가 되므로 이름 형태를 제한한다.
* 제한이 없으면 `PHP경로 /tmp/올려둔파일` 형태가 그대로 남는다.
*
* @param string $token 경로 토큰
* @return bool Composer 계열이면 true
*/
function installer_is_composer_binary_path(string $token): bool
{
if (! installer_binary_path_shape_ok($token)) {
return false;
}
$basename = basename(str_replace('\\', '/', $token));
if (preg_match('/^composer[0-9]*(\.[0-9]+)*(\.phar|\.exe|\.bat|\.cmd)?$/i', $basename)) {
return true;
}
return (bool) preg_match('/\.phar$/i', $basename);
}
}
if (! function_exists('installer_resolve_php_composer_pair')) {
/**
* 공백 분리 입력을 (PHP 인터프리터, Composer 바이너리) 두 토큰으로 해석합니다.
*
* 멀티 PHP 환경(시놀로지 DSM Web Station, cPanel/Plesk multi-PHP)에서 특정 PHP 로
* Composer 를 실행하려는 운영 의도를 계속 지원한다. 자리별 규칙을 모두 만족할 때만
* 해석에 성공한다.
*
* 공백이 포함된 디렉토리(`C:\Program Files\...`)는 토큰 분리 휴리스틱과 충돌해
* 지원하지 않는다 — `#361` 이 known_limitation 으로 명시한 기존 한계이며 이번 정책이
* 새로 만드는 제약이 아니다.
*
* @param string $raw 공백으로 구분된 원본 입력
* @return array{php: string, composer: string}|null 해석 실패 시 null
*/
function installer_resolve_php_composer_pair(string $raw): ?array
{
$raw = trim($raw);
if (! str_contains($raw, ' ')) {
return null;
}
$tokens = preg_split('/\s+/', $raw, 2);
if (! is_array($tokens) || count($tokens) !== 2) {
return null;
}
[$php, $composer] = $tokens;
// PHP 자리는 이름을 검사하지 않는다 — 인자가 고정되어 있어 코드 실행이 되지 않고,
// 설치 환경마다 실행 파일 이름이 다르다(래퍼 스크립트·버전 접미사·커스텀 이름).
if (! installer_binary_path_shape_ok($php)) {
return null;
}
if (! installer_is_composer_binary_path($composer)) {
return null;
}
return ['php' => $php, 'composer' => $composer];
}
}
+23 -3
View File
@@ -195,10 +195,30 @@ function handleStep3Post(string $currentLang, array &$formData, array &$errors):
$errors['admin_password_confirm'] = lang('error_password_mismatch');
}
// PHP CLI / Composer 경로 처리
// PHP CLI / Composer 경로 처리 — 저장 시점에 허용 형태를 강제한다.
// 이 값들은 설치 워커에서 실제 명령의 실행 바이너리가 되므로, 형태 위반 값이
// 애초에 .env / 설치 상태에 기록되지 않게 여기서 막는다.
require_once __DIR__.'/binary-path-policy.php';
$phpBinary = trim($formData['php_binary'] ?? 'php');
$formData['php_binary'] = $phpBinary !== '' ? $phpBinary : 'php';
$formData['composer_binary'] = trim($formData['composer_binary'] ?? '');
$phpBinary = $phpBinary !== '' ? $phpBinary : 'php';
if ($phpBinary !== 'php' && ! installer_binary_path_shape_ok($phpBinary)) {
$errors['php_binary'] = lang('error_php_binary_path_not_allowed', ['path' => $phpBinary]);
}
$formData['php_binary'] = $phpBinary;
$composerBinary = trim($formData['composer_binary'] ?? '');
if ($composerBinary !== '' && $composerBinary !== 'composer') {
// 단일 토큰이면 Composer 자리 규칙, 공백 분리 입력이면 (PHP, Composer) 쌍 해석.
$allowed = str_contains($composerBinary, ' ')
? installer_resolve_php_composer_pair($composerBinary) !== null
: installer_is_composer_binary_path($composerBinary);
if (! $allowed) {
$errors['composer_binary'] = lang('error_composer_binary_path_not_allowed', ['path' => $composerBinary]);
}
}
$formData['composer_binary'] = $composerBinary;
// Vendor 설치 모드 처리 (auto|composer|bundled)
$vendorMode = trim($formData['vendor_mode'] ?? 'auto');
+28 -45
View File
@@ -130,12 +130,27 @@ if (! function_exists('checkAbortStatusSSE')) {
// Task 함수 정의 (install-worker.php에서 이관됨 — 동작 동일)
// ============================================================================
// 실행 바이너리 경로 허용 형태 정책 — 인스톨러 API 와 동일 규칙을 공유한다.
require_once __DIR__.'/binary-path-policy.php';
if (! function_exists('getPhpBinary')) {
/**
* 설치 상태에 저장된 PHP 실행 경로를 돌려준다.
*
* 이 값은 실제 명령의 실행 바이너리가 되므로(설치 워커의 artisan 호출 등),
* 형태 규칙을 통과하지 못하면 시스템 기본값으로 폴백한다 — 설치 흐름은 유지하되
* 사용자 입력이 인자 자리로 흘러가지 않도록 한다.
*/
function getPhpBinary(): string
{
$state = getInstallationState();
$phpBinary = (string) ($state['config']['php_binary'] ?? '');
return $state['config']['php_binary'] ?? 'php' ?: 'php';
if ($phpBinary === '' || $phpBinary === 'php') {
return 'php';
}
return installer_binary_path_shape_ok($phpBinary) ? $phpBinary : 'php';
}
}
@@ -143,23 +158,12 @@ if (! function_exists('isInstallerExecutablePath')) {
/**
* 인스톨러가 exec 에 전달하기 안전한 단일 토큰 경로인지 검증한다.
*
* - 빈 문자열은 호출자가 시스템 기본값을 쓰겠다는 신호이므로 별도 처리.
* - 공백/세미콜론/백틱/`$` 등 셸 메타문자가 포함된 입력은 거부.
* - 파일 존재/실행 가능 검사는 open_basedir 같은 PHP 런타임 제약 환경의
* false negative 를 피하기 위해 생략. 실제 실행 가능 여부는 exec 결과로 판정.
* 판정은 공용 정책(binary-path-policy.php)이 소유한다 — 인스톨러 API 와 설치 워커가
* 서로 다른 규칙을 쓰면 한쪽이 다른 쪽의 우회로가 된다.
*/
function isInstallerExecutablePath(string $path): bool
{
if ($path === '') {
return false;
}
// 셸 메타문자 + 제어문자 차단. 백슬래시는 Windows 경로 구분자이므로 차단 대상 아님 —
// 셸 인젝션 차단은 호출자의 escapeshellarg 가 담당.
if (preg_match('/[\s;`$|<>"\'&\x00-\x1F]/', $path)) {
return false;
}
return true;
return installer_binary_path_shape_ok($path);
}
}
@@ -168,28 +172,13 @@ if (! function_exists('splitInstallerPhpComposerTokens')) {
* 공백 분리 입력을 "PHP 인터프리터 절대경로 + Composer 바이너리 절대경로" 두 토큰으로 분해.
*
* 멀티 PHP 버전 환경(시놀로지 DSM Web Station, cPanel/Plesk multi-PHP) 의
* 운영 의도를 지원한다. 두 토큰 모두 isInstallerExecutablePath 통과해야
* 정상 입력으로 간주.
* 운영 의도를 지원한다. 자리별 규칙은 공용 정책이 담당한다.
*
* @return array{php: string, composer: string}|null 분해 실패 시 null
*/
function splitInstallerPhpComposerTokens(string $path): ?array
{
if (! str_contains($path, ' ')) {
return null;
}
$tokens = preg_split('/\s+/', trim($path), 2);
if (! is_array($tokens) || count($tokens) !== 2) {
return null;
}
[$php, $composer] = $tokens;
if ($php === '' || $composer === '') {
return null;
}
return ['php' => $php, 'composer' => $composer];
return installer_resolve_php_composer_pair($path);
}
}
@@ -207,10 +196,7 @@ if (! function_exists('getComposerCommand')) {
// 멀티 PHP 환경에서 특정 PHP 인터프리터로 composer 를 실행하려는 운영 의도 지원.
if (str_contains($composerBinary, ' ')) {
$tokens = splitInstallerPhpComposerTokens($composerBinary);
if ($tokens === null
|| ! isInstallerExecutablePath($tokens['php'])
|| ! isInstallerExecutablePath($tokens['composer'])
) {
if ($tokens === null) {
return 'composer';
}
@@ -218,12 +204,12 @@ if (! function_exists('getComposerCommand')) {
}
// 검증 실패 시 시스템 기본 'composer' 로 폴백 — 설치 흐름은 유지하되
// 사용자 입력이 셸 명령으로 흘러가지 않도록 차단.
if (! isInstallerExecutablePath($composerBinary)) {
// 사용자 입력이 셸 명령이나 인자 자리로 흘러가지 않도록 차단.
if (! installer_is_composer_binary_path($composerBinary)) {
return 'composer';
}
if (str_ends_with($composerBinary, '.phar')) {
if (str_ends_with(strtolower($composerBinary), '.phar')) {
$phpBinary = getPhpBinary();
$phpArg = ($phpBinary !== 'php' && isInstallerExecutablePath($phpBinary))
? escapeshellarg($phpBinary)
@@ -249,21 +235,18 @@ if (! function_exists('getComposerCommandForDisplay')) {
// 공백 분리 입력 — 토큰 검증 통과 시 사람 친화적 표기로 그대로 노출.
if (str_contains($composerBinary, ' ')) {
$tokens = splitInstallerPhpComposerTokens($composerBinary);
if ($tokens === null
|| ! isInstallerExecutablePath($tokens['php'])
|| ! isInstallerExecutablePath($tokens['composer'])
) {
if ($tokens === null) {
return 'composer';
}
return $tokens['php'].' '.$tokens['composer'];
}
if (! isInstallerExecutablePath($composerBinary)) {
if (! installer_is_composer_binary_path($composerBinary)) {
return 'composer';
}
if (str_ends_with($composerBinary, '.phar')) {
if (str_ends_with(strtolower($composerBinary), '.phar')) {
return getPhpBinary().' '.$composerBinary;
}
+2
View File
@@ -877,6 +877,8 @@ Firewalls or proxies may be blocking long-lived HTTP connections.',
'error_php_path_empty' => 'PHP binary path is empty.',
'error_php_path_not_exists' => 'File does not exist: :path',
'error_php_exec_failed' => 'PHP execution failed: :path',
'error_php_binary_path_not_allowed' => 'This PHP path format cannot be used (:path). Enter the absolute path of the executable only — options (starting with -), relative paths and .. are not allowed.',
'error_composer_binary_path_not_allowed' => 'This Composer path format cannot be used (:path). Enter the absolute path of the composer executable or a .phar file. For multi-PHP environments use the "absolute-php-path absolute-composer-path" format.',
'error_php_version_too_low' => ':path — PHP :version (minimum :min required)',
'error_php_version_parse_failed' => 'Failed to parse PHP version.',
'error_php_cli_not_verified' => 'PHP CLI path has not been verified. Please click the "Verify Version" button.',
+2
View File
@@ -877,6 +877,8 @@ ini_set(\'zlib.output_compression\', \'off\');
'error_php_path_empty' => 'PHP 바이너리 경로가 비어있습니다.',
'error_php_path_not_exists' => '파일이 존재하지 않습니다: :path',
'error_php_exec_failed' => 'PHP 실행 실패: :path',
'error_php_binary_path_not_allowed' => '사용할 수 없는 PHP 경로 형식입니다 (:path). 실행 파일의 절대경로만 입력하세요 — 옵션(- 로 시작), 상대경로, .. 는 쓸 수 없습니다.',
'error_composer_binary_path_not_allowed' => '사용할 수 없는 Composer 경로 형식입니다 (:path). composer 실행 파일 또는 .phar 의 절대경로만 입력하세요. 멀티 PHP 환경은 "PHP절대경로 composer절대경로" 형식으로 입력할 수 있습니다.',
'error_php_version_too_low' => ':path — PHP :version (최소 :min 필요)',
'error_php_version_parse_failed' => 'PHP 버전을 파싱할 수 없습니다.',
'error_php_cli_not_verified' => 'PHP CLI 경로가 확인되지 않았습니다. "버전 확인" 버튼을 클릭해주세요.',