프로세스 또는 동적 라이브러리 실행 대상 조작

C/C++ 프로세스 또는 동적 라이브러리 실행 대상 조작

설명

신뢰할 수 없는 경로나 파일명을 프로세스 실행 또는 동적 라이브러리 API의 대상으로 사용하면 공격자가 애플리케이션이 로드하거나 실행할 코드를 선택할 수 있습니다. 입력값이 절대 경로인 경우에는 공격자가 준비한 파일을 직접 지정할 수 있고, 파일명이나 상대 경로인 경우에는 PATH, 라이브러리 검색 경로, 현재 디렉터리와 같은 검색 규칙을 악용해 개발자가 의도하지 않은 파일을 선택하게 할 수 있습니다.

Windows에서 CreateProcess*()의 lpApplicationName을 NULL로 전달하면 lpCommandLine의 첫 토큰에서 실행 파일 이름을 해석합니다. 이 상태에서 공백이 포함된 실행 파일 경로를 모호하게 구성하거나 검색 경로에 공격자가 쓸 수 있는 디렉터리가 있으면 다른 실행 파일이 선택될 수 있습니다. LoadLibrary*()도 대상 DLL뿐 아니라 그 DLL의 종속 DLL을 찾을 때 별도의 검색 규칙을 적용하므로, 대상 하나를 고정하는 것만으로 모든 DLL 검색 경로 문제가 해결되지는 않습니다.

이 문제는 셸이 명령 문자열을 해석하는 운영체제 명령어 주입과 구분됩니다. execve()나 CreateProcessW()는 기본적으로 셸 메타문자를 해석하지 않지만, 외부 입력이 실행 파일 자체를 선택한다면 공격자 코드가 실행될 수 있습니다. 반대로 실행 파일을 고정했더라도 신뢰할 수 없는 인자가 대상 프로그램의 옵션이나 문법으로 해석되는 문제는 별도로 검토해야 합니다.

잠재적 영향

  • 애플리케이션 계정 권한으로 임의 실행 파일이나 동적 라이브러리 코드가 실행될 수 있습니다.
  • 높은 권한의 서비스에서는 권한 상승, 보안 경계 우회 또는 시스템 전체 침해로 이어질 수 있습니다.
  • DLL 사이드로딩이나 검색 경로 하이재킹을 통해 지속성을 확보하거나 정상 프로그램의 동작을 바꿀 수 있습니다.
  • 악성 또는 호환되지 않는 대상을 실행해 민감정보를 유출하거나 서비스를 중단시킬 수 있습니다.

해결 방법

외부 입력은 실행 경로가 아니라 소수의 업무상 식별자로만 받고, 각 식별자를 신뢰할 수 있는 코드나 설정에 저장된 고정 대상에 매핑합니다. 다음 보호를 함께 적용합니다.

  1. 실행 파일과 라이브러리에는 신뢰할 수 있는 코드나 설정에 고정한 명시적 전체 경로를 사용합니다. 경로, 파일명 또는 확장자를 외부 입력으로 직접 받지 않습니다.
  2. 허용된 식별자와 정확히 일치하는 경우에만 코드에 고정된 경로를 선택하고 나머지는 거부합니다. 경로 정규화, 확장자 검사 또는 sanitize_path() 같은 함수 이름만으로 실행 권한을 부여하지 않습니다.
  3. POSIX에서는 고정된 절대 경로를 execve()나 posix_spawn()에 전달하고, 인자 배열과 최소한의 신뢰할 수 있는 환경을 별도로 구성합니다. 대상 선택에 PATH를 사용하는 execvp(), execvpe(), execlp(), posix_spawnp()는 피합니다.
  4. dlopen() 또는 dlmopen()에는 신뢰할 수 있는 전체 경로를 전달하고, 대상과 종속 라이브러리의 검색 경로·RPATH·RUNPATH·환경 변수 설정을 함께 검토합니다.
  5. Windows에서는 lpApplicationName에 고정된 실행 파일의 전체 경로를 명시하고, lpCommandLine은 API가 요구하는 쓰기 가능한 별도 버퍼로 구성합니다. DLL은 LoadLibraryExW()와 목적에 맞는 제한적인 LOAD_LIBRARY_SEARCH_* 플래그를 사용하고, 필요하면 SetDefaultDllDirectories()로 프로세스 기본 검색 정책도 제한합니다.
  6. 실행 파일, 라이브러리 및 종속 파일이 있는 디렉터리는 신뢰할 수 없는 사용자가 쓸 수 없도록 권한을 설정합니다. 프로세스 계정에는 최소 권한만 부여합니다.
  7. 코드 서명이나 해시 검증은 심층 방어로 사용할 수 있지만, 신뢰할 수 있는 경로와 파일 시스템 권한을 대신하지는 않습니다. 검증 후 실행 사이에 파일이 바뀌는 경쟁 조건도 고려해야 합니다.

