f(x) 协议:利用双重闪电贷攻击绕过访问控制

f(x) 协议中存在一个严重漏洞,攻击者可以通过双重闪电贷攻击绕过访问控制,在用户与协议交互时抢先交易并窃取抵押品。该漏洞源于协议错误地认为某些函数只能通过严格控制的内部执行流程访问,而实际上,攻击者可以利用嵌套的闪电贷重新进入协议并获得对其他用户头寸的未授权控制权限。

f(x) 协议:通过双重闪电贷攻击绕过访问控制

4 月 20 日,我们发现了 f(x) 协议 中的一个严重漏洞,该漏洞可能使攻击者能够抢先交易用户,并在用户与协议交互期间从他们的仓位中窃取抵押品,通过一次攻击将超过 200 万美元置于风险之中。根本原因是访问控制中一个微妙但严重的缺陷:该协议错误地认为某些功能只能通过严格控制的内部执行流程来访问。实际上,攻击者可以利用嵌套的闪电贷重新进入协议,并获得对其他用户仓位的未经授权的控制。在负责任地披露之后,f(x) 协议团队迅速做出响应并实施了修复措施,以减轻该问题。在本文中,我们将分解该漏洞利用,分析使其成为可能的有缺陷的假设,并分享保护复杂 DeFi 协议的关键经验教训,尤其是涉及闪电贷交互的协议。

闪电贷简述

闪电贷允许用户在没有抵押品的情况下借用资产,前提是贷款在同一交易内偿还。它们在 DeFi 中被广泛用于套利、抵押品互换和清算。

f(x) 协议

f(x) 协议 是由 AladdinDAO 开发的稳定币系统,允许用户通过创建 抵押债务仓位 (CDP) 来铸造 fxUSD 稳定币,每个 CDP 都表示为一个 非同质化代币 (NFT)。尽管用户可以直接与核心合约交互,但大多数操作都通过外围合约进行路由,从而简化了用户交互并实现了高级功能。这些外围合约与闪电贷提供商集成,允许在单个交易中无缝执行复杂的操作,例如解除仓位。

正常执行流程:关闭仓位

要了解协议的典型使用方式,以及为什么某些偏差会导致漏洞,最好先了解关闭仓位的标准流程。这种基线行为有助于阐明稍后讨论的缺陷。

此流程使用一个 外围合约,即 f(x) 路由器,它通过在 与仓位抵押品相同的代币中 发起 闪电贷 来实现关闭仓位。这使得合约能够原子地执行整个关闭和偿还周期:借入的抵押品被兑换为 fxUSD 以偿还债务,这会解锁原始抵押品,然后使用该解锁的抵押品来偿还闪电贷。

交易 1 - 授予批准

用户批准路由器转移其基于 NFT 的仓位,以便稍后可以代表他们操作该仓位。

交易 2 - 关闭仓位

1. 用户通过调用路由器上的 closeOrRemovePositionFlashLoanV2 来启动仓位关闭。

2. 合约从 Morpho(以抵押品代币)启动闪电贷,并在执行上下文中设置一个 HAS_FLASH_LOAN 标志。

3. Morpho 回调到合约的 onMorphoFlashLoan,检查上下文标志 并触发 onCloseOrRemovePositionFlashLoanV2

4. 这个Hook完成了操作:

  • 将抵押品兑换为 fxUSD 以偿还债务,
  • 从用户处提取仓位 NFT,
  • 与核心合约交互以关闭仓位,
  • 将 NFT 返回给用户。
function onCloseOrRemovePositionFlashLoanV2(
  ...
) external onlySelf {
  ...
  // swap collateral token to fxUSD
  _swap(IPool(pool).collateralToken(), fxUSD, borrowAmount, fxUSDAmount, swapTarget, swapData);

  // transfer the NFT from the user
  IERC721(pool).transferFrom(recipient, address(this), position);

  (, uint256 maxFxUSD) = IPool(pool).getPosition(position);
  if (fxUSDAmount >= maxFxUSD) {
    // close entire position
    IPoolManager(poolManager).operate(pool, position, type(int256).min, type(int256).min);
  } else {
    IPoolManager(poolManager).operate(pool, position, -int256(amount), -int256(fxUSDAmount));
    _checkPositionDebtRatio(pool, position, miscData);
  }

  // return NFT to the user
  IERC721(pool).transferFrom(address(this), recipient, position);

  emit CloseOrRemove(pool, position, recipient, amount, fxUSDAmount, borrowAmount);
}

