闪电贷以及如何利用它进行攻击:ERC 3156 漫游指南

  • 0xE
  • 更新于 3天前
  • 阅读 161

本文介绍了 ERC 3156 闪电贷规范以及闪电贷出借方和借款方可能受到攻击的方式。文章末尾还提供了建议的安全练习。

闪电贷是智能合约之间的贷款,必须在同一笔交易中偿还。本文介绍了 ERC 3156 闪电贷规范以及闪电贷出借方和借款方可能受到攻击的方式。文章末尾还提供了建议的安全练习。

下面是一个极其简单的闪电贷示例。

// not for production use
contract FlashLender {
    receive() external payable {}

    // call this function to get a flash loan
    function flashBorrow() external {
        // send borrower 1 ether and call borrower's function `onFlashLoan()`
        bytes32 ret = FlashBorrower(msg.sender).onFlashLoan{value: 1 ether}(bal);

        // expect it back in the same transaction
        require(ret == keccak256("BorrowMoney"), "invalid response");
        require(address(this).balance >= bal, "flash loan not paid back");
    }
}

contract FlashBorrower {
    FlashLender flashLender;

    constructor (FlashLender flashLender) {
        flashLender = flashLender_;
    }

    // ask for flash loan
    function initiateBorrow() external payable {
        flashLender.flashBorrow();
    }

    // flash loan calls this function
    function onFlashLoan(uint256 amount) external payable returns (bytes32) {
        require(msg.sender == address(flashLender), "only flash lender");

        // do something with the ether

        // pay back the ether
        (bool ok,) = address(flashLender).call{value: amount}("");
        require(ok, "transfer failed");
        return keccak256("BorrowMoney");
    }
}

如果借款人未能偿还贷款,带有“flash not paid back”消息的 require 语句将导致整个交易回滚。

只有合约能使用闪电贷

普通的 EOA(外部拥有账户)钱包无法调用一个函数来获取闪电贷,然后在同一笔交易中将代币返还。与闪电贷的集成需要一个单独的智能合约。

闪电贷不需要抵押品

如果闪电贷实现得当(重点是如果!),那么就不存在还不上贷款的风险。因为一旦 require 语句失败或交易回滚,整个交易都会作废,ETH 根本不会转移。

闪电贷的用途

套利

闪电贷最常见的用途是做套利交易。比如,如果 ETH 在一个池子的交易价格是 1,200 美元,而在另一个 DeFi 应用中是 1,300 美元,那么你可以在第一个池子买 ETH,再在第二个池子卖掉,赚取 100 美元的差价。但问题是,你需要有钱来先买 ETH。这时候,闪电贷就是完美的解决方案,因为你不需要手头有 1,200 美元。你可以借 1,200 美元的 ETH,然后以 1,300 美元卖出,偿还 1,200 美元后,把 100 美元的利润留下来(扣除手续费)。

贷款再融资

对于常规的 DeFi 贷款,通常需要一些抵押品。比如,如果你要借 10,000 美元的稳定币,你需要存入 15,000 美元的 ETH 作为抵押。

如果你的稳定币贷款利率是 5%,但你想用另一个借贷智能合约以 4% 的利率进行再融资,你需要:

  1. 偿还 10,000 美元的稳定币
  2. 提取 15,000 美元的 ETH 抵押品
  3. 将 15,000 美元的 ETH 抵押品存入另一个协议
  4. 再次以较低利率借出 10,000 美元的稳定币

如果你的 10,000 美元被其他应用锁住,这会带来问题。但通过闪电贷,你可以在不使用自己的稳定币的情况下完成步骤 1 到 4。

更换抵押品

在上面的例子中,借款人使用了 15,000 美元的 ETH 作为抵押品。但假设某个协议提供了更低的抵押率,使用的是 wBTC。借款人可以使用闪电贷,并通过类似的步骤来替换抵押品,而不是更换本金。

清算借款人

在 DeFi 贷款的背景下,如果抵押品的价值跌破某个门槛,抵押品可能会被清算——即被强制出售来偿还贷款。例如,在上面的例子中,如果 ETH 的价值跌到 12,000 美元,协议可能允许某人以 11,500 美元的价格购买 ETH ,前提是他们先偿还 10,000 美元的贷款。清算人可以使用闪电贷来支付 10,000 美元的稳定币贷款,并获得 11,500 美元的 ETH 。随后,他们可以在另一个交易所卖出 ETH 换取稳定币,然后偿还闪电贷。

为其他 DeFi 应用增加收益

像 Uniswap 和 AAVE 这样的协议通过交易手续费或借贷利息来为存款人赚取收益。但是,由于它们拥有如此大量的资本,还可以通过提供闪电贷来额外赚钱。这增加了资本的效率,因为同一笔资本现在有了更多的用途。

