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:
- Fix a trusted base directory in code or protected configuration. Reject absolute paths, empty components, and
.or..components. - 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. - 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.
- On Linux 5.6 or later, use a trusted directory file descriptor with
openat2()andRESOLVE_BENEATHorRESOLVE_IN_ROOT. AddRESOLVE_NO_MAGICLINKSto block magic links, orRESOLVE_NO_SYMLINKSto reject all symbolic links. Handle every error. - POSIX
openat()alone does not prevent escape through... Withoutopenat2(), consider strict component validation and traversal from a trusted directory descriptor usingO_NOFOLLOWfor each component, rather than a denylist. - 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 withGetFinalPathNameByHandle(). 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. - 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
#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
#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
#include <fstream>
int main(int argc, char **argv) {
if (argc != 2) {
return 2;
}
std::ifstream file{argv[1]};
return file ? 0 : 1;
}
After
#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 tostd::ifstreamas 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
- ISO/IEC 9899:2024 Programming languages — C
- ISO/IEC 14882:2024 Programming languages — C++
- Microsoft C++
<filesystem>functions - Microsoft C++
pathclass - Microsoft C++
basic_ifstreamclass - POSIX.1-2024
open(),openat() - POSIX.1-2024
realpath() - Linux man-pages 6.18
openat2(2) - Microsoft
CreateFileW - Microsoft
GetFinalPathNameByHandleW - Microsoft reparse points
- SEI CERT C FIO02-C: Canonicalize path names originating from tainted sources
- CWE-22: Improper Limitation of a Pathname to a Restricted Directory
- OWASP Top 10:2025 A01 Broken Access Control
- OWASP Top 10:2021 A01 Broken Access Control