Sebuah pathname dapat menunjuk ke objek yang berbeda ketika lookup kedua dilakukan. Di Linux, openat2() menangani batas ini dengan menempelkan aturan resolusi pada operasi kernel yang sama dengan operasi yang menelusuri pathname dan membuka objek hasilnya. Kebijakan dievaluasi selama lookup berlangsung, bukan disimpulkan dari pathname yang diperiksa sebelum atau sesudah file dibuka.
Perbedaan ini penting ketika proses menerima komponen path dari sumber yang kurang tepercaya tetapi ingin memastikan resolusi tetap berada di dalam direktori tertentu, menolak symbolic link, mencegah perpindahan mount, atau mewajibkan lookup yang hanya memakai cache. Objek yang penting bukan hanya string input, melainkan hasil resolusi string tersebut terhadap namespace aktif yang isi direktori, link, dan mount-nya dapat berubah secara bersamaan.
Directory descriptor menetapkan titik awal resolusi
openat() sudah memisahkan resolusi pathname dari current working directory proses. Untuk path relatif, argumen dirfd menentukan direktori tempat lookup dimulai.
Namun hal itu belum otomatis membentuk aturan containment. Path relatif yang berisi .. masih dapat bergerak ke atas direktori awal. Symbolic link yang ditemukan saat traversal dapat mengalihkan lookup. Symbolic link absolut dapat memulai ulang resolusi dari root proses. Mount point dapat membawa lookup ke filesystem lain.
Karena itu, directory descriptor hanya menjawab dari mana traversal relatif dimulai. Ia tidak otomatis menentukan perpindahan mana yang diizinkan selama traversal.
Secara historis, aplikasi mencoba menambahkan kebijakan semacam ini di userspace dengan memeriksa komponen path, memanggil operasi metadata, menolak string yang dianggap mencurigakan, atau membuka komponen satu per satu. Beberapa desain dapat dibuat kuat dengan traversal relatif-descriptor yang hati-hati, tetapi pola check-then-open tetap berbahaya jika kedua operasi melakukan resolusi pathname secara terpisah.
Namespace dapat berubah di antara dua operasi tersebut. Komponen yang terlihat sebagai direktori biasa saat validasi dapat diganti sebelum lookup berikutnya. Masalahnya bukan sekadar filter string yang kurang ketat, melainkan objek yang diperiksa berbeda dengan objek yang akhirnya dibuka.
Resolution flag memindahkan kebijakan ke dalam proses pathname walk
openat2() memperluas openat() dengan struktur open_how. Field resolve membawa flag yang membatasi proses resolusi pathname itu sendiri.
Bentuk pemanggilan sederhananya:
struct open_how how = {
.flags = O_RDONLY | O_CLOEXEC,
.resolve = RESOLVE_BENEATH | RESOLVE_NO_MAGICLINKS,
};
int fd = syscall(SYS_openat2, dirfd, path, &how, sizeof(how));Jika pemanggilan berhasil, descriptor yang dikembalikan berasal dari lookup yang memenuhi seluruh batas yang diminta. Tidak ada interval userspace di mana aplikasi memvalidasi satu hasil resolusi lalu meminta kernel melakukan resolusi lain yang tidak dibatasi untuk membuka file.
Sifat atomik ini memiliki cakupan yang sempit. Ia tidak membekukan namespace filesystem, tidak membuat lookup berikutnya identik, dan tidak mencegah proses lain melakukan rename setelah descriptor dikembalikan. Ia hanya mengikat kebijakan resolusi tertentu pada operasi open tersebut.
Setelah descriptor tersedia, operasi berbasis descriptor mengacu ke objek yang sudah dibuka sesuai semantik normalnya. Lookup pathname berikutnya adalah peristiwa resolusi baru dan dapat melihat namespace yang sudah berubah.
RESOLVE_BENEATH menolak traversal keluar dari tree awal
RESOLVE_BENEATH mensyaratkan bahwa resolusi yang berhasil tetap berada di bawah direktori yang dirujuk oleh dirfd. Path absolut ditolak, dan traversal yang mencoba keluar melalui .. atau symbolic link absolut tidak dapat berhasil.
Aturan ini lebih kuat daripada sekadar menghapus string literal ../. Traversal path bersifat semantik. Symbolic link dan perubahan namespace secara konkuren membuat normalisasi leksikal saja tidak cukup untuk menjamin batas akhir lookup.
Flag ini juga memiliki cakupan yang presisi: ia membatasi resolusi relatif terhadap directory descriptor yang diberikan. Ia tidak membuat root proses baru dan tidak mengubah semantik pathname bagi system call lain.
Perilaku Linux saat ini juga mencegah magic-link resolution ketika RESOLVE_BENEATH dipakai, tetapi interface terdokumentasi tidak menjamin bahwa implikasi itu akan selalu berlaku. Kode yang memang harus menolak magic link sebaiknya meminta RESOLVE_NO_MAGICLINKS secara eksplisit.
Kondisi keamanan idealnya diwakili oleh flag yang secara langsung menyatakan kondisi tersebut, bukan mengandalkan efek samping implementasi flag lain.
RESOLVE_IN_ROOT mengubah semantik path absolut untuk satu operasi
RESOLVE_IN_ROOT memberi batas yang berbeda. Selama resolusi berlangsung, direktori yang dirujuk dirfd diperlakukan sebagai root.
Path absolut diinterpretasikan relatif terhadap direktori itu, bukan root proses. Symbolic link absolut yang ditemui juga diproses relatif terhadap root yang diberikan. Komponen .. pada root tersebut tidak dapat naik lebih jauh.
Efeknya mirip root sementara untuk satu operasi pathname, bukan perubahan chroot() pada seluruh proses. Thread lain dan pemanggilan berikutnya tetap memakai konteks root yang sudah ada.
Ini berguna untuk kode yang membutuhkan virtualisasi path per operasi. Namun RESOLVE_IN_ROOT bukan sandbox umum. Ia hanya membatasi resolusi pathname pada pemanggilan tersebut. Capability proses, descriptor yang sudah terbuka, system call lain, akses jaringan, dan namespace lain tetap merupakan permukaan kontrol yang berbeda.
RESOLVE_BENEATH dan RESOLVE_IN_ROOT menyatakan kebijakan yang berhubungan tetapi tidak sama. Yang pertama menolak path yang keluar dari direktori awal. Yang kedua membuat direktori awal memiliki semantik seperti root selama lookup, termasuk untuk path absolut dan symbolic link absolut.
Kebijakan symbolic link berlaku di tingkat komponen
O_NOFOLLOW sering dipakai untuk perlindungan terhadap symbolic link, tetapi cakupannya hanya komponen terakhir pathname. Symbolic link pada komponen tengah masih dapat ikut dalam resolusi.
RESOLVE_NO_SYMLINKS berlaku pada seluruh pathname walk. Jika resolusi harus melewati symbolic link pada komponen mana pun, operasi gagal. Flag ini juga menyiratkan larangan terhadap magic link.
Aturan yang lebih ketat ini cocok ketika kontrak interface memang melarang symbolic link sama sekali, tetapi containment dan larangan link bukan hal yang sama. Sebuah symbolic link dapat tetap menunjuk ke lokasi di dalam tree yang diizinkan.
Karena itu ada dua pertanyaan terpisah: apakah link diperbolehkan, dan apakah traversal boleh keluar dari tree tertentu. RESOLVE_NO_SYMLINKS menjawab pertanyaan pertama. RESOLVE_BENEATH atau RESOLVE_IN_ROOT membantu menjawab pertanyaan kedua.
Perbedaan ini penting bagi kompatibilitas karena layout sistem dan aplikasi sering menggunakan symbolic link. Kebijakan no-link menyeluruh dapat membuat perubahan layout namespace yang sebenarnya aman berubah menjadi error aplikasi.
Batas mount merupakan dimensi yang berbeda
RESOLVE_NO_XDEV mencegah traversal pathname melintasi mount point, termasuk bind mount. Dengan flag ini, lookup dapat dipertahankan pada mount yang sama dengan titik awalnya.
Batas directory tree dan batas mount tidak identik. Sebuah mount dapat berada di bawah direktori yang diizinkan, sehingga path masih secara struktural berada di bawah dirfd tetapi sudah berpindah ke filesystem lain. Sebaliknya, suatu kebijakan mungkin mengizinkan subtree hasil mount sambil tetap melarang traversal ke atas direktori awal.
Memisahkan kebijakan mount ke flag tersendiri membuat perbedaan tersebut menjadi eksplisit.
Pembatasan ini juga memiliki konsekuensi operasional. Bind mount dan subtree hasil mount adalah bagian normal dari banyak layout Linux. Mengaktifkan RESOLVE_NO_XDEV tanpa kebutuhan nyata dapat menolak path yang sebenarnya masih sesuai dengan kebijakan containment direktori.
Tujuannya bukan selalu membuat pembatasan semaksimal mungkin, tetapi memastikan flag yang dipilih benar-benar sesuai dengan batas interface yang dibutuhkan.
Resolusi cache-only menciptakan batas retry
RESOLVE_CACHED mengubah sifat lain: pemanggilan hanya berhasil jika resolusi pathname dapat diselesaikan dari informasi lookup yang sudah ada di cache tanpa revalidasi atau I/O filesystem.
Jika syarat tersebut tidak terpenuhi, openat2() mengembalikan EAGAIN. Hasil ini tidak sama dengan file tidak ada atau akses ditolak. Artinya, pembatasan cache-only tidak dapat dipenuhi pada percobaan tersebut.
Ini mendukung arsitektur yang memisahkan fast path nonblocking atau berlatensi rendah dari fallback yang lebih lambat. Fallback adalah operasi baru dan kebijakannya tetap harus dipilih dengan sengaja. Retry tanpa RESOLVE_CACHED hanya menghapus syarat cache; pembatasan resolusi lain tetap dapat dipertahankan.
Menafsirkan EAGAIN sebagai kegagalan lookup permanen akan mengubah semantik interface. Sebaliknya, menjadikannya alasan untuk mencoba ulang tanpa semua batas juga dapat memperlebar kebijakan pathname secara diam-diam.
Kegagalan dapat berarti kondisi keamanan belum dapat dibuktikan
openat2() dapat mengembalikan EAGAIN bersama RESOLVE_BENEATH atau RESOLVE_IN_ROOT ketika kernel tidak dapat memastikan secara aman bahwa traversal .. tetap berada di dalam batas karena aktivitas konkuren. Caller dapat mencoba ulang operasi tersebut.
Ini penting karena kernel tidak perlu mengubah ketidakpastian menjadi keberhasilan. Jika invarian yang diminta tidak dapat dibuktikan pada percobaan itu, kegagalan mempertahankan batas keamanan.
EXDEV dapat menunjukkan bahwa aturan containment atau no-cross-mount dilanggar. ELOOP dapat menunjukkan traversal symbolic link atau magic link yang dilarang. Error ini merepresentasikan hasil kebijakan resolusi selain error biasa saat membuka file.
Memetakan semua error tersebut menjadi sekadar “not found” dapat menghilangkan informasi penting untuk audit, perilaku API, atau keputusan fallback. Namun mengekspos detail mentah filesystem ke caller yang tidak tepercaya juga dapat membocorkan informasi. Translasi error tetap merupakan keputusan interface yang terpisah dari enforcement.
Struktur yang dapat diperluas merupakan bagian dari kontrak
open_how memakai mekanisme versioning berbasis ukuran. Caller mengirim pointer ke struktur sekaligus ukurannya, sehingga interface kernel dapat menambah field di masa depan.
Struktur sebaiknya diinisialisasi ke nol. Nilai nol pada field ekstensi mempertahankan perilaku seolah field tersebut belum ada, sedangkan byte nonzero pada field baru dapat memicu error pada kernel dengan tingkat dukungan berbeda.
openat2() juga menolak flag yang tidak dikenal atau saling bertentangan, bukan diam-diam mengabaikannya seperti sebagian interface lama. Kekakuan ini penting ketika flag membawa kebijakan keamanan: menerima pembatasan yang sebenarnya tidak didukung akan menciptakan jarak antara kebijakan yang diminta dan yang benar-benar diterapkan.
Dukungan versi kernel tetap menjadi kondisi deployment. openat2() ditambahkan pada Linux 5.6 dan fitur resolusi individual memiliki riwayat ketersediaannya sendiri. Software yang mendukung kernel lebih lama membutuhkan fallback yang terdefinisi atau mode penolakan yang jelas. Fallback yang melakukan open dengan kebijakan lebih lemah tidak identik secara semantik hanya karena sama-sama mengembalikan file descriptor.
Descriptor adalah hasil tahan lama dari satu constrained lookup
Batas utama pada openat2() berada antara teks pathname dan identitas objek. Pathname diresolusikan melalui namespace yang dapat berubah; hasil suksesnya adalah descriptor yang mengacu ke objek yang dipilih oleh resolusi dengan batas tersebut.
Resolution flag memungkinkan caller menyatakan kondisi yang harus dipenuhi saat kernel memilih objek. Flag tersebut tidak menjadikan string path sebagai identifier objek yang stabil dan tidak membuat lookup di masa depan otomatis mewarisi kebijakan yang sama.
Untuk interface yang menerima path dari sumber kurang tepercaya, bentuk analisis race pun berubah. Pertanyaan utamanya bukan lagi apakah preflight check melihat path yang aman, melainkan apakah operasi yang menghasilkan descriptor benar-benar menerapkan batas traversal yang diperlukan saat menyelesaikan namespace yang hidup.