경로 조작 (Path Traversal)

경로 조작 (Path Traversal)

설명

사용자 입력으로 만든 파일 경로가 의도한 디렉터리를 벗어나면 다른 위치의 파일을 읽거나 쓸 수 있습니다. ../ 같은 상위 이동과 링크를 통한 우회가 대표적이며, 압축 해제에서도 같은 문제가 발생합니다.

잠재적 영향

  • 설정, 소스 코드, 자격 증명 같은 민감 정보가 노출될 수 있습니다.
  • 업로드·압축 해제에서 파일을 덮어써 데이터 손상이나 서비스 중단을 일으킬 수 있습니다.
  • 실행에 사용되는 파일을 수정할 수 있다면 코드 실행으로 이어질 수 있습니다.

해결 방법

  • 고정된 루트 아래에서만 파일을 열도록 하세요. filepath.Clean이나 문자열 접두사 검사는 실제 파일 접근의 경계를 보장하지 않습니다.
  • 파일명만 필요하면 단일 구성 요소만 허용하고, 상대 경로가 필요하면 filepath.Rel로 상위 탈출을 거부하세요.
  • 심볼릭 링크와 검사 후 교체에 대응하는 디렉터리 기준 열기 방식을 사용하세요. Go 1.24 이상에서는 os.OpenRoot를 검토할 수 있습니다. EvalSymlinks만 호출한 뒤 다시 여는 방식에는 경쟁 조건이 남습니다.
  • 파일별 접근 권한, 필요한 확장자·크기·깊이 제한을 적용하고 검증 실패 시 중단하세요.

예시

변경 후는 문자열 수준의 경로 범위 검사 예입니다. /var/app/data 트리는 신뢰할 수 있는 관리자가 통제하고, 외부로 향하는 링크나 공격자가 바꿀 수 있는 경로가 없어야 합니다. 그런 전제를 보장할 수 없다면 링크 우회를 막는 열기 방식으로 바꾸세요. 인증과 파일별 권한 검사는 생략했습니다.

변경 전

go
package main

import (
    "net/http"
    "os"
    "path/filepath"
    "github.com/gin-gonic/gin"
)

func download(c *gin.Context) {
    // BAD: 사용자 입력을 경로에 직접 사용
    // 예: ?file=../../etc/passwd
    file := c.Query("file")

    baseDir := "/var/app/data"
    // Join만으로는 우회 가능: Join은 Clean을 수행하며 상위로 탈출할 수 있음
    fullPath := filepath.Join(baseDir, file)

    if _, err := os.Stat(fullPath); err != nil {
        c.String(http.StatusNotFound, "not found")
        return
    }
    http.ServeFile(c.Writer, c.Request, fullPath)
}

func main() {
    r := gin.Default()
    r.GET("/download", download)
    r.Run()
}

변경 후

go
package main

import (
    "net/http"
    "os"
    "path/filepath"
    "runtime"
    "strings"
    "github.com/gin-gonic/gin"
)

func isUnderBase(base, target string) bool {
    // 경로 정규화 후 구분자도 함께 비교하여 base 하위 여부 확인
    b := filepath.Clean(base)
    t := filepath.Clean(target)
    sep := string(os.PathSeparator)
    if runtime.GOOS == "windows" {
        b = strings.ToLower(b)
        t = strings.ToLower(t)
    }
    return strings.HasPrefix(t+sep, b+sep)
}

func safeDownload(c *gin.Context) {
    file := c.Query("file")

    baseDir := "/var/app/data" // 고정 루트 디렉터리

    // 1) 절대경로, 상위 이동을 포함하더라도 baseDir 내부만 허용
    target := filepath.Join(baseDir, file)
    if !isUnderBase(baseDir, target) {
        c.String(http.StatusBadRequest, "invalid path")
        return
    }

    // 2) 필요 시 확장자 화이트리스트 예시
    // if !strings.HasSuffix(strings.ToLower(target), ".txt") { ... }

    // 3) 파일 제공
    if _, err := os.Stat(target); err != nil {
        c.String(http.StatusNotFound, "not found")
        return
    }
    http.ServeFile(c.Writer, c.Request, target)
}

func main() {
    r := gin.Default()
    r.GET("/download", safeDownload)
    r.Run()
}

설명:

  • 변경 전: filepath.Join은 경로를 정규화하지만 루트 밖으로 나가는 결과까지 거부하지는 않습니다. 쿼리의 파일 값으로 만든 경로를 ServeFile에 전달하므로 요청 URL 경로에 대한 검사만으로 보호되지 않습니다.
  • 변경 후: 구분자를 포함한 접두사 비교로 문자열 경계 밖의 경로를 거부합니다. 심볼릭 링크, 검사 후 경로 교체, 파일별 권한, 디렉터리 내용 공개까지 해결하는 예는 아닙니다.

참조