Tutorial

Enam Kebiasaan Fuzz Testing AFL++ yang Benar-Benar Menemukan Bug

Enam Kebiasaan Fuzz Testing AFL++ yang Benar-Benar Menemukan Bug

Chris Wellons mem-fuzz sekitar 1.000 proyek selama enam tahun, dan menurutnya setelan bawaan AFL++ meninggalkan bug yang seharusnya tertangkap. Masalah paling konkret ada di Address Sanitizer: defaultnya hanya mengisi 4 KiB pertama setiap alokasi dengan pola, sehingga bug use-after-free pada alokasi besar tidak pernah terdeteksi. Artikel Tips for more effective fuzz testing with AFL++ merangkum enam kebiasaan yang mengubah hasil fuzzing dari "tidak ada temuan" menjadi "ada temuan".

TL;DR

  • Setel ASAN_OPTIONS dengan abort_on_error, halt_on_error, dan symbolize=0. Tanpa itu AFL++ tidak bisa menangkap crash dengan benar.
  • Naikkan cakupan pengisian pola dari default 4 KiB menjadi 1 GiB (1<<30). Bug pada alokasi besar baru terlihat setelah ini.
  • Pakai persistent mode karena biasanya satu orde besaran lebih cepat, tapi perhatikan isolasi state antar tes.
  • Jangan pernah meneruskan buffer fuzz langsung ke target: buffer AFL++ lebih besar dari panjang input, jadi ASan tidak bisa mendeteksi overflow baca.

Kenapa dua sanitizer lebih baik daripada satu?

Karena sanitizer berfungsi seperti assertion yang dipasang otomatis oleh compiler, dan makin banyak assertion makin banyak peluang crash yang bisa ditemukan. Wellons menyarankan selalu memakai Address Sanitizer dan Undefined Behavior Sanitizer bersamaan lewat satu flag.

afl-gcc-fast -g3 -fsanitize=address,undefined ...

Flag -g3 bukan hiasan: tanpa informasi debug, crash report akan berisi alamat memori tanpa petunjuk baris kode, dan biaya menyelidikinya jauh lebih besar daripada biaya kompilasi ulang. Dokumentasi lengkap opsi sanitizer ada di halaman resmi AddressSanitizer.

Setelan ASAN_OPTIONS mana yang wajib diubah?

Minimal tiga opsi wajib disetel eksplisit, dan satu opsi tambahan sangat disarankan. Tanpa tiga yang wajib, AFL++ tidak mendapat sinyal yang ia butuhkan untuk mengenali proses sebagai crash.

export ASAN_OPTIONS="abort_on_error=1:halt_on_error=1:symbolize=0"
ASAN_OPTIONS+=":detect_stack_use_after_return=1"
ASAN_OPTIONS+=":max_malloc_fill_size=$((1<<30))"

Opsi symbolize=0 sengaja dimatikan saat fuzzing karena simbolisasi memakan waktu dan baru dibutuhkan ketika crash akan dianalisis ulang. Opsi detect_stack_use_after_return=1 memperlambat fuzzing sedikit, tapi menurut artikel itu sepadan dengan bug tambahan yang tertangkap.

OpsiDefault ASanDisarankan untuk fuzzing
abort_on_errorMatiAktif
halt_on_errorMatiAktif
symbolizeAktifMati saat fuzzing
detect_stack_use_after_returnMatiAktif
max_malloc_fill_size4 KiB1 GiB

Baris terakhir adalah temuan dengan dampak terbesar. ASan mengisi 4 KiB pertama alokasi baru dengan pola tertentu supaya bug use-after-free cepat terlihat. Kalau program mengalokasikan lebih dari 4 KiB lalu memakai memori itu tanpa menginisialisasi, kemungkinan besar ada byte nol setelah batas 4 KiB, sehingga strlen berhenti normal dan bug-nya lolos. Menurut Wellons, batas 4 KiB ini menyembunyikan sejumlah bug sampai ia menyadarinya.

Apa itu persistent mode dan kenapa lebih cepat?

Persistent mode menjalankan banyak tes di dalam satu proses, alih-alih meluncurkan proses baru untuk setiap input. Wellons menyebutnya biasanya satu orde besaran lebih cepat dibanding mode biasa yang hanya mengganti compiler, dan itu cukup alasan untuk menulis harness sendiri.

#include <unistd.h>
__AFL_FUZZ_INIT();

