Crash parser di backend C sering terlihat seperti masalah acak: proses mati mendadak, log tidak membantu, dan hanya request tertentu yang memicu segmentation fault. Dalam banyak kasus, akar masalahnya bukan hal "misterius", melainkan parser yang terlalu percaya pada field panjang, header, atau struktur input yang sebenarnya belum tervalidasi dengan benar.

Studi kasus ini membahas pola bug yang umum pada service backend berbasis C: request malformed menyebabkan parser membaca di luar buffer, menghitung offset yang salah, atau mengalokasikan ukuran yang tidak valid. Fokusnya bukan eksploitasi, tetapi pelajaran engineering: bagaimana mereproduksi crash secara lokal, menemukan root cause, memperbaiki kode, dan menambah guard agar regresi tidak terulang.

Gejala Nyata di Produksi

Masalah seperti ini biasanya muncul dengan pola yang cukup konsisten:

  • Proses backend sesekali crash tanpa log yang jelas.
  • Hanya request tertentu yang memicu segfault, request normal tetap lolos.
  • Crash sulit direproduksi dari traffic biasa, tetapi stabil jika input malformed yang sama dikirim ulang.
  • Monitoring hanya menunjukkan worker restart atau koneksi terputus mendadak.

Pada service C yang memproses protokol biner atau format request kustom, gejala ini sering terkait dengan asumsi parser seperti:

  • Field length selalu benar.
  • Header selalu lengkap.
  • Payload minimal pasti ada.
  • Perhitungan offset tidak akan overflow.
  • Nilai yang dibaca dari network aman dipakai langsung untuk memcpy, malloc, atau indexing.

Jika parser membaca metadata dari input yang tidak dipercaya, maka setiap field panjang, offset, count, dan ukuran buffer harus diperlakukan sebagai data berbahaya sampai terbukti valid.

Reproduksi Lokal dengan Input Malformed

Langkah pertama adalah membuat crash bisa direproduksi secara deterministik. Tanpa itu, investigasi akan lambat dan penuh tebakan.

1. Simpan request pemicu crash

Jika memungkinkan, ambil payload mentah dari reverse proxy, packet capture, atau log hexdump internal. Hindari hanya menyimpan hasil parsing, karena bug sering terjadi sebelum parser sempat membentuk struktur logis.

Contoh pendekatan praktis:

  • Tambahkan logging hexdump terbatas untuk request gagal, dengan batas ukuran agar log tidak membengkak.
  • Simpan payload mentah ke file saat mode debug aktif.
  • Pastikan data sensitif disanitasi jika request berasal dari sistem nyata.

2. Jalankan service dengan satu input spesifik

Usahakan ada mode lokal yang memproses satu file input tanpa seluruh stack produksi. Ini mempersingkat siklus debug.

./parser_service --input ./samples/crash.bin

Jika service normalnya menerima socket, buat harness kecil yang memanggil fungsi parser langsung. Ini sering lebih efektif daripada mencoba mereproduksi crash lewat jaringan setiap kali.

int main(int argc, char **argv) {
    if (argc != 2) return 1;

    FILE *fp = fopen(argv[1], "rb");
    if (!fp) return 1;

    fseek(fp, 0, SEEK_END);
    long sz = ftell(fp);
    fseek(fp, 0, SEEK_SET);
    if (sz <= 0) return 1;

    unsigned char *buf = malloc((size_t)sz);
    if (!buf) return 1;

    if (fread(buf, 1, (size_t)sz, fp) != (size_t)sz) return 1;
    fclose(fp);

    int rc = parse_request(buf, (size_t)sz);
    free(buf);
    return rc;
}

Harness seperti ini memudahkan pengujian dengan gdb, sanitizer, dan minimisasi input.

3. Minimalkan input sampai tetap crash

Sering kali payload asli besar dan berisi banyak noise. Tujuan minimisasi adalah mencari subset byte terkecil yang masih memicu crash. Manfaatnya:

  • Backtrace lebih mudah dibaca.
  • Korelasi antara field dan bug lebih jelas.
  • Regression test lebih kecil dan lebih stabil.

