Fuzzing Trình Biên Dịch Gleam: Phát Hiện 9 Lỗi Nhờ Sinh Chương Trình Ngẫu Nhiên
Bài viết giới thiệu dự án fuzzing có cấu trúc cho trình biên dịch Gleam, một ngôn ngữ lập trình biên dịch sang cả Erlang và JavaScript. Tác giả đã xây dựng một trình sinh chương trình ngẫu nhiên có kiểu an toàn để so sánh hành vi giữa hai nền tảng, từ đó phát hiện 9 lỗi, bao gồm cả lỗi trong codegen JavaScript và một lỗi được chuyển ngược lên Erlang/OTP.

Fuzzing Trình Biên Dịch Gleam: Cách Sinh Chương Trình Ngẫu Nhiên Để Tìm Bug
Dự án fuzzing có cấu trúc cho trình biên dịch Gleam đã tìm ra 9 vấn đề mới, bao gồm cả lỗi codegen JavaScript và một lỗi hiếm gặp được báo cáo ngược lên Erlang/OTP. Tác giả đã xây dựng một trình "smith" chương trình có kiểu an toàn (type-safe) để sinh vô số chương trình Gleam hợp lệ, sau đó chạy và so sánh kết quả giữa hai nền tảng Erlang và JavaScript — nơi mà mọi khác biệt đều là dấu hiệu tiềm năng của lỗi. Đây là một cách tiếp cận độc đáo kết hợp kỹ thuật fuzzing hiện đại với kiến trúc đa nền tảng đặc thù của Gleam.
Vì sao chọn Gleam để fuzzing?
Gleam là ứng viên lý tưởng cho kỹ thuật fuzzing có cấu trúc nhờ một vài đặc điểm nổi bật:
- Compile sang hai nền tảng: Erlang và JavaScript. Việc so sánh output của cùng một chương trình trên cả hai target giúp phát hiện các lỗi logic—nếu Erlang chạy đúng mà JavaScript sai, đó chính là bug.
- Cú pháp tối giản: So với nhiều ngôn ngữ phổ biến khác, cú pháp Gleam khá gọn gàng, giúp việc sinh ra các chương trình hợp lệ bao phủ gần hết các khái niệm ngôn ngữ chỉ với một lượng mã tương đối nhỏ.
- Tính hàm (functional): Mọi thứ trong Gleam đều là biểu thức, giúp việc tổ hợp và xây dựng chương trình một cách tự nhiên.
- Viết bằng Rust: Điều này giúp tích hợp dễ dàng với các công cụ fuzzing vốn rất mạnh trong hệ sinh thái Rust như
cargo-fuzzvàlibFuzzer. Bạn có thể test từng phần của compiler mà không cần chạy bất kỳ file.gleamnào.
Chiến lược fuzzing: hai giai đoạn
Tác giả chia dự án thành hai giai đoạn chính, mỗi giai đoạn nhắm vào một lớp lỗi khác nhau.
Giai đoạn 1: Fuzzing trình phân tích cú pháp (Parser)
Đây là bước đơn giản nhất: dùng libFuzzer để "bắn" các chuỗi byte ngẫu nhiên không có cấu trúc vào compiler, mục tiêu chỉ là tìm xem có làm cho trình biên dịch crash hay không.
Ngay lập tức, cách tiếp cận này đã phát hiện một lỗi hồi quy trên bản nightly mà bản v1.18.1 (bản mới nhất tại thời điểm viết bài) không mắc phải:
thread panicked at src/parse.rs:5226:52:
Token could not be converted to binop.
Input gây lỗi hóa ra lại cực kỳ đơn giản, chỉ là const b = 1 |> 2 — một phép pipeline trong biểu thức hằng số.
Giai đoạn 2: Sinh chương trình có kiểu an toàn (Type-safe Programs)
Đây là phần thú vị và phức tạp hơn. Ném byte ngẫu nhiên vào compiler không thể tìm thấy lỗi trong code generation—vì để lọt qua được bước phân tích kiểu (type analysis), chương trình phải hợp lệ.
Tác giả xây dựng một "smith" : một thư viện sinh chương trình Gleam có cấu trúc. Khi cần một biểu thức có kiểu Int, thay vì chỉ đưa ra số 3, nó có thể chọn cách tạo một hàm ẩn danh fn() { 3 }() hoặc một biến, hoặc phép toán... Điều này tạo ra vô số biến thể ngẫu nhiên và đầy đủ ý nghĩa.
Kết quả là những chương trình "vô nghĩa" nhưng lại cực kỳ giá trị:
pub const k_seed: Bool = False
pub const k_e: Int = 5
fn walk(xs: List(Int), acc: Int) -> Int {
case xs {
[] -> acc
[x, ..rest] -> walk(rest, acc + x)
}
}
// ... và rất nhiều code phức tạp khác
"Đó là một mớ hỗn độn, và đó chính là ý đồ!" — tác giả chia sẻ. Mục tiêu là kết hợp các biểu thức lẻ với nhau để tìm ra những tổ hợp chưa ai nghĩ tới.
Vấn đề "echo" và so sánh kết quả
Cách phát hiện bug chính là so sánh output của chương trình trên cả hai target Erlang và JavaScript. Nhưng có một vấn đề: JavaScript và Erlang biểu diễn giá trị runtime khác nhau.
Ví dụ với lệnh echo:
- Erlang: bit array
<<1, 2, 3>>được in ra là"\u{0001}\u{0002}\u{0003}", còn1.0được in là1.0. - JavaScript: bit array được in là
<<1, 2, 3>>, còn1.0lại được in là1(vì JS không phân biệt float và int).
Điều này khiến việc so sánh output trực tiếp trở nên khó khăn.
Tác giả đã chọn giải pháp: viết code Rust để "parse" output của lệnh echo, dựa trên AST của module đã biết trước những giá trị nào sẽ được in ra. Đây là một vấn đề được ràng buộc rõ ràng, và kết quả là một bộ enum Rust có thể so sánh được giữa hai nền tảng.
Xử lý các phát hiện trùng lặp
Một khi đã tìm ra bug, trình fuzzer sẽ liên tục tạo ra những chương trình khác nhau nhưng đều rơi vào đúng cái bug đó. Điều này rất phiền phức.
Tác giả sử dụng các heuristic — kiểm tra chuỗi output hoặc dấu hiệu của lỗi đã biết để bỏ qua chúng:
// https://github.com/gleam-lang/gleam/issues/6182
fn is_gleam_issue_6182(raw: &str) -> bool {
raw.contains("SyntaxError: Unexpected token '&&'")
|| raw.contains("SyntaxError: Unexpected token ')'")
}
"Giải pháp này có nhược điểm là có thể bỏ lỡ những bug mới có signature tương tự, nhưng với quy mô current, việc kiểm tra thủ công 100 chương trình mỗi batch vẫn khả thi."
Những phát hiện đáng chú ý
Sau thời gian chạy các batch chương trình, trình fuzzer đã tìm ra 9 vấn đề:
- Unreachable branches gây panic Erlang codegen
- Lỗi codegen JavaScript khi matching với bit array
- Compiler crash trên nightly khi dùng
|>trongconst - Hàm
wibbleshadowing biến local trên JavaScript - Một lỗi được chuyển ngược lên Erlang/OTP — đây là thành quả đáng tự hào nhất
Điều này cho thấy ngay cả những dự án đã "chín" như Erlang/OTP cũng có thể có bug mà chỉ fuzzing "nghịch ngợm" mới phát hiện ra.
Tương lai của dự án
Tác giả nêu ra nhiều hướng phát triển đầy tiềm năng:
- Tích hợp vào GitHub Actions để chạy khi maintainer review PR
- Xây dựng cơ chế deduplicate thông minh hơn thay vì fix cứng bằng heuristic
- Fuzzing cho language server (vốn rất dễ trigger lỗi ở các state không ngờ tới)
- Áp dụng metamorphic testing — sinh ra các chương trình tương đương nhau để so sánh output
- Fuzzing cho generics và type inference — những nơi cần một "smith" phức tạp hơn
- Chạy LLM fuzzing với model mở trên GPU để tìm kiếm tự động 24/7
Kết luận
"Phần thú vị nhất của một test suite là nó luôn mang tính cộng dồn — mỗi edge case mới, mỗi bug fix đều giúp cho tương lai an toàn hơn."
Dự án này là một minh chứng tuyệt vời cho thấy sức mạnh của kỹ thuật fuzzing có cấu trúc: không chỉ tìm ra các lỗi crash đơn giản, mà còn phát hiện những lỗi logic tinh vi trong code generation, nơi mà việc giữ cho hai target Erlang và JavaScript hoạt động nhất quán là một bài toán cực kỳ khó.
Với cộng đồng Gleam tại Việt Nam, đây cũng là một nguồn cảm hứng để tham gia đóng góp cho các dự án open source — không chỉ viết tính năng mới, mà còn là tìm ra những bug thú vị thông qua các công cụ mạnh mẽ như fuzzing.