FlawOpen

flawopen.com/path-traversal/C-cpp

● CWE-22 · High
FlawOpen Security Research

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.

💡 Plain English Explainer (ELI5)

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) with name = "../../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, but strncmp(real, "/srv/uploads", 12) also accepts /srv/uploads-old, and a symlink swapped between realpath() and open() 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, and RESOLVE_BENEATH makes 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_BENEATH still refuses ...
libarchive ARCHIVE_EXTRACT_SECURE_* flags
archive_write_disk extracts .. components, absolute paths and paths through existing symlinks by default. Setting ARCHIVE_EXTRACT_SECURE_NODOTDOT, ARCHIVE_EXTRACT_SECURE_SYMLINKS and ARCHIVE_EXTRACT_SECURE_NOABSOLUTEPATHS makes it reject them.

Framework mappings

  • CWE reference for the weakness described in this guide: CWE-22

Step-by-Step Attack Flow

Step 1

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.

Step 2

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.

Step 3

The kernel opens the resolved path

open() or libarchive's disk writer resolves every .. and link with the process's permissions.

Step 4

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

UNPATCHED FLAW
/* 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;
}
HARDENED SECURE PATCH
/* 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

Sources