Minimisasi bisa dilakukan manual dengan memangkas bagian payload sedikit demi sedikit, atau dengan skrip sederhana yang mencoba menghapus blok byte dan memeriksa apakah proses tetap crash.

Investigasi: dari Log Minim ke Root Cause

Tambahkan log kontekstual, bukan log berlebihan

Ketika parser crash, log paling berguna biasanya bukan dump semua variabel, melainkan konteks sebelum operasi berisiko:

  • Ukuran buffer total yang diterima.
  • Nilai field header penting.
  • Offset baca saat ini.
  • Panjang payload yang diklaim input.
  • Ukuran minimum yang dibutuhkan parser untuk langkah berikutnya.

Contoh log yang membantu:

LOG_DEBUG("parse frame: buf_len=%zu type=%u declared_len=%u offset=%zu",
          buf_len, hdr->type, declared_len, offset);

Yang perlu dihindari:

  • Log setelah dereference pointer berisiko, karena bisa terlambat.
  • Log yang mencetak field tanpa memastikan header cukup panjang untuk dibaca.
  • Log terlalu besar di jalur panas hingga mengganggu reproduksi.

Aktifkan core dump

Jika proses benar-benar segfault, core dump sering menjadi bukti terbaik karena menyimpan state saat crash.

ulimit -c unlimited
./parser_service --input ./samples/crash.bin

Setelah crash, buka file core dengan gdb:

gdb ./parser_service core

Perintah dasar yang berguna:

(gdb) bt
(gdb) frame 0
(gdb) info locals
(gdb) p offset
(gdb) x/32xb buf

Backtrace biasanya langsung mengarah ke operasi seperti:

  • memcpy dengan panjang salah,
  • akses buf[offset] saat offset >= buf_len,
  • pointer hasil perhitungan offset yang keluar dari buffer,
  • alokasi ukuran yang overflow lalu dipakai menulis lebih banyak data.

Gunakan ASan dan UBSan

Sanitizer sangat efektif untuk bug parser karena ia menangkap akses memori ilegal dan perilaku tak terdefinisi lebih dekat ke sumber masalah.

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

Kemudian jalankan input pemicu:

./parser_service --input ./samples/crash.bin

Apa yang biasanya terungkap:

  • ASan: heap-buffer-overflow, stack-buffer-overflow, use-after-free, out-of-bounds read.
  • UBSan: signed integer overflow, invalid shift, misaligned access, pointer arithmetic bermasalah.

Untuk kasus validasi panjang lemah, output ASan sering lebih cepat menunjukkan lokasi bug dibanding segfault biasa, karena ia berhenti tepat saat akses out-of-bounds terjadi.

Contoh Root Cause: Validasi Length/Header Lemah

Berikut pola bug yang sangat umum pada parser backend C. Misalkan format request diawali header sederhana:

  • 1 byte tipe
  • 2 byte panjang payload
  • payload sebanyak nilai panjang tersebut

Implementasi rentan sering terlihat seperti ini:

struct msg_header {
    unsigned char type;
    unsigned short len;
};

int parse_request(const unsigned char *buf, size_t buf_len) {
    const struct msg_header *hdr = (const struct msg_header *)buf;
    unsigned short payload_len = hdr->len;

    unsigned char tmp[256];
    memcpy(tmp, buf + sizeof(*hdr), payload_len);

    return handle_message(hdr->type, tmp, payload_len);
}

Masalahnya banyak:

  1. Tidak ada pengecekan bahwa buf_len >= sizeof(*hdr).
  2. Parser langsung cast buffer mentah menjadi struct, yang berisiko pada alignment dan representasi field.
  3. payload_len dipercaya mentah-mentah tanpa memastikan data benar-benar tersedia di buffer.
  4. tmp hanya 256 byte, tetapi payload_len bisa lebih besar.

Request malformed dengan header pendek atau field panjang palsu dapat memicu segfault, out-of-bounds read, atau stack overflow tergantung jalur eksekusi.

Variasi bug yang lebih halus: integer boundary

Bahkan jika ada validasi dasar, bug masih bisa lolos saat perhitungan ukuran tidak aman.

size_t needed = sizeof(struct msg_header) + payload_len;
if (buf_len < needed) {
    return -1;
}

