flawopen.com/path-traversal/C-cpp
Path Traversal in C/C++ (openat2, libarchive)
Building a path with snprintf("%s/%s") keeps ../ for the kernel to follow, checking realpath() with strncmp() and then calling open() accepts /srv/uploads-old and races against symlink swaps, and libarchive extracts .. and absolute member names unless told not to. How each mistake lets a request read or write files outside your C service's upload folder, and how openat2() with RESOLVE_BENEATH and libarchive's secure-extract flags stop it.
Imagine a service elevator whose controller is anchored to one floor. The kernel accepts a relative room name and opens it in one operation; with openat2() and RESOLVE_BENEATH, any route that climbs above the anchored floor is refused, including an escaping symlink. That closes the gap in realpath() followed by open(): nobody can swap a doorway between the check and the entry. A strncmp() check is weaker still because /srv/uploads-old shares the /srv/uploads prefix. Archive extraction has its own guards: libarchive must reject dot-dot, symlinks and absolute paths with ARCHIVE_EXTRACT_SECURE_NODOTDOT, ARCHIVE_EXTRACT_SECURE_SYMLINKS and ARCHIVE_EXTRACT_SECURE_NOABSOLUTEPATHS.
Core Concepts & Subsystem Terms
snprintf("%s/%s") keeps ..snprintf(path, sizeof path, "%s/%s", UPLOAD_DIR, name)withname = "../../etc/hostname"produces/srv/uploads/../../etc/hostname, which the kernel resolves to/etc/hostname. String building never confines a path.realpath() then open()realpath()resolves..and symlinks at check time, butstrncmp(real, "/srv/uploads", 12)also accepts/srv/uploads-old, and a symlink swapped betweenrealpath()andopen()still escapes (a time-of-check to time-of-use race).openat2() with RESOLVE_BENEATH- Linux 5.6+
openat2()resolves the name relative to a directory descriptor, andRESOLVE_BENEATHmakes the kernel refuse any.., absolute path or symlink that would leave that directory, atomically during the open itself. Rejecting / for flat file names- When a feature only needs files directly inside one folder, rejecting any name that contains
/(and empty names) removes nested paths entirely;RESOLVE_BENEATHstill refuses... libarchive ARCHIVE_EXTRACT_SECURE_* flagsarchive_write_diskextracts..components, absolute paths and paths through existing symlinks by default. SettingARCHIVE_EXTRACT_SECURE_NODOTDOT,ARCHIVE_EXTRACT_SECURE_SYMLINKSandARCHIVE_EXTRACT_SECURE_NOABSOLUTEPATHSmakes it reject them.
Framework mappings
- CWE reference for the weakness described in this guide: CWE-22
Step-by-Step Attack Flow
Request value reaches a file API
A CGI program or embedded web server copies a query parameter into name, or an uploaded archive supplies member names, and the code formats them into a path.
The value leaves the intended folder
It contains ../ segments, names a sibling folder with the same prefix, or a symlink is swapped in after the check. The benign test value ../../etc/hostname illustrates the read case.
The kernel opens the resolved path
open() or libarchive's disk writer resolves every .. and link with the process's permissions.
The attacker reads or overwrites files
Reads expose configuration and keys; archive writes can replace binaries, init scripts or libraries, which on embedded devices often run as root.
Source Code: Flaw vs. Secure Implementation
/* files.c: each function lets request data choose a path */
#include <archive.h>
#include <archive_entry.h>
#include <fcntl.h>
#include <limits.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#define UPLOAD_DIR "/srv/uploads"
/* snprintf keeps ../ and open() follows it */
int open_upload(const char *name)
{
char path[PATH_MAX];
snprintf(path, sizeof path, "%s/%s", UPLOAD_DIR, name);
return open(path, O_RDONLY);
}
/* strncmp accepts /srv/uploads-old, and a symlink can be swapped before open() */
int open_checked(const char *name)
{
char path[PATH_MAX], real[PATH_MAX];
snprintf(path, sizeof path, "%s/%s", UPLOAD_DIR, name);
if (realpath(path, real) == NULL || strncmp(real, UPLOAD_DIR, strlen(UPLOAD_DIR)) != 0)
return -1;
return open(real, O_RDONLY);
}
/* default options extract .., absolute names and through symlinks */
int extract_archive(const char *archive_path)
{
struct archive *in = archive_read_new();
struct archive *out = archive_write_disk_new();
struct archive_entry *entry;
archive_read_support_format_all(in);
archive_read_support_filter_all(in);
archive_write_disk_set_options(out, ARCHIVE_EXTRACT_TIME);
if (archive_read_open_filename(in, archive_path, 10240) != ARCHIVE_OK)
return -1;
while (archive_read_next_header(in, &entry) == ARCHIVE_OK)
archive_read_extract2(in, entry, out);
archive_read_free(in);
archive_write_free(out);
return 0;
}
/* files.c: kernel-enforced containment and secure extraction */
#define _GNU_SOURCE
#include <archive.h>
#include <archive_entry.h>
#include <fcntl.h>
#include <linux/openat2.h>
#include <string.h>
#include <sys/syscall.h>
#include <unistd.h>
#define UPLOAD_DIR "/srv/uploads"
/* the kernel refuses .., absolute paths and symlinks that leave dir */
static int open_beneath(const char *dir, const char *name)
{
int dirfd = open(dir, O_RDONLY | O_DIRECTORY | O_CLOEXEC);
if (dirfd < 0)
return -1;
struct open_how how = {
.flags = O_RDONLY | O_CLOEXEC,
.resolve = RESOLVE_BENEATH | RESOLVE_NO_MAGICLINKS,
};
int fd = (int)syscall(SYS_openat2, dirfd, name, &how, sizeof how);
close(dirfd);
return fd;
}
/* every open goes through RESOLVE_BENEATH */
int open_upload(const char *name)
{
return open_beneath(UPLOAD_DIR, name);
}
/* flat folder: any / is rejected before opening */
int open_checked(const char *name)
{
if (name[0] == '\0' || strchr(name, '/') != NULL)
return -1;
return open_beneath(UPLOAD_DIR, name);
}
/* secure flags reject .., absolute names and symlink tricks */
int extract_archive(const char *archive_path)
{
struct archive *in = archive_read_new();
struct archive *out = archive_write_disk_new();
struct archive_entry *entry;
archive_read_support_format_all(in);
archive_read_support_filter_all(in);
archive_write_disk_set_options(out, ARCHIVE_EXTRACT_TIME
| ARCHIVE_EXTRACT_SECURE_NODOTDOT
| ARCHIVE_EXTRACT_SECURE_SYMLINKS
| ARCHIVE_EXTRACT_SECURE_NOABSOLUTEPATHS);
if (archive_read_open_filename(in, archive_path, 10240) != ARCHIVE_OK)
return -1;
int status = 0;
while (archive_read_next_header(in, &entry) == ARCHIVE_OK) {
if (archive_read_extract2(in, entry, out) != ARCHIVE_OK)
status = -1;
}
archive_read_free(in);
archive_write_free(out);
return status;
}
Engineering & System Hardening Checklist
- ✓Run CodeQL
cpp/path-injectionin CI and flag everysnprintf,strcatorstd::stringconcatenation that builds a path from request data. - ✓Open user-named files with
openat2()andRESOLVE_BENEATHon Linux 5.6+, or walk components withopenat()andO_NOFOLLOWelsewhere; never make a security decision withrealpath()followed byopen(). - ✓Reject names containing
/when only direct children are allowed, and reject empty names. - ✓Configure libarchive with
ARCHIVE_EXTRACT_SECURE_NODOTDOT,ARCHIVE_EXTRACT_SECURE_SYMLINKSandARCHIVE_EXTRACT_SECURE_NOABSOLUTEPATHSand check the result of every extract call. - ✓Run the service as an unprivileged user in a directory tree it cannot write outside of, never as root.