경로 탐색

C/C++ 파일 경로 탐색

설명

외부 입력이 파일 시스템 API의 경로 인자로 전달되면 공격자가 .. 구성 요소나 절대 경로를 사용해 의도한 기준 디렉터리 밖의 파일을 선택할 수 있습니다. Windows에서는 \와 /, 드라이브·UNC·장치 경로 같은 여러 경로 형식도 고려해야 합니다. 심볼릭 링크나 Windows 재분석 지점이 경로 해석 중 다른 위치로 연결되면, 문자열만 검사한 경로도 실제로는 기준 디렉터리 밖을 가리킬 수 있습니다.

basename()과 C++의 std::filesystem::path::filename()은 마지막 경로 구성 요소를 반환할 뿐입니다. 입력이 . 또는 ..일 수 있고, 반환된 이름이 임의의 로컬 파일이나 링크를 선택할 수도 있으므로 일반적인 경로 정제 함수가 아닙니다. lexically_normal()은 파일 시스템을 조회하지 않는 어휘적 변환이며, canonical()과 weakly_canonical()도 신뢰할 수 있는 기준 디렉터리와의 포함 관계나 검사 후 사용 사이의 원자성을 그 자체로 보장하지 않습니다. 문자열 접두사 검사도 /var/app/data-escape를 /var/app/data 내부로 잘못 판단할 수 있으며, 파일을 열기 전 경로가 바뀌는 경쟁 조건을 막지 못합니다.

잠재적 영향

  • 프로세스 권한으로 파일을 읽거나 생성·교체·이름 변경·삭제할 수 있습니다.
  • 설정, 자격 증명, 키 또는 소스 코드가 노출되거나 변조될 수 있습니다.
  • 높은 권한의 프로세스에서는 권한 상승, 코드 실행 또는 서비스 중단으로 이어질 수 있습니다.

해결 방법

가장 안전한 설계는 외부 입력을 경로로 해석하지 않는 것입니다. 예를 들어 profile 같은 제한된 식별자를 허용 목록으로 검증한 뒤 서버가 관리하는 고정 경로에 매핑합니다.

외부 입력의 일부를 경로로 사용해야 한다면 다음 원칙을 함께 적용합니다.

  1. 신뢰할 수 있는 기준 디렉터리를 코드나 보호된 설정으로 고정하고 절대 경로, 빈 구성 요소, . 및 .. 구성 요소를 거부합니다.
  2. 대상 운영체제의 규칙으로 경로를 정규화하고, 기준 경로와 같거나 기준 경로 뒤의 첫 문자가 경로 구분자인 경우에만 내부 경로로 인정합니다. 단순 strncmp() 접두사 비교는 사용하지 않습니다.
  3. 심볼릭 링크, 하드 링크, 마운트 지점, Windows 재분석 지점과 검사 후 사용 시점 사이의 경쟁 조건을 고려합니다. 가능하면 문자열을 검사한 뒤 다시 여는 방식보다 디렉터리 핸들·파일 디스크립터를 기준으로 조회와 열기를 한 연산 흐름에서 제한합니다.
  4. Linux 5.6 이상에서는 신뢰할 수 있는 디렉터리 파일 디스크립터와 openat2()의 RESOLVE_BENEATH 또는 RESOLVE_IN_ROOT를 사용합니다. 매직 링크를 막으려면 RESOLVE_NO_MAGICLINKS를, 모든 심볼릭 링크를 금지해야 하면 RESOLVE_NO_SYMLINKS를 함께 사용하고 모든 오류를 처리합니다.
  5. POSIX openat()만으로는 .. 이탈을 막을 수 없습니다. openat2()를 사용할 수 없다면 각 경로 구성 요소를 거부 목록이 아닌 엄격한 형식으로 검증하고, 신뢰할 수 있는 디렉터리 디스크립터에서 구성 요소별로 O_NOFOLLOW를 사용해 이동하는 방식을 검토합니다.
  6. Windows에서는 CreateFile()의 재분석 지점 동작을 고려합니다. 기존 읽기 대상은 생성·자르기 없이 최소 접근 권한으로 핸들을 열고, 실제 데이터를 사용하기 전에 GetFinalPathNameByHandle()로 최종 경로를 확인하는 등 핸들 기반 검증을 적용합니다. 생성·교체 작업은 검증 전에 파괴적인 열기를 수행하지 말고 고정 경로 매핑이나 플랫폼별 안전한 설계를 사용합니다. 최종 경로 조회 자체가 포함 관계를 강제하지 않으므로 볼륨 표현과 경로 구성 요소 경계를 일관되게 비교해야 합니다.
  7. 파일 작업에 필요한 최소 권한만 부여하고 예상하지 않은 파일 형식·소유자·권한을 거부합니다.

realpath()는 기존 경로의 .·..와 심볼릭 링크를 정규화하는 데 유용하지만, 기준 디렉터리 확인과 경쟁 조건 방지까지 자동으로 제공하지 않습니다. 존재하지 않는 생성 대상은 부모 디렉터리와 마지막 구성 요소를 별도로 안전하게 처리해야 합니다.

예시

C 예시

변경 전

c
#include <stdio.h>

int main(int argc, char **argv) {
    if (argc != 2) {
        return 2;
    }

    FILE *file = fopen(argv[1], "rb");
    if (file == NULL) {
        return 1;
    }
    return fclose(file);
}

변경 후

c
#include <stdio.h>
#include <string.h>

int main(int argc, char **argv) {
    if (argc != 2) {
        return 2;
    }

    const char *path = NULL;
    if (strcmp(argv[1], "profile") == 0) {
        path = "/var/app/data/profile.json";
    } else if (strcmp(argv[1], "settings") == 0) {
        path = "/var/app/data/settings.json";
    } else {
        return 2;
    }

    FILE *file = fopen(path, "rb");
    if (file == NULL) {
        return 1;
    }
    return fclose(file);
}

설명:

  • 변경 전: 외부 입력이 파일 경로 위치에 직접 도달합니다.
  • 변경 후: 외부 입력을 경로로 해석하지 않고 서버가 관리하는 키와 고정 경로의 허용 목록으로 변환합니다. 따라서 절대 경로나 .. 경로 조각이 파일 API에 도달하지 않습니다.

C++ 예시

변경 전

cpp
#include <fstream>

int main(int argc, char **argv) {
    if (argc != 2) {
        return 2;
    }

    std::ifstream file{argv[1]};
    return file ? 0 : 1;
}

변경 후

cpp
#include <fstream>
#include <string_view>

int main(int argc, char **argv) {
    if (argc != 2) {
        return 2;
    }

    const char *path = nullptr;
    const std::string_view key{argv[1]};
    if (key == "profile") {
        path = "/var/app/data/profile.json";
    } else if (key == "settings") {
        path = "/var/app/data/settings.json";
    } else {
        return 2;
    }

    std::ifstream file{path};
    return file ? 0 : 1;
}

설명:

  • 변경 전: argv[1]이 std::ifstream의 경로 인자로 직접 전달되어 절대 경로나 상위 디렉터리 구성 요소를 선택할 수 있습니다.
  • 변경 후: 입력 문자열은 두 개의 식별자와만 비교되고, 실제 경로는 각 식별자에 대응하는 고정 상수에서 선택됩니다. std::filesystem::path::filename()이나 정규화 함수의 이름만으로 안전하다고 가정하지 않습니다.

참조