Path Traversal

Filesystem path traversal in C/C++

Description

When external input becomes a filesystem API's path argument, an attacker can use .. components or absolute paths to select files outside the intended base directory. Windows also requires consideration of \ and /, drive paths, UNC paths, and device paths. Symbolic links or Windows reparse points may redirect path resolution, so a path checked only as a string can still reach outside the base directory.

basename() and C++'s std::filesystem::path::filename() only return the last path component. They are not general path sanitizers: input may be . or .., and the returned name may select an arbitrary local file or link. lexically_normal() transforms text without consulting the filesystem. canonical() and weakly_canonical() do not themselves establish containment in a trusted base directory or make checking and use atomic. A string-prefix check may wrongly accept /var/app/data-escape as inside /var/app/data, and cannot prevent paths changing before they are opened.

Potential impact

  • Reading, creating, replacing, renaming, or deleting files with the process's privileges
  • Exposure or modification of configuration, credentials, keys, or source code
  • Privilege escalation, code execution, or service disruption when the process has elevated privileges

Remediation

Prefer designs that never interpret external input as a path. For example, validate a limited identifier such as profile against an allow-list and map it to a fixed, server-managed path.

If input must form part of a path:

  1. Fix a trusted base directory in code or protected configuration. Reject absolute paths, empty components, and . or .. components.
  2. Normalize paths using the target operating system's rules. Check equality with the base or a directory-separator boundary after the base; do not rely on a simple strncmp() prefix comparison.
  3. Account for symbolic links, hard links, mount points, Windows reparse points, and changes between checking and use. Where possible, constrain traversal and opening through directory handles or file descriptors rather than checking a string and opening it again.
  4. On Linux 5.6 or later, use a trusted directory file descriptor with openat2() and RESOLVE_BENEATH or RESOLVE_IN_ROOT. Add RESOLVE_NO_MAGICLINKS to block magic links, or RESOLVE_NO_SYMLINKS to reject all symbolic links. Handle every error.
  5. POSIX openat() alone does not prevent escape through ... Without openat2(), consider strict component validation and traversal from a trusted directory descriptor using O_NOFOLLOW for each component, rather than a denylist.
  6. On Windows, account for CreateFile() reparse-point behavior. For existing read targets, open with minimal access without creating or truncating files, then validate the handle before using data, for example with GetFinalPathNameByHandle(). Creation and replacement need fixed path mappings or a platform-specific safe design; do not perform a destructive open before validation. Querying the final path does not enforce containment, so compare volume representations and component boundaries consistently.
  7. Grant only the permissions needed for the operation and reject unexpected file types, ownership, or permissions.

realpath() resolves ., .., and symbolic links in existing paths, but does not automatically check containment or prevent races. For a new file, handle its parent directory and final component safely and separately.

Examples

C examples

Before

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);
}

After

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);
}

Explanation:

  • Before: External input is passed directly as a file path.
  • After: Input selects from an allow-list of server-managed keys and fixed paths. It is never interpreted as a path, so absolute paths and .. components do not reach the file API.

C++ examples

Before

cpp
#include <fstream>

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

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

After

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;
}

Explanation:

  • Before: argv[1] is passed directly to std::ifstream as a path, allowing absolute paths or parent-directory components.
  • After: Input is compared only with two identifiers. The actual path comes from the fixed constant for that identifier. Do not infer safety from names such as std::filesystem::path::filename() or from normalization alone.

References