AI 에이전트나 MCP 서버에 로컬 파일 조작을 맡기는 기회가 급증하고 있습니다. 하지만 Python 표준의 경로 검증에는 검사(check)와 실행(use) 사이에 시간 차이를 노리는 치명적인 보안 위험이 도사리고 있습니다.
본 글에서는 OS 커널의 시스템 콜과 정리 증명 지원계인 Lean 4를 결합하여, 경로 탐색(path traversal)이나 심볼릭 링크 공격을 근본적으로 차단하는 순수 Python 라이브러리 PathFence의 설계와 구현 과정을 설명합니다.
특정 디렉터리 하위만 접근을 허용하고 싶을 때, 많은 개발자들은 os.path.realpath()나 pathlib.Path.resolve()를 사용해 경로를 정규화(normalize)한 후, startswith()나 is_relative_to()로 문자열 비교를 수행하고 open()을 호출합니다.
- # 위험한 구현 예시: Check-then-Use 방식의 TOCTOU 취약점이 있음
- real_root = os.path.realpath(root)
- real_target = os.path.realpath(os.path.join(root, user_path))
...
위의 구식 구현에는 TOCTOU(Time-of-Check to Time-of-Use)라고 불리는 검사 시점과 사용 시점 간의 경쟁 상태가 존재합니다1. realpath()로 안전성을 확인한 직후, 그리고 open()이 실행되기 전의 아주 짧은 틈 사이에 공격자가 중간 디렉터리나 파일을 외부로 연결되는 심볼릭 링크로 교체하면 경계가 돌파될 수 있습니다.
PathFence는 경로 문자열이 아닌 파일 디스크립터(file descriptor)를 신뢰의 기반으로 삼아 이 문제를 해결합니다. 외부 C 확장에는 전혀 의존하지 않는 Pure Python이자 제로 의존성임에도 불구하고, ctypes 등을 통해 OS의 저수준 API를 직접 호출합니다.
- Linux 환경에서의 원자적(Atomic) 해결
Linux 5.6 이후 버전에서는 openat2(2) 시스템 콜과 RESOLVE_BENEATH | RESOLVE_NO_SYMLINKS | RESOLVE_NO_MAGICLINKS | RESOLVE_NO_XDEV 플래그를 사용합니다. 커널 내부에서 루트 디렉터리로부터의 탈출이나 심볼릭 링크 추적이 즉시 거부되므로, 단 한 번의 시스템 콜로 완전한 TOCTOU 내성을 구현할 수 있습니다.
- POSIX 폴백 및 macOS 환경
openat2가 사용 불가능한 환경에서는 루트 디렉터리를 O_RDONLY | O_DIRECTORY | O_NOFOLLOW로 열고, 각 경로 요소를 한 단계씩 openat과 O_NOFOLLOW로 탐색합니다. 나아가 fstat을 통해 장치 ID나 inode를 검증하여, 오픈 후의 디렉터리 이동이나 교체를 감지합니다.
- Windows 환경에서의 다층 방어
예약된 장치 이름인 CON, NUL, COM1 등 NTFS 대체 데이터 스트림인 : , 끝에 점(.)이나 공백, UNC 경로 등을 엄격하게 거부합니다. 또한 FSCTL_GET_REPARSE_POINT를 이용한 얀션(junction)이나 심볼릭 링크 감지, 그리고 GetFinalPathNameByHandleW를 통한 핸들 기반의 실체 경로 검증을 수행합니다.
보안 라이브러리에서 '테스트가 통과했다'는 것만으로는 알려지지 않은 에지 케이스(edge case)를 완전히 배제할 수 없습니다. 따라서 PathFence에서는 정리 증명 지원계인 Lean 4를 사용하여 경로 정규화와 경계 판정 알고리즘의 사양을 모델링하고, 어떤 입력에 대해서도 경계 외부로 탈출하는 것이 불가능함을 수학적으로 증명했습니다2.
구체적으로는, 루트로부터의 상대적인 깊이를 나타내는 상태 전이 모델에서 임의의 경로 요소열 $P = (c_1, c_2, lankdots, c_n)$을 처리했을 때의 깊이 함수를 다음과 같이 정의합니다.
\forall k \in \{1, \dots, n\}, \quad \text{depth}(c_1, \dots, c_k) \geq 0 \implies \text{ResolvedPath}(P) \subseteq \text{Subtree}(\text{Root})
리포지토리 내 formal/ 디렉터리에는 sorry나 독자적인 공리를 전혀 사용하지 않고 완성된 15개의 정리가 수록되어 있습니다.
| 정리 이름 | 증명된 보안 특성 |
|---|
normalize_rejects_escape | 부모 디렉터리로 역추적하여 깊이가 음수가 되는 경로는 반드시 거부된다 |
normalize_preserves_containment | 정규화를 통과한 모든 경로는 루트 하위의 부분 트리에 엄격하게 포함된다 |
symlink_free_walk_stays_in_root | 심볼릭 링크를 허용하지 않는 단계적 탐색은 항상 루트 경계 내에 머무른다 |
normalize_idempotent | 정규화 함수를 두 번 적용해도 결과가 변하지 않으며, 이중 정규화로 인한 불일치가 발생하지 않는다 |
beneath_composable | 서브 워크스페이스의 경계 제약은 부모 워크스페이스의 경계 제약을 추이적으로 만족시킨다 |
클릭하여 Workspace를 사용한 안전한 파일 조작 코드 예시 펼치기
# PathFence에서 Workspace를 사용한 안전한 파일 조작 라이프사이클
from pathfence import Workspace, PathTraversalError, SymlinkError
# 루트 디렉터리를 고정하여 Workspace 초기화
...
보안과 성능은 상충 관계에 놓이기 쉽지만, PathFence는 커널 API를 직접 호출함으로써 극도로 낮은 오버헤드를 구현했습니다. Linux 6.8 환경에서 10만 회 반복 테스트를 진행한 벤치마크 결과는 다음과 같습니다.
| 실행 경로/조작 내용 | 평균 지연 시간 (Latency) | 초당 처리 횟수 (ops/sec) |
|---|---|
openat2 고속 경로에서의 파일 열기 | 3.6 μs | 약 277,000 ops/sec |
O_NOFOLLOW 폴백을 사용한 1단계 탐색 | 9.8 μs | 약 102,000 ops/sec |
O_NOFOLLOW 폴백을 사용한 3단계 탐색 | 17.4 μs | 약 57,000 ops/sec |
realpath + open을 통한 기존의 안전하지 않은 방법 | 3.9 μs | 약 256,000 ops/sec |
Linux의 openat2가 사용 가능한 환경에서는, 기존의 안전하지 않은 코드보다 오히려 빠른 3.6 마이크로초 만에 완전한 경계 방어가 완료됩니다.
AI 에이전트의 도구 실행 기반이나, 사용자로부터 업로드된 아카이브의 전개 처리, 멀티테넌트 환경의 스토리지 조작 등에서 PathFence는 단 한 줄의 코드로 커널 레벨의 안전성을 제공합니다.
부디 직접 프로젝트에 도입해 보시고 피드백이나 Star를 주시면 감사하겠습니다.