Menu

Undefined Behavior di C: Artinya dan Kenapa Berbahaya

Undefined behavior adalah kode yang sama sekali tidak diatur oleh standar C, jadi apa pun bisa terjadi, termasuk optimizer menghapus pengecekanmu. Berikut penyebabnya, kenapa "di komputerku jalan" tidak membuktikan apa-apa, dan flag yang bisa menangkapnya.

Halaman ini dilengkapi editor yang bisa dijalankan. Edit, jalankan, dan lihat output-nya seketika.

"Undefined behavior" (perilaku tak terdefinisi) terdengar seperti istilah teknis untuk "tidak bisa ditebak". Artinya lebih keras daripada itu. Ketika standar C menyatakan sebuah konstruksi punya undefined behavior, artinya standar sama sekali tidak memberi persyaratan atas apa yang dilakukan program: tidak atas nilainya, tidak atas pernyataannya, tidak atas programnya.

Inilah bagian yang mengejutkan banyak orang: undefined behavior tidak terbatas pada baris yang bermasalah. Compiler boleh berasumsi hal itu tidak pernah terjadi, lalu menulis ulang kode di sekitarnya berdasarkan asumsi itu. Hasilnya bisa berupa program di mana sebuah pengecekan yang jelas-jelas kamu tulis tidak ada di dalam binary-nya.

Model Kontrak

Anggap standar C sebagai kontrak antara kamu dan compiler. Kamu berjanji tidak melakukan hal-hal tertentu; sebagai gantinya, compiler berjanji programmu berarti persis seperti yang tertulis.

Jangan mengakses indeks di luar array. Jangan membuat signed integer overflow. Jangan membaca nilai yang belum diinisialisasi. Jangan memakai pointer setelah dibebaskan. Jangan mengubah objek yang sama dua kali dalam satu ekspresi tanpa sequence point di antaranya.

Langgar satu pasal saja dan kesepakatannya batal, untuk seluruh program, bukan hanya baris itu. Tidak ada "cadangan yang masuk akal" dan tidak ada kewajiban untuk crash.

Ada tiga istilah berkaitan yang perlu dibedakan:

  • Undefined behavior: apa pun bisa terjadi. Akses di luar batas, signed overflow, use after free.
  • Unspecified behavior: salah satu dari beberapa hasil yang sah, dan compiler tidak wajib memberitahumu yang mana. Contohnya urutan evaluasi argumen fungsi.
  • Implementation-defined behavior: implementasinya yang memilih, dan wajib mendokumentasikan pilihannya. Misalnya apakah char bersifat signed, atau seberapa besar sebuah int.

Hanya yang pertama yang berbahaya dalam arti "optimizer menghapus kodeku".

Sumber-sumber Utama

Signed Integer Overflow

Aritmetika unsigned berputar (wrap), dan standar menyatakannya dengan jelas. Aritmetika signed tidak: melewati rentangnya adalah undefined.

Pengecekan if (a > INT_MAX - b) berjalan sepenuhnya di dalam rentang yang sah, dan itulah yang membuatnya menjadi uji overflow yang benar. Menulis if (a + b < 0) justru melakukan overflow lebih dulu lalu bertanya soal hasilnya, dan compiler, yang berhak berasumsi overflow itu tidak pernah terjadi, bisa saja menghapus pengecekannya.

Akses di Luar Batas

Membaca atau menulis di luar array adalah undefined, entah program crash atau tidak:

int arr[5] = {1, 2, 3, 4, 5};
int x = arr[5];        /* UB: index 5 does not exist */
arr[-1] = 0;           /* UB */
int *p = arr + 10;     /* UB even to compute this pointer */

Perhatikan baris terakhir: membuat pointer yang menunjuk lebih dari satu posisi setelah akhir array sudah undefined walaupun pointer itu tidak pernah di-dereference. Standar mengizinkan arr + 5 (satu posisi setelah akhir, untuk mengakhiri perulangan), tetapi tidak arr + 6.

Kelebihan yang kecil justru yang berbahaya. Biasanya tidak menyebabkan segfault; kelebihan itu diam-diam menimpa variabel di sebelahnya, dan jawaban yang salah muncul di tempat lain yang tidak berhubungan.

Membaca Nilai yang Belum Diinisialisasi

int x;
printf("%d\n", x);     /* UB: reading an indeterminate value */

int *p;
*p = 42;               /* UB: dereferencing an indeterminate pointer */

Kamu mungkin tergoda berpikir "isinya cuma sampah", tetapi bukan itu yang dikatakan standar, dan compiler memanfaatkan perbedaannya. GCC diketahui pernah menyimpulkan bahwa variabel yang dibaca sebelum diisi boleh berisi nilai apa pun yang ia mau, termasuk nilai yang membuat sebuah cabang hilang.

Dangling Pointer

int *p = malloc(sizeof *p);
free(p);
*p = 42;               /* UB: use after free */
free(p);               /* UB: double free */

