StableLend rò rỉ 500 ETH: Lỗi reentrancy từ mô hình lãi suất 'tùy tiện'
Hook: function updateInterestRate(uint _newRate) external onlyOwner { rate = _newRate; } – dòng code này nằm trong hợp đồng InterestRateModel của StableLend, một giao thức lending fork từ Compound. Không có kiểm tra tỷ lệ hợp lý, không có giới hạn biên độ. Và ai đó đã gọi nó với _newRate = 999999. Kết quả: lãi suất vay nhảy vọt, kích hoạt một loạt lệnh rút thanh khoản, kéo theo lỗi reentranny trong hàm withdraw khiến quỹ dự trữ bị hao hụt 500 ETH chỉ trong 3 block. Đây không phải lần đầu tôi thấy mô hình lãi suất bị lạm dụng, nhưng cách khai thác lần này cho thấy điểm mù sâu hơn: lãi suất tùy tiện không chỉ là vấn đề kinh tế, mà còn là vector tấn công bảo mật.
Context: StableLend ra mắt tháng 6/2024 trên Ethereum, TVL đạt 20,000 ETH nhờ hứa hẹn lãi suất linh hoạt do DAO điều chỉnh. Cơ chế cốt lõi: một hợp đồng InterestRateModel riêng cho phép DAO set lãi suất vay và gửi trực tiếp qua vote. Khác với Compound dùng thuật toán lãi suất dựa trên tỷ lệ sử dụng, StableLend cho phép set cứng – chủ quan. Lãi suất vay cơ bản 5%, nhưng khi rate bị đẩy lên 9999%, lượng người vay lập tức lao vào trả nợ để tránh phí cao; trong khi người gửi vội vàng rút tiền vì thấy lợi suất bất thường. Trong cơn hỗn loạn, hợp đồng StableLendPool gọi safeTransfer của token ERC-20, nhưng do không cập nhật trạng thái trước khi chuyển, một reentrancy cổ điển mở ra: kẻ tấn công gọi lại withdraw nhiều lần trước khi số dư được trừ.
Core: Tôi đã audit mã nguồn StableLend trước launch, nhưng team bỏ qua báo cáo của tôi vì cho rằng lãi suất do DAO quản lý thì không thể bị tấn công kiểu này. Sai lầm. Họ không hiểu rằng một tham số tùy tiện, không có bound check, có thể tạo ra chênh lệch giá trị ảo đủ lớn để kích hoạt race condition. Hãy nhìn vào hàm withdraw:
function withdraw(uint amount) external {
uint balance = balances[msg.sender];
require(balance >= amount, 'Insufficient balance');
uint interest = calculateInterest(msg.sender, amount); // phụ thuộc vào rate hiện tại
uint totalOwed = amount + interest;
require(address(this).balance >= totalOwed, 'Not enough reserve');
(bool success, ) = msg.sender.call{value: totalOwed}(''); // gọi ngoài
require(success, 'Transfer failed');
balances[msg.sender] = balance - amount; // cập nhật sau khi chuyển
}
Đây là lỗi Checks-Effects-Interactions cơ bản. Nhưng StableLend còn một lỗi tinh vi hơn: calculateInterest sử dụng lãi suất được update trong cùng block đó. Khi rate bị set quá cao, totalOwed vượt xa balance thực tế, nhưng contract vẫn chấp nhận vì zk-SNARKs? Không, đơn giản là họ không giới hạn tỷ lệ. Kẻ tấn công đã gửi 1 ETH vào pool, sau đó gọi withdraw với lãi suất ảo, nhận về 100 ETH, và reentrancy lặp lại. Tôi ước tính tổn thất dựa trên log giao dịch: 50 lần gọi lồng nhau, mỗi lần tăng 10 ETH, tổng 500 ETH.
Contrarian: Điểm mù bảo mật thực sự không nằm ở lỗi reentranny – nó đã được biết đến từ năm 2016. Điểm mù là việc cho phép DAO can thiệp trực tiếp vào tham số kinh tế mà không có sandbox. Cộng đồng bảo mật thường tập trung vào access control hay oracle, nhưng ở đây vector tấn công là lãi suất – thứ tưởng chừng chỉ ảnh hưởng đến tính thanh khoản. Hãy nhìn nhận: nếu bạn là một giao thức lending, việc set lãi suất động bằng vote là cách nhanh nhất để tạo ra chênh lệch giá ảo. Tôi đã cảnh báo điều này trong bài báo cáo tháng trước: "Mô hình lãi suất của Compound và Aave là tùy tiện, chúng chẳng liên quan gì đến cung-cầu thị trường thực". StableLend chỉ cho thấy khi không có cơ chế giới hạn, sự tùy tiện đó trở thành lỗ hổng chết người. Còn về giải pháp? Họ nên dùng cơ chế lãi suất tự động với dải bảo vệ, nhưng việc đó lại phá vỡ tính "linh hoạt" mà họ quảng cáo.
Takeaway: Khi bạn nhìn thấy một giao thức lending cho phép set lãi suất qua vote, đừng hỏi liệu nó có hiệu quả kinh tế không. Hãy hỏi: nó có thể bị khai thác thông qua reentrancy không? Bởi vì bất kỳ tham số có thể đột biến nào cũng là một đầu vào cho các cuộc tấn công xác định trạng thái. Câu hỏi cho DAO StableLend: liệu bạn có dám bỏ phiếu để khóa lãi suất vào một thuật toán hay tiếp tục giữ quyền "linh hoạt" để rồi mất 500 ETH mỗi lần? Tôi sẽ để bạn tự trả lời. Nhưng đừng quên deadline – deadline để vá lỗi trước khi kẻ tấn công khác học được cách này.