Phần 5 sinh dữ liệu ngẫu nhiên để kiểm tính chất nghiệp vụ của hàm thuần. Fuzzing là người anh em cùng họ nhưng khác mục tiêu: không hỏi "kết quả có đúng không" mà hỏi "có đầu vào nào làm nó sập không" — panic, đọc quá biên, treo, tràn số. Nó nhắm vào lớp mã đọc byte từ bên ngoài: parser, decoder, bất cứ thứ gì nhận chuỗi từ người dùng hay từ mạng. Go có fuzzing tích hợp từ 1.18, hướng dẫn bởi độ phủ, và bài này đo nó trên bốn hàm có lỗi thật: tìm ra trong bao lâu, đầu vào thu nhỏ còn gì, seed corpus đổi được bao nhiêu, và chuyện gì xảy ra sau khi sửa.

Bốn hàm đọc đầu vào ngoài

Ảnh chụp đoạn mã nền tối fuzzing với go test fuzz bốn hàm đọc đầu vào ngoài bốn lỗi với Go 1.23 fuzzing tích hợp hướng dẫn bởi độ phủ 10 CPU mỗi mục tiêu hai bản không seed và có seed FUZZ_FIX 1 bật bản sửa, bốn mục tiêu và lỗi cấy vào gồm ParseRange 10-20 với n bằng hi trừ lo cộng 1 và out bằng make int n nên hi nhỏ hơn lo cho n âm panic makeslice, ParseCSVLine s với if s i bằng nháy và s i cộng 1 bằng nháy nên nháy ở cuối chuỗi index out of range, ParseDuration 1h với n bằng n nhân 10 cộng d và total cộng n nhân mul nên tràn int64 thành thời lượng âm không panic, Decode b với n k bằng Uvarint b pos và b pos tới pos cộng n nên n lớn hơn còn lại slice bounds out of range, mục tiêu fuzz không panic hoặc một bất biến gồm FuzzParseRange f.Fuzz gọi ParseRange s với panic là lỗi, FuzzDuration gọi ParseDuration nếu err nil và d âm thì t.Fatalf thời lượng âm là bất biến, FuzzCodec gọi Decode data không panic với mọi byte rồi strs bằng string data và x rồi Decode Encode strs phải bằng strs, bản Seeded thêm f.Add 1-10 f.Add he said hi 1 f.Add 1h30m15s f.Add Encode a bc, cách đo go test run rỗng fuzz FuzzX fuzztime 30s lấy wall time tới crash đã biên dịch trước và đầu vào Go thu nhỏ, go test thường để file trong testdata fuzz chạy như test thường hồi quy miễn phí, FUZZ_FIX 1 go test fuzz fuzztime 20s xem bản sửa còn lọt gì không

Hình 1: Bốn lỗi điển hình của lớp đọc byte, bốn mục tiêu fuzz chỉ nói "không panic" hoặc một bất biến, và cách đo.

Bốn hàm nhỏ kiểu ai cũng từng viết:

  • ParseRange("10-20") trả về danh sách trang. Lỗi: không kiểm hi >= lo, nên "7-0" cho độ dài âm và make panic.
  • ParseCSVLine tách một dòng CSV có dấu nháy, với "" là nháy thoát. Lỗi: kiểm s[i+1] mà không kiểm còn ký tự không — dấu nháy đứng cuối là đọc quá biên.
  • ParseDuration("1h30m") ra số giây. Lỗi: n = n*10 + d không kiểm tràn, nên số đủ lớn quay vòng thành âm. Không panic — chỉ là kết quả sai dấu.
  • Decode đọc định dạng [độ dài uvarint][bytes].... Lỗi: không kiểm độ dài khai báo so với dữ liệu còn lại — một byte 0x30 khai "48 byte" trên dữ liệu dài 1 byte là slice vượt biên.

Mục tiêu fuzz cho ba hàm đầu chỉ là gọi hàm, không panic. ParseDuration cần thêm bất biến: không lỗi thì kết quả không âm. Decode có hai phần: không panic với mọi chuỗi byte, và vòng tròn Decode(Encode(x)) == x. Mỗi mục tiêu có hai bản: không seed (fuzzer bắt đầu từ chuỗi rỗng) và có seed (vài đầu vào thật như "1-10", "he said ""hi""",1, "1h30m15s") để đo seed đáng giá bao nhiêu.

Đo thật: tới crash trong bao lâu, và sau khi sửa

