[Note] PortSwigger - Path Traversal 토픽 정리 및 실습

목차


들어가며

Path Traversal은 애플리케이션이 파일을 다루는 정상 기능을 통해 서버의 임의 파일을 읽어내는 취약점이다.
공격자가 새로운 실행 경로를 여는 것이 아니라, 파일 경로를 해석하는 주체가 애플리케이션이 아니라 운영체제라는 사실을 이용한다는 점이 특징이다.

이 글에서는 Path Traversal이 성립하는 조건과 경로가 조립·해석되는 과정, 실무에서 쓰이는 네 가지 방어와 각각이 무너지는 지점, 그리고 진단과 대응 방법을 정리한다.
유형별 Lab 풀이는 별도의 Write-up으로 정리했으며, 본문 마지막에서 연결한다.

image.png https://portswigger.net/web-security/all-labs#path-traversal


Path Traversal이란?

Path Traversal은 디렉터리 순회(Directory Traversal)라고도 부르며, 애플리케이션이 파일 경로를 만들 때 사용자 입력을 검증 없이 사용해 의도된 디렉터리 바깥의 파일에 접근하게 되는 취약점이다.

읽어낼 수 있는 대상은 애플리케이션 프로세스의 권한이 닿는 모든 파일이다. 소스 코드, 데이터베이스 접속 정보가 담긴 설정 파일, 인증서 개인키, 로그, 컨테이너 환경이라면 서비스 계정 토큰까지 포함된다. 코드 실행이 아닌 읽기 권한에 그치지만, 여기서 얻은 자격 증명이 다음 단계의 인증 우회나 원격 코드 실행으로 이어지는 경우가 많아 단독 심각도보다 연쇄 가능성으로 평가해야 한다.

같은 결함이 파일 쓰기 경로에 존재하면 성격이 달라진다. 임의 위치에 파일을 생성할 수 있게 되므로 웹 셸 업로드나 설정 파일 덮어쓰기로 곧장 이어진다. 실제 사례로는 Pwn2Own Ireland 2024에서 사용된 Galaxy S24의 Quick Share 취약점(CVE-2024-49421)이 있는데, 전송 대상 경로를 /../../../../../../GPUWatch_Dump/html/로 조작해 파일을 원하는 위치에 떨어뜨리는 방식이었다. 자세한 흐름은 Samsung Galaxy S24 White Paper 분석에 정리해 두었다.


Path Traversal은 어떻게 동작하는가

성립 조건은 세 가지다.

조건내용확인 방법
파일 참조 지점요청 값이 파일 이름이나 경로로 사용된다이미지·다운로드·템플릿·로그 조회 엔드포인트 목록화
입력이 경로 조립에 참여그 값이 기준 디렉터리와 결합되어 파일 API로 전달된다존재하지 않는 파일명에 대한 오류 응답 확인
경로 구성 문자 통과.., /, \, %00 같은 문자가 그대로 도달한다무해한 접두를 붙여 응답 변화 관찰

상품 이미지를 표시하는 기능을 예로 보자.

<img src="/loadImage?filename=218.png">

애플리케이션은 /var/www/images/라는 기준 디렉터리 뒤에 filename 값을 붙여 파일을 읽는다. 정상 요청이라면 /var/www/images/218.png가 열린다. 그런데 이 결합에 검증이 없다면 다음 요청이 그대로 처리된다.

https://insecure-website.com/loadImage?filename=../../../etc/passwd

조립된 문자열은 /var/www/images/../../../etc/passwd이고, 이 경로가 최종적으로 가리키는 위치는 /etc/passwd다.

여기서 짚어둘 점은 경로를 정규화하는 주체가 애플리케이션이 아니라는 것이다. 애플리케이션 입장에서 ../../../etc/passwd는 파일명이라는 이름의 데이터일 뿐이지만, 그 문자열이 open()을 통해 커널에 도달하는 순간 ..는 상위 디렉터리로 이동하라는 지시가 된다. 값으로 다루던 것이 계층 경계를 넘으면서 구조가 되는, 인젝션 계열 취약점의 전형적인 형태다.