int main(void) {
    __AFL_INIT();
    unsigned char *buf = __AFL_FUZZ_TESTCASE_BUF;
    while (__AFL_LOOP(10000)) {
        int len = __AFL_FUZZ_TESTCASE_LEN;
        /* olah buf dengan panjang len */
    }
}

Karena prosesnya tidak di-restart, mode ini punya jebakan: semua state global terbawa dari tes sebelumnya. Wellons menyarankan menyiapkan template sendiri, bukan memakai contoh resmi apa adanya, karena template resmi punya cacat di bagian isolasi.

Kenapa source file lebih baik daripada header?

Karena menyertakan file sumber langsung ke harness memungkinkan pengujian fungsi static dan menghilangkan konfigurasi build yang rumit. Dengan cara ini hanya dibutuhkan dua translation unit: satu untuk kode yang diinstrumentasi, satu untuk pustaka yang tidak.

#include "src/utils.c"
#include "src/parser.c"
#include <unistd.h>

Trik ini terlihat aneh bagi orang yang terbiasa satu file sumber satu translation unit, tapi efeknya besar: tidak ada lagi pengelolaan object file terpisah antara build fuzzing dan build normal, dan fungsi internal yang tidak pernah diuji bisa diuji. Untuk program yang punya main sendiri, nama fungsi itu bisa diganti sementara sebelum di-include:

#define main oldmain
#include "src/utils.c"
#include "src/parser.c"
#undef main

Pendekatan ini bertentangan dengan praktik rekayasa perangkat lunak yang rapi, dan Wellons mengakuinya. Alasannya pragmatis: sebagian besar proyek yang ia fuzz adalah proyek open source yang ia periksa sambil lewat, dan menulis ulang struktur build-nya bukan pilihan.

Kenapa buffer fuzz tidak boleh dipakai langsung?

Karena buffer yang dialokasikan AFL++ lebih besar daripada panjang input, sehingga ASan tidak akan pernah mendeteksi pembacaan melewati batas. Ini kesalahan paling umum dan paling halus, terutama karena template resmi justru mendorongnya.

myprogram(buf, len);   // salah, overflow tidak terdeteksi
src = realloc(src, len);
memcpy(src, buf, len);
myprogram(src, len);   // benar, ukuran pas

Kalau kode yang sedang diuji membaca satu byte lebih jauh dari len, pada buffer asli byte itu masih berada di dalam alokasi sehingga tidak ada yang dilanggar. Setelah disalin ke buffer berukuran pas, pembacaan itu langsung menjadi out-of-bounds. Wellons menyebut teknik ini berulang kali memunculkan temuan baru pada proyek yang test harness-nya sudah ada.

Bagaimana menjaga isolasi antar tes?

Dengan menganggap setiap tes mulai dari kondisi bersih, termasuk state yang tidak terlihat seperti buffer internal strtok. Dalam persistent mode, satu variabel global yang tidak di-reset bisa membuat tes berikutnya menerima sisa data dari tes sebelumnya, dan bug yang muncul jadi sangat sulit direproduksi.

  • Reset semua variabel global di awal setiap iterasi, termasuk buffer statis di dalam pustaka C.
  • Jangan meneruskan memori yang belum diinisialisasi ke fungsi yang diuji, misalnya parameter keluaran yang dialokasikan di stack.
  • Kalau target menerima arena atau allocator khusus, isi dengan pola sebelum setiap tes.
  • Sadari bahwa alamat heap berubah antar tes, jadi program yang perilakunya bergantung pada nilai alamat akan sulit direproduksi.

Poin soal strtok layak digarisbawahi karena banyak kode C lama memakainya tanpa sadar. Dalam satu proses panjang, buffer internal fungsi itu menyimpan posisi parsing terakhir, dan itu cukup untuk membuat dua tes yang seharusnya identik memberi hasil berbeda.

Perlukah membebaskan memori di akhir tes?

Tidak, dan sebaiknya jangan. Wellons menyarankan menghindari pekerjaan yang tidak relevan dengan pengujian, termasuk memanggil destructor, karena AFL++ akan me-reset proses sebelum memori habis pada hampir semua kasus.

Ada dua keuntungan tambahan. Pertama, tes jadi lebih cepat. Kedua, isolasi antar tes lebih terjaga karena memori yang tidak dibebaskan tidak diisi ulang oleh data dari tes sebelumnya. Pengecualiannya kalau destructor justru ingin diuji, misalnya untuk mencari bug pada jalur pembersihan sumber daya.