int *q;
{
    int local = 10;
    q = &local;
}
printf("%d\n", *q);    /* UB: the object's lifetime ended */

Akibatnya saat program berjalan dibahas di halaman segmentation fault; intinya di sini, crash adalah hasil yang beruntung.

Specifier printf yang Salah

printf("%d\n", 3.14);        /* UB: %d with a double */
printf("%s\n", 42);          /* UB: %s with an int - usually crashes */
printf("%d %d\n", 1);        /* UB: fewer arguments than specifiers */
long n = 5;
printf("%d\n", n);           /* UB on systems where long is wider than int */

printf adalah fungsi variadik: fungsi ini membaca argumen sesuai format string dan tidak bisa mengeceknya. Ketidakcocokan membuatnya membaca jumlah byte yang salah dari tempat yang salah. Compile dengan -Wall dan compiler akan mengecek format string-nya untukmu. Ini salah satu peringatan paling berharga di antara semuanya.

Mengubah Objek Dua Kali dalam Satu Ekspresi

int i = 0;
i = i++ + ++i;             /* UB */
arr[i] = i++;              /* UB */
printf("%d %d\n", i++, i); /* UB */

Semua ini undefined, bukan sekadar "tergantung compiler". Teka-teki di buku teks yang menanyakan berapa hasil i = i++ + ++i tidak punya jawaban yang benar.

Strict Aliasing

Mengakses sebuah objek lewat pointer dengan tipe yang tidak kompatibel adalah undefined, dan yang satu ini mengejutkan bahkan programmer berpengalaman:

float f = 1.0f;
int *p = (int *)&f;
printf("%d\n", *p);        /* UB: reading a float through an int * */

Compiler berasumsi int * dan float * tidak pernah menunjuk ke memori yang sama, lalu menyusun ulang urutan operasi berdasarkan itu. Cara yang terdefinisi untuk menafsirkan ulang byte adalah memcpy (yang dioptimasi menjadi instruksi yang sama) atau union:

char * adalah pengecualiannya: kamu selalu boleh memeriksa byte objek apa pun lewat unsigned char *.

Kenapa "Di Komputerku Jalan" Tidak Membuktikan Apa-apa

Undefined behavior sering tampak berjalan dengan benar, dan justru itulah yang membuatnya berbahaya. Program berjalan benar selama pengembangan dan pengujian, lalu rusak ketika sesuatu yang sama sekali tidak berhubungan berubah:

  • Versi compiler baru dengan optimizer yang lebih pintar.
  • Beralih dari -O0 ke -O2 untuk build rilis.
  • Menambahkan fungsi yang tidak berhubungan, sehingga tata letak stack bergeser dan kelebihan tulis kini mengenai sesuatu yang penting.
  • Mesin lain, libc lain, sistem operasi lain.

Tampak berjalan bukanlah bukti kebenaran, karena standar tidak pernah menjanjikan apa-apa. Itu adalah bug yang sedang tidur, dan pemicunya biasanya build rilis.

Cara Optimizer Memanfaatkannya

Ini contoh yang biasanya mengakhiri perdebatan. Seorang programmer menulis pengecekan null:

void process(int *p) {
    int value = *p;              /* dereference */
    if (p == NULL) {             /* then check for null */
        return;
    }
    printf("%d\n", value * 2);
}

Urutannya salah, karena pengecekannya datang setelah dereference, tetapi pengecekannya tetap dijalankan, kan?

Tidak harus. Compiler bernalar begini: *p sudah di-dereference, jadi p tidak mungkin NULL (dereference NULL itu undefined, jadi di program mana pun yang perilakunya terdefinisi, nilainya bukan NULL), jadi p == NULL selalu salah, jadi seluruh badan if adalah kode mati dan boleh dihapus.

Fungsi hasil kompilasinya sama sekali tidak punya pengecekan null. Contoh nyata pola ini di kernel Linux menjadi CVE-2009-1897, di mana GCC menghapus persis pengecekan seperti itu dan mengubah kesalahan urutan yang tampak sepele menjadi celah keamanan yang bisa dieksploitasi.

Contoh kedua yang lebih kecil:

/* An overflow check that does not work */
int safe_add(int a, int b) {
    int sum = a + b;
    if (sum < a) {          /* "did it wrap?" */
        return -1;
    }
    return sum;
}

Untuk tipe signed, compiler boleh berasumsi a + b tidak overflow, dan dalam hal itu sum < a hanya mungkin terjadi saat b < 0. Dengan asumsi itu, pengecekannya menguji sesuatu yang bukan maksud penulisnya, dan dengan b >= 0 pengecekan itu bisa dioptimasi sampai hilang sepenuhnya. Versi yang benar mengecek lebih dulu:

Kedua pengujian tetap berada di dalam rentang yang bisa direpresentasikan, jadi overflow tidak pernah terjadi dan tidak ada yang bisa diasumsikan hilang oleh optimizer. (GCC dan clang juga menyediakan __builtin_add_overflow, yang melakukan ini dalam satu instruksi.)

Mendeteksinya