在单笔交易中构建杠杆循环

通过使用借贷协议,可以实现杠杆多头和空头。例如,要做 ETH 的杠杆多头,用户可以将 ETH 存为抵押品到借贷池中,借出稳定币,再用稳定币换成 ETH ,然后将 ETH 再次存入借贷池并重复这个过程。这样,抵押品和借入的 ETH 总量将比最初的金额大,从而让借款人对 ETH 的价格有更大的敞口。

要做 ETH 的杠杆空头,用户可以将稳定币存入借贷池,借出 ETH ,然后将 ETH 换成稳定币,并将稳定币存回借贷池中,反复进行这个操作。这样用户会积累大量的 ETH 债务,如果 ETH 的价格下跌,偿还这些债务就会变得更容易。

可以通过公式来计算在这种方式下可借入的资产总额,其中 $\text{LTV}$ 是协议允许的最大贷款价值比。例如,如果协议要求存入价值 1,000 美元的稳定币来借出 800 美元的 ETH ,那么 LTV 为 $800/1000 = 0.8$。因此,用户的 ETH 敞口可以高达 $1/(1-0.8) = 5$ 倍,也就是说,用户用 1,000 美元的存款可以获得价值 5,000 美元的 ETH 敞口。

与其通过多次交易构建杠杆循环,消耗大量 Gas 费,用户可以通过以下步骤来简化操作:

  1. 使用闪电贷借出 5,000 美元的稳定币
  2. 用稳定币兑换价值 5,000 美元的 ETH
  3. 将 ETH 存入借贷池作为抵押品
  4. 从借贷池借出 4,000 美元的稳定币
  5. 将自己 1,000 美元的稳定币与借贷池中借出的 4,000 美元稳定币一起用于偿还闪电贷

这样,用户现在拥有 5,000 美元的 ETH 作为抵押品,并可以从借贷池借出了 4,000 美元的稳定币。

黑客攻击智能合约

闪电贷最为人熟知的用途,可能就是被黑帽黑客用来攻击协议。闪电贷的主要攻击向量是价格操纵和治理(投票)操纵。如果 DeFi 应用的防御措施不足,攻击者可以通过闪电贷大量买入某种资产,抬高其价格,或者获得大量的投票代币,推动通过某个治理提案。

以下是一些闪电贷攻击的案例列表,供感兴趣的人参考。不过,漏洞并不只是单向的。如果闪电贷的借贷合约实现不当,它本身也可能成为攻击目标,导致资金损失。

闪电贷攻击的案例

闪电贷攻击是最常见的利用方式之一,可能是因为从 web2 背景转到 DeFi 的开发者不习惯这种机制。以下是一些臭名昭著的例子:

  • rekt.news/deus-dao-rekt/
  • rekt.news/jimbo-rekt/
  • rekt.news/platypus-finance-rekt/
  • rekt.news/beanstalk-rekt/
  • rekt.news/inverse-rekt2/

利用闪电贷攻击协议是一个独立的话题,而本文则主要关注闪电贷借贷合约中存在的安全隐患。

ERC3156 协议

ERC3156 旨在为闪电贷提供标准化接口。虽然工作流程相对简单,但具体的实现细节需要敲定。例如,应该调用哪个函数?是 getFlashLoanonFlashLoan,还是其他名称?此外,这个函数应该接受哪些参数?

ERC3156 接收方规范

该标准的第一个方面是借款人需要实现的接口,具体如下所示。借款人只需要实现一个函数。

interface IERC3156FlashBorrower {
    /**
     * @dev Receive a flash loan.
     * @param initiator The initiator of the loan.
     * @param token The loan currency.
     * @param amount The amount of tokens lent.
     * @param fee The additional amount of tokens to repay.
     * @param data Arbitrary data structure, intended to contain user-defined parameters.
     * @return The keccak256 hash of "ERC3156FlashBorrower.onFlashLoan"
     */
    function onFlashLoan(
        address initiator,
        address token,
        uint256 amount,
        uint256 fee,
        bytes calldata data
    ) external returns (bytes32);
}

参数说明:

initiator

这是发起闪电贷的地址。你可能需要在这里进行某种验证,以确保不可信的地址不能在你的合约上发起闪电贷。通常,这个地址应该是你自己,但你不能盲目假设。

onFlashLoan 函数应该由闪电贷合约调用,而不是由发起者调用。因此,你需要在 onFlashLoan() 函数内部检查 msg.sender 是否为闪电贷合约,因为这个函数是 external 的,任何人都可以调用它

initiator 并不是 msg.sender 也不是闪电贷合约,而是触发闪电贷合约调用接收者的 onFlashLoan 函数的地址。