Secara konsep benar, tetapi tetap perlu hati-hati pada kasus umum seperti:

  • tipe integer berbeda antara field input dan variabel internal,
  • konversi signed ke unsigned,
  • perhitungan offset bertingkat seperti offset + item_count * item_size,
  • nilai besar yang menyebabkan wraparound pada tipe lebih sempit.

Pola lebih aman adalah memvalidasi berdasarkan sisa buffer, bukan hanya akumulasi ukuran total.

if (buf_len < HEADER_SIZE) {
    return PARSE_ERR_TRUNCATED_HEADER;
}

size_t offset = HEADER_SIZE;
size_t remaining = buf_len - offset;

if (payload_len > remaining) {
    return PARSE_ERR_TRUNCATED_PAYLOAD;
}

Pendekatan ini mengurangi risiko salah hitung dan lebih mudah diaudit.

Perbaikan Kode yang Lebih Aman

Prinsip utama perbaikan parser C adalah validasi sebelum akses, bukan sesudahnya. Semua field yang berasal dari input harus dicek sebelum dipakai untuk:

  • dereference pointer,
  • perhitungan offset,
  • copy memori,
  • alokasi heap/stack,
  • iterasi loop.

Hindari cast buffer mentah langsung ke struct

Untuk protokol network atau format file, lebih aman membaca field byte per byte atau dengan helper yang memeriksa batas buffer.

static int read_u16_be(const unsigned char *buf, size_t buf_len, size_t off, unsigned short *out) {
    if (off + 2 > buf_len) {
        return 0;
    }
    *out = (unsigned short)((buf[off] << 8) | buf[off + 1]);
    return 1;
}

int parse_request(const unsigned char *buf, size_t buf_len) {
    if (!buf) {
        return PARSE_ERR_INVALID_ARG;
    }

    if (buf_len < 3) {
        return PARSE_ERR_TRUNCATED_HEADER;
    }

    unsigned char type = buf[0];
    unsigned short payload_len = 0;
    if (!read_u16_be(buf, buf_len, 1, &payload_len)) {
        return PARSE_ERR_TRUNCATED_HEADER;
    }

    size_t offset = 3;
    size_t remaining = buf_len - offset;

    if ((size_t)payload_len > remaining) {
        return PARSE_ERR_TRUNCATED_PAYLOAD;
    }

    if (payload_len > 256) {
        return PARSE_ERR_PAYLOAD_TOO_LARGE;
    }

    unsigned char tmp[256];
    memcpy(tmp, buf + offset, payload_len);

    return handle_message(type, tmp, (size_t)payload_len);
}

Kenapa pendekatan ini lebih aman:

  • Header minimum dicek sebelum byte diakses.
  • Panjang payload divalidasi terhadap sisa buffer nyata.
  • Batas buffer tujuan lokal juga dicek.
  • Tidak ada asumsi alignment struct dari input mentah.

Tambah guard clause sedini mungkin

Guard clause membuat parser gagal cepat saat menemukan input tidak valid, sehingga alur kontrol lebih sederhana dan jalur berbahaya lebih sedikit.

if (item_count == 0) {
    return PARSE_ERR_EMPTY_ITEMS;
}

if (item_count > MAX_ITEMS) {
    return PARSE_ERR_TOO_MANY_ITEMS;
}

if (item_size == 0 || item_size > MAX_ITEM_SIZE) {
    return PARSE_ERR_INVALID_ITEM_SIZE;
}

Trade-off-nya: parser menjadi lebih verbose. Namun untuk service C yang menerima data tak terpercaya, verbosity validasi jauh lebih murah dibanding satu crash di produksi.

Strategi Debug yang Efektif

1. Korelasikan crash dengan input, bukan hanya stack trace

Stack trace memberi lokasi jatuh, tetapi root cause sering terjadi beberapa langkah sebelumnya ketika field panjang pertama kali diterima tanpa validasi. Simpan informasi seperti:

  • request ID,
  • jenis pesan,
  • nilai length/count utama,
  • ukuran buffer aktual,
  • offset terakhir sebelum crash.

2. Bedakan bug parser dari bug concurrency