예시

C

변경 전

c
#include <dlfcn.h>
#include <stddef.h>

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

    void *handle = dlopen(argv[1], RTLD_NOW);
    if (handle == NULL) {
        return 1;
    }
    dlclose(handle);
    return 0;
}

argv[1]이 로드할 라이브러리 경로를 직접 결정하므로 공격자가 애플리케이션 권한으로 로드될 코드를 선택할 수 있습니다.

변경 후

c
#include <dlfcn.h>
#include <stddef.h>
#include <string.h>

static const char *plugin_path(const char *selector) {
    if (strcmp(selector, "image") == 0) {
        return "/opt/example/lib/image-plugin.so";
    }
    if (strcmp(selector, "report") == 0) {
        return "/opt/example/lib/report-plugin.so";
    }
    return NULL;
}

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

    const char *path = plugin_path(argv[1]);
    if (path == NULL) {
        return 2;
    }

    void *handle = dlopen(path, RTLD_NOW | RTLD_LOCAL);
    if (handle == NULL) {
        return 1;
    }
    dlclose(handle);
    return 0;
}

외부 입력은 image와 report라는 식별자 중 하나를 선택할 뿐이며, 실제 라이브러리 경로는 코드에 고정되어 있습니다. 배포 환경에서는 이 디렉터리와 두 라이브러리의 종속 파일도 신뢰할 수 없는 사용자가 변경할 수 없어야 합니다.

고정된 프로세스를 실행할 때도 대상 경로와 인자를 분리합니다.

c
#include <spawn.h>
#include <stddef.h>

int run_worker(char *job_id) {
    char *const arguments[] = {
        "worker", "--job-id", job_id, NULL
    };
    char *const environment[] = {
        "LANG=C", NULL
    };
    pid_t child;

    return posix_spawn(
        &child,
        "/opt/example/bin/worker",
        NULL,
        NULL,
        arguments,
        environment);
}

이 예시는 실행 파일을 절대 경로로 고정하고 인자와 환경을 별도로 전달합니다. job_id에는 애플리케이션의 업무 형식에 맞는 길이·문자·값 검증도 적용해야 하며, 대상 프로그램이 인자를 옵션이나 다른 문법으로 재해석하는지 별도로 확인해야 합니다.

C++

변경 전

cpp
#include <dlfcn.h>
#include <iostream>
#include <string>

int main() {
    std::string path;
    if (!std::getline(std::cin, path)) {
        return 2;
    }

    void *handle = ::dlopen(path.c_str(), RTLD_NOW);
    if (handle == nullptr) {
        return 1;
    }
    ::dlclose(handle);
    return 0;
}

std::string에 저장하거나 c_str()로 변환해도 입력값에 실행 권한이 부여되지는 않습니다. 이 코드는 표준 입력이 지정한 경로의 라이브러리를 그대로 로드합니다.

변경 후

cpp
#include <dlfcn.h>
#include <iostream>
#include <string>
#include <string_view>

static const char *plugin_path(std::string_view selector) {
    if (selector == "image") {
        return "/opt/example/lib/image-plugin.so";
    }
    if (selector == "report") {
        return "/opt/example/lib/report-plugin.so";
    }
    return nullptr;
}

int main() {
    std::string selector;
    if (!std::getline(std::cin, selector)) {
        return 2;
    }

    const char *path = plugin_path(selector);
    if (path == nullptr) {
        return 2;
    }

    void *handle = ::dlopen(path, RTLD_NOW | RTLD_LOCAL);
    if (handle == nullptr) {
        return 1;
    }
    ::dlclose(handle);
    return 0;
}

외부 입력은 두 업무 식별자 중 하나만 선택하고 실제 경로는 코드에 고정되어 있습니다. std::filesystem::path로 경로를 표현하거나 canonical()로 해석하는 것만으로는 이 식별자 매핑을 대신할 수 없습니다.

참조