Ảnh chụp output thật nền tối 8 trên 8 crash trong 0,1 tới 1,6 s bản sửa lần 1 vẫn lọt với go-lab Go 1.23 10 CPU fuzztime 30 s nhưng không mục tiêu nào sống quá 2 giây, mã có lỗi thời gian tới crash và đầu vào Go đã thu nhỏ gồm FuzzParseRange không seed 1,3 s đầu vào 07-0 panic makeslice len out of range, FuzzParseRangeSeeded có seed 0,1 s đầu vào 7-0 cùng panic, FuzzCSV không seed 0,1 s đầu vào hai dấu nháy panic index out of range, FuzzCSVSeeded 0,1 s cùng đầu vào, FuzzDuration không seed 1,1 s đầu vào 8000000000000000h thời lượng âm -8093488147419103232, FuzzDurationSeeded 1,6 s đầu vào 1016070000000000000000h thời lượng âm -8222608088118591488, FuzzCodec 0,1 s đầu vào byte 00 panic slice bounds out of range, FuzzCodecSeeded 0,1 s đầu vào byte 0 cùng panic, ghi chú seed giúp ParseRange 1,3 xuống 0,1 s ba mục tiêu kia lỗi ở đầu vào 1 tới 2 byte nên seed không đổi gì, hồi quy miễn phí và bản sửa lần 1 bị chính corpus bắt gồm 16 file gây lỗi lưu trong testdata fuzz nên go test không fuzz cho FAIL FuzzParseRange chạy lại như test thường, FUZZ_FIX 1 sửa lần 1 chỉ kiểm tràn khi đọc chữ số FuzzDuration FAIL ngay ở corpus 8000000000000000h thành -8093488147419103232 vì 8e15 nhân 3600 tràn ở phép nhân đơn vị, sửa lần 2 kiểm cả n nhân mul và total cộng n nhân mul FuzzDuration ok 10.601.508 execs trong 20 s không lỗi, mã đã sửa tốc độ khi không còn gì để tìm 10 s 10 CPU gồm FuzzParseRange 5.281.690 execs 481.806 mỗi giây new interesting 0 total 38, FuzzCSV 5.107.194 execs 468.685 mỗi giây total 52, FuzzDuration 5.317.855 execs 472.361 mỗi giây total 24, FuzzCodec 2.781.911 execs 234.708 mỗi giây total 19, go test thường với 17 file corpus làm seed ok 0.001s, nói thẳng bốn lỗi đều ở lớp đọc byte nên fuzzer bắt trong vài giây lỗi nghiệp vụ sâu hơn cần bất biến đúng và lâu hơn nhiều thứ không phụ thuộc may mắn file crash trở thành test hồi quy và nó bắt luôn bản sửa nửa vời

Hình 2: Tám lần fuzz, tám lần crash dưới hai giây. Phần giữa là chuyện đáng nhớ nhất: bản sửa đầu tiên bị chính file crash bắt lại.

mục tiêu không seed có seed đầu vào thu nhỏ
ParseRange 1,3 s 0,1 s "7-0"
CSV 0,1 s 0,1 s "\"\""
Duration 1,1 s 1,6 s "8000000000000000h"
Codec 0,1 s 0,1 s []byte("0")

Tám trên tám crash, không cái nào sống quá hai giây. Giới hạn đặt là 30 giây; fuzzer không cần tới. Lý do: cả bốn lỗi đều nằm ở lớp đọc byte và lộ ra với đầu vào cực ngắn — hai dấu nháy, một byte, "7-0". Fuzzer hướng dẫn bởi độ phủ bắt đầu từ chuỗi rỗng, đột biến từng byte, và giữ lại mọi đầu vào mở ra nhánh mới; với hàm vài chục dòng, nó duyệt hết nhánh trong tích tắc. Đầu vào in ra đã được Go thu nhỏ: "07-0" ở bản không seed, "7-0" ở bản có seed, hai dấu nháy, một byte — đọc là hiểu ngay lỗi ở đâu, không khác gì phản ví dụ của phần 5.

Seed chỉ đáng kể khi cấu trúc đầu vào không tầm thường. ParseRange không seed mất 1,3 giây — fuzzer phải tự "phát hiện" rằng cần một dấu gạch với số ở hai bên; có seed "1-10" thì 0,1 giây. Ba mục tiêu kia không đổi vì lỗi nằm ở đầu vào một hai byte mà đột biến mù cũng chạm tới ngay. Với định dạng thật (JSON, protobuf, một giao thức nhị phân), seed là thứ quyết định fuzzer có tới được phần sâu hay mắc kẹt ở lớp từ chối cú pháp.