이 성질에서 실무적인 요령도 하나 따라온다. 루트 디렉터리에서 ..는 다시 루트를 가리키므로, 기준 디렉터리의 깊이를 모를 때는 ../를 넉넉하게 붙여도 결과가 달라지지 않는다.

운영체제에 따라 사용할 수 있는 구분자와 검증 대상 파일이 다르다.

구분Linux · UnixWindows
경로 구분자/\/ 모두 허용
이탈 시퀀스../..\ , ../
검증용 대상 파일/etc/passwdC:\windows\win.ini

/etc/passwd가 검증 대상으로 쓰이는 이유는 그 안에 민감한 값이 있어서가 아니다. 실제 해시는 /etc/shadow에 있고 일반 권한으로는 읽히지 않는다. /etc/passwd는 어떤 배포판에도 존재하고, 모든 사용자에게 읽기 권한이 있으며, 텍스트라서 응답에 섞여 있어도 즉시 식별된다. 즉 임의 파일 읽기가 성립했다는 사실을 최소한의 부작용으로 증명하는 마커다. Windows의 win.ini도 같은 역할을 한다.


방어와 우회

실무에서 관찰되는 방어는 크게 네 가지이며, 랩들은 각각이 어떻게 무너지는지를 하나씩 보여준다.

방어원리주된 실패 지점
차단 목록입력에 ../가 있으면 요청을 거부이탈 수단이 ../ 하나가 아님
시퀀스 제거입력에서 ../를 지워 안전하게 만듦치환 결과가 다시 패턴을 만듦
접두 검증경로가 기준 디렉터리로 시작하는지 확인정규화 전 문자열을 검사
확장자 검증파일명이 허용된 확장자로 끝나는지 확인확장자는 위치를 보증하지 않음

차단 목록

가장 먼저 시도되는 방어이자 가장 먼저 무너지는 방어다. ../를 막았다고 해서 기준 디렉터리를 벗어나는 방법이 사라지지는 않는다.

절대경로를 그대로 지정하면 경로 이탈 시퀀스 없이 목적지에 도달할 수 있다. 성립 여부는 애플리케이션이 쓰는 경로 결합 API에 달려 있다.

언어·APIjoin("/var/www/images", "/etc/passwd") 결과절대경로 우회
Python os.path.join()/etc/passwd성립
Java Path.resolve()/etc/passwd성립
Java new File(parent, child)/var/www/images/etc/passwd불성립
단순 문자열 연결/var/www/images/etc/passwd불성립

os.path.join()Path.resolve()는 뒤쪽 인자가 절대경로면 앞 인자를 버린다. 문서에 명시된 정상 동작이지만, 이 함수들로 기준 디렉터리를 강제하고 있었다면 그 전제가 통째로 사라진다. 이 경로는 absolute path bypass 랩에서 확인할 수 있다.

시퀀스 제거

입력에서 ../를 지워 안전한 값으로 만들려는 접근이다. 문제는 치환이라는 연산 자체가 새로운 문자열을 만들어낸다는 데 있다. 제거가 한 번만 수행된다면 제거된 뒤에 다시 ../가 되도록 입력을 구성할 수 있다.

입력제거 대상제거 후
....//../../
.../././../
....\/..\../
....\\..\..\

진단에서 먼저 확정해야 할 것은 필터가 차단하는지 제거하는지다. 존재하는 파일에 ../를 붙였을 때 오류가 나면 차단이므로 패턴을 피하는 방향으로, 파일이 그대로 반환되면 제거이므로 제거 결과를 이용하는 방향으로 가야 한다. 이 구분 없이 페이로드부터 던지면 어느 쪽이 막혔는지 알 수 없는 실패만 쌓인다. 관련 랩은 stripped non-recursively다.

인코딩과 중복 디코딩

차단 목록과 시퀀스 제거를 모두 적용해도, 검증이 보는 문자열과 파일 API가 받는 문자열이 다르면 방어는 성립하지 않는다.