Jika crash hanya muncul pada payload tertentu dan konsisten di mode single process, kemungkinan besar penyebabnya validasi parser, bukan race condition. Ini penting agar investigasi tidak melebar ke thread scheduling sebelum perlu.

3. Gunakan build debug yang masuk akal

Untuk investigasi memori:

  • aktifkan simbol debug -g,
  • nonaktifkan optimisasi agresif jika backtrace sulit dibaca,
  • aktifkan sanitizer di lingkungan dev/test, bukan produksi utama.

4. Minimalkan input sebelum memperbaiki kode

Jika patch dibuat saat payload masih besar dan kompleks, ada risiko perbaikan hanya menutup satu gejala. Input minimal memudahkan Anda memastikan bahwa invariannya memang benar: header cukup, length masuk akal, dan copy hanya dilakukan jika data tersedia penuh.

Regression Test agar Bug Tidak Kembali

Setelah root cause ditemukan, jangan berhenti di patch. Tambahkan test untuk memastikan parser gagal secara aman pada input malformed.

void test_truncated_header(void) {
    unsigned char buf[] = { 0x01 };
    assert(parse_request(buf, sizeof(buf)) == PARSE_ERR_TRUNCATED_HEADER);
}

void test_truncated_payload(void) {
    unsigned char buf[] = { 0x01, 0x00, 0x05, 'A', 'B' };
    assert(parse_request(buf, sizeof(buf)) == PARSE_ERR_TRUNCATED_PAYLOAD);
}

void test_payload_too_large(void) {
    unsigned char buf[3 + 300] = {0};
    buf[0] = 0x01;
    buf[1] = 0x01;
    buf[2] = 0x2C; /* 300 */
    assert(parse_request(buf, sizeof(buf)) == PARSE_ERR_PAYLOAD_TOO_LARGE);
}

Jenis test yang layak ditambahkan:

  • header terpotong,
  • payload lebih pendek dari length yang diklaim,
  • length nol jika tidak valid,
  • length sangat besar,
  • count/item size yang menyebabkan perhitungan offset ekstrem,
  • input valid minimal dan valid maksimal.

Jika memungkinkan, tambahkan juga fuzz testing sederhana terhadap fungsi parser. Bahkan fuzzing ringan sering cepat menemukan kombinasi malformed input yang sebelumnya tidak terpikir.

Checklist Pencegahan Bug Serupa di Service Backend C

  • Jangan percaya field length, count, offset, atau ukuran apa pun dari input.
  • Validasi ukuran minimum sebelum membaca header.
  • Validasi setiap langkah parsing terhadap remaining bytes, bukan asumsi global.
  • Hindari cast langsung buffer mentah ke struct protokol.
  • Pastikan ukuran tujuan memcpy/memmove juga divalidasi.
  • Gunakan tipe ukuran yang konsisten untuk offset dan panjang, umumnya size_t untuk ukuran buffer internal.
  • Waspadai signed/unsigned conversion dan overflow pada perhitungan ukuran.
  • Tambahkan error code parser yang spesifik agar log lebih informatif daripada sekadar "invalid request".
  • Aktifkan ASan/UBSan di CI atau minimal pada pipeline test keamanan parser.
  • Simpan sampel input pemicu bug sebagai regression test permanen.

Penutup

Debug backend C: crash parser karena validasi panjang lemah hampir selalu bisa diurai dengan pendekatan yang sistematis: reproduksi lokal, tambahkan log kontekstual, ambil core dump, gunakan ASan/UBSan, analisis dengan gdb, lalu minimalkan input sampai pola bug terlihat jelas. Dalam banyak kasus, penyebab utamanya sederhana tetapi dampaknya besar: parser membaca lebih jauh dari buffer karena terlalu percaya pada header atau perhitungan ukuran yang rapuh.

Perbaikan yang baik bukan hanya membuat crash hilang, tetapi juga memperkuat kontrak parser: semua field diverifikasi sebelum dipakai, semua batas dicek terhadap buffer aktual, dan semua kasus malformed punya regression test. Untuk service backend C yang memproses input dari luar, disiplin ini bukan tambahan opsional, melainkan syarat dasar agar parser tetap stabil di bawah input yang tidak ideal.