[DVD-01] Unstoppable: phá vỡ invariant ERC4626 bằng một khoản donate để khóa vault
Mục tiêu là làm cho mọi flash loan của UnstoppableVault thất bại để monitor pause vault. Lỗ hổng nằm ở chỗ totalAssets() đọc balanceOf trực tiếp, khiến invariant ERC4626 convertToShares(totalSupply) == totalAssets() bị phá vỡ chỉ bằng một khoản transfer 1 wei.
Tóm tắt
Unstoppable là challenge mở đầu của Damn Vulnerable DeFi v4, khai thác một vault ERC4626 kiêm flash lender. Chỉ cần một giao dịch: chuyển thẳng 1 wei token vào vault, bỏ qua hàm deposit(). Khoản “donate” này làm lệch số dư token thực tế so với số share đang lưu hành, phá vỡ invariant mà flashLoan() dùng để kiểm tra, khiến mọi flash loan sau đó revert. Monitor contract phát hiện sự cố, pause vault và trả quyền sở hữu — đúng điều kiện thắng.
Bối cảnh & Mục tiêu
Hai contract tham gia:
- UnstoppableVault (
src/unstoppable/UnstoppableVault.sol) — vault ERC4626 (totalSupplylà share,assetlà DVT) triển khai chuẩn flash loan ERC3156. Phí flash loan là 0 nếublock.timestamp < endvà số vay nhỏ hơnmaxFlashLoan()(dòng 61–65). Vault có cơ chế pause và hàmexecute()chỉ owner dùng khi paused. - UnstoppableMonitor (
src/unstoppable/UnstoppableMonitor.sol) — contract giám sát, là owner của vault. HàmcheckFlashLoan()(dòng 36–53) thử vay flash loan; nếu thất bại, nó emitFlashLoanStatus(false), gọivault.setPause(true)vàvault.transferOwnership(owner).
Trạng thái ban đầu (test/unstoppable/Unstoppable.t.sol): deployer deposit 1.000.000 DVT vào vault, nhận về 1.000.000 share (tỷ lệ 1:1); player có 10 DVT; monitor vừa chạy một lần checkFlashLoan(100e18) thành công.
Điều kiện thắng trong _isSolved() (dòng 106–116): lần checkFlashLoan tiếp theo phải thất bại, vault bị pause, và ownership chuyển về deployer.
Phân tích lỗ hổng
flashLoan() bắt đầu bằng một invariant check theo chuẩn ERC4626 (dòng 84–85):
uint256 balanceBefore = totalAssets();
if (convertToShares(totalSupply) != balanceBefore) revert InvalidBalance(); // enforce ERC4626 requirement
Vấn đề nằm ở định nghĩa totalAssets() (dòng 71–73):
function totalAssets() public view override nonReadReentrant returns (uint256) {
return asset.balanceOf(address(this));
}
totalAssets() đọc số dư token thực tế của vault, chứ không phải một biến kế toán nội bộ. Bất kỳ ai cũng có thể token.transfer(vault, x) mà không cần qua deposit(). Khi đó balanceOf(vault) tăng nhưng totalSupply (share) không đổi.
Với solmate ERC4626, convertToShares(totalSupply) = totalSupply.mulDivDown(totalSupply, totalAssets()). Ban đầu 1.000.000e18 token ↔ 1.000.000e18 share, invariant đúng. Sau khi donate 1 wei:
totalAssets()= 1.000.000e18 + 1convertToShares(totalSupply)= (1.000.000e18)² / (1.000.000e18 + 1) = 1.000.000e18 − 1 (làm tròn xuống)
Hai giá trị lệch nhau → revert InvalidBalance() ngay trước khi chuyển token. Mọi flash loan từ đó đều bị revert. Monitor nhận thấy thất bại và giả định vault gặp sự cố, liền pause vault và trả ownership cho deployer — không hề biết “sự cố” chỉ là một khoản donate 1 wei của kẻ tấn công.
Đây là một biến thể của lớp lỗ hổng donation/inflation attack trên ERC4626: invariant được kiểm tra bằng giá trị balance có thể bị thao túng từ bên ngoài, thay vì dùng sổ kế toán do chính contract quản lý.
Khai thác
Toàn bộ exploit chỉ là một dòng (trong test_unstoppable()):
token.transfer(address(vault), 1);
Kịch bản diễn ra như sau:
- Player gửi 1 wei DVT thẳng vào vault, không qua
deposit(). _isSolved()giả lập deployer gọimonitorContract.checkFlashLoan(100e18).vault.flashLoan(...)revertInvalidBalancevì invariant đã vỡ.- Monitor bắt được exception, emit
FlashLoanStatus(false), pause vault, chuyển ownership về deployer. - Assert pass:
vault.paused() == true,vault.owner() == deployer.
Kết quả
Chạy forge test --match-contract UnstoppableChallenge -vv:
Ran 2 tests for test/unstoppable/Unstoppable.t.sol:UnstoppableChallenge
[PASS] test_assertInitialState() (gas: 57303)
[PASS] test_unstoppable() (gas: 66791)
Suite result: ok. 2 passed; 0 failed; 0 skipped
Bài học & Cách phòng tránh
- Không dùng
balanceOflàm nguồn kế toán.totalAssets()nên được tính từ sổ sách nội bộ (tổng deposit trừ tổng withdraw) hoặc dùng virtual offset như các implementation ERC4626 chống inflation (ví dụ OpenZeppelin dùng_asset+_decimalsOffset), để token chuyển thẳng vào vault không làm thay đổi giá trị share. - Kiểm tra invariant theo hướng an toàn: nếu bắt buộc dùng balance, invariant check phải chấp nhận
totalAssets() >= convertToShares(totalSupply)thay vì yêu cầu bằng tuyệt đối. - Phân biệt “token bị kẹt” và “sự cố hệ thống”: monitor tự động pause và trao quyền khẩn cấp dựa trên một lần kiểm tra đơn lẻ là thiết kế nguy hiểm — kẻ tấn công có thể giả tạo sự cố để kích hoạt các hành động phản ứng tự động.
Tham khảo
- Challenge: Unstoppable — Damn Vulnerable DeFi
- Source:
src/unstoppable/UnstoppableVault.sol,src/unstoppable/UnstoppableMonitor.sol,test/unstoppable/Unstoppable.t.sol