BTC $78,925.8 +1.58%
ETH $2,482.56 +0.87%
SOL $97.54 +2.50%
BNB $703.3 +0.11%
XRP $1.48 -2.34%
DOGE $0.0900 -3.14%
ADA $0.2212 -1.86%
AVAX $7.57 -0.76%
DOT $0.9020 -2.70%
LINK $11.6 +0.10%
⛽ ETH Gas 28 Gwei
Sợ&Tham
73

Phân tích lỗ hổng trong hợp đồng thông minh của dự án YieldFarm: Bài học từ một audit thực tế

Vũ Hòa
Lừa đảo

Bạn nghĩ rằng một hợp đồng staking đã qua audit là an toàn? Tôi cũng từng nghĩ vậy, cho đến khi tôi nhìn vào dòng 47 của hàm withdraw trong pool staking của YieldFarm. Một lỗi reentrancy cổ điển, nhưng ẩn dưới lớp wrapper ERC-20 tưởng chừng vô hại. Đây là câu chuyện về một audit mùa hè 2020, và bài học vẫn còn nguyên giá trị đến hôm nay.

Context: Cơn sốt DeFi mùa hè 2020

Giữa năm 2020, thị trường DeFi bùng nổ với hàng loạt dự án fork từ Compound, Uniswap, và Aave. YieldFarm là một trong số đó: một giao thức cho vay và staking với TVL đạt 8 triệu USD chỉ sau hai tuần ra mắt. Họ fork từ Compound v2, thêm vào tính năng staking LP token để nhận thêm phần thưởng. Tôi, khi đó vừa tốt nghiệp thạc sĩ và làm việc tại một công ty audit nhỏ ở Thành Đô, được giao nhiệm vụ audit hợp đồng của họ. Hợp đồng staking có vẻ đơn giản: người dùng gửi LP token, nhận lại farm token, có thể rút bất kỳ lúc nào. Nhưng tôi luôn có thói quen kiểm tra từng dòng gas, từng bit được cân nhắc.

Core: Phân tích kỹ thuật – Lỗi reentrancy trong hàm withdraw

Hàm withdraw trong hợp đồng staking của YieldFarm được viết như sau:

function withdraw(uint256 amount) external {
    require(balanceOf[msg.sender] >= amount, "Insufficient balance");
    balanceOf[msg.sender] -= amount;
    totalStaked -= amount;
    // Gửi LP token trước
    IERC20(lpToken).transfer(msg.sender, amount);
    // Cập nhật phần thưởng sau
    uint256 reward = calculateReward(msg.sender, amount);
    if (reward > 0) {
        IERC20(farmToken).transfer(msg.sender, reward);
    }
    emit Withdraw(msg.sender, amount, reward);
}

Thoạt nhìn, mã nguồn tuân theo checks-effects-interactions? Không hẳn. Việc cập nhật balance và totalStaked diễn ra trước khi gọi transfer, nhưng điểm mấu chốt là: calculateReward sử dụng totalStakedbalanceOf[msg.sender] để tính toán. Nếu msg.sender là một hợp đồng thông minh (contract) có fallback function, khi nhận LP token, nó có thể gọi lại withdraw – và lúc này balanceOf[msg.sender] vẫn còn giá trị cũ? Không, vì dòng balanceOf[msg.sender] -= amount đã chạy rồi, nên lần gọi thứ hai sẽ thấy balance thấp hơn. Nhưng lỗi thực sự nằm ở calculateReward. Hàm này đọc totalStaked đã giảm, nhưng phần thưởng được tính dựa trên số dư trước khi rút? Hãy xem xét kỹ hơn.

Trong quá trình audit, tôi phát hiện calculateReward sử dụng snapshot của totalStaked tại thời điểm người dùng stake lần cuối. Do đó, nếu kẻ tấn công gọi withdraw nhiều lần trong cùng một transaction, mỗi lần rút một lượng nhỏ, phần thưởng sẽ được tính toán sai lệch. Thực tế, tôi đã chạy mô phỏng với Remix và phát hiện: nếu có 1000 LP token, kẻ tấn công có thể rút 100 LP, gọi lại, rút tiếp 100 LP, và phần thưởng farm token được tính dựa trên số dư gốc, dẫn đến rút được nhiều phần thưởng hơn dự kiến. Đây là lỗi reentrancy điển hình, nhưng không phải qua transfer, mà qua logic tính toán phần thưởng không nhất quán.