File crash thành test hồi quy, không cần làm gì. Go ghi đầu vào gây lỗi vào testdata/fuzz/<tên>/, và go test thường (không -fuzz) chạy mọi file ở đó như ca kiểm bình thường. 16 file sau tám lần fuzz; chạy go test, FuzzParseRange đỏ ngay — lỗi được giữ lại trong repo, chạy trên CI mỗi lần, dù không ai bật fuzz nữa.

Và chính cơ chế đó bắt bản sửa lần một của tôi. Sửa ParseDuration bằng cách kiểm tràn khi đọc chữ số (n > (1<<62)/10 thì từ chối) — trông đủ. Bật bản sửa, chạy lại: FuzzDuration đỏ ngay ở corpus, cùng đầu vào "8000000000000000h": 8×10¹⁵ qua được phép kiểm chữ số, nhưng nhân với 3600 ở bước đổi đơn vị là 2,9×10¹⁹, vượt int64. Lỗi tràn có hai chỗ, tôi chỉ chặn một. Sửa lần hai kiểm cả phép nhân đơn vị lẫn phép cộng tổng: 10,6 triệu lượt trong 20 giây, sạch. Không có file crash lưu lại thì bản sửa nửa vời đã được merge với cảm giác "đã fix".

Tốc độ khi không còn gì để tìm: khoảng 470.000 lượt mỗi giây cho ba hàm chuỗi, 235.000 cho codec (phải cấp phát), trên 10 CPU. "New interesting: 0" sau 10 giây nghĩa là không còn nhánh mới — không phải không còn lỗi, mà là fuzzer không còn thấy đường mới để đi. Đó là lúc tăng thời gian hoặc thêm seed có cấu trúc, không phải lúc kết luận an toàn.

Đánh đổi cần cân nhắc

Fuzzing chỉ tìm được thứ bạn bảo nó tìm. Mặc định là panic và treo; mọi thứ khác cần một bất biến trong mục tiêu — "không âm", "vòng tròn", "không lỗi thì kết quả nằm trong miền". ParseDuration không panic bao giờ; không có dòng d < 0 thì fuzz 30 giây rồi báo sạch. Nghĩ ra bất biến là việc của người, giống phần 5.

Nhắm vào biên hệ thống. Chỗ đáng fuzz là nơi byte từ ngoài đi vào: parser, decoder, xử lý tệp tải lên, header HTTP tự phân tích. Hàm nghiệp vụ nhận struct đã hợp lệ thì PBT hợp hơn — fuzzer sẽ tốn phần lớn thời gian ở lớp từ chối đầu vào.

Chi phí CI: fuzz là vòng lặp vô tận theo thiết kế. Trong pipeline thường chỉ chạy go test (corpus đã lưu, mili-giây); fuzz thật chạy theo lịch riêng với -fuzztime cố định, và mỗi crash mới commit file vào testdata. Cũng cần -parallel/CPU hợp lý: 470.000 lượt/giây ở đây là 10 CPU; CI chia sẻ sẽ chậm hơn nhiều.

Phạm vi đo. Bốn lỗi cấy vào ở tầng nông, nên mọi con số "tới crash" đều nhỏ; chúng cho thấy cơ chế hoạt động, không cho thấy fuzzer tìm được lỗi sâu nhanh thế nào. Thứ bền hơn con số: thu nhỏ đầu vào, corpus thành hồi quy, và việc corpus bắt bản sửa sai.

Ba ý mang về

  1. Fuzzer tìm lỗi lớp đọc byte trong vài giây với đầu vào thu nhỏ còn vài byte (8/8 crash dưới 2 giây, "7-0", hai dấu nháy, một byte); seed chỉ đáng kể khi đầu vào có cấu trúc.
  2. File crash trong testdata/fuzz là test hồi quy miễn phí, và nó bắt luôn bản sửa nửa vời: sửa lần một chỉ chặn tràn ở chữ số, corpus bắt tràn ở phép nhân đơn vị; sửa lần hai sạch qua 10,6 triệu lượt.
  3. Fuzz chỉ tìm thứ bạn bảo nó tìm: panic là mặc định, còn lại cần bất biến; nhắm vào biên hệ thống, chạy fuzz theo lịch riêng và để go test thường chỉ chạy corpus.

Nguồn

Phần sau quay ống kính ngược lại: không hỏi mã có lỗi không, mà hỏi bộ test có đủ tốt để phát hiện lỗi không — cấy đột biến vào mã sản phẩm và đếm bao nhiêu đột biến sống sót qua bộ test đang xanh.