fix(security): 스케줄 셸 인터프리터 인라인 코드 우회 차단

셸 스케줄 검증이 첫 토큰 basename 만 화이트리스트와 대조하고 뒤 인자를
검증하지 않아, 운영자가 bash 를 등록하면 `bash -c id` 로 임의 OS 명령이
실행됐다(KVE-2026-1653). 인터프리터는 범용 크론 용도로 등록을 허용하되,
스크립트 자리(첫 인자)에 형태 규칙을 적용해 인라인 코드·상대경로·트래버설·
artisan 우회·완전거부형(env/awk/make 등)을 저장·실행 양쪽에서 거부한다.
This commit is contained in:
HeuJung committed 2026-08-17 20:59:42 +09:00
1 parent 19424877e8
commit 2fb452aa44
12 files changed
+955 -7

No files matched your search

+163 -5
View File
@@ -47,6 +47,24 @@ class ScheduleCommandValidator
/** 허용 개수를 넘는 위치 인자가 쓰임 */
public const ARTISAN_REASON_ARGUMENT = 'argument_denied';
/** Shell — 게이트 off 또는 화이트리스트 빔 */
public const SHELL_REASON_DISABLED = 'shell_disabled';
/** Shell — 셸 메타문자/제어문자로 토큰화 불가 */
public const SHELL_REASON_METACHARACTER = 'shell_metacharacter';
/** Shell — 첫 토큰 basename 이 화이트리스트에 없음 */
public const SHELL_REASON_NOT_ALLOWED = 'shell_not_allowlisted';
/** Shell — 완전 거부형 인터프리터 / 인자 없는 인터프리터 / 스크립트 자리 artisan */
public const SHELL_REASON_INTERPRETER = 'shell_interpreter';
/** Shell — 인터프리터 뒤 스크립트 자리가 하이픈(인라인 코드 플래그) */
public const SHELL_REASON_INLINE_CODE = 'shell_inline_code';
/** Shell — 스크립트 경로 형태 위반(상대경로 / `..` / `#`) */
public const SHELL_REASON_SCRIPT_PATH = 'shell_script_path';
/** 셸 해석을 유발하는 메타문자 — 하나라도 있으면 인자 배열 실행이 불가하므로 거부 */
private const SHELL_METACHARACTERS = [
'|', '&', ';', '$', '`', '>', '<', '(', ')', '{', '}',
@@ -56,16 +74,35 @@ class ScheduleCommandValidator
/**
* Shell 타입 command 를 실행해도 되는지 판정합니다.
*
* 게이트가 켜져 있고, 화이트리스트가 비어있지 않으며, 메타문자가 없고,
* 첫 토큰의 basename 이 화이트리스트와 완전 일치할 때만 true.
* 판정은 전적으로 `inspectShellCommand()` 에 위임한다 — 사유가 필요 없는 호출부
* (실행부의 마지막 방어선 등)를 위한 bool 형태이며, 두 경로의 결과가 갈릴 여지가 없다.
*
* @param string $command 스케줄에 저장된 shell command 문자열
* @return bool 실행을 허용하면 true
*/
public static function isShellCommandAllowed(string $command): bool
{
return self::inspectShellCommand($command)['allowed'];
}
/**
* Shell command 를 실행 가능한지 판정하고 거부 사유를 함께 돌려줍니다.
*
* 판정 순서:
* 1. 게이트 off 또는 화이트리스트 빔 → SHELL_REASON_DISABLED
* 2. 메타문자/제어문자로 토큰화 불가 → SHELL_REASON_METACHARACTER
* 3. 첫 토큰 basename 이 화이트리스트에 없음 → SHELL_REASON_NOT_ALLOWED
* 4. 첫 토큰이 완전 거부형(reject_binaries) → SHELL_REASON_INTERPRETER (화이트리스트보다 우선)
* 5. 첫 토큰이 스크립트 실행형 인터프리터 → 스크립트 자리 형태 규칙 적용
* 6. 그 외(일반 스크립트, 예 backup.sh) → 통과 (기존 계약 보존)
*
* @param string $command 스케줄에 저장된 shell command 문자열
* @return array{allowed: bool, reason: string|null}
*/
public static function inspectShellCommand(string $command): array
{
if (! (bool) config('schedule_security.shell.enabled', false)) {
return false;
return self::shellVerdict(false, self::SHELL_REASON_DISABLED);
}
$allowed = array_values(array_filter(array_map(
@@ -74,16 +111,137 @@ class ScheduleCommandValidator
)));
if ($allowed === []) {
return false;
return self::shellVerdict(false, self::SHELL_REASON_DISABLED);
}
$tokens = self::tokenize($command);
if ($tokens === null || $tokens === []) {
return self::shellVerdict(false, self::SHELL_REASON_METACHARACTER);
}
$binary = basename($tokens[0]);
if (! in_array($binary, $allowed, true)) {
return self::shellVerdict(false, self::SHELL_REASON_NOT_ALLOWED);
}
$normalized = strtolower($binary);
// 완전 거부형 — 화이트리스트에 등재돼 있어도 실행하지 않는다.
if (in_array($normalized, self::shellList('reject_binaries'), true)) {
return self::shellVerdict(false, self::SHELL_REASON_INTERPRETER);
}
// 스크립트 실행형 인터프리터 — 첫 인자(스크립트 자리)에 형태 규칙을 적용한다.
if (in_array($normalized, self::shellList('script_interpreters'), true)) {
return self::inspectInterpreterScript($tokens);
}
// 일반 스크립트(인터프리터 아님) — 기존 계약 보존.
return self::shellVerdict(true, null);
}
/**
* 인터프리터 뒤 스크립트 자리(첫 인자)가 허용 형태인지 판정합니다.
*
* 인스톨러 선례 `public/install/includes/binary-path-policy.php` 의
* `installer_binary_path_shape_ok`("하이픈 선두 토큰 거부 + `..`/`#` 차단 + 절대경로 요구")
* 를 스케줄 Shell 스크립트 자리에 이식한 것이다.
*
* @param array<int, string> $tokens 토큰 배열 (첫 원소는 인터프리터)
* @return array{allowed: bool, reason: string|null}
*/
private static function inspectInterpreterScript(array $tokens): array
{
// 인자 0개 — 실행할 스크립트가 없다(인터프리터 REPL/셸 기동).
if (! isset($tokens[1]) || $tokens[1] === '') {
return self::shellVerdict(false, self::SHELL_REASON_INTERPRETER);
}
$script = $tokens[1];
// 하이픈 선두 = 인라인 코드/옵션 플래그(`-c`/`-e`/`-r`/`-m`/`-d`/`-x` 등) — 일괄 차단.
if ($script[0] === '-') {
return self::shellVerdict(false, self::SHELL_REASON_INLINE_CODE);
}
// 스크립트 자리 basename 이 artisan 이면 거부 — 코어 Artisan 축은 Artisan 타입으로.
$deniedNames = array_map(
static fn ($name): string => strtolower(trim((string) $name)),
self::shellList('denied_script_names'),
);
if (in_array(strtolower(basename(str_replace('\\', '/', $script))), $deniedNames, true)) {
return self::shellVerdict(false, self::SHELL_REASON_INTERPRETER);
}
// `#` 이후를 주석으로 흘려 확장자를 위장하는 트릭 차단.
if (str_contains($script, '#')) {
return self::shellVerdict(false, self::SHELL_REASON_SCRIPT_PATH);
}
// `..` 세그먼트 차단 (CWD 상대 이동/트래버설).
if (preg_match('#(^|[/\\\\])\.\.([/\\\\]|$)#', $script) === 1) {
return self::shellVerdict(false, self::SHELL_REASON_SCRIPT_PATH);
}
// 스크립트는 절대경로여야 한다 (CWD 에 의존한 실행 차단).
if (! self::isAbsolutePath($script)) {
return self::shellVerdict(false, self::SHELL_REASON_SCRIPT_PATH);
}
// 스크립트 뒤 인자는 스크립트 소유이므로 검사하지 않는다.
return self::shellVerdict(true, null);
}
/**
* 절대경로인지 판정합니다 (POSIX / Windows 드라이브 / UNC).
*
* @param string $token 경로 토큰
* @return bool 절대경로면 true
*/
private static function isAbsolutePath(string $token): bool
{
if ($token === '') {
return false;
}
return in_array(basename($tokens[0]), $allowed, true);
if ($token[0] === '/') {
return true;
}
if (preg_match('#^[A-Za-z]:[\\\\/]#', $token) === 1) {
return true;
}
return str_starts_with($token, '\\\\');
}
/**
* shell 정책 배열 설정을 소문자 문자열 목록으로 정규화합니다.
*
* @param string $key `schedule_security.shell` 하위 키
* @return array<int, string>
*/
private static function shellList(string $key): array
{
return array_values(array_filter(array_map(
static fn ($value): string => strtolower(trim((string) $value)),
(array) config('schedule_security.shell.'.$key, []),
)));
}
/**
* Shell 판정 결과 배열을 만듭니다.
*
* @param bool $allowed 허용 여부
* @param string|null $reason 거부 사유 코드
* @return array{allowed: bool, reason: string|null}
*/
private static function shellVerdict(bool $allowed, ?string $reason = null): array
{
return ['allowed' => $allowed, 'reason' => $reason];
}
/**