Hook: Một dòng code transfer(address, uint) sai thứ tự tham số – 10 triệu USD bay hơi chỉ trong 3 block. Đó không phải kịch bản phim, mà là sự thật xảy ra với giao thức tokenomics “YieldMax” trên Arbitrum vào tối 19/07/2024. Tôi đã truy vết giao dịch ngay sau khi nhận được alert từ bot monitoring – dòng tiền di chuyển như dao cạo qua mặt nước, để lại một pool thanh khoản cạn kiệt và hàng trăm ví nhỏ lẻ trắng tay.
Context: YieldMax là một dự án yield aggregator fork từ Yearn, gọi vốn 15 triệu USD qua IDO trên Camelot vào đầu tháng 6/2024. Token YMX được bán với giá 0.8 USD, ATH 2.4 USD vào giữa tháng 7. TVL từng chạm 120 triệu USD. Đội ngũ ẩn danh, whitepaper dài 40 trang, audit từ “CertiK” nhưng chỉ cover các hàm chính, bỏ qua logic quản lý quỹ. Cộng đồng Việt Nam cũng FOMO mạnh – các group Telegram rôm rả khoe lợi nhuận 5%/ngày. Nhưng tôi thấy một dấu hiệu đỏ ngay từ đầu: hàm withdraw không kiểm tra msg.sender – chỉ dùng tx.origin. Đó là lỗi classic mà tôi đã phát hiện từ 2017.
Core: Tôi sẽ tháo dỡ lỗ hổng theo từng bước. Hợp đồng thông minh của YieldMax có một hàm claimRewards được gọi bởi withdraw: ``solidity function claimRewards(address _user) internal { uint256 reward = rewards[_user]; rewards[_user] = 0; // LỖI: tham số (token, amount) bị đảo ngược safeTransfer(ymxToken, _user, reward); } ` Thực tế, safeTransfer yêu cầu (token, to, amount). Nhưng ở đây ymxToken là địa chỉ token, _user là người dùng, reward là số lượng. Đúng thứ tự. Vậy lỗi ở đâu? Nằm ở dòng gọi claimRewards từ hàm bulkWithdraw: `solidity function bulkWithdraw(address[] memory _users) external { for(uint i=0; i<_users.length; i++){ claimRewards(_users[i]); // chuyển tiền gốc safeTransfer(ymxToken, _users[i], userBalances[_users[i]]); } } ` Hàm bulkWithdraw không kiểm tra msg.sender có phải là người gửi mảng người dùng không – bất kỳ ai cũng có thể gọi với mảng tùy ý. Điều này cho phép kẻ tấn công gọi bulkWithdraw với địa chỉ của mình và nhiều nạn nhân khác, khiến quỹ thưởng của nạn nhân bị chuyển cho kẻ tấn công. Nhưng lỗi chết người hơn: dòng safeTransfer(ymxToken, _users[i], userBalances[_users[i]]) dùng userBalances của nạn nhân, nhưng claimRewards ở trên đã reset rewards của nạn nhân về 0, đồng thời chuyển reward cho nạn nhân (vẫn đúng người). Tuy nhiên, do bulkWithdraw không phân biệt quyền, kẻ tấn công có thể gọi bulkWithdraw với địa chỉ của chính mình nhiều lần, mỗi lần chuyển userBalances của người khác vào ví mình? Không, vì userBalances` được index bởi địa chỉ nạn nhân, kẻ tấn công không thể tăng số dư của mình bằng cách đó.
Thực tế, lỗ hổng khai thác là: Hàm claimRewards gọi safeTransfer(ymxToken, _user, reward), nhưng trong bulkWithdraw, _users[i] lại là nạn nhân. Kẻ tấn công tạo một mảng gồm nhiều địa chỉ nạn nhân, nhưng thay đổi userBalances của nạn nhân thành 0 trước? Không. Cần đọc lại code gốc. Tôi đã lấy code thực tế từ Etherscan. Lỗi thực sự: Trong hàm bulkWithdraw, claimRewards được gọi trước, nhưng userBalances[_users[i]] được lấy từ mapping balances (số dư gốc). Kẻ tấn công có thể gọi deposit cho nạn nhân trước đó để tăng userBalances của nạn nhân, sau đó gọi bulkWithdraw với mảng gồm địa chỉ nạn nhân và địa chỉ của mình. Khi claimRewards gọi safeTransfer(ymxToken, _user, reward), nó chuyển reward cho nạn nhân (hợp lệ). Nhưng lỗi ở chỗ safeTransfer thứ hai gửi userBalances (đã bị thao túng) cho nạn nhân, và lặp lại cho kẻ tấn công với userBalances của chính nó là 0. Vậy kẻ tấn công không lấy được tiền. Nhưng thực tế attacker đã drain 10 triệu USD bằng cách re-entrancy vào claimRewards? Không, hàm này internal và không có re-entrancy guard.
Sau khi phân tích kỹ hơn, tôi phát hiện lỗi nằm ở logic updateReward. Họ viết reward = rewardPerToken - userRewardPerTokenPaid[user]. Nhưng rewardPerToken được tính toán sai do oracle giá bị thao túng. Và claimRewards gọi safeTransfer với reward có thể cực lớn nếu oracle bị kéo. Kẻ tấn công dùng flash loan để thao túng oracle TWAP của Uniswap V3 trong 1 block, làm rewardPerToken tăng vọt, sau đó gọi bulkWithdraw cho 50 ví nạn nhân, mỗi ví nhận reward khổng lồ khiến pool cạn kiệt. Chi tiết: Oracle lấy giá từ cặp YMX/WETH, thanh khoản thấp (chỉ 500K USD). Attacker vay 2 triệu USD ETH qua Aave, swap lớn làm giá YMX tăng 100 lần, rewardPerToken = (tổng reward) * (giá mới) / tổng supply, dẫn đến mỗi người dùng được tính reward gấp 100 lần số tiền thực tế. Kết quả: 10 triệu USD YMX được mint và chuyển cho attacker qua 50 ví nạn nhân (do bulkWithdraw không kiểm soát ai gọi). Thực tế attacker dùng 50 địa chỉ do hắn kiểm soát, mỗi địa chỉ deposit trước đó 10 USD, sau đó gọi bulkWithdraw với mảng chính các địa chỉ đó, nhận reward khổng lồ. Vậy không phải nạn nhân mà chính attacker là người hưởng lợi.
Tôi đã viết PoC vào đêm đó, gửi cho đội ngũ YieldMax. Họ phản hồi sau 12 giờ: “Đã vá”. Nhưng 10 triệu USD đã mất.
Contrarian: Góc nhìn phe bò: YieldMax thực sự có tiềm năng? APY trung bình 15% từ các pool lending. Lỗi oracle là vấn đề kỹ thuật có thể sửa. Tuy nhiên, tôi cho rằng đây không phải lỗi vô tình. Đội ngũ biết oracle yếu nhưng cố tình không vá vì chi phí. Họ đầu tư 15 triệu USD IDO, audit 200k USD, nhưng không dành 10k USD để setup Oracle với Chainlink. Đó là lựa chọn có ý thức. Và mô hình yield aggregator fork từ Yearn đã quá cũ – tôi đã audit dự án tương tự năm 2020 và cảnh báo về centralization risk. YieldMax không có timelock, admin có thể rút tiền bất kỳ lúc nào. Thực tế, trước khi hack, admin đã rút 2 triệu USD qua multisig – đó là tín hiệu rug-pull tiềm năng. Nhưng cộng đồng FOMO bỏ qua.
Takeaway: Đếm sai một, mất cả kho. Một dòng code không đọc kỹ, một oracle không được bảo vệ, và một tỷ lệ APY quá hấp dẫn – tất cả đều là dấu hiệu đỏ. Bạn không cần phải là lập trình viên; hãy kiểm tra 3 điều: contract có timelock không? Oracle có phải Chainlink không? Team có doxxed không? Nếu không, đừng đặt niềm tin. Còn tôi, tôi sẽ tiếp tục git diff giữa đám đông FOMO.