token

这是你借用的 ERC20 代币的地址。提供闪电贷的合约通常会持有多个代币,它们可以用来发放闪电贷。ERC3156 闪电贷标准并不支持直接借用原生 ETH,但可以通过借用 WETH 来实现,借款人可以在借到 WETH 后将其转换为 ETH。由于借款合约不一定是调用闪电贷合约的合约,借款合约可能需要知道具体借出了哪种代币。

fee

fee 是需要支付的代币费用,表示为绝对值,而非百分比。

data

如果你的闪电贷接收合约并不是硬编码来在收到闪电贷时执行某个特定操作,你可以通过 data 参数来动态传递执行行为。比如说,如果你的合约用于套利多个交易池,那么你可以通过 data 指定要交易的池子。

return value

合约必须返回 keccak256("ERC3156FlashBorrower.onFlashLoan"),具体原因稍后会讨论。

借款者的参考实现

以下代码经过修改,源自 ERC3156 规范中的代码片段,做了精简。请注意,这个合约仍然完全信任闪电贷提供方。如果闪电贷提供方被黑客攻击,合约可能会因为接收到伪造的 amountfeeinitiator 数据而被利用。如果闪电贷提供方是不可变的,这不是问题;但如果提供方是可升级的,这就可能成为一个攻击向量。

contract FlashBorrower is IERC3156FlashBorrower {
    IERC3156FlashLender lender_;
    address trustedInitiator;

    constructor (IERC3156FlashLender lender_, address initiator_) {
        lender = lender_;
        trustedInitiator = initiator_;
    }

    /// @dev ERC-3156 Flash loan callback
    function onFlashLoan(
        address initiator,
        address token,
        uint256 amount,
        uint256 fee,
        bytes calldata data
    ) external override returns(bytes32) {
        require(msg.sender == address(lender), "FlashBorrower: Untrusted lender");
        require(initiator == trustedInitiator, "FlashBorrower: Untrusted loan initiator");

        // (parsedData) = abi.decode(data, (DataTypes));
        // do something with the flashloan

        // allow the flash loan to take the tokens back
        IERC20(token).approve(address(lender), amount + fee);

        return keccak256("ERC3156FlashBorrower.onFlashLoan");
    }
}

ERC3156 借款提供方规范

以下是 ERC3156 规范中借款提供方的接口:

import "./IERC3156FlashBorrower.sol";

interface IERC3156FlashLender {

    // for a particular token, how much can be flash loaned out
    function maxFlashLoan(address token) external view returns (uint256);

    // for a particular token, how much interest is charged.
    // units are in the token quanitity, not interest rate
    function flashFee(address token,uint256 amount) external view returns (uint256);

    // initiate the flash loan for a particular token and amount 
    // ANYONE CAN CALL THIS WITH ANY ARGUMENTS
    function flashLoan(
        IERC3156FlashBorrower receiver,
        address token,
        uint256 amount,
        bytes calldata data
    ) external returns (bool);
}

闪电贷接口中的参数与之前描述的一样,这里不再重复说明。

flashLoan() 函数需要完成几个重要操作:

  1. 可能有人会调用 flashLoan() 函数,传入合约不支持的代币,这种情况必须进行检查。
  2. 也可能有人传入的借款金额超过了 maxFlashLoan 限定的最大金额,这也需要进行检查。
  3. data 参数只是简单地转发给借款方。

更为重要的是,flashLoan() 必须负责将代币转给借款方,并在之后确保代币被转回来。它不应该依赖借款方主动将代币归还。关于为什么不能依赖借款方归还代币的原因,我们将在下一章节详细讨论。为了强调关键部分,我们在此引用了 EIP-3156 规范中的参考实现:

function flashLoan(
    IERC3156FlashBorrower receiver,
    address token,
    uint256 amount,
    bytes calldata data
) external override returns (bool) {
    require(
        supportedTokens[token],
        "FlashLender: Unsupported currency"
    );
    uint256 fee = _flashFee(token, amount);
    require(
        IERC20(token).transfer(address(receiver), amount),
        "FlashLender: Transfer failed"
    );
    require(
        receiver.onFlashLoan(msg.sender, token, amount, fee, data) == CALLBACK_SUCCESS,
        "FlashLender: Callback failed"
    );
    require(
        IERC20(token).transferFrom(address(receiver), address(this), amount + fee),
        "FlashLender: Repay failed"
    );
    return true;
}

请注意,参考实现假设 ERC20 代币在操作成功时会返回 true,但并非所有代币都遵守这一标准。因此,如果使用不符合 ERC20 标准的代币,建议使用 SafeTransfer 库进行安全转账。

