Setiap kesalahan program biasanya punya akibat yang bisa dijelaskan: sintaks salah ditolak compiler, pembagian dengan nol pada tipe floating-point menghasilkan inf, dan exception bisa tertangkap. Kelas kesalahan ketiga tidak berperilaku sebaik itu. Standar C++ menyebutnya undefined behavior (UB): program yang melakukan sesuatu yang tidak diatur standar, sehingga standar tidak memberi jaminan apa pun atas hasilnya. Section Keamanan dan Undefined Behavior dibuka dengan konsep ini karena hampir semua celah keamanan khas C++ — akses di luar batas, use-after-free, integer overflow — pada dasarnya adalah bentuk UB yang dipakai menembus program.
Apa Itu Undefined Behavior #
Undefined behavior adalah kontrak negatif dari standar: bila program memasuki kondisi tertentu, standar tidak menjanjikan apa pun — bukan crash, bukan pesan error, bukan nilai tertentu. Boleh jadi program terlihat berjalan normal, boleh jadi menghasilkan angka yang tidak masuk akal, boleh jadi membuka jalan bagi penyerang untuk mengeksekusi kode miliknya. Compiler pun berhak mengasumsikan kondisi itu tidak pernah terjadi, karena standar tidak mengikat apa pun ketika asumsi itu meleset.
Perbandingan dengan bahasa lain membuat kontrak ini lebih jelas. Pada Java, membaca indeks di luar array melempar ArrayIndexOutOfBoundsException setiap kali terjadi; kontraknya tegas dan seragam. Pada C++, v[i] dengan i di luar batas adalah UB: tidak ada exception, tidak ada pemeriksaan, dan hasilnya sepenuhnya bergantung pada keadaan memori saat itu. Kontrak longgar ini adalah salah satu sumber kecepatan C++ sekaligus sumber tanggung jawab yang jatuh ke tangan kita sebagai penulis kode.
Mengapa Standar Membiarkan UB #
Alasan utamanya adalah optimizer. Bila standar menjamin “penjumlahan int yang meluap pasti dibungkus”, compiler wajib mengeluarkan instruksi penjumlahan mentah plus pemeriksaan bungkus di hampir setiap operasi aritmetika, padahal mayoritas perhitungan di dunia nyata tidak pernah meluap. Dengan menyatakan “meluap tidak terdefinisi”, standar memberi compiler izin untuk mengasumsikan rentang nilai tertentu dan menyederhanakan kode di sekitarnya.
Asumsi itu bisa terlihat langsung. Fungsi cek di bawah mengembalikan apakah x + 100 masih lebih besar dari x — pemeriksaan yang sederhana, tetapi mengandung UB bila x + 100 meluap:
#include <cstdio>
#include <cstdlib>
bool cek(int x) { return x + 100 > x; } // x + 100 bisa meluap: UB
int main(int argc, char** argv) {
int x = std::atoi(argv[1]);
std::printf("cek(%d) = %d\n", x, cek(x));
}Kita kompilasi dua kali dengan nilai 2147483647 (int maksimum) sebagai argumen. Hasil nyata di sandbox penulis:
$ g++ -std=c++23 -O2 cek-assumsi.cpp -o cek && ./cek 2147483647
cek(2147483647) = 1
$ g++ -std=c++23 -O2 -fwrapv cek-assumsi.cpp -o cek-w && ./cek-w 2147483647
cek(2147483647) = 0Tanpa flag tambahan, compiler melipat x + 100 > x menjadi true — dia berhak melakukannya karena x + 100 tidak boleh meluap menurut asumsi UB. Dengan -fwrapv (ekstensi GCC yang menjamin luapan int dibungkus), bungkusnya dihitung sungguhan dan hasilnya 0. Kedua jawaban sama-sama “sah” menurut standar — inilah wajah UB yang paling mengecoh: bukan crash, melainkan kode yang diam-diam berarti lain dari yang kita tulis.
Kategori UB yang Paling Sering #
UB tidak mungkin dihafal satu per satu, tetapi kelompok besarnya bisa dipetakan. Empat kelompok ini mencakup hampir semua kasus yang kita temui:
| Kategori | Contoh klasik | Mengapa berbahaya |
|---|---|---|
| Akses memori ilegal | indeks di luar batas, use-after-free, null dereference | pintu utama eksploitasi memori |
| Aritmetika tak terdefinisi | luapan int, shift melebihi lebar tipe, pembagian bulat dengan nol | hasil perhitungan ukuran dan indeks bisa dipalsukan |
| Data belum siap | membaca variable uninitialized, mengakses objek di luar masa hidupnya | nilai sampah sering bisa dikendalikan dari luar |
| Eksekusi tidak teratur | data race antar thread, modifikasi objek const, evaluasi tak berurutan dalam satu ekspresi | perilaku bisa berubah antar build tanpa jejak |
Kelompok pertama dan kedua akan kita bedah satu per satu sepanjang section ini, karena keduanya paling sering muncul di laporan kerentanan nyata.
Mendeteksi UB dengan UBSan #
Recap singkat dari Section Debugging dan Code Quality: flag -fsanitize=undefined (UBSan) menyisipkan pemeriksaan runtime untuk banyak kasus UB aritmetika dan perilaku, lalu melaporkannya ke stderr dengan format berkas:baris:kolom: runtime error: .... Tiga program kecil yang sengaja bermasalah — luapan int, pembagian dengan nol, dan shift 40 bit pada int 32-bit — menghasilkan laporan nyata seperti ini:
ub-overflow.cpp:5:9: runtime error: signed integer overflow: 2147483647 + 1 cannot be represented in type 'int'
ub-divzero.cpp:4:16: runtime error: division by zero
ub-shift.cpp:4:15: runtime error: shift exponent 40 is too large for 32-bit type 'int'Perhatikan bahwa laporan ini tidak memuat alamat memori, sehingga teksnya konsisten antar mesin. Secara bawaan UBSan bersifat recoverable: program melanjutkan eksekusi setelah laporan (kasus pembagian nol kemudian tetap dihentikan sistem oleh sinyal SIGFPE). Untuk pipeline uji, gabungkan UBSan dengan -fno-sanitize-recover agar laporan pertama langsung menggagalkan build.
Merasakan Batasnya lewat Program yang Aman #
Program berikut tidak mengandung satu baris UB pun, tetapi menunjukkan garis batasnya dengan jelas: unsigned memang boleh melingkar (bungkusnya terdefinisi rapi), sedangkan untuk int kita harus menjaga perhitungan agar tidak pernah sampai ke luapan.
// asumsi-aman.cpp — unsigned melingkar terdefinisi; int dijaga tanpa menghitung melampaui batas.
#include <iostream>
#include <limits>
int main() {
unsigned u = std::numeric_limits<unsigned>::max() - 50u; // dekat batas atas
std::cout << "u awal : " << u << "\n";
std::cout << "u + 100 > u : " << std::boolalpha << (u + 100u > u) << "\n";
u = u + 100u; // melingkar ke angka kecil: terdefinisi RAPI untuk unsigned
std::cout << "u sesudah + 100 : " << u << "\n";
int i = std::numeric_limits<int>::max(); // dekat batas atas
std::cout << "i maksimum : " << i << "\n";
bool muat = i < std::numeric_limits<int>::max(); // cek TANPA menghitung i + 1
std::cout << "i + 1 masih aman? : " << muat << "\n";
return 0;
}Output yang Dihasilkan:
u awal : 4294967245
u + 100 > u : false
u sesudah + 100 : 49
i maksimum : 2147483647
i + 1 masih aman? : falseBedah baris-per-baris:
- Baris 7 (
unsigned u = ...max() - 50u;) —udiletakkan 50 di bawah batas atasunsigned(4294967295), posisi yang paling rawan untuk menunjukkan mekanisme bungkus. - Baris 9 (
u + 100u > u) — penjumlahannya melampaui batas, tetapi untukunsignedhasilnya terdefinisi: dibungkus modulo 2^32 menjadi 49, sehingga49 > 4294967245bernilaifalse. Untukint, pemeriksaan bentuk ini justru bisa dilipat compiler menjaditruekarena UB — persis yang kita lihat di demo sebelumnya. - Baris 10 (
u = u + 100u;) — bungkusnya ditulis eksplisit: nilaiukini 49. Ini perilaku yang dijamin standar, bukan kebetulan implementasi. - Baris 14–15 (
bool muat = i < ...max();) — untukint, kita tidak pernah menghitungi + 1. Pemeriksaan dilakukan dengan membandingkaniterhadapstd::numeric_limits<int>::max()terlebih dahulu, sehingga hasilnya valid bahkan ketikaiberada tepat di batas.
UB memberi compiler ruang untuk mempercepat program, dan tugas kita adalah tidak pernah melewati garis itu. Garis berikutnya lebih halus lagi: perilaku yang justru terdefinisi dengan baik, tetapi keputusannya diserahkan pada implementasi — sizeof, signedness char, urutan byte — dan itulah topik chapter berikutnya.
Referensi: cppreference — Undefined behavior , cppreference — Order of evaluation .