Mulai dari peringatan statis, karena gratis:

gcc -Wall -Wextra -Wpedantic program.c -o program

Itu menangkap ketidakcocokan format string, sebagian pembacaan nilai yang belum diinisialisasi, perbandingan yang mencurigakan, dan kode yang tidak terjangkau.

Lalu sanitizer, yang menginstrumentasi program dan melapor tepat saat pelanggaran terjadi:

gcc -g -fsanitize=undefined program.c -o program
./program
program.c:8:15: runtime error: signed integer overflow:
2147483647 + 1 cannot be represented in type 'int'

Gabungkan dengan AddressSanitizer untuk sisi memorinya:

gcc -g -Wall -Wextra -fsanitize=address,undefined program.c -o program

Bersama-sama, keduanya menangkap akses di luar batas, use-after-free, double free, signed overflow, shift yang tidak valid, pointer yang tidak sejajar (misaligned), dan dereference null, masing-masing dengan file, baris, dan stack trace. Kira-kira 2x lebih lambat, yang tidak berarti apa-apa selama pengembangan.

valgrind ./program tidak perlu compile ulang dan menangkap pembacaan nilai yang belum diinisialisasi serta error memori, walaupun tidak menangkap UB aritmetika. clang --analyze dan gcc -fanalyzer menemukan sebagian masalahnya tanpa menjalankan program sama sekali.

Aturan praktisnya: jalankan tes-tesmu di bawah sanitizer di CI. UB yang tampak berjalan di komputermu adalah persis yang ingin mereka bongkar.

Hidup Berdampingan dengannya

Kamu tidak bisa menghindari undefined behavior hanya dengan berhati-hati, karena semua orang pada akhirnya akan menulisnya. Yang berhasil adalah membuatnya terdengar keras:

  • Build dengan -Wall -Wextra sejak hari pertama, dan perbaiki setiap peringatan.
  • Jalankan tes di bawah -fsanitize=address,undefined.
  • Inisialisasi setiap variabel saat deklarasi, dan setiap pointer dengan NULL.
  • Cek indeks array terhadap panjangnya, dan buat perulangan dengan i < n.
  • Cek overflow sebelum aritmetikanya, dengan <limits.h>.
  • Isi pointer dengan NULL setelah membebaskannya.
  • Pakai tipe unsigned jika wraparound memang perilaku yang diinginkan, karena di sana perilakunya terdefinisi.
  • Pilih memcpy daripada cast pointer saat menafsirkan ulang byte.

Kecepatan C berasal dari compiler yang boleh berasumsi kamu menepati kontraknya. Itu pertukaran yang nyata, bukan cacat desain, dan alat-alat di atas mengembalikan sebagian besar keamanannya dengan biaya nol selama pengembangan.

Untuk wajah aturan-aturan ini saat program berjalan, lihat halaman segmentation fault; untuk kesalahan saat kompilasi yang biasanya muncul lebih dulu, lihat halaman error umum.

Pertanyaan yang sering diajukan

Apa itu undefined behavior di C?

Kode yang sama sekali tidak diberi persyaratan apa pun oleh standar C. Compiler bebas menghasilkan apa saja: crash, jawaban yang salah, kode yang tampak berjalan, atau kode yang cabang bermasalahnya dihapus sepenuhnya. Ini bukan "implementation-defined" atau "acak": ini kontrak yang kamu langgar, dan sesudahnya tidak ada yang dijanjikan.

Kenapa C punya undefined behavior, bukannya mendefinisikan semuanya?

Demi kecepatan dan portabilitas. Mewajibkan pengecekan batas pada setiap akses array akan menelan biaya performa yang sengaja dihindari C sejak awal; mendefinisikan signed overflow sebagai wraparound akan memaksa instruksi tambahan di hardware yang justru melakukan trap. Membiarkan kasus-kasus itu tidak terdefinisi memungkinkan compiler berasumsi semua itu tidak pernah terjadi, lalu mengoptimasi berdasarkan asumsi tersebut.

Apakah signed integer overflow termasuk undefined behavior di C?

Ya. INT_MAX + 1 tidak terdefinisi: hasilnya tidak dijamin berputar ke INT_MIN. Overflow unsigned berbeda: sepenuhnya terdefinisi dan berputar modulo 2^N. Karena itulah compiler boleh berasumsi x + 1 > x selalu benar untuk x yang signed, lalu menghapus pengecekan overflow yang ditulis dengan cara itu.

Bagaimana cara mendeteksi undefined behavior di program C?

Compile dengan -Wall -Wextra untuk menangkap apa yang bisa dilihat compiler secara statis, lalu jalankan tes dengan -fsanitize=address,undefined, yang melaporkan akses di luar batas, use-after-free, signed overflow, dan lainnya tepat saat terjadi, lengkap dengan nama file dan barisnya. valgrind menangkap kumpulan masalah serupa tanpa perlu compile ulang.

Ilustrasi bahasa pemrograman di Coddy

Belajar coding dengan Coddy

Mulai