安全注意事项

借款人的访问控制和输入验证

借款智能合约必须确保只有闪电贷合约可以调用 onFlashLoan() 函数。否则,闪电贷之外的其他实体可能会调用 onFlashLoan(),导致意外行为。

此外,任何人都可以随意调用 flashLoan(),并将任意借款者作为目标,同时传入任意数据。为了确保传入的数据不是恶意的,闪电贷接收者合约应当限制能够发起借贷请求的地址集合。

重入锁至关重要

根据 ERC3156 标准的定义,无法完全遵循“检查-效果-交互”模式来防止重入攻击。它必须先通知借款人已收到代币(进行外部调用),然后再转回代币。因此,合约中应添加 nonReentrant 锁以防止重入攻击。

关键在于,代币的转回必须由借贷方执行,或者必须确保合约中已设置重入锁。

在上述实现中,代币的归还是由借贷方负责,而不是借款方。这一设计避免了所谓的“侧门攻击”,即借款方通过将资金作为借贷方存入协议,导致协议认为资金已经归还,但实际上借款方变成了大额存款的借贷方。

例如,UniswapV2 的闪电贷在操作结束后不会自动将代币转回。然而,它通过设置重入锁来防止借款方通过存入资金来“归还贷款”,并伪装成借贷方的行为。

对借款人来说,确保只有闪电贷合约能调用 onFlashLoan

闪电贷合约被设计为只能调用接收者的 onFlashLoan() 函数,不能调用其他函数。如果借款方能够指定闪电贷合约调用哪个函数,闪电贷就有可能被恶意利用,导致闪电贷合约转出其持有的其他代币(通过调用 ERC20.transfer),或者为恶意地址授权其代币余额(通过调用 approve)。

由于这些操作必须通过显式调用 ERC20.transferapprove 才能执行,因此只要闪电贷合约只能调用 onFlashLoan() 函数,就可以避免此类问题的发生。

这种攻击在现实世界中已经发生过。Rekt News 记录了一起 MEV 机器人遭黑客攻击 的事件。

使用 token.balanceOf(address(this)) 可能被操控

在上述实现中,我们并未使用 balanceOf(address(this)) 来判断贷款是否已归还,除了用于确定最大闪电贷金额外。因为有人可以直接将代币转入合约,从而干扰逻辑。我们通过借贷方转回贷款金额加上手续费来确保贷款已归还。当然,使用 balanceOf(address(this)) 来检查还款也是一种可行的方法,但必须结合重入检查,以防止贷款通过存款的方式被归还。

为什么借款方需要返回 keccak256(“ERC3156FlashBorrower.onFlashLoan”)

这样做是为了处理以下情况:某个拥有 fallback 函数的合约(而不是闪电贷合约)已授权闪电贷合约。当某人反复使用该合约作为接收者发起闪电贷时,可能会发生以下情况:

  1. 受害合约收到闪电贷。
  2. 闪电贷合约调用 onFlashLoan(),并触发合约中的 fallback 函数,而该函数并没有回滚。fallback 函数会响应任何不匹配合约中其他函数的调用,因此它将响应 onFlashLoan() 调用。
  3. 闪电贷合约从借款方提取代币加上手续费。

如果这种操作反复进行,带有 fallback 函数的受害合约会被耗尽。同样的攻击也可能发生在 EOA 钱包上,因为直接调用钱包地址的 onFlashLoan() 并不会回滚。

仅仅检查 onFlashLoan() 函数没有回滚是不够的。闪电贷合约还会检查是否返回了 keccak256("ERC3156FlashBorrower.onFlashLoan"),以确保借款方确实有意借用代币并支付手续费。

与闪电贷相关的练习题

以下来自 DamnVulnerableDeFiMr Steal Yo Crypto 的练习题可以帮助你练习上述攻击向量。理解闪电贷的最佳方式之一就是学习在实现它们时应该避免哪些问题。

先复习一下 ERC4626 的知识,然后尝试这些练习。

  • Unstoppable(这个难度较大,最后完成。目标是阻止合约运作,而不是窃取资金)。
  • Flash Loaner(来自 Mr Steal Yo Crypto,确保理解 ERC4626)

这些练习的重点是黑掉借款人或借贷方,而不是使用闪电贷来攻击其他系统。


原文链接:https://www.rareskills.io/post/erc-3156

点赞 1
收藏 2
分享
本文参与登链社区写作激励计划 ,好文好收益,欢迎正在阅读的你也加入。

0 条评论

请先 登录 后评论
0xE
0xE
0x59f6...a17e
17年进入币圈,做过FHE,联盟链,现在是智能合约开发者。 刨根问底探链上真相,品味坎坷悟Web3人生。