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 가 되는 것까지 확인했다.
130 lines
5.7 KiB
PHP
130 lines
5.7 KiB
PHP
<?php
|
|
|
|
// 스케줄러 Shell/Artisan 실행 보안 정책.
|
|
// 관리자 환경설정에 노출하지 않는 코드 소유 정책 — command 실행 능력은 신뢰 경계 안에서만 넓힌다.
|
|
// (스케줄 권한만 위임받은 관리자가 화이트리스트를 넓혀 임의 명령 실행을 복구하는 것을 막는다.)
|
|
return [
|
|
'shell' => [
|
|
// Shell 타입 스케줄 실행 자체를 켜는 opt-in 게이트 (기본 차단).
|
|
'enabled' => env('SCHEDULE_SHELL_ENABLED', false),
|
|
|
|
// 허용 실행 파일 basename 목록. 빈 배열이면 모든 shell 명령을 거부한다.
|
|
'allowed_binaries' => array_values(array_filter(array_map(
|
|
'trim',
|
|
explode(',', (string) env('SCHEDULE_SHELL_ALLOWED_BINARIES', '')),
|
|
))),
|
|
],
|
|
|
|
'artisan' => [
|
|
// 실행을 허용하는 artisan 명령 목록 (허용목록 — 여기 없으면 거부).
|
|
//
|
|
// 차단목록 방식은 등록 명령 238개 중 9개만 막아 나머지를 무조건 허용했고,
|
|
// `migrate:refresh` 가 빠져 있는 등 "빠뜨림" 이 구조적으로 반복됐다.
|
|
// 코드 생성·스키마 변경·비밀값 접근이 없는 유지보수 명령만 등재한다.
|
|
//
|
|
// 각 항목의 `options` 는 허용 롱옵션 이름(`--` 없이)이며, 선언되지 않은 옵션은 거부한다.
|
|
// 실제 시그니처(`getDefinition()`)를 확인해 채운 값이다 — 경로·클래스·파일을 지정하거나
|
|
// 안전장치를 무력화하는 옵션(`--path` `--model` `--force` `--daemon` 등)은 제외했다.
|
|
// `max_arguments` 는 허용 위치 인자 개수이며 생략 시 0 이다.
|
|
'allowlist' => [
|
|
// 캐시·컴파일 산출물 정리
|
|
'cache:clear' => ['options' => [], 'max_arguments' => 0],
|
|
'config:clear' => ['options' => []],
|
|
'route:clear' => ['options' => []],
|
|
'view:clear' => ['options' => []],
|
|
'event:clear' => ['options' => []],
|
|
'optimize:clear' => ['options' => []],
|
|
'hooks:clear' => ['options' => []],
|
|
'hooks:cache' => ['options' => []],
|
|
|
|
// 큐
|
|
'queue:restart' => ['options' => []],
|
|
'queue:prune-failed' => ['options' => ['hours']],
|
|
'queue:prune-batches' => ['options' => ['hours', 'unfinished', 'cancelled']],
|
|
'queue:work' => ['options' => [
|
|
'queue', 'once', 'stop-when-empty', 'stop-when-empty-for',
|
|
'delay', 'backoff', 'max-jobs', 'max-time',
|
|
'memory', 'sleep', 'rest', 'timeout', 'tries',
|
|
]],
|
|
|
|
// 만료 데이터 정리
|
|
'auth:clear-resets' => ['options' => []],
|
|
'sanctum:prune-expired' => ['options' => ['hours']],
|
|
'model:prune' => ['options' => ['chunk', 'pretend']],
|
|
'notification:cleanup' => ['options' => []],
|
|
'layout-previews:cleanup' => ['options' => []],
|
|
'ext-bundles:cleanup' => ['options' => []],
|
|
|
|
// SEO
|
|
'seo:warmup' => ['options' => ['layout']],
|
|
'seo:clear' => ['options' => ['layout']],
|
|
'seo:generate-sitemap' => ['options' => ['sync', 'rebuild', 'mode']],
|
|
|
|
// 기타 유지보수
|
|
'geoip:update' => ['options' => ['dry-run']],
|
|
'search:index' => ['options' => ['filter']],
|
|
'inspire' => ['options' => []],
|
|
],
|
|
|
|
// 설치된 확장이 소유한 명령을 자동으로 허용할지 여부.
|
|
// 확장 설치는 서버에 코드를 배치하는 행위로 이미 스케줄 권한보다 높은 신뢰를 요구하고,
|
|
// 그 확장의 getSchedules() 명령은 이미 무인 실행되고 있다. 필요 시 끌 수 있다.
|
|
'allow_extension_commands' => true,
|
|
|
|
// 확장 소유 판정 기준 — 명령 인스턴스의 클래스 네임스페이스 접두사.
|
|
// Symfony 가 실제 실행에 쓰는 레지스트리를 보므로 이름만으로는 위조할 수 없다.
|
|
'extension_namespaces' => ['Modules\\', 'Plugins\\'],
|
|
|
|
// 최종 거부권. 허용목록·확장 자동 허용보다 먼저 평가되어,
|
|
// 확장이 코어 명령 이름을 가로채 등록하는 경우도 막는다.
|
|
'denylist' => [
|
|
// 임의 코드 실행
|
|
'tinker',
|
|
'invoke-serialized-closure',
|
|
// 스키마·데이터 파괴
|
|
'db',
|
|
'db:wipe',
|
|
'db:seed',
|
|
'schema:dump',
|
|
'migrate',
|
|
'migrate:fresh',
|
|
'migrate:reset',
|
|
'migrate:rollback',
|
|
'migrate:refresh',
|
|
'migrate:install',
|
|
// 비밀값·환경
|
|
'env',
|
|
'env:encrypt',
|
|
'env:decrypt',
|
|
'key:generate',
|
|
// 서비스 상태 조작
|
|
'schedule:run',
|
|
'schedule:work',
|
|
'serve',
|
|
'down',
|
|
'up',
|
|
'test',
|
|
'clear-compiled',
|
|
// 파일 배치·코어/확장 변경
|
|
'stub:publish',
|
|
'vendor:publish',
|
|
'package:discover',
|
|
'core:update',
|
|
'core:build',
|
|
'core:execute-upgrade-steps',
|
|
'core:execute-bundled-updates',
|
|
'extension:composer-install',
|
|
'module:install',
|
|
'plugin:install',
|
|
'template:install',
|
|
'module:seed',
|
|
'plugin:seed',
|
|
'hotfix:rollback-stale-files',
|
|
'playwright:issue-token',
|
|
],
|
|
|
|
// 접두사 단위 거부권. `make:*` 41개를 일일이 나열하지 않는다.
|
|
'denylist_prefixes' => ['make:'],
|
|
],
|
|
];
|