[DVD-02] Naive Receiver: forced flash loan rút phí victim và mạo danh fee receiver qua trusted forwarder
NaiveReceiverPool không kiểm tra initiator của flash loan, cho phép bất kỳ ai bắt FlashLoanReceiver trả phí cố định 1 WETH mỗi lần. Sau khi receiver cạn tiền, lỗ hổng _msgSender() của trusted forwarder kết hợp multicall giúp player mạo danh deployer để rút toàn bộ pool trong đúng 2 giao dịch.
Tóm tắt
Challenge yêu cầu rút toàn bộ WETH từ cả pool lẫn contract receiver chỉ trong tối đa 2 giao dịch. Pool hỗ trợ flash loan với phí cố định 1 WETH nhưng không xác thực người khởi tạo khoản vay — player lợi dụng kẽ hở này để ép receiver contract thanh toán khoản phí 10 lần liên tiếp, làm cạn kiệt 10 WETH của receiver. Sau đó, player kết hợp cơ chế trusted forwarder của pool với việc đính kèm địa chỉ deployer vào cuối calldata, khiến _msgSender() nhận diện sai người gửi là deployer và rút toàn bộ 1010 WETH còn lại trong pool về tài khoản recovery.
Bối cảnh & Mục tiêu
Các contract liên quan:
- NaiveReceiverPool (
src/naive-receiver/NaiveReceiverPool.sol) — flash lender ERC3156 với phí cố địnhFIXED_FEE = 1e18(dòng 12). Pool kế thừaMulticallvà hỗ trợ meta-transaction qua trusted forwarder. - FlashLoanReceiver (
src/naive-receiver/FlashLoanReceiver.sol) — contract victim giữ 10 WETH, sẵn sàng nhận flash loan.onFlashLoanchỉ kiểm tracaller == pool, rồi approve pool đủamount + feeđể hoàn trả. - BasicForwarder (
src/naive-receiver/BasicForwarder.sol) — forwarder EIP712;execute()nốirequest.fromvào cuối calldata trước khi gọi target (dòng 63). - Multicall (
src/naive-receiver/Multicall.sol) —multicall()delegatecall từng phần tửdata[i]vào chính pool (dòng 9–15).
Trạng thái ban đầu: pool chứa 1000 WETH (deposit 1000 của deployer trong constructor, deposits[deployer] = 1000), receiver chứa 10 WETH, fee receiver là deployer.
Điều kiện thắng (_isSolved(), dòng 126–137): player thực hiện ≤ 2 giao dịch; receiver và pool đều hết WETH; recovery nhận đủ 1010 WETH.
Phân tích lỗ hổng
1. Flash loan không kiểm tra initiator
flashLoan() (dòng 43–64) chuyển token tới receiver, gọi callback, rồi transferFrom(receiver, amount + fee). Không có bất kỳ kiểm tra nào về việc ai gọi flashLoan() hay receiver có đồng ý vay hay không:
function flashLoan(IERC3156FlashBorrower receiver, address token, uint256 amount, bytes calldata data)
external
returns (bool)
{
if (token != address(weth)) revert UnsupportedCurrency();
// Transfer WETH and handle control to receiver
weth.transfer(address(receiver), amount);
totalDeposits -= amount;
...
uint256 amountWithFee = amount + FIXED_FEE;
weth.transferFrom(address(receiver), address(this), amountWithFee);
totalDeposits += amountWithFee;
deposits[feeReceiver] += FIXED_FEE;
Với amount = 0, pool chuyển 0 token tới receiver nhưng vẫn gọi callback; receiver approve đủ amount + fee và pool rút đúng 1 WETH phí. Mỗi lần gọi như vậy, receiver mất 1 WETH còn deposits[feeReceiver] tăng 1 WETH. Gọi 10 lần liên tiếp (gói trong một multicall) là đủ để rút cạn 10 WETH của receiver.
2. _msgSender() có thể bị giả mạo qua calldata
Pool dùng trusted forwarder để lấy người gửi thật (dòng 86–92):
function _msgSender() internal view override returns (address) {
if (msg.sender == trustedForwarder && msg.data.length >= 20) {
return address(bytes20(msg.data[msg.data.length - 20:]));
} else {
return super._msgSender();
}
}
Khi được gọi qua forwarder, 20 byte cuối calldata được coi là “người gửi”. BasicForwarder đúng ra nối request.from (đã xác thực chữ ký EIP712) vào calldata — nhưng điều đó chỉ đúng cho lời gọi ngoài cùng. Nếu lời gọi đó là multicall(), các delegatecall bên trong dùng chính data[i] làm calldata, mà attacker kiểm soát toàn bộ nội dung. msg.sender vẫn là forwarder (delegatecall giữ nguyên), còn 20 byte cuối của data[i] là do attacker chọn — vậy _msgSender() trả về bất kỳ địa chỉ nào attacker muốn, ở đây là deployer (fee receiver, người sở hữu deposits[deployer] = 1010).
Khai thác
Giao dịch 1 — rút cạn receiver: player gọi pool.multicall() với 10 phần tử, mỗi phần tử là flashLoan(receiver, weth, 0, ""). Kết quả: receiver mất 10 WETH tiền phí, deposits[deployer] tăng lên 1010, pool hiện giữ 1010 WETH.
Giao dịch 2 — rút sạch pool: player ký một request EIP712 (chữ ký của chính player, hợp lệ với forwarder) với data = pool.multicall([withdrawCall]), trong đó:
bytes memory withdrawCall = abi.encodePacked(
abi.encodeCall(NaiveReceiverPool.withdraw, (totalStolen, payable(recovery))),
bytes20(deployer)
);
Khi forwarder thực thi: multicall() delegatecall withdrawCall. Trong withdraw(), msg.sender == trustedForwarder và 20 byte cuối calldata là deployer, nên _msgSender() trả về deployer. withdraw(1010, recovery) trừ deposits[deployer] (1010 − 1010 = 0) và chuyển 1010 WETH về recovery. Chữ ký EIP712 không cần của deployer — điểm mấu chốt là forwarder chỉ xác thực request ngoài, còn “người gửi” của lệnh trong lại do attacker tự đặt.
Kết quả
Chạy forge test --match-contract NaiveReceiverChallenge -vv:
Ran 2 tests for test/naive-receiver/NaiveReceiver.t.sol:NaiveReceiverChallenge
[PASS] test_assertInitialState() (gas: 34786)
[PASS] test_naiveReceiver() (gas: 399463)
Suite result: ok. 2 passed; 0 failed; 0 skipped
Bài học & Cách phòng tránh
- Flash loan phải xác thực người khởi tạo.
flashLoan()nên chỉ chấp nhận receiver do chính người dùng chỉ định (initiator phải làmsg.sender) hoặc yêu cầu receiver chủ động gọi vay, tránh kiểu tấn công “forced flash loan” buộc victim gánh khoản phí. - Không tin 20 byte cuối calldata. Cơ chế
_msgSender()kiểu này chỉ an toàn nếu mọi đường gọi đều đi qua forwarder và forwarder là người duy nhất nốifromđã xác thực vào cuối calldata. Khi códelegatecall/multicallchèn giữa, chuỗi calldata bị phá vỡ. Nên lưu người gửi vào storage qua_msgSender()một lần ở điểm vào duy nhất, hoặc dùng cơ chế context (ví dụ_msgDatacủa OpenZeppelin) nhất quán. - Kiểm tra phí và số dư trước khi thực hiện effect: receiver không nên approve vô điều kiện
amount + feekhi amount do bên ngoài quyết định.
Tham khảo
- Challenge: Naive Receiver — Damn Vulnerable DeFi
- Source:
src/naive-receiver/NaiveReceiverPool.sol,src/naive-receiver/FlashLoanReceiver.sol,src/naive-receiver/BasicForwarder.sol,src/naive-receiver/Multicall.sol,test/naive-receiver/NaiveReceiver.t.sol