Sanitizer dan pengujian bertingkat pada chapter-chapter sebelumnya memeriksa input yang kita bayangkan; padahal kegagalan paling mahal justru sering datang dari input yang tidak terpikirkan oleh siapa pun. Fuzzing menjawab celah itu dengan mengotomatiskan pencarian input tak terduga: program target diberi jutaan input acak maupun hasil mutasi, dan setiap respons yang tidak wajar dicatat sebagai temuan. libFuzzer, pustaka fuzzing resmi proyek LLVM, mengangkat teknik ini ke tingkat yang efektif untuk kode C++ nyata karena umpan balik cakupan (coverage-guided): input yang membuka cabang eksekusi baru dipertahankan sebagai bahan mutasi berikutnya. Chapter ini membahas mekanisme libFuzzer, cara menulis fuzz target, kombinasi dengan AddressSanitizer, hingga membedah input yang memicu crash — ditutup dengan miniatur yang benar-benar kita jalankan untuk membuktikan alurnya.
Cara Kerja Coverage-Guided Fuzzing #
Fuzzing tanpa umpan balik hanya menebak: mengganti byte secara acak dan berharap sesuatu terjadi. libFuzzer lebih cerdas dengan tiga komponen yang bekerja bergantian. Pertama, corpus — kumpulan input berbentuk file yang diperlakukan sebagai benih (seed). Kedua, mutasi: alat mengubah byte benih dengan operasi sederhana seperti mengganti, menghapus, atau menambah byte. Ketiga, umpan balik cakupan: compiler menanamkan instrumentasi yang melaporkan cabang eksekusi yang dilalui setiap input; bila sebuah mutasi berhasil mencapai cabang yang belum pernah dilalui, input itu dimasukkan ke corpus.
Siklus tersebut menjelaskan mengapa fuzzing menemukan bug yang lolos dari pengujian biasa. Input valid yang menempuh jalur panjang dalam parser tidak perlu kita tulis manual — alat yang menemukannya, menyimpannya, lalu memutasi kembali dari titik yang jauh. Semakin banyak cabang tercapai, semakin luas wilayah program yang sudah diuji, dan itulah ukuran kemajuan sesi fuzzing.
Menulis Fuzz Target: LLVMFuzzerTestOneInput #
libFuzzer tidak menguji program utama kita; alat menguji satu fungsi yang kita sediakan, berbentuk fuzz target dengan tanda tangan tetap. Fungsi LLVMFuzzerTestOneInput menerima pointer ke byte input beserta ukurannya, dan tugasnya memakai byte itu seolah-olah input nyata — mengurainya, memanggil fungsi yang diuji, lalu kembali. Berikut bentuk tipikalnya untuk parser format biner:
#include <cstdint>
#include <cstddef>
#include <vector>
// Fuzz target: dipanggil libFuzzer dengan input yang terus berubah.
extern "C" int LLVMFuzzerTestOneInput(const std::uint8_t* data,
std::size_t ukuran) {
std::vector<std::uint8_t> masukan(data, data + ukuran);
(void)parseBerkas(masukan); // fungsi yang diuji; tugas kita: tidak crash
return 0;
}Tiga aturan praktis membuat fuzz target bermakna. Fungsi target sebaiknya kecil dan fokus pada satu antarmuka yang berhadapan dengan input eksternal, karena target besar menyamarkan sumber crash. Input yang dipakai untuk membangun struktur data harus selalu melewati pemeriksaan batas, sebab byte apa pun bisa muncul. Terakhir, target tidak boleh mencetak atau menghentikan program saat input ditolak — menolak input adalah alur normal, sedangkan crash, hang, dan pelanggaran sanitizer adalah temuan.
Kombinasi dengan AddressSanitizer #
libFuzzer menemukan alur input yang mencurigakan; AddressSanitizer dari chapter sebelumnya yang memastikan pelanggaran memori benar-benar tertangkap. Keduanya dirancang untuk dipakai bersama dalam satu build: satu flag compiler mengaktifkan keduanya, sehingga setiap input yang diputar oleh libFuzzer diperiksa oleh sanitizer di jalur eksekusinya.
Bentuk sesinya tampak seperti berikut. Program berjalan tanpa henti, mencetak statistik berkala, dan baru berhenti saat menemukan masalah:
$ clang++ -g -O1 -fsanitize=fuzzer,address fuzz-target.cpp parser.cpp -o fuzz
$ ./fuzz corpus/
INFO: Running with entropic power schedule (0xFF, 100).
#2 INITED cov: 3 ft: 4 corp: 2/12b exec/s: 0 rss: 30Mb
#418 NEW cov: 4 ft: 5 corp: 3/23b exec/s: 20900 rss: 31Mb
==1234== ERROR: AddressSanitizer: heap-buffer-overflow on address ...
Test unit written to ./crash-0f4a2b1c...Baris NEW menandai input baru yang memperluas cakupan; baris ERROR adalah temuan. Ketika temuan terjadi, libFuzzer menyimpan input pemicu ke file crash-* lalu berhenti — sesi dapat dilanjutkan setelah bug diperbaiki, dan corpus yang terkumpul tetap dipakai sebagai benih.
Membedah Input yang Memicu Crash #
File crash-* adalah aset investigasi, bukan sekadar laporan. Karena target kita deterministik, menjalankan ulang binary fuzz terhadap file itu memutar ulang kegagalan yang persis sama — replai cukup dilakukan dengan melewatkan file sebagai argumen. Dari sini kita membaca tiga hal: byte mana yang memicu pelanggaran, cabang parser yang menyalahartikan input, dan perbaikan pemeriksaan yang dibutuhkan. Setelah perbaikan masuk, file crash yang sama dijadikan unit pengujian permanen agar bug tidak kembali.
Miniatur: Memutar Alur Fuzzing Sendiri #
Karena sandbox course ini tidak memasang Clang, kita membuktikan seluruh alur di atas dengan miniatur yang dikompilasi memakai g++: parser format biner berbuga, mutator tiga operasi, penghitung cakupan per cabang, dan corpus yang bertambah saat cabang baru terbuka. Seed std::mt19937(42) membuat seluruh sesi bisa diputar ulang persis — properti yang pada libFuzzer nyata disediakan lewat flag -seed.
// mutator-mini.cpp — miniatur coverage-guided fuzzing yang berjalan mandiri
// Kompilasi: g++ -std=c++23 -Wall -Wextra mutator-mini.cpp -o mutator-mini
#include <cstddef>
#include <cstdint>
#include <format>
#include <print>
#include <random>
#include <stdexcept>
#include <string>
#include <vector>
enum class Status { OK, TOLAK };
int hitungCabang[4]{}; // penghitung mini "coverage": satu sel per cabang pemeriksa
// Parser format biner fiktif: [0]='K', [1]='P', [2]=panjang payload, sisanya payload.
// BUG buatan: byte panjang dipercaya mentah-mentah tanpa dicek terhadap ukuran
// buffer. Pada program nyata, pembacaan melampaui batas seperti ini adalah
// undefined behavior yang dilaporkan AddressSanitizer; di sini kita memasang
// penjaga eksplisit (.at()) supaya "kecelakaan" yang sama tercatat secara
// deterministik alih-alih bergantung pada UB.
Status parse(const std::vector<std::uint8_t>& data) {
if (data.size() < 4) { // cabang 0: input terlalu pendek
++hitungCabang[0];
return Status::TOLAK;
}
if (data[0] != 'K' || data[1] != 'P') { // cabang 1: header salah
++hitungCabang[1];
return Status::TOLAK;
}
const std::size_t panjangKlaim = data[2]; // BUG: tidak pernah divalidasi
++hitungCabang[2]; // cabang 2: loop payload berjalan
unsigned jumlah = 0;
for (std::size_t i = 0; i < panjangKlaim; ++i) {
jumlah += data.at(3 + i); // penjaga: melempar bila di luar batas
}
++hitungCabang[3]; // cabang 3: checksum dievaluasi
return (jumlah == 0) ? Status::OK : Status::TOLAK;
}
std::string keHex(const std::vector<std::uint8_t>& data) {
std::string hasil;
for (std::uint8_t b : data) {
hasil += std::format(" {:02X}", b);
}
return hasil;
}
int cakupanSaatIni() {
int total = 0;
for (int c : hitungCabang) {
total += (c > 0) ? 1 : 0;
}
return total;
}
// Tiga operasi mutasi sederhana, meniru keluarga operasi mutasi libFuzzer
// (ChangeByte, EraseBytes, InsertByte).
enum class Op { GantiByte, HapusByte, TambahByte };
const char* namaOp(Op op) {
switch (op) {
case Op::GantiByte: return "GantiByte";
case Op::HapusByte: return "HapusByte";
case Op::TambahByte: return "TambahByte";
}
return "?";
}
void mutasi(std::vector<std::uint8_t>& data, Op op, std::mt19937& gen) {
switch (op) {
case Op::GantiByte:
data[gen() % data.size()] = static_cast<std::uint8_t>(gen() % 256);
break;
case Op::HapusByte:
if (data.size() > 1) {
data.erase(data.begin() + static_cast<std::ptrdiff_t>(gen() % data.size()));
}
break;
case Op::TambahByte:
if (data.size() < 24) {
data.push_back(static_cast<std::uint8_t>(gen() % 256));
}
break;
}
}
int main() {
std::mt19937 gen(42); // seed tetap: seluruh sesi bisa diputar ulang persis
// Corpus benih: satu input valid panjang, satu input tidak valid pendek.
std::vector<std::uint8_t> benihValid{'K', 'P', 0x02, 'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H'};
std::vector<std::uint8_t> benihPendek{'K'};
std::vector<std::vector<std::uint8_t>> korpus{benihValid, benihPendek};
std::println("-- mutator mini: benih, mutasi, umpan balik cakupan");
for (const auto& benih : korpus) {
try {
parse(benih);
} catch (const std::out_of_range&) {
// Benih yang kita pilih tidak memicu bug; baris ini hanya pengaman.
}
}
std::println("INITED benih=2 cakup={}/4 corpus={}", cakupanSaatIni(), korpus.size());
int iterasi = 0;
bool crashDitemukan = false;
std::vector<std::uint8_t> pelanggar;
while (!crashDitemukan && iterasi < 100000) {
++iterasi;
const int cakupSebelum = cakupanSaatIni();
// 1) pilih induk secara acak dari corpus, salin, lalu mutasi 1-3 langkah
std::vector<std::uint8_t> kandidat = korpus[gen() % korpus.size()];
std::string jejak;
const int langkah = 1 + static_cast<int>(gen() % 3);
for (int l = 0; l < langkah; ++l) {
Op op = static_cast<Op>(gen() % 3);
mutasi(kandidat, op, gen);
jejak += (l == 0) ? namaOp(op) : std::format("-{}", namaOp(op));
}
// 2) jalankan target; tangkap penjaga batas sebagai sinyal crash
try {
parse(kandidat);
} catch (const std::out_of_range&) {
pelanggar = kandidat;
crashDitemukan = true;
break;
}
// 3) umpan balik cakupan: simpan kandidat yang membuka cabang baru
if (cakupanSaatIni() > cakupSebelum && korpus.size() < 8) {
korpus.push_back(kandidat);
std::println("NEW iterasi={:>3} cakup={}/4 corpus={} mutasi={}",
iterasi, cakupanSaatIni(), korpus.size(), jejak);
}
}
if (crashDitemukan) {
const std::size_t klaim = pelanggar.size() >= 3 ? pelanggar[2] : 0;
std::println("CRASH iterasi={:>3} status=out_of_range klaim_panjang={} ukuran_input={}",
iterasi, klaim, pelanggar.size());
std::println("input pelanggar:{}", keHex(pelanggar));
} else {
std::println("tidak ada crash setelah {} iterasi", iterasi);
}
std::println("ringkasan cabang: pendek={} header={} payload={} checksum={} | corpus={} iterasi={}",
hitungCabang[0], hitungCabang[1], hitungCabang[2], hitungCabang[3],
korpus.size(), iterasi);
return 0;
}Output yang Dihasilkan:
-- mutator mini: benih, mutasi, umpan balik cakupan
INITED benih=2 cakup=3/4 corpus=2
NEW iterasi= 1 cakup=4/4 corpus=3 mutasi=HapusByte-GantiByte-TambahByte
CRASH iterasi= 38 status=out_of_range klaim_panjang=65 ukuran_input=10
input pelanggar: 4B 50 41 43 44 45 46 47 48 3D
ringkasan cabang: pendek=16 header=13 payload=11 checksum=10 | corpus=3 iterasi=38Mari kita telaah apa yang terjadi pada bagian-bagian penting program di atas:
- Baris 12-13 (
hitungCabang): empat sel penghitung meniru instrumentasi cakupan — setiap cabang parser menandai ketercapaiannya, dancakupanSaatIni()menghitung berapa cabang yang sudah terbuka. - Baris 32-36 (
parse): parser memercayai byte panjang tanpa memeriksa batas; perulangan membaca melewati akhir input dan penjaga.at()melemparstd::out_of_range— di program nyata, pelanggaran seperti inilah yang akan dilaporkan AddressSanitizer. - Baris 71-87 (
mutasi): tiga operasi mutasi meniru keluarga operasi libFuzzer (ChangeByte, EraseBytes, InsertByte) — perubahan kecil, bukan penulisan ulang acak penuh, karena bug biasanya berjarak satu-dua byte dari input sah. - Baris 110-131 (siklus utama): setiap iterasi memilih induk dari corpus, memutasi satu-tiga langkah, menjalankan target, lalu menyimpan kandidat yang membuka cabang baru — persis siklus corpus-mutasi-umpan-balik libFuzzer.
- Baris 141-145 (laporan crash): saat penjaga terpicu, input pelanggar dicetak dalam bentuk hex — analogi file
crash-*yang bisa diputar ulang; ringkasan per cabang menunjukkan seluruh 4 cabang sempat tercapai sebelum temuan.
Perlu ditegaskan perbedaan skala: miniatur ini berjalan beberapa milidetik, sedangkan sesi fuzzing profesional berlangsung berjam-jam dengan jutaan eksekusi per detik, dictionary input, dan corpus yang dirawat lintas versi. Struktur pemikirannya tetap sama, sehingga saat kita bertemu libFuzzer di proyek nyata, setiap komponennya sudah dikenal.
Rujukan resmi: LLVM — libFuzzer documentation , LLVM — Fuzzing with libFuzzer tutorial , cppreference — std::out_of_range .