Devin.KR

경로와 명령 판정

85분 안팎

학습 목표

경로 탈출·허용 밖 파일·명령을 실행하지 않고 Python 정책 함수로 거절합니다.

개념

문자열 앞부분이 경로 경계는 아닙니다

할 일 앱 소스만 읽게 하려는데 src라는 글자로 시작하는지를 확인하면 src-backup의 설정도 허용될 수 있습니다. 경로는 파일 위치를 뜻하고 문자열 접두사는 글자 배열의 일치일 뿐입니다. 이번 과제는 가상 파일 이름과 명령 배열을 판정하는 순수 함수를 만듭니다. 파일을 열거나 명령을 실행하지 않은 채 ALLOW 또는 DENY만 출력합니다. 경로를 엄격하게 제한하는 규칙과 실제 파일 시스템의 위치 검사를 구분해 설명할 수 있어야 합니다.

브라우저의 입력과 우선순위

입력은 정확히 op·path 또는 op·argv를 가진 JSON 객체입니다. op는 read·write·command 중 하나입니다. read는 RULES.md와 src/main/java/lab/TaskService.java를 허용하며 write는 handoff.md만 허용합니다. command는 배열 [python3, check_rules.py]와 [python3, verify_app.py]만 허용합니다. 추가 키, 지원하지 않는 행동, 잘못된 자료형은 DENY입니다. 오류가 여러 개 있어도 출력은 DENY 하나이므로 중간 디버깅 문구를 표준 출력에 넣지 않습니다.

정규화보다 먼저 계약을 선택합니다

이번 브라우저 계약은 ./RULES.md와 src/../RULES.md를 정상 이름으로 바꾸어 받아들이지 않습니다. 슬래시로 나눈 요소 중 빈 문자열·점·상위 이동이 하나라도 있으면 거절합니다. 절대 경로, 역슬래시, NUL 문자도 거절합니다. 이 선택은 정규화가 언제나 틀렸다는 뜻이 아니라 같은 파일을 가리키는 여러 표기를 교육 인터페이스에서 받지 않겠다는 약속입니다. 시작부터 표준 표기를 좁히면 로그와 허용 목록의 대조가 쉬워집니다. 허용 목록과 별개로 형식 조건도 검사합니다.

허용 파일은 정확히 일치시킵니다

RULES.md.bak는 RULES.md가 아니며 handoff.md라는 글자가 포함된 다른 파일도 쓰기 대상이 아닙니다. 확장자를 보기 좋게 맞추었다고 허용하지 않습니다. 읽을 수 있는 파일과 쓸 수 있는 파일은 다른 집합이므로 읽기 목록에서 검색한 결과를 쓰기 판정에 재사용하지 않습니다. 앱 테스트는 읽기 자료로 검토할 수 있어도 수정 허가가 따라오지 않습니다. 함수는 op별 집합에서 전체 문자열을 비교합니다. 정상 파일 이름 하나와 비슷하지만 다른 이름 하나를 짝으로 검사합니다.

PurePosixPath가 하는 일과 하지 않는 일

브라우저에서는 POSIX 표기를 고정해 PurePosixPath로 절대 경로 여부를 확인할 수 있습니다. 이 객체는 실제 파일을 조회하지 않는 어휘적 경로 모델입니다. 따라서 심볼릭 링크가 외부를 가리키는지 알아내지 못합니다. PurePosixPath는 점이나 반복 구분자를 정리할 수 있으므로 원래 문자열의 요소 검사도 따로 수행합니다. 이미 정리된 parts만 보면 처음의 src//main 표기를 놓칠 수 있습니다. 과제는 파일의 존재 여부를 확인하는 도구가 아니라 제한된 요청을 비교하는 판정기입니다.

로컬 미션에서는 링크를 추가로 검사합니다

실제 작업 폴더를 다루는 미션의 safe_path는 기준 루트를 resolve로 계산합니다. 각 경로 요소를 따라가며 심볼릭 링크를 발견하면 목적지가 안쪽이든 바깥이든 거절합니다. 대상 경로도 resolve한 뒤 relative_to로 루트 안에 있는지 확인합니다. 파일을 새로 만들 수 있으므로 아직 존재하지 않는 최종 대상은 허용 목록에 있다면 판정 대상이 될 수 있습니다. 브라우저의 문자열 검사가 현실 경로의 링크 방어까지 검증했다는 설명은 피합니다.

경로 검사 뒤 교체되는 상황은 남습니다

