Là một lập trình viên, bạn không thể lường trước được tất cả các đầu vào mà chương trình hoặc hàm của mình có thể nhận được. Mặc dù bạn có thể xác định các trường hợp đặc biệt chính, nhưng bạn vẫn không thể dự đoán chương trình của mình sẽ hoạt động như thế nào trong trường hợp có một số đầu vào bất ngờ kỳ lạ. Nói cách khác, bạn thường chỉ có thể tìm thấy những lỗi mà bạn mong đợi tìm thấy.
Đó là lúc kiểm thử mờ hay fuzzing đến để giải cứu. Và trong hướng dẫn này, bạn sẽ học cách thực hiện kiểm thử mờ trong Go.
Fuzz Testing là gì?
Fuzzing là một kỹ thuật kiểm thử phần mềm tự động liên quan đến việc nhập một lượng lớn dữ liệu ngẫu nhiên hợp lệ, gần như hợp lệ hoặc không hợp lệ vào một chương trình máy tính và quan sát hành vi và đầu ra của nó. Vì vậy, mục tiêu của fuzzing là tiết lộ lỗi, sự cố và lỗ hổng bảo mật trong mã nguồn mà bạn có thể không tìm thấy thông qua các phương pháp kiểm thử truyền thống.
Mã Go có thể hoạt động tốt trừ khi bạn cung cấp một đầu vào nhất định, ví dụ như:
func Equal(a []byte, b []byte) bool { for i := range a { // can panic with runtime error: index out of range. if a[i] != b[i] { return false } } return true
}
Hàm mẫu này hoạt động hoàn hảo miễn là độ dài của hai slices bằng nhau. Nhưng nó sẽ hoảng sợ khi slice đầu tiên dài hơn slice thứ hai (lỗi index out of range). Hơn nữa, nó không trả về kết quả chính xác khi slice thứ hai là tập hợp con của slice thứ nhất.
Kỹ thuật fuzzing sẽ dễ dàng phát hiện ra lỗi này bằng cách bắn phá hàm này với nhiều đầu vào khác nhau.
Việc tích hợp fuzzing vào vòng đời phát triển phần mềm (SDLC) của nhóm bạn cũng là một cách thực hành tốt. Ví dụ: Microsoft sử dụng fuzzing như một trong những giai đoạn trong SDLC của mình để tìm ra các lỗi và lỗ hổng tiềm ẩn.
Fuzz Testing trong Go và các bước thực hiện
Có rất nhiều công cụ fuzzing đã có sẵn được một thời gian – chẳng hạn như oss-fuzz – nhưng kể từ Go 1.18, fuzzing đã được thêm vào thư viện chuẩn của Go. Vì vậy, bây giờ nó là một phần của gói kiểm thử thông thường vì nó là một loại kiểm thử. Bạn cũng có thể sử dụng nó cùng với các nguyên thủy kiểm thử khác, điều này rất hay.
Các bước để tạo fuzz test trong Go như sau:
- Trong một tệp _test.go, hãy tạo một hàm bắt đầu bằng Fuzz chấp nhận *testing.F
- Thêm các hạt giống corpus bằng f.Add() để cho phép fuzzer tạo dữ liệu dựa trên nó.
- Gọi fuzz target bằng f.Fuzz() bằng cách chuyển các đối số fuzzing mà hàm mục tiêu của chúng tôi chấp nhận.
- Khởi động fuzzer bằng lệnh go test thông thường, nhưng với cờ –fuzz=Fuzz
Lưu ý rằng các đối số fuzzing chỉ có thể thuộc các loại sau:
- string, byte, []byte
- int, int8, int16, int32/rune, int64
- uint, uint8, uint16, uint32, uint64
- float32, float64
- bool
Một fuzz test đơn giản cho hàm Equal ở trên có thể trông như thế này:
// Fuzz test
func FuzzEqual(f *testing.F) { // Seed corpus addition f.Add([]byte{'f', 'u', 'z', 'z'}, []byte{'t', 'e', 's', 't'}) // Fuzz target with fuzzing arguments f.Fuzz(func(t *testing.T, a []byte, b []byte) { // Call our target function and pass fuzzing arguments Equal(a, b) })
}
Theo mặc định, các fuzz test chạy mãi mãi, vì vậy bạn cần chỉ định giới hạn thời gian hoặc đợi fuzz test thất bại. Bạn có thể chỉ định test nào để chạy bằng cách sử dụng đối số --fuzz.
go test --fuzz=Fuzz -fuzztime=10s
Nếu có bất kỳ lỗi nào trong quá trình thực thi, đầu ra sẽ trông giống như sau:
go test --fuzz=Fuzz -fuzztime=30s
--- FAIL: FuzzEqual (0.02s) --- FAIL: FuzzEqual (0.00s) testing.go:1591: panic: runtime error: index out of range Failing input written to testdata/fuzz/FuzzEqual/84ed65595ad05a58 To re-run: go test -run=FuzzEqual/84ed65595ad05a58
Lưu ý rằng đầu vào mà fuzz test đã thất bại được ghi vào một tệp trong thư mục testdata và có thể được phát lại bằng cách sử dụng mã định danh đầu vào đó.
go test -run=FuzzEqual/84ed65595ad05a58
Thư mục testdata có thể được kiểm tra vào kho lưu trữ và được sử dụng cho các bài kiểm tra thông thường, bởi vì các fuzz test cũng có thể hoạt động như các bài kiểm tra thông thường khi được thực thi mà không có cờ --fuzz.
Fuzzing HTTP Services: Kiểm thử với httptest
Cũng có thể fuzz test các dịch vụ HTTP bằng cách viết test cho HandlerFunc của bạn và sử dụng gói httptest. Điều này có thể rất hữu ích nếu bạn cần kiểm tra toàn bộ dịch vụ HTTP, không chỉ các hàm bên dưới.
Bây giờ, chúng ta hãy giới thiệu một ví dụ thực tế hơn, chẳng hạn như HTTP Handler chấp nhận một số đầu vào của người dùng trong phần thân yêu cầu và sau đó viết một fuzz test cho nó.
Trình xử lý của chúng tôi chấp nhận yêu cầu JSON với các trường limit và offset để phân trang một số dữ liệu tĩnh được mock. Hãy xác định các loại trước.
type Request struct { Limit int `json:"limit"` Offset int `json:"offset"`
} type Response struct { Results []int `json:"items"` PagesCount int `json:"pagesCount"`
}
Hàm handler của chúng ta sau đó phân tích cú pháp JSON, phân trang slice tĩnh và trả về một JSON mới để phản hồi.
func ProcessRequest(w http.ResponseWriter, r *http.Request) { var req Request // Decode JSON request if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } // Apply offset and limit to some static data all := make([]int, 1000) start := req.Offset end := req.Offset + req.Limit res := Response{ Results: all[start:end], PagesCount: len(all) / req.Limit, } // Send JSON response if err := json.NewEncoder(w).Encode(res); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } w.WriteHeader(http.StatusOK)
}
Như bạn có thể đã nhận thấy, hàm này không xử lý các hoạt động slice rất tốt và có thể dễ dàng panic. Ngoài ra, nó có thể panic nếu nó cố gắng chia cho 0. Sẽ rất tốt nếu chúng ta có thể phát hiện ra điều này trong quá trình phát triển hoặc chỉ sử dụng các unit test, nhưng đôi khi không phải mọi thứ đều hiển thị với mắt chúng ta và trình xử lý của chúng ta có thể chuyển đầu vào cho các hàm khác, v.v.
Theo ví dụ FuzzEqual của chúng ta ở trên, hãy triển khai fuzz test cho trình xử lý ProcessRequest. Điều đầu tiên chúng ta cần làm là cung cấp các đầu vào mẫu cho fuzzer. Đây là dữ liệu mà fuzzer sẽ sử dụng và sửa đổi thành các đầu vào mới được thử. Chúng ta có thể tạo một số yêu cầu JSON mẫu và sử dụng f.Add() với loại []byte.
func FuzzProcessRequest(f *testing.F) { // Create sample inputs for the fuzzer testRequests := []Request{ {Limit: -10, Offset: -10}, {Limit: 0, Offset: 0}, {Limit: 100, Offset: 100}, {Limit: 200, Offset: 200}, } // Add to the seed corpus for _, r := range testRequests { if data, err := json.Marshal(r); err == nil { f.Add(data) } } // ...
}
Sau đó, chúng ta có thể sử dụng gói httptest để tạo máy chủ HTTP thử nghiệm và gửi yêu cầu đến nó.
Lưu ý: Vì fuzzer của chúng ta có thể tạo các yêu cầu không phải JSON không hợp lệ, nên tốt hơn là chỉ bỏ qua chúng và bỏ qua bằng t.Skip(). Chúng ta cũng có thể bỏ qua các lỗi BadRequest.
func FuzzProcessRequest(f *testing.F) { // ... // Create a test server srv := httptest.NewServer(http.HandlerFunc(ProcessRequest)) defer srv.Close() // Fuzz target with a single []byte argument f.Fuzz(func(t *testing.T, data []byte) { var req Request if err := json.Unmarshal(data, &req); err != nil { // Skip invalid JSON requests that may be generated during fuzz t.Skip("invalid json") } // Pass data to the server resp, err := http.DefaultClient.Post(srv.URL, "application/json", bytes.NewBuffer(data)) if err != nil { t.Fatalf("unable to call server: %v, data: %s", err, string(data)) } defer resp.Body.Close() // Skip BadRequest errors if resp.StatusCode == http.StatusBadRequest { t.Skip("invalid json") } // Check status code if resp.StatusCode != http.StatusOK { t.Fatalf("non-200 status code %d", resp.StatusCode) } })
}
Fuzz target của chúng ta có một đối số duy nhất với loại []byte chứa toàn bộ yêu cầu JSON, nhưng bạn có thể thay đổi nó để có nhiều đối số.
Mọi thứ đã sẵn sàng để chạy fuzz test của chúng ta. Khi fuzzing các máy chủ HTTP, bạn có thể cần điều chỉnh số lượng worker song song, nếu không tải có thể làm quá tải máy chủ thử nghiệm. Bạn có thể làm điều đó bằng cách đặt cờ -parallel=1.
go test --fuzz=Fuzz -fuzztime=10s -parallel=1
go test --fuzz=Fuzz -fuzztime=30s
--- FAIL: FuzzProcessRequest (0.02s) --- FAIL: FuzzProcessRequest (0.00s) runtime error: integer divide by zero runtime error: slice bounds out of range
Và như mong đợi, chúng ta sẽ thấy các lỗi trên được phát hiện.
Chúng ta cũng có thể thấy các đầu vào fuzz trong thư mục testdata để xem JSON nào đã góp phần gây ra lỗi này. Dưới đây là nội dung mẫu của tệp:
go test fuzz v1
[]byte("{"limit":0,"offset":0}")
Để khắc phục sự cố đó, chúng ta có thể giới thiệu xác thực đầu vào và cài đặt mặc định:
if req.Limit <= 0 { req.Limit = 1
}
if req.Offset < 0 { req.Offset = 0
}
if req.Offset > len(all) { start = len(all) - 1
}
if end > len(all) { end = len(all)
}
Với thay đổi này, các fuzz test sẽ chạy trong 10 giây và thoát mà không có lỗi.
Kết luận
Viết fuzz test cho các dịch vụ HTTP hoặc bất kỳ phương thức nào khác của bạn là một cách tuyệt vời để phát hiện các lỗi khó tìm. Fuzzers có thể phát hiện các lỗi khó phát hiện chỉ xảy ra đối với một số đầu vào bất ngờ kỳ lạ.
Thật tuyệt vời khi thấy rằng fuzzing là một phần của thư viện kiểm thử tích hợp sẵn của Go, giúp dễ dàng kết hợp với các bài kiểm tra thông thường. Lưu ý: trước Go 1.18, các nhà phát triển đã sử dụng go-fuzz, đây cũng là một công cụ tuyệt vời để fuzzing.