단일 URL 인코딩(%2e%2e%2f)이 잘 통하지 않는 이유는 필터가 강해서가 아니라 웹 서버와 프레임워크가 쿼리 문자열을 애플리케이션에 넘기기 전에 이미 디코딩하기 때문이다. 필터가 값을 받을 시점에는 이미 ../로 복원되어 있다. 그런데 애플리케이션이 값을 사용하기 전에 디코딩을 한 번 더 수행한다면, 디코딩 지점이 두 곳인데 검증은 그 사이에 한 번만 놓이게 된다.

단계입력값 %252e%252e%252f의 상태
HTTP 계층 디코딩 후%2e%2e%2f
필터 검증 시점%2e%2e%2f — 이탈 시퀀스가 보이지 않아 통과
애플리케이션의 추가 디코딩 후../
파일 API 전달../ 로 경로 이탈 성립

같은 계열의 변형은 대상 컴포넌트가 해당 인코딩을 받아들일 때만 성립한다. 통하지 않았다고 해서 필터가 안전하다는 뜻은 아니다.

형태복원 결과성립 조건
%2e%2e%2f../디코딩이 검증 이후에 일어남
%252e%252e%252f../디코딩이 검증 전후로 두 번 일어남
..%c0%af../오버롱 UTF-8을 허용하는 레거시 디코더
..%u2215../%u 표기를 처리하는 비표준 파서
%2e%2e/../부분 인코딩. 문자열 비교식 필터에 유효

이중 인코딩 경로는 superfluous URL-decode 랩에서 확인할 수 있다.

접두 검증

“이 경로가 기준 디렉터리 안에 있는가"는 방어가 물어야 할 정확한 질문이다. 문제는 그 질문을 정규화되지 않은 문자열에 던졌을 때 생긴다.

검증:  "/var/www/images/../../../etc/passwd".startsWith("/var/www/images/")  →  true
해석:  /var/www/images/../../../etc/passwd  →  /etc/passwd

기준 경로를 접두로 남긴 채 그 뒤에서 빠져나오면 두 조건이 동시에 충족된다. 애플리케이션이 검사한 것은 사용자가 보낸 문자열이고, 커널이 여는 것은 그 문자열을 정규화한 결과이기 때문이다. 해당 랩은 validation of start of path다.

확장자 검증

파일명이 .jpg로 끝나는지 확인하는 방식이다. 여기서 null 바이트가 쓰이는 이유는 계층마다 문자열의 끝을 다르게 판단하기 때문이다.

계층문자열 표현"../../../etc/passwd\0.jpg"를 읽는 방식
애플리케이션 (Java·PHP 등)길이를 함께 보관\0도 하나의 문자. 전체가 .jpg로 끝남
시스템 콜 (C 문자열)\0을 종단 표시로 사용\0 이전까지만 경로로 인식

PHP는 5.3.4, Java는 7u40에서 경로에 null 바이트가 포함되면 예외를 던지도록 바뀌어 현대 환경에서 그대로 재현되는 경우는 드물다. 다만 구버전 런타임, 경로를 그대로 넘기는 네이티브 확장, 서로 다른 구현이 같은 경로 문자열을 순차 처리하는 구간에서는 여전히 유효하다.

더 근본적인 문제는 확장자가 파일의 위치에 대해 아무것도 보증하지 않는다는 점이다. null 바이트를 막더라도 기준 디렉터리 바깥에 .jpg로 끝나는 파일이 하나라도 있으면 그 파일은 그대로 읽힌다. 관련 랩은 null byte bypass다.


우회가 반복되는 이유

네 가지 방어와 다섯 가지 우회를 하나로 묶으면 문장 하나가 남는다. 검증한 값과 사용한 값이 다르다.

방어검증이 본 값파일 API가 받은 값
차단 목록/etc/passwd (이탈 시퀀스 없음)/etc/passwd — 기준 경로가 버려짐
시퀀스 제거....//etc/passwd../etc/passwd — 치환이 패턴을 재생성
이중 인코딩%2e%2e%2f...../... — 검증 이후 디코딩
접두 검증정규화 전 문자열정규화된 최종 경로
확장자 검증\0을 포함한 전체 문자열\0 이전까지의 경로

