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.
/* 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:
--- 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:
- Baris 14-19 (
has_traversal): Lapisan pertama.strstr(path, "..")menangkap pola traversal literal. Pemeriksaanpath[0] == '/'menolak absolute path—pengguna tidak boleh meminta/etc/passwdmeskipun 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. - Baris 22-43 (
validate_path): Lapisan kedua dan ketiga digabung.snprintfmembangun path lengkap dengan aman.realpathmengembalikan NULL bila file tidak ada atau path tidak valid—kasus Test 2 di manasub/file_b.txtbelum dibuat. Pemeriksaan prefixstrncmp(real, BASE_DIR, base_len)memastikan hasil kanonikasi tetap berada di dalam direktori basis. - Baris 36-39 (cek karakter setelah prefix): Pemeriksaan tambahan yang krusial. Bayangkan base adalah
/var/app/datadan realpath mengembalikan/var/app/data_leaked/file.strncmpsederhana akan menganggap prefix cocok karena/var/app/dataadalah awalan dari/var/app/data_leaked. Pemeriksaanreal[base_len] == '/'memastikan karakter setelah prefix adalah pemisah direktori—bukan bagian dari nama lain. - Baris 46-58 (setup test): Membuat direktori basis, satu file
file_a.txt, dan satu subdirektorisub. Setup ini memungkinkanrealpathberhasil pada Test 1 dan Test 5 (yang sebenarnya ditolak di lapisan pertama karena mengandung..). - Baris 60-69 (loop): Test 1 diterima karena file ada dan path kanonik berada di dalam basis. Test 2 ditolak karena
realpathgagal—file belum ada. Test 3 dan 4 ditolak di lapisan pertama. Test 5 menarik: meskipun secara semantiksub/../file_a.txtmenunjuk kefile_a.txtyang 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:
/* 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:
[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—ditangkaphas_traversal.- Absolute path:
/etc/passwd—ditangkaphas_traversal. - Encoding URL:
%2e%2e%2fatau..%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. Modernrealpathaman dari serangan ini. - Unicode normalization:
../(fullwidth dot)—normalisasi Unicode ke..saat diproses oleh file system. - Symlink:
link.txt -> /etc/passwd—realpathakan 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:
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.