12:47:03 UTC. Một giao dịch duy nhất, hash 0x... trên Base.
Block #... . Từ địa chỉ được gắn nhãn 'VanceDAO: MultiSig Wallet', một cuộc gọi đến hàm castVote() đã thay đổi trạng thái của đề xuất #042. Kết quả: đề xuất được thông qua với 91.4% phiếu ủng hộ. Nhưng có một vấn đề: hacker đã kiểm soát 60% trong số 91.4% đó.
Kẻ tấn công đã khai thác lỗ hổng reentrancy trong logic tính toán trọng số phiếu bầu, dựa trên số dư token bị khóa trong hợp đồng. Bằng cách gọi lại hàm vote() trước khi cập nhật lastVoteTime, hắn đã bỏ phiếu nhiều lần với cùng một số dư. ICO 2018: lỗi đa ký, bài học nhức nhối.
Context: VanceDAO tự quảng cáo là 'DAO quản trị phi tập trung đầu tiên dành cho các dự án Layer2'. Cơ chế bỏ phiếu của nó dựa trên token staking. Người dùng stake token ERC-20 của VanceDAO, và nhận được trọng số phiếu bầu tương ứng với số dư đã stake. Nhưng có một chi tiết kỹ thuật: trọng số được tính lại mỗi lần bỏ phiếu dựa trên số dư hiện tại của người dùng trong hợp đồng stake. Điều này tạo ra cơ hội cho reentrancy. EVM không tha thứ cho sự cẩu thả.
Core: Hãy phân tích mã nguồn. Hợp đồng Voting.sol có hàm castVote(proposalId, support) như sau:
function castVote(uint256 proposalId, bool support) external {
uint256 weight = IStaking(stakingContract).getVotes(msg.sender);
require(weight > 0, "No voting power");
require(!hasVoted[proposalId][msg.sender], "Already voted");
// Cập nhật trạng thái hasVoted[proposalId][msg.sender] = true;
if (support) { proposals[proposalId].forVotes += weight; } else { proposals[proposalId].againstVotes += weight; }
emit VoteCast(msg.sender, proposalId, support, weight); } ```
Lỗ hổng nằm ở dòng IStaking(stakingContract).getVotes(msg.sender). Hàm getVotes() này không phải là view thuần túy. Nó có thể gọi lại (callback) vào hợp đồng gọi (Voting.sol) thông qua cơ chế delegatecall hoặc hook. Trong trường hợp này, kẻ tấn công đã triển khai một hợp đồng độc hại làm stakingContract. Mỗi lần getVotes() được gọi, hợp đồng độc hại gọi lại castVote() để thay đổi weight trước khi lưu vào proposals[].forVotes.
Trong quá trình audit của tôi, tôi thường kiểm tra pattern này. Checks-Effects-Interactions là nguyên tắc cơ bản. Ở đây, effect (cập nhật hasVoted) xảy ra trước, nhưng interaction (gọi getVotes()) thậm chí còn xảy ra trước cả effect. Kết quả: kẻ tấn công bỏ phiếu 10 lần với cùng một balance. 91.4% phiếu ủng hộ là ảo. Đề xuất #042 đã được thông qua để chuyển toàn bộ treasury (2,400 ETH) vào một địa chỉ do hacker kiểm soát.
Contrarian: Góc nhìn phản trực giác ở đây là: lỗ hổng này không nằm ở mã nguồn VanceDAO, mà nằm ở thiết kế kiến trúc tổng thể. Hầu hết các bài phân tích đều đổ lỗi cho castVote(). Nhưng nếu mình đứng ở phía attacker, mình sẽ thấy rằng điểm yếu thực sự là việc cho phép stakingContract là một địa chỉ có thể thay đổi. VanceDAO đã cài đặt stakingContract là một proxy có thể nâng cấp. Kẻ tấn công đã chiếm quyền sở hữu proxy đó thông qua một lỗ hổng trong logic upgrade. Sau đó, hắn thay thế implementation bằng hợp đồng độc hại. Bài học: quyền sở hữu proxy là single point of failure.
Takeaway: VanceDAO đã chết. Treaury rỗng. Cộng đồng mất niềm tin. Nhưng câu hỏi thực sự là: bao nhiêu DAO khác đang ngồi trên quả bom hẹn giờ tương tự? Hãy kiểm tra owner() của proxy của bạn ngay bây giờ. Nếu nó là một EOA hoặc multisig ít chữ ký, bạn đang chơi trò chơi may rủi. Lightning Network đã nửa sống nửa chết suốt bảy năm; DAO governance cũng vậy.