Crash C yang hanya muncul setelah optimasi compiler atau saat trafik backend meningkat sering menandakan undefined behavior (UB). Salah satu sumbernya adalah aritmetika pointer yang dimulai dari alamat invalid. Masalah ini dapat tampak seperti kegagalan acak: worker mati dengan SIGSEGV, proses restart oleh supervisor, atau request tertentu gagal tanpa pola yang jelas.

Ekspresi pemantik &((int*)-8)[3], yang dibahas dalam tulisan Getting Silly with C, mengilustrasikan jebakan tersebut. Secara intuitif, seseorang mungkin menghitung nilai alamatnya secara numerik. Namun, dalam C, cast integer menjadi pointer tidak menciptakan objek atau memori yang dapat diakses. Aritmetika pointer hanya sah pada elemen-elemen dalam objek atau array yang valid, termasuk tepat satu posisi setelah elemen terakhir untuk tujuan perbandingan tertentu. Pointer hasil dari (int*)-8 tidak memenuhi syarat itu.

Mengapa crash baru muncul setelah optimasi atau lonjakan trafik?

Kode dengan UB tidak wajib gagal pada setiap eksekusi. Pada build tanpa optimasi, susunan stack, register, dan urutan instruksi dapat kebetulan menyamarkan bug. Ketika compiler mengaktifkan optimasi, compiler boleh mengasumsikan program tidak melakukan operasi yang dilarang oleh bahasa. Asumsi itu dapat mengubah atau menghapus pemeriksaan yang tampaknya relevan bagi programmer.

Lonjakan trafik juga bukan penyebab langsung aritmetika pointer invalid, tetapi dapat meningkatkan peluang jalur bermasalah dijalankan, misalnya ketika:

  • parser menerima payload lebih bervariasi atau terpotong;
  • cache menyimpan nilai sentinel yang tidak diharapkan;
  • kode batching memakai offset dari request sebelumnya;
  • race condition membuat metadata buffer dan buffer aktual tidak lagi konsisten;
  • alokator menggunakan tata letak heap berbeda ketika konkurensi meningkat.

Karena itu, jangan menyimpulkan bahwa crash adalah masalah kapasitas hanya karena terjadi saat trafik tinggi. Perlakukan trafik sebagai kondisi yang mengekspos cacat memori yang sudah ada.

Studi kasus: native extension memakai sentinel sebagai alamat

Misalkan layanan backend menerima frame biner melalui runtime tingkat tinggi, lalu native extension C memproses frame tersebut. Protokol lama menggunakan nilai -8 untuk menyatakan bahwa field opsional tidak ada. Implementasi yang buruk menyimpan nilai itu dalam tipe pointer atau langsung menggunakannya sebagai basis aritmetika.

Contoh kode yang buruk

#include <stdint.h>
#include <stddef.h>

typedef struct {
    intptr_t field_pos;  /* -8 berarti field tidak ada */
} Record;

int read_field_bad(const unsigned char *buffer, const Record *record) {
    const int *base = (const int *)record->field_pos;

    /* Jika field_pos == -8, ini membentuk pointer dari alamat invalid. */
    const int *value = &base[3];
    return *value;
}

Kode tersebut mempunyai beberapa masalah. Nilai field_pos bukan alamat buffer, melainkan offset atau sentinel yang disamarkan sebagai alamat. Selain itu, base[3] berarti aritmetika pointer pada objek array int yang seharusnya valid. Cast dari integer tidak membuat array tersebut ada. Mengambil alamat dengan & tidak memperbaiki operasi yang sudah tidak sah.

Bahkan apabila alamat hasil hitungan terlihat kecil, tidak nol, atau konsisten pada satu mesin, program tetap tidak portable dan perilakunya tidak terdefinisi. Compiler, arsitektur CPU, sistem operasi, serta mode optimasi dapat menghasilkan gejala berbeda.

Perbaikan: representasikan offset dan sentinel secara eksplisit

Jangan gunakan pointer untuk menyimpan status seperti “tidak ada”, dan jangan ubah integer menjadi pointer sebelum validasi. Simpan posisi sebagai integer bertanda yang dapat memuat sentinel, validasi nilainya terhadap panjang buffer, lalu hitung alamat hanya setelah semua syarat terpenuhi.

#include <stdint.h>
#include <stddef.h>
#include <string.h>

typedef struct {
    int64_t field_offset; /* -1 berarti field tidak ada */
} Record;

enum {
    READ_OK = 0,
    READ_ABSENT = 1,
    READ_INVALID = -1
};

