File descriptor yang dibuat tanpa status close-on-exec dapat ikut terbawa ke program baru selama celah konkurensi yang sangat sempit. Pada proses Linux multithread, mengatur FD_CLOEXEC lewat pemanggilan fcntl() setelah descriptor dibuat meninggalkan interval antara pembuatan descriptor dan pembaruan flag tersebut.

O_CLOEXEC menghilangkan operasi dua tahap itu. Kernel membuat descriptor dengan flag close-on-exec yang sudah aktif sejak awal, sehingga thread lain tidak dapat melihat kondisi antara ketika descriptor sudah ada tetapi masih bisa diwariskan melalui execve() yang berhasil.

Status descriptor melewati fork dan bertemu exec

Proses yang dibuat dengan fork() mewarisi salinan file descriptor milik parent. Descriptor tersebut tetap mengacu ke open file description yang sama seperti descriptor pasangannya pada parent.

execve() yang berhasil mengganti image proses, tetapi tidak otomatis menutup semua descriptor. Descriptor yang memiliki FD_CLOEXEC akan ditutup sebagai bagian dari eksekusi yang berhasil. Descriptor tanpa flag tersebut tetap terbuka.

Karena itu, pewarisan descriptor berada di batas antara dua mekanisme: fork() menyalin tabel descriptor, sedangkan execve() menerapkan status close-on-exec saat mengganti image proses.

Pemanggilan fcntl setelahnya meninggalkan celah

Pola dua langkah lama membuat descriptor terlebih dahulu lalu menandainya sesudah itu:

int fd = open(path, O_RDONLY);
if (fd == -1)
    return -1;

int flags = fcntl(fd, F_GETFD);
if (flags == -1)
    return -1;

if (fcntl(fd, F_SETFD, flags | FD_CLOEXEC) == -1)
    return -1;

Pada proses single-thread dengan perilaku signal yang terkendali, celah ini mungkin mudah dibatasi. Pada proses multithread, thread lain dapat menjalankan jalur pembuatan proses selama interval tersebut.

Urutan yang mungkin terjadi:

  1. Thread A memanggil open() dan menerima descriptor tanpa FD_CLOEXEC.
  2. Thread B memanggil fork().
  3. Child dari thread B memanggil execve().
  4. Thread A baru mengatur FD_CLOEXEC.

Pembaruan flag pada langkah 4 tidak dapat mengubah tabel descriptor yang sudah disalin ke child. Jika child mencapai execve() yang berhasil sebelum menerima status close-on-exec yang setara, program baru akan tetap membawa descriptor tersebut.

Kebocoran ini bukan hanya soal angka descriptor. Descriptor yang terwariskan dapat mempertahankan akses ke file, socket, ujung pipe, device, atau objek lain yang didukung kernel dan sebenarnya tidak pernah dimaksudkan untuk dimiliki program yang dijalankan.

O_CLOEXEC memindahkan flag ke saat pembuatan descriptor

open() menerima O_CLOEXEC sebagai file creation flag:

int fd = open(path, O_RDONLY | O_CLOEXEC);

Descriptor yang dikembalikan sudah memiliki FD_CLOEXEC sejak saat pertama terlihat oleh proses pemanggil. Tidak ada lagi interval userspace terpisah antara alokasi descriptor dan status close-on-exec.

Sifat atomiknya hanya mencakup pembuatan descriptor dan inisialisasi flag descriptor. Hal ini tidak membuat operasi I/O berikutnya menjadi atomik dan tidak mengubah file status flag yang tersimpan pada open file description.

Perbedaan ini terlihat pada descriptor hasil duplikasi. Status file seperti O_APPEND dimiliki oleh open file description yang dipakai bersama, sedangkan FD_CLOEXEC adalah flag per descriptor. Dua descriptor dapat berbagi offset dan status file, tetapi memiliki status close-on-exec yang berbeda.

API yang menghasilkan descriptor memerlukan sifat yang sama

Race ini tidak khusus untuk open(). Operasi apa pun yang membuat descriptor lalu mengandalkan fcntl(F_SETFD) terpisah dapat membuka celah serupa.

Linux menyediakan varian close-on-exec atomik pada berbagai interface pembuat descriptor. Contohnya pipe2() dengan O_CLOEXEC, dup3() dengan O_CLOEXEC, accept4() dengan SOCK_CLOEXEC, eventfd() dengan EFD_CLOEXEC, dan F_DUPFD_CLOEXEC untuk menduplikasi descriptor.

Nama flagnya berbeda karena setiap API memiliki namespace flag yang berbeda, tetapi batasnya sama: status close-on-exec sudah ditetapkan sebelum descriptor baru dikembalikan ke userspace.

exec yang berhasil menjadi batas penutupan

FD_CLOEXEC tidak menutup descriptor ketika fork() terjadi. Child pada awalnya tetap mewarisi descriptor beserta flag descriptor tersebut. Penutupan baru terjadi saat execve() berhasil.

Jika execve() gagal, image proses tidak diganti dan descriptor tetap terbuka. Kode yang menangani kegagalan eksekusi masih memiliki descriptor tersebut sampai menutupnya lewat jalur lain.

Perilaku ini penting untuk error path yang tetap berjalan setelah percobaan eksekusi gagal. Close-on-exec bukan mekanisme umum untuk mengatur lifetime resource; ia adalah aturan transisi khusus yang melekat pada eksekusi program yang berhasil.

Race ini adalah cacat pada batas resource

Pewarisan descriptor yang tidak disengaja dapat membuat resource tetap hidup setelah pemilik semula melepasnya. Ujung pipe yang bocor dapat menunda terdeteksinya EOF. Socket yang bocor dapat memperpanjang umur koneksi. File descriptor yang bocor dapat mempertahankan akses ke objek bahkan setelah permission pathname atau struktur direktori berubah.

Pembuatan descriptor dengan close-on-exec atomik mencegah celah pewarisan sejak awal, bukan mencoba memperbaikinya setelah descriptor dibuat. Invarian penting ditetapkan pada saat descriptor masuk ke tabel proses, sebelum pembuatan proses secara konkuren sempat menyalin status tersebut.