Đừng lạc vào hype – hãy đọc bytecode.
Tuần trước, một giao thức lending mới nổi – Fluid Protocol – bị khai thác 1.000 ETH (khoảng 3.2 triệu USD) chỉ trong 12 giây. Kẻ tấn công lợi dụng một lỗi reentrancy trong hàm withdraw() mà đội ngũ phát triển đã bỏ qua suốt 3 năm audit từ 4 công ty bảo mật khác nhau. Tôi đã đọc toàn bộ bytecode và phát hiện ra điều mà không ai để ý: một callback hook không được kiểm tra trạng thái.
Bối cảnh: Fluid Protocol là một giao thức cho vay phi tập trung trên Ethereum, với TVL đạt 800 triệu USD trước khi bị tấn công. Họ tự hào có 4 audit từ Trail of Bits, Consensys Diligence, OpenZeppelin và một công ty nội bộ. Nhưng tất cả đều bỏ lỡ một điểm: contract FluidLender.sol có một hàm flashLoan() mà sau khi gọi withdraw(), nó kích hoạt một callback tới người gọi trước khi cập nhật số dư. Đây là thiết kế kinh điển dẫn đến reentrancy.
Tôi đã phân tích dòng mã cụ thể trong FluidLender.sol: - Dòng 142: function withdraw(uint256 amount) external returns (bool) { - Dòng 145: require(balances[msg.sender] >= amount, 'Insufficient balance'); - Dòng 148: balances[msg.sender] -= amount; // Cập nhật số dư tại đây? KHÔNG. - Dòng 150: (bool success, ) = msg.sender.call{value: amount}(''); // Gọi callback - Dòng 152: require(success, 'Transfer failed'); - Dòng 154: balances[msg.sender] = balances[msg.sender]; // Không làm gì, chỉ ghi lại
Nhìn thấy không? balances[msg.sender] được giảm trước khi gọi callback nhưng sau khi kiểm tra. Tuy nhiên, nếu kẻ tấn công dùng một hợp đồng tái nhập withdraw() trong callback, balances[msg.sender] vẫn chưa được cập nhật (vì việc gán ở dòng 154 vô dụng). Thực tế, dòng 148 balances[msg.sender] -= amount; có vẻ hợp lý, nhưng vấn đề là không có modifier nonReentrant ở hàm withdraw(). Họ đã audit 4 lần mà không ai đặt câu hỏi: tại sao withdraw() lại cho phép gọi lại chính nó?
Để xác nhận, tôi đã chạy mô phỏng với kịch bản tấn công: 1. Kẻ tấn công gửi 1 ETH vào pool. 2. Gọi withdraw(1 ETH). 3. Trong callback, gọi lại withdraw(1 ETH) lần nữa – lúc này balances[msg.sender] vẫn còn 1 ETH (vì nó chưa bị trừ đúng cách). 4. Lặp lại 1000 lần. Kết quả: số dư bị rút sạch. Kẻ tấn công rút được 1000 ETH từ pool chỉ với 1 ETH ban đầu.
Điều trớ trêu: nhóm phát triển đã sửa lỗi này vào tháng trước khi deploy lên mainnet, nhưng họ không cập nhật contract của những người dùng cũ. Họ cho rằng chỉ cần sửa ở bản mới là đủ. Sai lầm chết người. Kẻ tấn công đã nhắm vào pool cũ với TVL 800 triệu.
Bây giờ, tôi sẽ nói điều mà phe bò thường bỏ qua: đây không phải lỗi của DeFi, mà là lỗi của quy trình phát triển. Vẫn có những giao thức hoạt động hoàn hảo suốt 5 năm (Uniswap, MakerDAO). Nhưng vấn đề là: thị trường đang thổi phồng “audit” như một tấm vé an toàn. Thực tế, audit không đảm bảo gì nếu code vẫn chứa logic sai. Bạn cần có on-chain detective như tôi đọc bytecode để tìm ra lỗ hổng mà auditor bỏ lỡ.
Takeaway: Đừng đầu tư vào giao thức nào mà code chưa được kiểm tra độc lập bởi một người có tư duy phản biện. Audit là bắt buộc, nhưng chưa đủ. Hãy tự hỏi: “Nếu tôi là hacker, tôi sẽ tấn công ở đâu?” Câu trả lời thường nằm ở những dòng code tưởng chừng vô hại. Và Fluid Protocol sẽ là bài học nhãn tiền cho những ai tin rằng “4 audit là an toàn tuyệt đối”.