파일 위치를 검사한 다음 읽기까지의 사이에 다른 프로세스가 링크나 폴더를 교체하면 검사 시점과 사용 시점이 달라질 수 있습니다. 이번 미션은 단일 작성자 환경이고 이런 동시 교체를 방지하는 파일 열기 계층은 구현하지 않습니다. resolve와 링크 거절을 썼다는 이유로 모든 경로 공격이 해결되었다고 보고하지 않습니다. 교육 fixture는 기존 링크의 탐지와 잘못된 표기의 거절을 검사합니다. 실제 서비스로 옮길 때에는 파일 접근 주체와 교체 가능성을 따로 검토해야 합니다.

명령도 이름보다 전체 배열을 비교합니다

python3라는 프로그램이 허용되어도 -c로 임의 코드를 실행하는 요청은 이 작업의 검사 명령이 아닙니다. 따라서 argv 전체가 등록된 배열과 같은지 비교합니다. 문자열 명령을 split으로 나누어 받거나 셸을 거쳐 재해석하지 않습니다. check_rules.py 뒤에 추가 인자를 붙이면 다른 요청으로 거절합니다. 세미콜론이 배열 원소로 나타나는 경우에도 허용 배열과 다르므로 DENY입니다. 이 실습에서 위험 문자열을 검사한다는 사실은 그 문자열을 실행했다는 뜻이 아닙니다.

셸 해석을 제거해도 검사 파일은 신뢰해야 합니다

subprocess.run에 배열을 주고 shell을 사용하지 않으면 셸의 연결 기호 해석을 피할 수 있습니다. 그렇다고 허용한 검사 스크립트가 임의 네트워크 전송을 포함해도 안전해지는 것은 아닙니다. 미션은 고정된 검사 파일을 보호 해시로 대조하며 변경된 스크립트는 범위 검사에서 발견합니다. 명령 등록과 실행 파일 내용 검토를 함께 해야 효과를 설명할 수 있습니다. 정책 함수 자체나 보호 해시를 공격자가 마음대로 바꾸는 위협까지 이 교육 코드가 막지는 않습니다.

실패한 테스트는 어느 규칙을 놓쳤는지 알려 줍니다

기대 DENY인데 ALLOW인 사례의 입력부터 읽습니다. src/../RULES.md라면 요소 검사, RULES.md.bak라면 정확한 일치, 숫자 path라면 자료형 검사를 확인합니다. TypeError가 나오면 split 전에 문자열인지 검사했는지 봅니다. 명령 배열 뒤 추가 인자가 통과했다면 첫 원소나 앞 두 원소만 비교했을 가능성이 있습니다. solution의 분기 순서를 외우기보다 각 실패가 어떤 계약을 깨는지 말하고 해당 조건만 고칩니다. 정상 요청도 다시 돌려 거절 범위가 너무 넓어지지 않았는지 확인합니다.

반례를 먼저 적고 구현합니다

starter는 모든 요청을 ALLOW로 처리합니다. 정상 읽기·정상 쓰기·정상 명령은 우선 통과하지만 범위 밖 입력은 실패합니다. 상위 이동, 절대 경로, 점 별칭, 반복 슬래시, 접두사가 같은 다른 파일, 읽기 파일에 대한 쓰기, 추가 명령 인자, 추가 승인 키를 각각 기대 DENY로 적습니다. 테스트 일부만 골라 고치면 다른 우회가 남을 수 있으므로 전체 사례를 확인합니다. 판정 함수가 원래 입력을 바꾸지 않는지도 스스로 작은 입력 복사 비교로 확인할 수 있습니다.

다음 단계에 넘길 정책의 의미

브라우저가 통과하면 정해진 문자열 계약을 구현했다는 근거가 생깁니다. 미션에서는 이 개념에 실제 루트·링크·고정 실행 진입점을 더합니다. 허용은 실행 후보이며 파일 읽기 성공 또는 검사 통과가 아닙니다. 새 파일이 필요할 때에는 작업 이유와 보호 기준을 검토한 뒤 정책을 변경하고 fixture도 보완합니다. 실패를 지우려고 허용 목록을 넓히는 것은 범위 판단을 바꾸는 행위입니다. 더 읽기에서는 경로 외에도 입력 출처와 도구 권한을 함께 살펴볼 수 있습니다.

따라하기

정확한 경로 집합을 비교합니다

비슷한 파일 이름과 상위 이동 문자열을 허용 파일과 비교합니다.

