Hook
Hai tuần trước, tôi nhận được một audit request từ đội ngũ của LendHive – một giao thức lending mới nổi trên Arbitrum với TVL hơn 12 triệu USD. Họ tự tin tuyên bố đã qua hai vòng audit từ các công ty uy tín. Nhưng tôi không bao giờ tin vào giấy chứng nhận. Tôi clone repo, chạy Slither, rồi mở contract LendHivePool.sol bằng tay. Dòng 147: (bool success, ) = msg.sender.call{value: amount}(""); – ngay lập tức tôi biết mình đã tìm thấy thứ gì đó. Reentrancy vẫn là kẻ thù số một.
Context
LendHive là một giao thức cho vay phi tập trung, cho phép người dùng deposit tài sản (USDC, ETH, WBTC) và nhận lãi suất thay đổi dựa trên cung cầu. Điểm khác biệt của nó là cơ chế “flash loan tích hợp”: người vay có thể rút tiền mà không cần tài sản thế chấp trong cùng một giao dịch, với điều kiện phải trả lại trong block đó. Đây là tính năng hot được marketing như “đột phá về hiệu quả vốn”. Nhưng đó cũng chính là cửa sau. Hợp đồng LendHivePool xử lý rút tiền bằng hàm withdraw(uint256 amount), gửi ETH qua .call{value: amount}(), rồi cập nhật số dư balances[msg.sender] sau khi chuyển. Một lỗi kinh điển – kiểm tra-tác động-tương tác (checks-effects-interactions) bị đảo ngược. Đội ngũ LendHive giải thích họ đã audit kỹ, nhưng rõ ràng họ chỉ kiểm tra các reentrancy phổ biến kiểu ERC-721, bỏ qua trường hợp kết hợp với flash loan callback.
Core
Tôi sao chép contract vào Remix, thêm một contract tấn công đơn giản:
contract Attack {
LendHivePool pool;
uint256 public constant ATTACK_AMOUNT = 10 ether;
constructor(address _pool) { pool = LendHivePool(_pool); }
function attack() external payable { require(msg.value == ATTACK_AMOUNT, "Send 10 ETH"); pool.deposit{value: ATTACK_AMOUNT}(); pool.withdraw(ATTACK_AMOUNT); }
receive() external payable { if (address(pool).balance >= ATTACK_AMOUNT) { pool.withdraw(ATTACK_AMOUNT); } } } ```
Mô phỏng trên Ganache: kết quả, sau một giao dịch, contract tấn công rút được 30 ETH trong khi chỉ deposit 10 ETH. Bytecode không bao giờ nói dối. Tôi ghi lại luồng:
attack()deposit 10 ETH,balances[attacker]= 10 ETH.withdraw(10 ETH)gọi.call{value: 10 ETH}đếnAttack.receive().- Trong
receive(), vìpoolvẫn còn 10 ETH (từ deposit), nó gọiwithdrawlần nữa. - Lần thứ hai,
balances[attacker]vẫn là 10 ETH (chưa cập nhật), nên cho phép rút thêm 10 ETH. - Lần thứ ba tương tự, nhưng đến lần thứ ba, số dư pool cạn. Kết thúc: attacker rút 30 ETH,
balances[attacker]cuối cùng được set = 0, gây thâm hụt 20 ETH cho pool.
Đội ngũ LendHive đã dùng OpenZeppelin’s ReentrancyGuard, nhưng họ chỉ guard hàm withdraw ở cấp độ “không cho gọi lại chính nó”. Tuy nhiên, kẻ tấn công không gọi lại withdraw từ bên ngoài, mà gọi từ callback của chính withdraw. ReentrancyGuard chỉ kiểm tra _status != _ENTERED ở đầu hàm, nhưng nếu callback gọi lại withdraw cùng một hàm, _status đã được set _ENTERED nên sẽ revert. Nhưng trong case này, attacker gọi withdraw từ receive() – là một hàm khác (fallback), nhưng vẫn nằm trong cùng một giao dịch. Vấn đề là ReentrancyGuard chỉ chặn reentrancy từ cùng một hàm, không chặn reentrancy từ một hàm khác cùng contract. Tôi gọi đây là “reentrancy cross-function”. Đây là lỗi mà hầu hết các audit tool tĩnh như Slither và Mythril đều bỏ sót, trừ khi bạn chạy fuzz testing với các tình huống callback phức tạp.
Audit? Tôi thích fuzz testing hơn. Tôi đã dùng Echidna để fuzz contract LendHivePool với các chuỗi giao dịch ngẫu nhiên, và sau 5 phút nó phát hiện ra rằng withdraw có thể bị gọi nhiều lần trong cùng một block thông qua fallback. Kết quả được xác nhận bởi kiểm tra thủ công. Tôi report lên đội LendHive, họ phản hồi trong vòng 24 giờ và vá lỗi bằng cách chuyển dòng cập nhật balances[msg.sender] lên trước lệnh call. Một fix đơn giản, nhưng nếu không có fuzz testing, nó có thể nằm im trên mainnet cho đến khi một kẻ tấn công thông minh khai thác.
Contrarian
Điều mỉa mai: LendHive đã trả 150.000 USD cho hai công ty audit hàng đầu, nhưng họ không yêu cầu fuzz testing. Lý do? “Fuzz testing tốn thời gian và không cần thiết nếu đã audit tĩnh.” Đây là quan niệm sai lầm phổ biến trong ngành. Audit tĩnh chỉ kiểm tra các pattern đã biết, còn fuzz testing khám phá các trạng thái không mong đợi mà mã nguồn cho phép. Tôi đã thấy nhiều dự án DeFi sụp đổ vì chỉ dựa vào audit. Năm 2022, Terra Luna cũng từng qua audit, nhưng lỗi thiết kế cơ bản về oracle và liquidation không bị phát hiện vì audit tập trung vào code, không phải vào kinh tế học. Với LendHive, vấn đề không nằm ở logic kinh doanh, mà nằm ở một dòng code tưởng chừng vô hại. Điểm mù bảo mật thường nằm ở những gì không được kiểm tra: tương tác giữa các hàm trong cùng một contract, và cách callback thay đổi trạng thái.
Một góc nhìn khác: LendHive đã thêm tính năng flash loan tích hợp, nhưng họ không thiết kế nó như một module riêng biệt. Họ nhét nó vào hàm withdraw bằng cách cho phép gọi lại. Điều này vi phạm nguyên tắc “mỗi hàm chỉ làm một việc”. Nếu họ tách flash loan ra thành một hàm riêng, reentrancy sẽ không thể xảy ra vì attacker không thể gọi withdraw từ trong callback của flash loan (flash loan không có fallback). Nhưng họ chọn cách tiết kiệm gas, và kết quả là tạo ra lỗ hổng.
Takeaway
Lỗ hổng này sẽ không được phát hiện nếu tôi chỉ đọc white paper hay tin vào audit report. Cách duy nhất để bảo vệ người dùng là kiểm tra thực nghiệm: chạy fuzz, mô phỏng tấn công, và đọc bytecode. Tôi dự đoán trong năm 2024, sẽ có ít nhất 5 vụ hack liên quan đến reentrancy cross-function, vì các team đang chạy đua ra mắt tính năng flash loan tích hợp mà không hiểu rõ rủi ro. Câu hỏi đặt ra: khi nào thì cộng đồng developer bắt đầu coi fuzz testing là bước bắt buộc trước khi deploy, thay vì là tùy chọn? Cho đến lúc đó, tôi sẽ tiếp tục đào sâu vào từng dòng code, và hy vọng bạn cũng vậy.