int read_field(const unsigned char *buffer, size_t buffer_len,
               const Record *record, int *out) {
    const size_t width = sizeof(*out);
    int64_t raw_offset = record->field_offset;

    if (raw_offset == -1) {
        return READ_ABSENT;
    }

    if (raw_offset < 0) {
        return READ_INVALID;
    }

    size_t offset = (size_t)raw_offset;

    /* Bentuk ini menghindari overflow pada offset + width. */
    if (offset > buffer_len || width > buffer_len - offset) {
        return READ_INVALID;
    }

    /* memcpy menghindari asumsi alignment dan strict aliasing. */
    memcpy(out, buffer + offset, width);
    return READ_OK;
}

Poin penting pada perbaikan tersebut adalah:

  • Sentinel memiliki representasi eksplisit. Nilai -1 diperiksa sebelum dikonversi menjadi size_t.
  • Offset tetap berupa integer. Pointer hanya dibentuk sebagai buffer + offset setelah buffer, offset, dan lebar data tervalidasi.
  • Pemeriksaan batas menghindari overflow. Pola width > buffer_len - offset lebih aman daripada memeriksa offset + width > buffer_len.
  • Pembacaan biner memakai memcpy. Cast langsung dari unsigned char * ke int * dapat menambah masalah alignment dan aturan aliasing. Perhatikan pula endianness protokol secara terpisah bila field berasal dari jaringan.

Jika domain offset memang tidak memerlukan nilai negatif, gunakan size_t sejak awal dan representasikan “tidak ada” dengan flag terpisah, tipe status, atau struktur yang menyatakan keberadaan field. Sentinel integer sebaiknya dibatasi pada satu lapisan parsing, bukan diteruskan ke seluruh kode.

Memperkecil reproduksi crash

Sebelum mengubah banyak bagian sistem, kecilkan kasusnya hingga menjadi input dan jalur fungsi yang deterministik. Tujuannya bukan hanya membuat crash terjadi, tetapi menemukan kontrak data yang dilanggar: offset negatif, offset di luar buffer, panjang frame yang salah, atau metadata yang berasal dari request berbeda.

  1. Simpan payload mentah, panjang payload, jenis pesan, dan versi protokol dari request yang memicu crash. Hindari menyimpan data sensitif tanpa redaksi.
  2. Tambahkan logging terstruktur pada batas native extension: panjang buffer, nilai offset mentah, status sentinel, dan nama operasi. Jangan mencetak pointer sebagai pengganti offset.
  3. Buat test C kecil yang memanggil parser atau fungsi native dengan payload hasil tangkapan tersebut.
  4. Uji variasi batas: buffer kosong, offset -1, offset negatif lain, offset sama dengan panjang buffer, serta field yang terpotong satu byte.
  5. Jika crash tergantung konkurensi, ulangi test dengan data yang sama sambil menjalankan beberapa worker, tetapi tetap pisahkan bug memori dari dugaan race condition dengan sanitizer dan pemeriksaan sinkronisasi.

Tambahkan assertion untuk invariant internal pada build pengujian. Contohnya, descriptor yang sudah lolos parser tidak boleh memiliki offset negatif selain sentinel yang didokumentasikan. Assertion bukan pengganti validasi input produksi, tetapi berguna untuk menemukan titik pertama ketika invariant rusak.

Membaca core dump dan backtrace GDB

Untuk crash produksi, core dump memungkinkan pemeriksaan proses pada saat sinyal terjadi. Pastikan binary dan modul native memiliki simbol debug yang cocok dengan artefak yang berjalan. Simpan pula informasi build seperti commit, opsi compiler, dan checksum binary; backtrace dari binary yang berbeda dapat menyesatkan.

Setelah core dump tersedia melalui mekanisme sistem operasi atau pengelola layanan, buka dengan GDB:

gdb /path/to/backend-binary /path/to/core
(gdb) thread apply all bt full
(gdb) frame 0
(gdb) info registers
(gdb) info locals
(gdb) disassemble /m

Fokus awalnya adalah frame teratas yang berada di kode aplikasi atau native extension, bukan semata fungsi libc yang menerima sinyal. Dari sana, periksa:

  • nilai pointer basis, offset mentah, dan panjang buffer pada frame terkait;
  • apakah alamat yang diakses sangat kecil, misalnya dekat nol, yang sering konsisten dengan sentinel yang salah diperlakukan sebagai alamat;
  • rantai pemanggil menggunakan bt full, khususnya titik ketika descriptor atau metadata dibuat;
  • thread lain dengan thread apply all bt full bila ada kemungkinan buffer dibebaskan atau dimodifikasi secara bersamaan.