allowed = {'RULES.md'}
for name in ('RULES.md', 'RULES.md.bak', '../RULES.md'):
    print(name, 'ALLOW' if name in allowed else 'DENY')

실행 결과

RULES.md ALLOW
RULES.md.bak DENY
../RULES.md DENY

원래 경로 표기를 검사합니다

PurePosixPath로 정리되기 전의 요소를 확인합니다.

for name in ('src/main/Task.java', 'src//main/Task.java', './RULES.md', 'src/../RULES.md'):
    bad = any(part in ('', '.', '..') for part in name.split('/'))
    print(name, 'DENY' if bad else '형식 통과')

실행 결과

src/main/Task.java 형식 통과
src//main/Task.java DENY
./RULES.md DENY
src/../RULES.md DENY

추가 인자까지 비교합니다

이 코드는 명령을 실행하지 않습니다. 허용한 배열과 값이 같은지만 봅니다.

allowed = [['python3', 'check_rules.py']]
for argv in (['python3', 'check_rules.py'], ['python3', 'check_rules.py', '--extra'], ['python3', '-c', 'print(1)']):
    print('ALLOW' if argv in allowed else 'DENY')

실행 결과

ALLOW
DENY
DENY

완성 함수의 경계 입력을 확인합니다

함수 본문을 새 파일에 넣고 입력 없이 고정된 사례를 실행합니다. 함수는 파일을 열지 않습니다.

import json, sys
from pathlib import PurePosixPath

def judge(request):
    if not isinstance(request, dict):
        return 'DENY'
    op = request.get('op')
    if op == 'command':
        commands = [['python3', 'check_rules.py'], ['python3', 'verify_app.py']]
        return 'ALLOW' if set(request) == {'op', 'argv'} and request['argv'] in commands else 'DENY'
    if op not in ('read', 'write') or set(request) != {'op', 'path'}:
        return 'DENY'
    name = request['path']
    if not isinstance(name, str) or not name or '\\' in name or '\x00' in name:
        return 'DENY'
    if PurePosixPath(name).is_absolute() or any(p in ('', '.', '..') for p in name.split('/')):
        return 'DENY'
    allowed = {'read': {'RULES.md', 'src/main/java/lab/TaskService.java'}, 'write': {'handoff.md'}}
    return 'ALLOW' if name in allowed[op] else 'DENY'

for request in ({'op':'write','path':'handoff.md'}, {'op':'write','path':'RULES.md'}, {'op':'read','path':'RULES.md','approved':True}):
    print(judge(request))

실행 결과

ALLOW
DENY
DENY

확인 문제

실습

JSON 객체 하나를 읽고 ALLOW 또는 DENY 한 줄을 출력합니다. read는 RULES.md와 src/main/java/lab/TaskService.java, write는 handoff.md만 허용합니다. 입력 키는 정확히 op·path입니다. 빈 경로·절대 경로·역슬래시·NUL·빈 요소·점·상위 이동 요소를 거절합니다. command는 정확히 op·argv이며 ["python3","check_rules.py"] 또는 ["python3","verify_app.py"] 배열만 허용합니다. 추가 키·다른 행동·다른 자료형은 DENY입니다. 실제 파일과 명령은 실행하지 않습니다. 잘못된 JSON 문법은 입력 범위 밖입니다.

모범 답안
import json, sys
from pathlib import PurePosixPath

def judge(request):
    if not isinstance(request, dict):
        return 'DENY'
    op = request.get('op')
    if op == 'command':
        commands = [['python3', 'check_rules.py'], ['python3', 'verify_app.py']]
        return 'ALLOW' if set(request) == {'op', 'argv'} and request['argv'] in commands else 'DENY'
    if op not in ('read', 'write') or set(request) != {'op', 'path'}:
        return 'DENY'
    name = request['path']
    if not isinstance(name, str) or not name or '\\' in name or '\x00' in name:
        return 'DENY'
    if PurePosixPath(name).is_absolute() or any(p in ('', '.', '..') for p in name.split('/')):
        return 'DENY'
    allowed = {'read': {'RULES.md', 'src/main/java/lab/TaskService.java'}, 'write': {'handoff.md'}}
    return 'ALLOW' if name in allowed[op] else 'DENY'

print(judge(json.load(sys.stdin)))

더 읽기

면접 질문

  • AI가 만든 코드가 실행될 때 추가로 확인할 내용을 설명해 주시면 됩니다.