Path traversal adalah serangan di mana pengguna memanipulasi input path agar dapat mengakses file di luar direktori yang diizinkan. CWE-22 (Path Traversal) menyebut pola klasik seperti ../../etc/passwd atau /etc/shadow. Konsekuensinya bervariasi: pembocoran file sensitif, modifikasi konfigurasi, hingga eksekusi kode bila attacker berhasil menulis ke file yang dimuat sebagai modul. Pada chapter ini kita membangun validator path tiga lapis: penolakan pola .. dan absolute path, canonicalization melalui realpath(), serta pemeriksaan prefix terhadap direktori basis.

Tiga Lapisan Pencegahan #

Lapisan pertama menolak input yang mengandung .. atau diawali /. Lapisan kedua memakai realpath() untuk menormalisasi path—menyelesaikan symlink, menghapus ./ dan ../ yang valid, dan menghasilkan path absolut kanonik. Lapisan ketiga memeriksa bahwa path hasil kanonikasi benar-benar berawalan dari direktori basis yang diizinkan. Tanpa lapisan ketiga, symlink di luar basis dapat membuka pintu following symlink attack.

c
/* path_traversal.c */
/* Cegah path traversal: tolak "..", gunakan realpath() + cek prefix. */
#define _XOPEN_SOURCE 500
#define _POSIX_C_SOURCE 200809L
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <limits.h>
#include <errno.h>

#define BASE_DIR "/tmp/safe_app_data_12a"

/* Cek sederhana: tolak ".." dalam path. */
static int has_traversal(const char *path) {
    if (path == NULL) return 1;
    if (strstr(path, "..") != NULL) return 1;
    if (path[0] == '/') return 1; /* tolak absolute path */
    return 0;
}

/* Validasi lengkap: gabungkan dengan base, resolve, lalu cek prefix. */
static int validate_path(const char *user_path, char *resolved, size_t resolved_size) {
    if (has_traversal(user_path)) {
        fprintf(stderr, "[REJECT] path mengandung pola traversal: '%s'\n", user_path);
        return 0;
    }

    char full[PATH_MAX];
    int n = snprintf(full, sizeof full, "%s/%s", BASE_DIR, user_path);
    if (n < 0 || (size_t)n >= sizeof full) {
        fprintf(stderr, "[REJECT] path terlalu panjang\n");
        return 0;
    }

    /* realpath menulis path absolut kanonik ke real (bila ada filenya). */
    char real[PATH_MAX];
    if (realpath(full, real) == NULL) {
        fprintf(stderr, "[REJECT] realpath gagal: %s\n", strerror(errno));
        return 0;
    }

    /* Cek prefix: pastikan resolved ada di dalam BASE_DIR. */
    size_t base_len = strlen(BASE_DIR);
    if (strncmp(real, BASE_DIR, base_len) != 0) {
        fprintf(stderr, "[REJECT] resolved di luar base: '%s'\n", real);
        return 0;
    }
    /* Pastikan karakter setelah prefix adalah '/' atau akhir string. */
    if (real[base_len] != '/' && real[base_len] != '\0') {
        fprintf(stderr, "[REJECT] prefix tidak lengkap: '%s'\n", real);
        return 0;
    }

    snprintf(resolved, resolved_size, "%s", real);
    return 1;
}

int main(void) {
    /* Jaga urutan output: stdout tidak di-buffer supaya selaras dengan stderr. */
    setvbuf(stdout, NULL, _IONBF, 0);

    /* Buat direktori base dan file uji untuk realpath(). */
    char cmd[256];
    snprintf(cmd, sizeof cmd, "mkdir -p %s", BASE_DIR);
    system(cmd);
    snprintf(cmd, sizeof cmd, "touch %s/file_a.txt", BASE_DIR);
    system(cmd);
    snprintf(cmd, sizeof cmd, "mkdir -p %s/sub", BASE_DIR);
    system(cmd);

    const char *inputs[] = {
        "file_a.txt",          /* valid */
        "sub/file_b.txt",      /* tidak ada, realpath gagal */
        "../etc/passwd",       /* traversal */
        "/etc/passwd",         /* absolute path */
        "sub/../file_a.txt",   /* setelah resolve valid */
    };
    const size_t n = sizeof(inputs) / sizeof(inputs[0]);

    for (size_t i = 0; i < n; i++) {
        char resolved[PATH_MAX];
        printf("--- Test %zu: '%s' ---\n", i + 1, inputs[i]);
        if (validate_path(inputs[i], resolved, sizeof resolved)) {
            printf("[ACCEPT] resolved = '%s'\n", resolved);
        }
    }
    return 0;
}

Output yang Dihasilkan:

text
--- Test 1: 'file_a.txt' ---
[ACCEPT] resolved = '/tmp/safe_app_data_12a/file_a.txt'
--- Test 2: 'sub/file_b.txt' ---
[REJECT] realpath gagal: No such file or directory
--- Test 3: '../etc/passwd' ---
[REJECT] path mengandung pola traversal: '../etc/passwd'
--- Test 4: '/etc/passwd' ---
[REJECT] path mengandung pola traversal: '/etc/passwd'
--- Test 5: 'sub/../file_a.txt' ---
[REJECT] path mengandung pola traversal: 'sub/../file_a.txt'