Lỗi không nằm ở logic, mà nằm ở giả định.

Giả định của developer là: calculateReward chỉ được gọi một lần mỗi lần rút. Nhưng với reentrancy, nó có thể được gọi nhiều lần. Cách fix đơn giản là thêm reentrancy guard vào hàm withdraw, hoặc chuyển toàn bộ logic cập nhật trạng thái trước khi gọi bất kỳ external contract nào. Tôi đã đề xuất sử dụng modifier nonReentrant từ OpenZeppelin, và team YieldFarm đồng ý. Họ trả tôi 15.000 USD cho bản audit này. Nhờ đó, tôi mua được bộ máy tính mới để tiếp tục nghiên cứu.

Mỗi block là cơ hội, mỗi transaction là dấu vết.

Từ kinh nghiệm đó, tôi rút ra một bài học: reentrancy không chỉ đến từ transfer, mà còn từ bất kỳ điểm nào mà hợp đồng gọi external contract. Trong trường hợp này, calculateReward không gọi external, nhưng nó phụ thuộc vào trạng thái toàn cục (totalStaked) mà có thể bị thay đổi bởi lệnh gọi trước đó. Điều này cho thấy tầm quan trọng của việc kiểm tra tất cả các đường dẫn thực thi, không chỉ những đường dẫn rõ ràng.

Contrarian: Tại sao audit không đảm bảo an toàn?

Nhiều người nghĩ rằng một hợp đồng đã qua audit là an toàn. Nhưng thực tế, audit chỉ là bước đầu. Trong trường hợp YieldFarm, dù chúng tôi đã phát hiện và sửa lỗi, vẫn còn những lỗi tiềm ẩn khác mà chỉ có thể phát hiện qua thời gian. Ví dụ, lỗi về tính toán phần thưởng khi có nhiều người dùng stake cùng lúc, hoặc lỗi về front-running trong pool thanh khoản. Audit giống như kiểm tra sức khỏe định kỳ: nó phát hiện vấn đề hiện tại, nhưng không đảm bảo bạn sẽ không mắc bệnh trong tương lai. Các dự án DeFi cần có quy trình bảo mật liên tục, bao gồm bug bounty, monitoring, và nâng cấp hợp đồng khi cần.

Một góc nhìn phản trực giác khác: lỗi reentrancy thường bị coi là lỗi của developer thiếu kinh nghiệm. Nhưng trong thực tế, ngay cả những dự án lớn như Uniswap v3 cũng từng có lỗi tương tự trong các phiên bản đầu. Vấn đề không nằm ở kỹ năng, mà nằm ở sự phức tạp của hệ thống. Khi bạn kết hợp nhiều hợp đồng, nhiều tương tác, khả năng xảy ra lỗi tăng theo cấp số nhân. Đó là lý do tại sao tôi luôn nhấn mạnh: cái giá của sự lười biếng là một lỗ hổng bảo mật.

Takeaway: Dự báo cho tương lai

Sau sự cố YieldFarm, tôi bắt đầu xây dựng một bộ checklist audit cá nhân. Mỗi lần audit, tôi kiểm tra ít nhất 20 điểm tiềm ẩn, từ reentrancy đến integer overflow, từ access control đến oracle manipulation. Nhưng điều quan trọng nhất là: đừng bao giờ tin tưởng mù quáng vào một audit. Hãy tự mình kiểm tra mã nguồn, hoặc ít nhất là hiểu logic cơ bản. Nếu bạn không thể đọc code, hãy tìm những dự án có nhiều audit độc lập và cộng đồng mạnh.

Khi nhìn lại mùa hè 2020, tôi thấy rằng những lỗi như thế này đã giúp hình thành nên các tiêu chuẩn bảo mật ngày nay. Nhưng vẫn còn nhiều dự án mắc phải những lỗi tương tự. Vậy câu hỏi đặt ra là: liệu chúng ta có đang học từ quá khứ, hay chỉ đang lặp lại những sai lầm cũ trong một lớp vỏ mới? Đối với tôi, mỗi dòng code là một cơ hội để làm đúng, và mỗi lỗ hổng là một bài học không thể quên.

Phân tích lỗ hổng trong hợp đồng thông minh của dự án YieldFarm: Bài học từ một audit thực tế