5. 剩余的抵押品可以选择性地进行兑换并返回给用户。

正常执行流程。

漏洞利用

攻击者通过 抢跑受害者的批准交易 来利用该协议。通过在受害者的仓位关闭期间注入第二个闪电贷,攻击者重新进入合约并利用受害者的批准来:

1. 关闭受害者的仓位,以及

2. 窃取抵押品

以下是此操作方式的分步分解。

步骤 1:创建一个最小仓位

为了与路由器交互,攻击者首先在核心系统中开设一个仓位。为了提高效率,他们创建一个 空仓位,在最大限度地降低成本的同时,为漏洞利用搭建舞台。

步骤 2:使用恶意负载触发闪电贷

攻击者在路由器上调用 closeOrRemovePositionFlashLoanV2,目标是 他们自己的 仓位。作为调用的一部分,他们包含一个带有 空交换路径 的负载。

这里发生了什么?

路由器通常会将闪电贷借入的代币兑换为 fxUSD 以偿还债务,使用用户定义的路径。但是,如果交换路径为空,则合约 会回退到调用 用户提供的代币地址上的 transferFrom。攻击者通过传入一个 恶意合约 而不是合法的代币来利用此回退。当调用 transferFrom 时,恶意合约重新获得对执行的控制。

步骤 3:首次闪电贷

路由器从 Morpho 启动闪电贷以支付未偿还的债务,并进入内部函数 onCloseOrRemovePositionFlashLoanV2。这里的一切看起来仍然正常,并且上下文是正确的。

步骤 4:通过恶意 transferFrom 进行第二次闪电贷

在执行攻击者的仓位关闭期间,路由器到达尝试获取 fxUSD 的点。由于攻击者提供了一个空的交换路径,因此它默认调用所提供的“代币”上的 transferFrom。该“代币”是一个恶意合约。当调用 transferFrom 时,攻击者劫持控制流并 启动第二次闪电贷,这次是从 Balancer 发起的,所有这些都在原始 (Morpho) 贷款的执行上下文中进行。

为什么这很危险?

协议使用一个上下文标志 (HAS_FLASH_LOAN) 来跟踪当前是否在闪电贷执行中。但是,它并非旨在处理 嵌套的 闪电贷回调。当第二次 (Balancer) 闪电贷到达时,HAS_FLASH_LOAN 标志仍然从第一次闪电贷设置。因此,协议错误地将重入调用视为原始流程的延续。这允许攻击者使用未验证的 calldata 在执行过程中重新进入系统,现在目标是 受害者的仓位

步骤 5:关闭受害者的仓位并窃取抵押品

协议:

  1. 利用受害者最近的批准来转移他们的 NFT。
  2. 关闭受害者的仓位。
  3. 使用闪电贷借入的资金来偿还受害者的债务。
  4. 将剩余的抵押品留在合约中。
  5. 最后,恢复执行初始闪电贷回调Hook,该Hook现在将 受害者的抵押品视为其余额的一部分,并将其作为常规执行的一部分转移出去。

完整的漏洞利用图。

重要说明:所有者检查被绕过

在预期的执行流程中,onCloseOrRemovePositionFlashLoanV2 仅在 closeOrRemovePositionFlashLoanV2 之后调用。在这种情况下,协议完全控制参数,最重要的是,它将 msg.sender 硬编码为授权用户:

  function closeOrRemovePositionFlashLoanV2(
    ...
  ) external nonReentrant {
    address collateralToken = IPool(pool).collateralToken();

    _invokeFlashLoan(
      collateralToken,
      borrowAmount,
      abi.encodeCall(
        PositionOperateFlashLoanFacetV2.onCloseOrRemovePositionFlashLoanV2,
        (pool, positionId, amountOut, borrowAmount, msg.sender, data)   // 硬编码 msg.sender
      )
    );
    ...
  }