Mari kita telaah baris-baris kode di atas:

  1. Baris 14-19 (has_traversal): Lapisan pertama. strstr(path, "..") menangkap pola traversal literal. Pemeriksaan path[0] == '/' menolak absolute path—pengguna tidak boleh meminta /etc/passwd meskipun direktori basis ada di dalam /etc. Pemeriksaan ini bersifat deny-list dan tidak boleh menjadi satu-satunya lapisan karena ada banyak trik bypass seperti .../, .\./, atau encoding URL %2e%2e.
  2. Baris 22-43 (validate_path): Lapisan kedua dan ketiga digabung. snprintf membangun path lengkap dengan aman. realpath mengembalikan NULL bila file tidak ada atau path tidak valid—kasus Test 2 di mana sub/file_b.txt belum dibuat. Pemeriksaan prefix strncmp(real, BASE_DIR, base_len) memastikan hasil kanonikasi tetap berada di dalam direktori basis.
  3. Baris 36-39 (cek karakter setelah prefix): Pemeriksaan tambahan yang krusial. Bayangkan base adalah /var/app/data dan realpath mengembalikan /var/app/data_leaked/file. strncmp sederhana akan menganggap prefix cocok karena /var/app/data adalah awalan dari /var/app/data_leaked. Pemeriksaan real[base_len] == '/' memastikan karakter setelah prefix adalah pemisah direktori—bukan bagian dari nama lain.
  4. Baris 46-58 (setup test): Membuat direktori basis, satu file file_a.txt, dan satu subdirektori sub. Setup ini memungkinkan realpath berhasil pada Test 1 dan Test 5 (yang sebenarnya ditolak di lapisan pertama karena mengandung ..).
  5. Baris 60-69 (loop): Test 1 diterima karena file ada dan path kanonik berada di dalam basis. Test 2 ditolak karena realpath gagal—file belum ada. Test 3 dan 4 ditolak di lapisan pertama. Test 5 menarik: meskipun secara semantik sub/../file_a.txt menunjuk ke file_a.txt yang valid, lapisan pertama kami bersifat konservatif—kehadiran .. apa pun langsung ditolak. Pendekatan ini mengorbankan sedikit fleksibilitas demi keamanan yang lebih ketat.

Alternatif: Allow-List Karakter #

Pendekatan yang lebih ketat daripada deny-list adalah allow-list: hanya karakter yang diizinkan yang diterima. Berikut validator nama file yang menerima hanya alfanumerik, titik, underscore, dan minus:

c
/* extra_path_traversal.c — allow-list nama file sederhana. */
#include <stdio.h>
#include <string.h>
#include <ctype.h>

static int is_safe_filename(const char *name) {
    if (name == NULL || *name == '\0') return 0;
    size_t len = strlen(name);
    if (len > 255) return 0;
    for (size_t i = 0; i < len; i++) {
        char c = name[i];
        if (!(isalnum((unsigned char)c) || c == '.' || c == '_' || c == '-')) {
            return 0;
        }
    }
    if (strstr(name, "..") != NULL) return 0;
    if (name[0] == '.') return 0;
    return 1;
}

int main(void) {
    const char *tests[] = {
        "file.txt",
        "report_2024.csv",
        "../etc/passwd",
        "file.txt;rm -rf /",
        "file name.txt",
        ".hidden",
        "ok-file_v1.dat",
    };
    const size_t n = sizeof(tests) / sizeof(tests[0]);
    for (size_t i = 0; i < n; i++) {
        printf("[%s] '%s'\n",
               is_safe_filename(tests[i]) ? "ACCEPT" : "REJECT",
               tests[i]);
    }
    return 0;
}

Output yang Dihasilkan:

text
[ACCEPT] 'file.txt'
[ACCEPT] 'report_2024.csv'
[REJECT] '../etc/passwd'
[REJECT] 'file.txt;rm -rf /'
[REJECT] 'file name.txt'
[REJECT] '.hidden'
[ACCEPT] 'ok-file_v1.dat'

Validator ini menolak spasi, titik koma, dan karakter shell lain—mencegah command injection bila nama file diteruskan ke system(). Pola .. dan file tersembunyi (diawali .) juga ditolak. Allow-list jauh lebih ketat daripada deny-list karena karakter baru yang tidak dikenal otomatis ditolak.

Bypass yang Perlu Diwaspadai #

Daftar berikut merangkum trik bypass yang sering dipakai penyerang:

  • ../ klasik: ../../etc/passwd—ditangkap has_traversal.
  • Absolute path: /etc/passwd—ditangkap has_traversal.
  • Encoding URL: %2e%2e%2f atau ..%2f—bila aplikasi memproses URL decoding sebelum validasi, input terurai menjadi ../. Selalu decode dulu, baru validasi.
  • Null byte: file.txt\0/../../../etc/passwd—pustaka lama memotong string di null byte. Modern realpath aman dari serangan ini.
  • Unicode normalization: ../ (fullwidth dot)—normalisasi Unicode ke .. saat diproses oleh file system.
  • Symlink: link.txt -> /etc/passwd—realpath akan menyelesaikan symlink ke path asli. Tanpa cek prefix ketat, file di luar basis tetap dapat diakses.

Direktori Sementara dan mkdtemp #

Untuk aplikasi yang memerlukan direktori kerja sementara per sesi, gunakan mkdtemp() agar nama direktori acak dan tidak dapat ditebak:

c
char tmpl[] = "/tmp/app_XXXXXX";
char *dir = mkdtemp(tmpl);
if (dir == NULL) { /* error */ }

mkdtemp membuat direktori unik dengan izin 0700—hanya pemilik yang dapat mengaksesnya. Pendekatan ini menghindari race condition dan predictable filename attack.