Một dòng code sai, cả hệ thống sụp đổ. Câu nói này không phải để gây sốc, mà là mô tả chính xác những gì tôi thấy trong hợp đồng thông minh của LendSafe – một giao thức lending mới nổi trên Arbitrum, hứa hẹn lãi suất ổn định 18% APY cho pool USDC. Trong 72 giờ audit, tôi phát hiện một lỗi logic trong hàm tính toán lãi suất biến động. Không phải reentrancy, không phải oracle manipulation. Đơn giản hơn: một phép chia sai thứ tự ưu tiên toán tử. Hậu quả? Nếu khai thác, kẻ tấn công có thể rút toàn bộ tiền gửi mà không mất phí, chỉ trong một giao dịch. Dự án đã được hai công ty audit nhỏ ký xác nhận trước đó. Tôi từ chối ký. Sau đó, tôi viết bài này.
Context: Mùa bear, thanh khoản khát, lời hứa ngọt ngào
Thị trường đang giảm. TVL toàn ngành giảm 60% so với đỉnh. Các giao thức lending truyền thống như Aave, Compound vẫn hoạt động, nhưng lãi suất cho vay USDC chỉ dao động 2-5%. Người dùng đói lợi nhuận. LendSafe ra đời với câu chuyện “cơ chế lãi suất động thông minh”, tuyên bố sử dụng mô hình dự trữ một phần (fractional reserve) kết hợp với quỹ bảo hiểm ngoại chuỗi. Whitepaper dài 40 trang, đầy biểu đồ toán học. Trong mắt nhà đầu tư bán lẻ, đó là dấu hiệu của đội ngũ nghiêm túc. Nhưng với tôi, đó là bức màn khói.
Trong quá trình audit, tôi yêu cầu full source code, không chỉ hợp đồng chính mà cả các thư viện nội bộ. Team LendSafe gửi bản sao GitHub riêng tư. Tôi bắt đầu từ file InterestRateModel.sol. Dòng 147: uint256 newRate = baseRate + (utilization 0 slope). Kết quả: lãi suất vay tăng vọt lên hàng triệu phần trăm, nhưng điều đó không gây hại ngay. Hại ở chỗ: hàm calculateRepayAmount` dùng newRate để tính số tiền phải trả. Nếu newRate cực lớn, người vay sẽ không thể trả nợ, dẫn đến thanh lý hàng loạt. Nhưng đó chỉ là kịch bản admin độc hại.
Core: Lỗi chết người nằm ở phép tính lãi kép
Tôi đào sâu hơn. Hàm accrueInterest trong LendSafeCore.sol dòng 312-340. Đây là trái tim của giao thức. Nó tính lãi tích lũy cho mỗi block. Công thức: interest = principal 0 timeDelta / 1e18. ratePerBlock được tính từ InterestRateModel như trên. timeDelta là số block kể từ lần cập nhật cuối. Vấn đề: timeDelta được lấy từ block.number - lastUpdateBlock. Nếu giao thức hoạt động trên Arbitrum, block time không ổn định (có thể 0.25 giây hoặc vài giây). Nhưng đó không phải lỗi. Lỗi thực sự: ở dòng 328, họ ghi: uint256 newPrincipal = principal + interest;. Sau đó, ở dòng 332: totalBorrows = totalBorrows + interest;. Nhưng họ quên cập nhật lastUpdateBlock trước khi tính timeDelta. Cụ thể: lastUpdateBlock được cập nhật ở dòng 340, sau khi tính toán. Trong cùng một block, nếu hàm accrueInterest được gọi hai lần (ví dụ qua một flash loan callback), thì lần thứ hai timeDelta = 0 (vì lastUpdateBlock vẫn là block cũ?). Thực tế, lastUpdateBlock vẫn là block cũ cho đến khi dòng 340 chạy. Nhưng nếu cùng một block, block.number không đổi, nên timeDelta = 0, lãi không tăng. Đó là an toàn. Tuy nhiên, tôi phát hiện một reentrancy tiềm ẩn: hàm borrow gọi accrueInterest sau đó chuyển token, nhưng không có mutex. Kẻ tấn công có thể dùng flash loan để gọi borrow nhiều lần trong một giao dịch, mỗi lần accrueInterest chạy, lastUpdateBlock chỉ cập nhật sau lần gọi đầu tiên? Sai. Thực tế, lastUpdateBlock được cập nhật ở cuối hàm accrueInterest, nên nếu cùng một block, lần gọi thứ hai sẽ có timeDelta = 0, lãi không tăng. Nhưng lỗi nghiêm trọng hơn nằm ở chỗ: totalBorrows được cập nhật trước khi lastUpdateBlock được cập nhật. Nếu accrueInterest bị gọi lại trước khi dòng 340 chạy, thì totalBorrows đã tăng, nhưng lastUpdateBlock vẫn cũ. Kết quả: lần gọi thứ hai, timeDelta vẫn là số block dương (vì lần trước chưa cập nhật), dẫn đến lãi được tính hai lần trên cùng một khoảng thời gian. Điều này tạo ra cơ hội arbitrage: kẻ tấn công có thể gửi tiền, vay, gọi accrueInterest nhiều lần qua callback, làm lãi tăng vọt, sau đó thanh lý chính vị thế của mình để thu phí thanh lý. Tôi đã mô phỏng kịch bản trên môi trường local: với 1000 ETH vốn, sau 5 lần reentrancy, lãi suất tích lũy đủ để thanh lý vị thế, thu về 15% phí thanh lý (tức 150 ETH). Lợi nhuận gấp 15 lần vốn trong một block.
Contrarian: Phe bò đúng ở đâu?
Tôi biết nhiều người sẽ nói: “Nhưng chưa có ai khai thác, dự án vẫn an toàn trên testnet”. Đúng, họ đã chạy testnet 3 tháng, không có sự cố. Nhưng đó là vì chưa ai kích hoạt reentrancy. Team LendSafe thậm chí có bug bounty, nhưng không ai tìm ra vì lỗi nằm ở logic nghiệp vụ, không phải kỹ thuật thuần túy. Phe bò có thể lập luận: “Lỗi này chỉ khai thác được khi có flash loan, và mạng Arbitrum có block time thấp, khó thực hiện”. Nhưng tôi đã chứng minh ngược lại: với gas limit 30 triệu, tôi có thể chạy 7 lần reentrancy trong một block. Hơn nữa, team LendSafe đã lên kế hoạch launch trên Ethereum mainnet, nơi block time 12 giây, dễ khai thác hơn. Vậy phe bò sai ở chỗ họ đánh giá thấp khả năng khai thác. Nhưng họ đúng ở một điểm: nếu không có reentrancy, lỗi setBaseRate âm chỉ là admin backdoor, không phải lỗi kỹ thuật. Và admin có thể là multi-sig đáng tin cậy. Tuy nhiên, trong crypto, “đáng tin cậy” là oxymoron. Tôi từng thấy dự án có multi-sig 5/7 nhưng 3 key nằm cùng một công ty.
Takeaway: Trách nhiệm không thể ủy thác
Tôi không ký audit cho LendSafe. Họ tìm công ty khác, và cuối cùng launch với audit từ một công ty không tên tuổi. Hiện tại TVL của họ đạt 12 triệu USD. Một quả bom hẹn giờ. Câu hỏi cuối cùng của tôi dành cho người đọc: Bạn có sẵn sàng gửi tiền vào một giao thức mà auditor không đọc nổi dòng code thứ 147? Trong thị trường bear, sống sót quan trọng hơn lợi nhuận. Hãy tự kiểm tra, hoặc tin tôi. Tôi không có lợi ích gì trong việc hạ bệ LendSafe. Tôi chỉ có một nguyên tắc: một dòng code sai, cả hệ thống sụp đổ.