这确保只有调用者的仓位可以被修改。

但是,在漏洞利用场景中,攻击者通过外部闪电贷回调直接重新进入 onCloseOrRemovePositionFlashLoanV2,完全绕过预期的流程。至关重要的是,该协议仍然认为它在通过 closeOrRemovePositionFlashLoanV2 初始化的原始闪电贷的上下文中运行。这是因为在第一次闪电贷完成后,内部上下文标志从未重置。因此,应该限制闪电贷回调的检查被绕过:

function receiveFlashLoan(
  address[] memory tokens,
  uint256[] memory amounts,
  uint256[] memory feeAmounts,
  bytes memory userData
) external {
  if (msg.sender != balancer) revert ErrorNotFromBalancer();

  // make sure call invoked by router
  LibRouter.RouterStorage storage S = LibRouter.routerStorage();
  // 由于在第一次闪电贷后缺少上下文重置而被绕过
  (bool success, ) = address(this).call(userData);    // 调用 onCloseOrRemovePositionFlashLoanV2
  ...
}

当攻击者从 Balancer 触发第二次闪电贷时,易受攻击的合约会收到回调 (receiveFlashLoan),并且由于缺少上下文验证,因此假定该调用是合法的。然后,它执行攻击者提供的负载,其中包括任意 calldata,包括属于另一个用户的 positionId。

结论

这是我们的一位审计员在内部研究期间发现的实时漏洞。该协议假定了一个单一的、受控的闪电贷上下文,但未能考虑到嵌套的或外部触发的闪电贷如何破坏这些假设并损害信任和控制流。通过结合重入、用户控制的 calldata 和闪电贷误导,攻击者可以利用此漏洞来关闭另一个用户的仓位并耗尽其抵押品,所有这些都在单个交易中完成。在负责任地披露之后,f(x) 团队迅速删除了 Balancer V2 闪电贷集成,以消除此攻击向量。

给协议开发者的重要经验教训

用户控制的兑换路由可能会劫持执行

允许用户定义任意交换路径会带来严重的风险。在这种情况下,它使攻击者可以控制执行,以触发第二次闪电贷并将执行重定向回具有伪造参数的易受攻击的合约。

难以推断闪电贷

闪电贷场景中的执行路径通常是非线性和不透明的。如果没有仔细建模,很容易忽略通过可组合交互隐式跨越信任边界的情况。

上下文验证必须是健壮的

在这种情况下,仅检查闪电贷是否来自受信任的协议是不够的。该合约还需要确保闪电贷是由其逻辑启动的,并且内部状态没有错误地从先前的、不相关的闪电贷继承。为了提高健壮性,协议应考虑以下措施:

1. 在执行闪电贷回调时,散列并验证相关的上下文数据(例如,调用者、所有者、仓位 ID),确保内部状态与预期的交易流程匹配。

2. 在闪电贷提供者回调到协议的那一刻,显式更新或重置闪电贷上下文。这避免了从先前的操作继承陈旧的或意外的状态。

关于我们

ChainSecurity 的使命是在区块链生态系统中建立信任,以使这项新兴技术能够在已建立的组织、政府和区块链公司中发挥其潜力。

如果你有任何疑问,请随时通过 contact@chainsecurity.com 与我们联系,以获取一般请求,包括审计请求,以及有关此漏洞或其他漏洞的问题。此外,请访问我们的网站 chainsecurity.com

external-link

  • 原文链接: chainsecurity.com/blog/f...
  • 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
点赞 0
收藏 0
分享
本文参与登链社区写作激励计划 ,好文好收益,欢迎正在阅读的你也加入。

0 条评论

请先 登录 后评论
chainsecurity
chainsecurity
江湖只有他的大名,没有他的介绍。