Một dòng require không đảm bảo an toàn — đó là câu nói tôi lặp lại với mọi đội ngũ khởi nghiệp đến xin audit.
Tuần trước, tôi nhận được mã nguồn từ một dự án lending mới trên Base chain. Họ đã huy động được 3 triệu USD từ quỹ đầu tư mạo hiểm. Hợp đồng thông minh nhìn sạch sẽ, giao diện đẹp, roadmap hứa hẹn. Tôi mở file LendingPool.sol, dòng 89:
function withdraw(uint256 amount) external {
require(balanceOf[msg.sender] >= amount, "Insufficient balance");
(bool success, ) = msg.sender.call{value: amount}("");
require(success, "Transfer failed");
balanceOf[msg.sender] -= amount;
}
Đây là lỗi reentrancy cổ điển — giống hệt vụ DAO hack năm 2016. Tất cả những gì kẻ tấn công cần làm là deploy một hợp đồng callback, gọi withdraw trong hàm receive(), và rút toàn bộ pool trước khi số dư được cập nhật.
Tôi gửi PoC cho đội ngũ. Họ phản hồi trong 2 giờ: "Chúng tôi đã đọc qua OpenZeppelin, nhưng không nghĩ điều này áp dụng cho cơ chế của chúng tôi."
Không, họ nghĩ rằng require(success) là đủ. Nó không đủ. Trong smart contract, không có gọi là "kiểm tra đầy đủ" — chỉ có "kiểm tra đúng thứ tự".
Context: Tại sao reentrancy vẫn là vấn đề số một trong 2024?
Khi tôi bắt đầu audit vào năm 2017, reentrancy là lỗ hổng được nói đến nhiều nhất. Seven năm sau, nó vẫn đứng đầu danh sách lý do các giao thức DeFi mất tiền. Theo báo cáo của Immunefi, reentrancy chiếm 45% tổng số vụ hack trong Q1 2024, với tổng thiệt hại hơn 200 triệu USD.
Tại sao? Bởi vì mỗi thế hệ developer mới đều tự tin rằng họ "hiểu" vấn đề, nhưng không ai thực sự test edge cases. Họ đọc OpenZeppelin, thấy pattern Checks-Effects-Interactions, và nghĩ: "À, mình biết rồi." Nhưng khi triển khai, họ vẫn đặt call trước khi cập nhật state.
Có ba lý do kỹ thuật chính khiến reentrancy sống dai:

- Sự phức tạp của cross-contract calls: Khi một hợp đồng gọi hợp đồng khác, đặc biệt qua
delegatecall, việc theo dõi flow trở nên khó khăn. Một lỗi nhỏ trong logic callback có thể mở ra vector tấn công.
- Flash loan cho phép tái sử dụng vốn: Kẻ tấn công không cần có sẵn tiền. Họ vay triệu USD từ Aave, thực hiện reentrancy, trả nợ — tất cả trong một block. Điều này làm tăng scale của tấn công.
- Tối ưu hóa gas sai lầm: Nhiều developer cố gắng giảm gas bằng cách gộp các thao tác. Họ move
requirera ngoài, đặtcalltrước state update, và nghĩ rằng mình đang "smart". Họ không phải. Họ đang mở cửa cho hacker.
Core: Phân tích mã nguồn từ góc nhìn audit
Tôi đã decompile 15 hợp đồng bị hack trong 6 tháng qua. Pattern lặp lại giống nhau đến mức nhàm chán. Hãy xem một ví dụ thực tế từ dự án tôi audit tuần trước:
function flashLoan(address token, uint256 amount) external {
uint256 balanceBefore = IERC20(token).balanceOf(address(this));
IERC20(token).transfer(msg.sender, amount);
IFlashLoanReceiver(msg.sender).executeOperation{gas: gasleft()}(token, amount, fee); uint256 balanceAfter = IERC20(token).balanceOf(address(this)); require(balanceAfter >= balanceBefore + fee, "Not enough repayment"); } ```

Lỗi ở đây là gì? transfer được thực hiện trước khi executeOperation. Nếu executeOperation gọi lại flashLoan, token sẽ được chuyển nhiều lần. Dự án này đã mất 1.2 triệu USD trong testnet.

Giải pháp? Sử dụng reentrancy guard từ OpenZeppelin — không phải tự viết. nonReentrant modifier đã được test hàng nghìn lần. Đừng sáng tạo lại bánh xe.
Tôi cũng khuyên sử dụng pattern Checks-Effects-Interactions một cách nghiêm ngặt. Nghĩa là: kiểm tra điều kiện → cập nhật state → tương tác bên ngoài. call luôn ở cuối cùng.
Contrarian: Điểm mù bảo mật mà kiểm toán viên thường bỏ qua
Tôi sẽ nói điều mà ít người trong ngành muốn nghe: Kiểm toán không phải là vé an toàn. Đa số kiểm toán viên chỉ chạy Slither và Mythril, đọc qua báo cáo, và ký tên. Họ không test trên môi trường thực tế với các điều kiện edge case.
Một điểm mù phổ biến là cross-function reentrancy. Khi hàm A gọi hàm B, và B có thể gọi lại A trước khi A kết thúc. Các công cụ tự động thường bỏ lỡ điều này. Chỉ có audit thủ công với test case cụ thể mới phát hiện được.
Ví dụ: Tôi từng gặp một hợp đồng staking, nơi hàm stake gọi _mint (mint token ERC-20), và _mint trigger transfer callback đến người nhận. Trong callback, kẻ tấn công có thể gọi unstake — rút tiền trước khi stake được hoàn tất. Kết quả: họ nhận cả token staking lẫn token gốc.
Điểm mù thứ hai là reentrancy qua delegatecall. Khi một proxy contract gọi implementation qua delegatecall, nếu implementation có lỗ hổng reentrancy, proxy cũng bị ảnh hưởng. Nhưng vì proxy không xử lý logic trực tiếp, nhiều developer nghĩ rằng nó an toàn. Sai.
Takeaway: Dự báo lỗ hổng sẽ xuất hiện ở đâu?
Tôi dự đoán trong 12 tháng tới, reentrancy sẽ tấn công mạnh vào các giao thức cross-chain messaging và intent-based architecture. Khi message từ chain A đến chain B được xử lý, nếu không có reentrancy guard, kẻ tấn công có thể gọi lại bridge contract trước khi message được xác nhận.
Cụ thể: Wormhole và LayerZero đã từng bị hack vì vấn đề này. Các fork của chúng sẽ tiếp tục lặp lại sai lầm.
Câu hỏi tôi để lại cho bạn đọc: Khi bạn deploy contract tiếp theo, bạn có chắc rằng require của bạn đủ mạnh để chống lại reentrancy không? Hay bạn chỉ đang fork cái chết của người khác?