Trong 7 ngày qua, ba dự án NFT marketplace nhỏ đã báo cáo tổn thất tổng cộng 2.4 triệu USD do một lỗ hổng reentrancy trong hàm batch mint. Sự kiện này không gây chấn động lớn trên thị trường, nhưng với tư cách là một người từng kiểm toán hợp đồng thông minh, tôi nhận thấy đây là một lời nhắc nhở âm thầm: những cạm bẫy tưởng chừng đã được giải quyết vẫn còn tồn tại, ẩn mình trong những dòng code tưởng chừng vô hại.
Hàm batch mint là một tính năng phổ biến trong các hợp đồng NFT, cho phép người dùng mint nhiều token trong một giao dịch duy nhất nhằm tiết kiệm gas. Cấu trúc điển hình của nó là một vòng lặp for gọi hàm _safeMint hoặc _mint cho từng token ID. Trong các dự án bị tấn công, họ đã sử dụng thư viện OpenZeppelin và đã bật ReentrancyGuard ở cấp độ hàm chính. Tuy nhiên, lỗ hổng lại nằm ở cách họ xử lý callback từ hợp đồng người nhận. Cụ thể, khi _safeMint được gọi, nó kiểm tra xem địa chỉ nhận có phải là hợp đồng không, và nếu có, nó sẽ gọi hàm onERC721Received của hợp đồng đó. Chính tại điểm này, kẻ tấn công có thể thực hiện cuộc gọi lại (callback) vào chính hàm batch mint đang chạy, bỏ qua guard chỉ bảo vệ một lần vào hàm chính.
Để hiểu rõ, chúng ta hãy xem xét một đoạn code đơn giản hóa. Giả sử có hàm batchMint(address to, uint256[] memory tokenIds) external nonReentrant { for(uint i; inonReentrant ngăn việc gọi lại batchMint trực tiếp, nhưng nó không ngăn được các cuộc gọi đến các hàm khác trong cùng hợp đồng, chẳng hạn như một hàm emergencyWithdraw không được bảo vệ. Kẻ tấn công có thể tạo một hợp đồng độc hại, trong onERC721Received của nó, nó gọi lại emergencyWithdraw để rút toàn bộ số dư ETH hoặc token. Đây là một kiểu tấn công reentrancy cổ điển, nhưng vẫn xuất hiện. Trong mỗi lần gọi lại, có một cánh cửa sập vô hình mà team phát triển đã bỏ quên.
Điều đáng nói là cả ba dự án đều đã sử dụng công cụ phân tích tĩnh như Slither để quét hợp đồng. Slither có thể phát hiện reentrancy cơ bản, nhưng nó thường bỏ sót các trường hợp gián tiếp: khi _safeMint gọi callback, và callback gọi một hàm khác trong cùng hợp đồng (không phải chính batchMint). Một số lập trình viên cho rằng việc thêm nonReentrant vào tất cả các hàm có thể thay đổi state là đủ, nhưng điều này tạo ra một ảo giác an toàn. Trong mỗi lần gọi lại, có một cánh cửa sập vô hình – lần này là hàm withdraw không được bảo vệ, hoặc một hàm setApprovalForAll có thể được kẻ tấn công lợi dụng.
Theo kinh nghiệm kiểm toán của tôi, giải pháp triệt để không chỉ là thêm nonReentrant ở mọi nơi, mà còn phải tuân theo mô hình Checks-Effects-Interactions: cập nhật state trước khi thực hiện bất kỳ cuộc gọi ngoài nào. Trong hàm batch mint, điều này có nghĩa là phải đánh dấu token là đã mint (bằng cách tăng _totalSupply, hoặc thêm vào mapping) trước khi gọi _safeMint. Nếu thực hiện đúng, khi callback xảy ra, token đã được mint và state đã thay đổi, khiến cho các nỗ lực gọi lại không thể thay đổi được gì thêm. Nhưng hầu hết các lập trình viên đều viết code theo thứ tự tự nhiên: mint trước, cập nhật state sau, vì họ nghĩ rằng nonReentrant đã đủ.
Một góc nhìn phản trực giác khác: thị trường thường xem reentrangy là vấn đề đã được giải quyết từ năm 2016 (DAO hack), và các công cụ hiện đại được cho là đã bắt được hết. Nhưng thực tế, mỗi khi có một abstraction mới như _safeMint, bề mặt tấn công lại mở rộng. Các đội ngũ nhỏ, thiếu kinh nghiệm có xu hướng copy-paste code từ các dự án nổi tiếng mà không hiểu sâu các giả định bảo mật. Họ thấy OpenZeppelin cung cấp ReentrancyGuard và nghĩ rằng chỉ cần import là xong. Họ không lường trước rằng _safeMint chính là một điểm vào (entry point) cho callback, và callback có thể nhắm vào bất kỳ hàm nào khác.
Trong mỗi lần gọi lại, có một cánh cửa sập vô hình. Câu này không chỉ là một ẩn dụ; nó là lời cảnh báo về sự phức tạp ẩn dưới các lớp trừu tượng. Khi một giao thức mới ra mắt với tính năng batch mint, tôi thường kiểm tra xem họ có xử lý callback an toàn không, chứ không chỉ dựa vào guard. Những gì xảy ra với ba dự án vừa qua là một minh chứng rõ ràng: ngay cả khi đã có guard, vẫn có thể có cánh cửa sập nếu bạn không kiểm soát thứ tự thực thi.
Cuối cùng, câu hỏi dành cho các nhà phát triển và người dùng: Liệu bạn có đang tin tưởng vào một lớp bảo vệ mà không hiểu rõ giới hạn của nó không? Khi thị trường đi xuống và vốn trở nên khan hiếm, những lỗ hổng như thế này sẽ trở thành điểm chảy máu chết người. Hãy nhìn vào code, không chỉ vào audit report.