모든 사례에서 검증은 자기가 받은 문자열에 대해 정확히 판단했다. 다만 그 문자열이 파일 API가 최종적으로 받는 문자열과 같지 않았을 뿐이다. 검증 시점과 해석 시점 사이에 변환이 하나라도 끼어 있으면, 검증은 실제로 사용될 값이 아니라 그 값의 중간 표현을 본 것이 된다.

이 구조는 Path Traversal에 국한되지 않는다. WAF가 검사한 요청과 백엔드가 파싱한 요청이 달라지는 WAF 우회, 프론트엔드와 백엔드가 요청 경계를 다르게 읽는 HTTP request smuggling, 필터가 본 문자열과 브라우저 파서가 본 문자열이 달라지는 XSS 필터 우회가 모두 같은 계열이다. 그래서 방어의 방향도 하나로 모인다. 모든 변환이 끝난 최종 값에 대해 검증한다.


Path Traversal 진단 방법

진단은 다음 순서로 진행한다.

  1. 파일을 참조하는 지점을 수집한다. 이미지·첨부파일 다운로드, 템플릿·언어 파일 선택, 로그·리포트 조회, 파일명이 담긴 쿠키나 JSON 필드까지 포함한다. 확장자가 값에 드러나 있는 파라미터가 1순위다.
  2. 입력이 경로 조립에 쓰이는지 확인한다. 존재하지 않는 파일명을 보내 오류 응답이 파일 부재를 알리는지 본다. 파라미터 값과 무관하게 같은 응답이 온다면 파일 참조가 아닐 수 있다.
  3. 필터가 차단인지 제거인지 구분한다. 존재하는 파일에 ../를 붙여 오류가 나면 차단, 원래 파일이 그대로 반환되면 제거다. 이 판단이 이후 시도의 방향을 결정한다.
  4. 이탈 수단을 하나씩 시도한다. 상대경로, 절대경로, 중첩 시퀀스, 인코딩, 역슬래시를 각각 독립적으로 확인한다. 한 가지가 막혔다고 나머지를 건너뛰지 않는다.
  5. 검증 통과 여부를 알려주는 신호를 찾는다. 응답 길이, 상태 코드, 오류 문구가 갈리면 그 차이가 그대로 오라클이 된다. 검증에 걸린 경우와 파일이 없는 경우의 응답이 다른지 확인한다.
  6. 확장자·접두 요구 조건을 함께 만족시킨다. 기준 경로를 접두로 유지하거나 null 바이트로 확장자를 붙이는 등, 검증이 요구하는 형태를 남긴 채 최종 경로만 바꾼다.

각 단계에서 판단이 갈리는 지점과 다음 시도는 Path Traversal Playbook에 표로 정리했다.


대응 방안

  • 사용자 입력을 파일 시스템 API에 전달하지 않는다. 가장 확실한 방어다. 파일을 식별자로 관리하고 입력에는 식별자만 받은 뒤, 실제 경로는 서버가 보유한 매핑 테이블에서 조회한다. 입력이 경로 조립에 참여하지 않으면 이 취약점은 성립할 수 없다.
  • 허용 목록으로 검증한다. 불가피하게 파일명을 받아야 한다면 허용된 값의 목록과 대조하거나, 영숫자처럼 경로 구성 문자가 포함될 수 없는 문자 집합만 허용한다. 차단 목록은 열거되지 않은 경로가 항상 남으므로 닫힌 검증이 되지 못한다.
  • 정규화 후 검증한다. 순서가 핵심이다. 경로를 조립한 뒤 정규화하고, 그 결과가 기준 디렉터리 하위인지 확인한다.
  • 실패 응답을 통일한다. 검증 실패와 파일 부재의 응답이 갈리면 그 차이가 우회 조건을 좁히는 오라클이 된다. 원인과 무관하게 동일한 응답을 반환한다.
  • 프로세스 권한을 최소화한다. 검증이 뚫렸을 때 읽히는 범위를 제한하는 마지막 방어선이다. 애플리케이션 계정에 필요한 파일만 읽기 권한을 부여하고, 컨테이너·chroot로 파일 시스템 자체를 좁힌다.