Alamat kecil bukan bukti tunggal. Use-after-free dapat menghasilkan alamat yang tampak masuk akal, sedangkan korupsi memori dapat membuat nilai offset berubah jauh dari sumber bug. Gunakan backtrace untuk melacak asal data, lalu konfirmasi dengan reproduksi dan sanitizer.

Audit jalur offset dan sentinel

Dalam code review, cari semua transisi antara tiga konsep yang sering tercampur: indeks, offset byte, dan pointer. Audit khususnya pada parser, serializer, cache metadata, callback FFI, dan kode yang melakukan cast seperti (T *)value atau (uintptr_t)ptr.

  • Pastikan unit offset konsisten: byte, elemen, atau record. Offset byte tidak boleh langsung ditambahkan ke int * karena aritmetika pointer dikalikan ukuran elemen.
  • Pastikan sentinel dicek sebelum konversi signed ke unsigned. Nilai -1 yang dikonversi ke size_t menjadi nilai sangat besar.
  • Validasi offset terhadap buffer yang benar. Panjang buffer lama atau buffer lain tidak membuktikan bahwa pointer saat ini valid.
  • Jangan menyimpan pointer ke buffer yang masa hidupnya lebih pendek daripada descriptor. Tetapkan kepemilikan dan masa hidup buffer secara jelas.
  • Jangan mengandalkan pemeriksaan seperti if (ptr) setelah pointer dibentuk dari offset invalid. Pointer non-null tetap dapat invalid.

Jalankan AddressSanitizer dan UndefinedBehaviorSanitizer

Sanitizer adalah cara paling efektif untuk mengubah crash sporadis menjadi laporan yang menunjukkan lokasi akses bermasalah. Untuk build pengujian atau staging, kompilasi dan link native extension beserta kode C/C++ yang relevan dengan flag berikut:

cc -g -O1 -fno-omit-frame-pointer \
   -fsanitize=address,undefined \
   -c parser.c -o parser.o

cc -fsanitize=address,undefined parser.o -o parser_test

AddressSanitizer (ASan) terutama membantu mendeteksi akses di luar batas, use-after-free, dan use-after-return pada konfigurasi yang didukung. UndefinedBehaviorSanitizer (UBSan) membantu menangkap sejumlah kategori UB, seperti overflow integer bertanda dan operasi invalid tertentu. Keduanya saling melengkapi, tetapi tidak menjamin mendeteksi seluruh pelanggaran aturan aritmetika pointer, terutama bila jalur akses belum dieksekusi atau optimasi mengubah bentuk kode.

Praktik yang berguna untuk native extension:

  • Sanitasi harus diterapkan saat kompilasi dan linking; mencampur artefak yang tidak kompatibel dapat menghasilkan laporan tidak lengkap atau kegagalan runtime.
  • Jalankan corpus payload produksi yang sudah disanitasi, fuzz test parser, dan test integrasi yang memuat extension melalui runtime backend sebenarnya.
  • Gunakan build terpisah untuk staging atau CI. Overhead memori dan waktu ASan biasanya tidak cocok untuk seluruh trafik produksi.
  • Jangan menekan laporan sanitizer sebelum akar masalah dipahami. Error pertama sering menjadi penyebab utama bagi error lanjutan.

Checklist penanganan insiden

  1. Ambil core dump dan cocokkan dengan binary serta simbol debug yang tepat.
  2. Identifikasi frame native pertama dan catat pointer, offset, panjang buffer, serta input pemicu.
  3. Telusuri semua jalur yang membuat atau mengubah offset dan sentinel.
  4. Ganti representasi pointer palsu dengan offset integer tervalidasi atau status eksplisit.
  5. Periksa batas buffer sebelum membentuk alamat dan hindari overflow saat memeriksa panjang.
  6. Gunakan memcpy untuk pembacaan biner bila alignment, aliasing, atau format data tidak menjamin cast pointer aman.
  7. Jalankan test reproduksi dengan ASan dan UBSan, lalu pertahankan kasusnya sebagai regression test.

Intinya, alamat bukan sekadar angka. Dalam C, validitas pointer bergantung pada objek, batas array, masa hidup, dan aturan bahasa. Dengan menjaga sentinel sebagai data, memvalidasi offset sebelum aritmetika alamat, serta memanfaatkan core dump dan sanitizer, crash sporadis pada backend dapat diubah menjadi bug yang dapat direproduksi dan diperbaiki.