Untuk C++ yang menautkan libstdc++, Wellons menambahkan satu flag lagi: -D_GLIBCXX_DEBUG. Alasannya, ASan tidak bisa melihat akses di luar ukuran logis sebuah kontainer selama masih di dalam kapasitasnya, dan assertion tambahan dari mode debug mengisi celah itu. Efek sampingnya ABI berubah, tapi menurut artikel itu justru langsung terlihat saat fuzzing.

Apa yang perlu dilakukan setelah crash ditemukan?

Reproduksi di build terpisah dengan simbol aktif, lalu verifikasi apakah crash itu benar-benar bug atau artefak harness. Karena symbolize dimatikan saat fuzzing, langkah pertama selalu menjalankan ulang input yang memicu crash tanpa setelan fuzzing.

Langkah kedua adalah memeriksa apakah crash hilang setelah max_malloc_fill_size dikembalikan ke default. Kalau hilang, kemungkinan besar itu bug use-after-free yang hanya terlihat karena pola pengisian, dan itu tetap bug nyata. Sebaliknya, kalau crash hanya muncul dengan detect_stack_use_after_return=1, jawabannya bug pada memori stack yang jarang terekspos di build produksi tapi tetap layak dilaporkan.

Dokumentasi resmi AFL++ menyediakan bagian performance tips yang melengkapi daftar ini, dan repo AFLplusplus memuat panduan mendalam yang jauh lebih panjang. Artikel Wellons berfungsi sebagai versi ringkas untuk orang yang baru mulai dan ingin hasil pada hari pertama, bukan pada bulan ketiga.

Urutan apa yang paling praktis untuk memulai hari ini?

Mulai dari harness yang benar, bukan dari jumlah CPU. Empat langkah berikut disusun dari enam tips di atas, dan semuanya bisa diselesaikan dalam satu sesi kerja.

  • Buat satu file harness yang menyertakan sumber target langsung, lalu ganti nama main menjadi oldmain.
  • Salin buffer input ke ukuran yang pas sebelum diteruskan ke fungsi yang diuji.
  • Setel ASAN_OPTIONS lengkap dengan empat opsi, lalu jalankan satu kali untuk memastikan tidak ada crash palsu.
  • Ubah ke persistent mode baru setelah harness stabil, karena isolasi state antar tes baru bisa dinilai saat harness sudah benar.

Kalau salah satu langkah dilewati, gejalanya biasanya menyesatkan. Misalnya, menjalankan persistent mode sebelum isolasi beres akan memberi crash yang tidak bisa direproduksi, dan orang biasanya menyimpulkan target-nya yang bermasalah, bukan harness-nya.

Satu hal yang perlu disadari: semua tips di atas bekerja pada kualitas harness, bukan pada jumlah inti CPU. Menambah mesin paralel dengan harness yang salah hanya mempercepat penemuan crash yang tidak bisa direproduksi, dan itu justru memperburuk keadaan karena laporan bugnya tidak bisa ditindaklanjuti.

FAQ

Apakah tips ini berlaku untuk bahasa selain C dan C++?

Sebagian. Konsep sanitizer, isolasi tes, dan penyalinan buffer berlaku umum, tapi detail flag dan persistent mode khusus untuk AFL++ pada target C dan C++ di Linux berbasis glibc.

Berapa lama sampai fuzzing menemukan bug pertama?

Artikel tidak memberi angka waktu, tapi menyatakan bahwa mengambil program apa pun yang belum pernah di-fuzz dan menerima input kompleks biasanya langsung memunculkan setidaknya satu bug. Pengalaman penulisnya mencakup sekitar 1.000 proyek dalam enam tahun.

Apakah persistent mode selalu lebih baik?

Tidak selalu. Ia lebih cepat, tapi menambah risiko kontaminasi state antar tes, jadi hanya cocok kalau semua state global benar-benar bisa di-reset. Untuk program dengan banyak singleton, mode biasa lebih aman.

Kenapa symbolize dimatikan saat fuzzing?

Karena simbolisasi memakan waktu dan tidak dibutuhkan selama proses pencarian. Simbol baru diperlukan ketika crash dianalisis, dan itu dilakukan di build atau run terpisah dengan setelan berbeda.

Apa risiko menaikkan max_malloc_fill_size sampai 1 GiB?

Waktu proses bertambah karena ASan menulis pola ke memori yang jauh lebih besar. Menurut artikel itu, biaya kecepatannya sepadan dengan bug yang tertangkap, khususnya use-after-free pada alokasi besar.

💬 Komentar (0)

Belum ada komentar. Jadilah yang pertama! 💬

Komentar yang memuat tautan, bahasa asing, atau aksara non-Latin ditahan dulu buat ditinjau.