세 번째 항목이 실제 구현에서 가장 자주 어긋나는 지점이므로 형태를 남겨 둔다.

File file = new File(BASE_DIRECTORY, userInput);
if (file.getCanonicalPath().startsWith(BASE_DIRECTORY)) {
    // 파일 처리
}

getCanonicalPath()...을 해소하고 심볼릭 링크까지 따라간 실제 경로를 돌려준다. 파이썬의 os.path.realpath(), Node.js의 fs.realpathSync()가 같은 역할을 한다. 심볼릭 링크까지 해소한다는 점이 중요한데, 기준 디렉터리 안에 바깥을 가리키는 링크가 있으면 문자열 정규화만으로는 걸러지지 않기 때문이다.

정규화 이후에도 접두 비교에는 함정이 하나 남는다. 경계 구분자를 포함하지 않으면 비교가 디렉터리 단위가 아니라 문자 단위로 이루어진다.

"/var/www/images-backup/secret".startsWith("/var/www/images")   →  true   // 통과되면 안 되는 경로
"/var/www/images-backup/secret".startsWith("/var/www/images/")  →  false  // 구분자 포함

핵심은 두 가지다. 첫째, 검증은 입력의 생김새가 아니라 정규화가 끝난 최종 경로에 걸어야 한다. 중간 표현을 대상으로 삼는 검증은 그 뒤에 변환이 하나만 있어도 무너진다.
둘째, 무엇을 막을지 열거하지 말고 무엇을 허용할지 확정한다. 차단 목록은 언제나 열거되지 않은 경로를 남긴다.


실습

PortSwigger Web Security Academy의 Path Traversal 랩을 유형별로 풀어 정리했다.
각 랩의 상세 풀이는 Write-up으로 별도 정리했으며, 아래는 유형별 개요다.

유형대표 Lab난이도
검증 부재simple caseAPPRENTICE
차단 목록 우회traversal sequences blocked with absolute path bypassPRACTITIONER
시퀀스 제거 우회traversal sequences stripped non-recursivelyPRACTITIONER
인코딩 우회traversal sequences stripped with superfluous URL-decodePRACTITIONER
접두 검증 우회validation of start of pathPRACTITIONER
확장자 검증 우회validation of file extension with null byte bypassPRACTITIONER

전체 풀이는 Path Traversal Write-up 아카이브에서 확인할 수 있다.


마치며

이 글에서는 Path Traversal의 성립 조건과 경로가 해석되는 과정, 차단 목록·시퀀스 제거·접두 검증·확장자 검증 네 가지 방어와 각각의 우회 경로, 진단과 대응 방법을 정리했다.

Path Traversal은 파일을 읽는다는 정상 기능과, 그 파일을 지정하는 문자열을 애플리케이션과 커널이 서로 다르게 취급한다는 사실 사이의 간극에서 생긴다. 애플리케이션에게 ../../../etc/passwd는 데이터지만 커널에게는 지시다. 그래서 방어의 방향도 하나로 모인다. 애초에 입력이 경로 조립에 참여하지 못하게 하거나, 조립이 끝난 뒤 커널이 볼 것과 같은 경로를 두고 판단하는 것이다.

랩들을 관통하는 교훈은 방어의 유무보다 방어가 걸리는 지점이 중요하다는 점이다. ../를 막아도 절대경로가 남고, 시퀀스를 지워도 치환 결과가 패턴을 되살리며, 접두를 검사해도 정규화 전 문자열이라면 의미가 없다. 검증이 최종 값에 도달하지 못하는 한, 우회는 방식만 바꿔 계속 반복된다.
이 글이 Path Traversal을 학습하거나 진단을 준비하는 과정에서 하나의 참고 자료로 활용되기를 바란다.


참고자료
https://portswigger.net/web-security/file-path-traversal
https://owasp.org/www-community/attacks/Path_Traversal
https://cwe.mitre.org/data/definitions/22.html