V12 发布 - 结合 AI、LLM 和传统静态分析技术的自主 Solidity 审计工具

  • zellic
  • 发布于 5小时前
  • 阅读 42

Zellic 发布了 V12,一款结合 AI、LLM 和传统静态分析技术的自主 Solidity 审计工具,能有效发现高危漏洞,已在实际审计和竞赛中发现 39 个问题。V12 具备直观的界面,支持导出报告,并计划支持更多语言和 L1,旨在提供经济高效的漏洞查找能力,但不能替代全面的人工审计。

我们即将发布 V12,我们的自主 Solidity 审计工具。与现有工具不同,V12 能够持续发现真正的漏洞:高危和严重级别的漏洞。它通过将 AI 和 LLM 与传统静态分析技术相结合来实现这一点。我们通过在 Zellic 的实际审计、审计竞赛以及实际应用中测试 V12,发现了 39 个发现。同时,在我们的内部基准测试中,V12 的性能已经饱和了我们目前的测试套件,该套件由我们在过去审计中发现的真实、有影响的漏洞组成。

虽然不能替代审计,但 V12 目前的漏洞发现能力与大多数初级审计师和一些审计公司相当或超过。在本文中,我们将探讨 V12 发现的漏洞示例。其中许多漏洞是在现场审计竞赛中发现的,或者被之前的审计人员遗漏了。V12 目前支持 Solidity,但未来我们将增加对其他语言和 L1 的支持,包括 Solana 上的 Rust。

除了强大的漏洞发现引擎外,V12 还包括一个功能齐全、直观且熟悉的 前端。在这个界面中,你可以启动审计并查看发现的错误,其工作流程类似于 Linear 或 Github Issues。你还可以将选定的问题导出为 Markdown、JSON 或 PDF 报告。这使得团队可以轻松地将 V12 纳入现有工作流程,或扫描他们计划集成的第三方项目。

V12 已经受到该领域最佳团队的信任:我们的设计合作伙伴包括 LayerZero、Starkware、Axiom、Avantis、Initia、Alkimiya 和 Succinct。今天,我们将推出 V12 的封闭测试版,你可以通过 这里↗ 访问。在此初始阶段之后,我们将完全免费向公众发布 V12。

logos34.png

为什么选择 V12?

我们成立 Zellic 是因为审计太糟糕了。在 2021 年,审计费用昂贵且速度慢。大型公司经常遗漏明显的、表面上的错误,例如缺少访问控制或重入漏洞。我们的使命很简单:提供 真正好的审计——更好、更快、更便宜,不遗漏任何错误。

虽然我们已经在 Zellic(以及最近的 Code4rena↗Zenith↗)完成了这项使命,但对于需要小型、快速审查的团队来说,仍然没有好的解决方案。这包括寻求持续安全的团队——为每个 pull request 进行一次审计——或者发布小型或增量更改的团队。对于评估第三方合约(例如 token)以进行潜在集成的团队来说,也没有好的解决方案。

今年早些时候,我们注意到一些审计提供商现在的表现不如前沿的 LLM。一般来说,低质量的审计师 (1) 很糟糕并且遗漏了明显的错误,(2) 具有不可接受的周转时间,或 (3) 缺乏简化的、一致的客户体验。同时,这些提供商通常对小型、简单的审查收取数千或数万美元的费用。

LLM 擅长发现表面上的编码错误,但它们很难发现更深层次的漏洞,例如协议设计或业务逻辑错误。根据 Zellic 超过 1,000 次审计的内部统计数据,大约 70% 的错误都是编码错误1。深入到关键和高危漏洞,这个比例仍然相似。因此,我们假设可以构建一种 AI 审计工具,在发现简单但重要的错误方面优于低质量的审计公司——同时承认它永远无法发现所有错误或优于最佳提供商。

简而言之:团队想要一个 一致、良好 的安全提供商,能够 可靠地发现重要的、直接的错误。他们希望在 紧急情况下 获得 某种保证,尽管它并不完美,也达不到高质量审计的水平。

这是我们对 V12 的问题陈述。我们不期望 V12 是完美的,但我们希望它至少与最差的审计公司一样好。我们希望团队拥有 廉价自助服务 的体验,使 安全感觉充足 且始终可访问,同时认识到 它不能替代 高质量提供商提供的 适当审计

高质量的审计仍然是必要的。AI 无法发现所有错误,即使是最好的 AI 系统,最好的人类仍然远远胜过。在加密货币领域,即使是一个漏洞也可能导致灾难性的、价值数十亿美元的攻击。因此,团队仍然应该由值得信赖的提供商对他们的代码进行专业审计。我们只是想帮助团队——尤其是那些依靠自筹资金的团队——减少对低质量审计的依赖。

我们将把 V12 集成到:

  • 任何人都可以使用的 免费、独立应用程序↗
  • Zellic 和 Zenith 的审计中,作为额外的质量保证层
  • Code4rena 的审计竞赛中,所有 V12 发现的问题都将被标记为已知问题——阻止 AI 垃圾邮件,鼓励 管理者 提交更高质量的问题,并最终节省客户的时间和金钱
  • Code4rena 的漏洞赏金计划中,其方式与审计竞赛类似
  • GitHub action,便于 CI/CD 集成

我们希望你喜欢使用 V12。在本博客文章的其余部分,我们将深入探讨 V12 在现实世界中的表现,重点介绍许多说明性案例研究和 V12 发现的错误。

评估

我们相信 V12 的性能不言而喻。尽管我们计划通过额外的资金、研究和开发来进一步改进 V12,但今天的 V12 已经是一款令人印象深刻的、功能强大的漏洞发现工具。在本节中,我们将探讨 V12 如何优于现有工具、解决方案和提供商的示例和案例研究。

V12 发现导致黑客攻击的遗漏错误

V12 能够持续发现审计公司遗漏的表面漏洞。虽然这不能代表所有公司,但一些审计公司总是会遗漏表面上的、容易发现的漏洞。由于这些错误相对简单,因此这些遗漏通常会导致黑客攻击。在这里,我们展示了一个具有代表性的示例,其中包含 V12 检测到但过去审计遗漏的现实漏洞利用中的错误。所有错误均由 V12 独立发现,无需任何特殊提示或人工协助。

漏洞 1:1inch(2025 年 3 月)

该漏洞是来自内联汇编中的未检查指针加法运算的整数溢出,最终导致内存损坏。总共被盗了 500 万美元的 USDT。根据 rekt.news,尽管 过去经过多次审计↗,但该漏洞仍被遗漏。V12 检测并报告了此错误,无需任何特殊提示或帮助。

漏洞详情

该合约在内存中的 offset 处写入一个结构体,该 offset 的计算方式为 ptr + interactionOffset + interactionLength23。但是,由于 interactionLength 完全由攻击者控制,并且加法运算未经过检查,因此攻击者可以控制 offset,使其基本上可以是任何偏移量。这最终允许攻击者重定向写入的位置,从而允许他们提供一个假的结构体实例来代替它。然后,该假的结构体最终使攻击者能够从受害者合约中窃取 token。易受攻击的代码如下所示:

function _settleOrder(bytes calldata data, address resolver, uint256 totalFee, bytes memory   tokensAndAmounts) private {
    // [...]
    assembly {
        // [...]
        let interactionLengthOffset := calldataload(add(data.offset, 0x40))
        let interactionOffset := add(interactionLengthOffset, 0x20)
        let interactionLength := calldataload(add(data.offset, interactionLengthOffset))
        // [...]

        let offset := add(add(ptr, interactionOffset), interactionLength) // <-- 错误:
        // Offset 是可控的,可以进行任意写入(内存损坏)

        mstore(add(offset, 0x04), totalFee)
        mstore(add(offset, 0x24), resolver)
        mstore(add(offset, 0x44), calldataload(add(order, 0x40)))  // takerAsset
        mstore(add(offset, 0x64), rateBump)
        mstore(add(offset, 0x84), takingFeeData)
        let tokensAndAmountsLength := mload(tokensAndAmounts)
        memcpy(add(offset, 0xa4), add(tokensAndAmounts, 0x20), tokensAndAmountsLength)
        mstore(add(offset, add(0xa4, tokensAndAmountsLength)), tokensAndAmountsLength)
        // [...]
    }
}

V12 报告了此漏洞,并附带以下描述:

_settleOrder 中,两个偏移量(interactionLengthOffsetinteractionOffset)直接从用户提供的 calldata 加载,没有任何边界检查。然后,这些值用于未经检查的算术运算,以计算内存写入位置(通过 mstorecalldatacopy 和自定义的 memcpy)。恶意调用者可以提供超出范围的偏移量,导致写入超出预期内存区域,从而导致任意内存损坏。

漏洞 2:SIR Trading(2025 年 4 月)

该漏洞是协议的 Uniswap V3 swap callback 中对瞬时内存(TSTORE/TLOAD)的滥用。总共被盗了 33.5 万美元。根据 rekt.news,该漏洞在 协议的审计中被遗漏↗。V12 检测并报告了此错误,无需任何特殊提示或帮助。

漏洞详情

Uniswap 将池地址写入瞬时存储槽 1,合约使用它来进行访问控制。但是,在回调结束时,它会重复使用相同的瞬时存储槽来存储其铸造的 token 数量。攻击者利用这一点,从其攻击合约恶意调用 swap callback,冒充 Uniswap 池,来耗尽 vault 45。攻击合约的地址与铸造的 token 数量一致,这是通过暴力破解找到的。例如,如果合约铸造了 95759995883742311247042417521410689 个 token,则攻击者需要将其合约部署到 0x00000000001271551295307acc16ba1e7e0d4281。易受攻击的代码如下所示:

function uniswapV3SwapCallback(int256 amount0Delta, int256 amount1Delta, bytes calldata data) external {
    // 检查调用者是否是合法的 Uniswap 池
    address uniswapPool;
    assembly {
        uniswapPool := tload(1)
    }
    require(msg.sender == uniswapPool);

    // 解码数据
    (
        address minter,
        address ape,
        SirStructs.VaultParameters memory vaultParams,
        SirStructs.VaultState memory vaultState,
        SirStructs.Reserves memory reserves,
        bool zeroForOne,
        bool isETH
    ) = abi.decode(
            data,
            (address, address, SirStructs.VaultParameters, SirStructs.VaultState, SirStructs.Reserves, bool, bool)
        );

    // 检索要存入的抵押品数量,并检查其是否未超过最大值
    (uint256 collateralToDeposit, uint256 debtTokenToSwap) = zeroForOne
        ? (uint256(-amount1Delta), uint256(amount0Delta))
        : (uint256(-amount0Delta), uint256(amount1Delta));

    // 如果这是一个 ETH 铸造,请尽快将 WETH 转移到池中
    if (isETH) {
        TransferHelper.safeTransfer(vaultParams.debtToken, uniswapPool, debtTokenToSwap);
    }

    // 其余的铸造逻辑
    require(collateralToDeposit <= type(uint144).max);
    uint256 amount = _mint(minter, ape, vaultParams, uint144(collateralToDeposit), vaultState, reserves);

    // 将债务 token 转移到池中
    // 这是最后完成的,以避免来自虚假债务 token 合约的重入攻击
    if (!isETH) {
        TransferHelper.safeTransferFrom(vaultParams.debtToken, minter, uniswapPool, debtTokenToSwap);
    }

    // 使用瞬时存储将铸造的 token 数量返回给铸造函数
    assembly {
        tstore(1, amount) // <-- 错误
    }
}

V12 报告了此漏洞,并附带以下描述:

回调使用非标准的汇编调用 tload(1) 来读取池地址,然后使用相同的槽索引 tstore(1, amount) 来写入铸造的数量。这重复使用了相同的临时存储槽,从而破坏了存储的池地址,并且允许来自意外 msg.sender 地址的未来调用。

漏洞 3:MonoX(2021 年 11 月)

该漏洞是在执行交换时缺少验证,从而允许将相同的资产用作 tokenIntokenOut,导致存储了不正确的 token 价格。这导致在两条链上被盗了 3100 万美元。根据 rekt.news↗,多个审计公司遗漏了这个简单的漏洞。V12 检测并报告了此错误,无需任何特殊提示或帮助。

漏洞详情

swapOut 函数未验证 tokenIntokenOut 是否不同。这允许攻击者使用相同的 token 作为两个参数调用 swapOut,导致第二次调用 _updateTokenInfo 覆盖第一次调用的状态更改并扭曲 token 的价格 67

// 从 tokenIn 交换到 tokenOut,tokenOut 的数量固定。
function swapOut(address tokenIn, address tokenOut, address from, address to,
    uint256 amountOut) internal lockToken(tokenIn) returns(uint256 amountIn)  {
  uint256 tokenInPrice;
  uint256 tokenOutPrice;
  uint256 tradeVcashValue;
  (tokenInPrice, tokenOutPrice, amountIn, tradeVcashValue) = getAmountIn(tokenIn, tokenOut, amountOut);

  address monoXPoolLocal = address(monoXPool);

  amountIn = transferAndCheck(from,monoXPoolLocal,tokenIn,amountIn);

  // uint256 halfFeesInTokenIn = amountIn.mul(fees)/2e5;

  uint256 oneSideFeesInVcash = tokenInPrice.mul(amountIn.mul(fees)/2e5)/1e18;

  // trading in
  if(tokenIn==address(vCash)){
    vCash.burn(monoXPoolLocal, amountIn);
    // 所有费用都给了买方
    oneSideFeesInVcash = oneSideFeesInVcash.mul(2);
  }else {
    _updateTokenInfo(tokenIn, tokenInPrice, 0, tradeVcashValue.add(oneSideFeesInVcash), 0);
  }

  // trading out
  if(tokenOut==address(vCash)){
    vCash.mint(to, amountOut);
    // 所有费用都给了卖方
    _updateVcashBalance(tokenIn, oneSideFeesInVcash, 0);
  }else{
    if (to != monoXPoolLocal) {
      IMonoXPool(monoXPoolLocal).safeTransferERC20Token(tokenOut, to, amountOut);
    }
    _updateTokenInfo(tokenOut, tokenOutPrice, tradeVcashValue.add(oneSideFeesInVcash), 0,
      to == monoXPoolLocal ? amountOut:0 );
  }

  if(pools[tokenIn].vcashDebt > 0 && pools[tokenIn].status == PoolStatus.OFFICIAL){
    _internalRebalance(tokenIn);
  }

  emit Swap(to, tokenIn, tokenOut, amountIn, amountOut, tradeVcashValue);

}

通过在循环中将 Mono token 交换为其自身,攻击者可以重复覆盖其自身的价格,从而任意地抬高价格。在抬高价格后,攻击者将高估的 Mono token 交换为池中的其他资产,从而耗尽资金。

V12 报告了此漏洞,并附带以下描述:

swapOut 未强制执行 tokenIn 和 tokenOut 不同。允许 tokenIn == tokenOut 会在一个执行中触发对同一池的两个顺序更新(tokenIn 和 tokenOut 的 _updateTokenInfo),从而导致冲突的状态更改、价格扭曲以及费用或最小池大小检查的绕过。

漏洞 4:Grim(2021 年 12 月)

该漏洞是 GrimBoostVault 的 depositFor 函数中的重入漏洞,当调用 transferFrom 时,恶意 token 可以重新进入该函数,从而导致池余额过时。总共被盗了超过 3000 万美元。 最初的 审计公司↗ 承认遗漏了这个错误。V12 检测并报告了此错误,无需任何特殊提示或帮助。

漏洞详情

depositFor 函数在更新用户的份额余额之前,先从用户处转移 token:

  function depositFor(address token, uint _amount,address user ) public {
      strategy.beforeDeposit();

      uint256 _pool = balance();
      IERC20(token).safeTransferFrom(msg.sender, address(this), _amount);
      earn();
      uint256 _after = balance();
      _amount = _after.sub(_pool); // deflationary token 的其他检查
      uint256 shares = 0;
      if (totalSupply() == 0) {
          shares = _amount;
      } else {
          shares = (_amount.mul(totalSupply())).div(_pool);
      }
      _mint(user, shares);
  }

由于转移调用是外部的,并且发生在状态更新之前,因此恶意 token 合约可以在其 transferFrom 函数中多次重新进入 depositFor,然后再执行合法的存款。然后,所有重入调用都将完成并根据陈旧的池余额计算份额,从而允许攻击者以相同的存款金额多次铸造份额。

V12 报告了此漏洞,并附带以下描述:

该函数在执行内部状态更新(_mint)之前,会进行多个外部调用(strategy.beforeDeposit()IERC20(token).safeTransferFrom 和调用 strategy.deposit()earn())。如果没有重入保护,恶意 token 或策略合约可以重新进入 vault 并在执行期间操纵状态。

V12 在野外发现错误

V12 发现野外实时问题。在没有任何特殊提示或帮助的情况下,V12 独立发现了 Pendle Finance 中的一个问题。受影响的合约已部署到几条链上,包括 Arbitrum↗,但目前尚未使用。

受影响的合约 ExpiredLpPtRedeemer↗ 于 2025 年 6 月添加到 repo 中。相关代码如下所示:

// [...]
if (netPtIn > 0) {
    _transferFrom(PT, msg.sender, address(this), netLpIn); // 错误
    totalPtRedeem += netPtIn;
}
// [...]

该合约在从用户转移 PT 时使用了错误的变量。它错误地转移了 netLpIn token,而不是 netPtIn。如果这些值不相等,则要么交易将回滚,要么将转移过多的 PT token。在后一种情况下,其他用户可以通过提供精心设计的 netLpIn 来提取额外的 token,或者在理想情况下,合约所有者可以使用 withdraw 来救援。

我们已将此问题报告给 Pendle。团队回应说,该发现是一个内部重复条目,存在一个已知问题,但尚未发布。不过,此反馈确认了该问题是有效的,并且该发现证明了 V12 能够发现值得修复的实际错误,即使是在野外。

V12 在审计竞赛中发现错误

我们通过在 Cantina、Sherlock 和 HackenProof 上的实时代码竞赛中部署 V12 来测试它。这些竞赛使我们能够直接将 V12 的性能与真实代码库上的人工研究人员进行比较,同时提交的漏洞由独立的第三方验证。我们排除了 Code4rena 竞赛,原因有两个:(1) 存在利益冲突,因为 我们拥有 Code4rena↗;(2) 许多竞赛都针对 V12 在之前的 Zellic 审计中已经运行过的项目。

我们总共参加了 6 场比赛,发现了 25 个漏洞。经过评估,2 个漏洞被评为高危等级,2 个中危,4 个低危,9 个信息级。3 个漏洞由于提交过程中的格式问题而被拒绝(否则为高危),2 个漏洞被标记为超出范围。所有错误均由 V12 独立发现,无需任何特殊提示或人工协助,除了为符合提交要求而进行的格式更改。与典型的人工参与者不同,我们没有在评估过程中升级发现或对降级严重程度提出异议。

下面,我们将重点介绍在实时竞赛中发现并接受的具有代表性的错误样本。

错误 1:Cantina 竞赛中的高危漏洞

该漏洞是缺少对两个函数的检查,从而允许攻击者耗尽其他用户的奖励。该漏洞被评为高危等级,并且是比赛期间报告的仅有的 2 个高危等级漏洞之一。总共有 323 名研究人员中的 69 名也提交了此问题。V12 在没有任何特殊提示的情况下自行发现并报告了此漏洞。

漏洞详情

易受攻击的代码如下所示:

  function overrideReceiver(address overrideAddress, bool migrateExistingRewards) external whenNotPaused nonReentrant {
      if (migrateExistingRewards) { _migrateRewards(msg.sender, overrideAddress); }
      require(overrideAddress != address(0) && overrideAddress != msg.sender, InvalidAddress());
      overrideAddresses[msg.sender] = overrideAddress;
      emit OverrideAddressSet(msg.sender, overrideAddress);
  }
  // [...]
  function removeOverrideAddress(bool migrateExistingRewards) external whenNotPaused nonReentrant {
      address toBeRemoved = overrideAddresses[msg.sender];
      require(toBeRemoved != address(0), NoOverriddenAddressToRemove());
      if (migrateExistingRewards) { _migrateRewards(toBeRemoved, msg.sender); }
      overrideAddresses[msg.sender] = address(0);
      emit OverrideAddressRemoved(msg.sender);
  }

攻击者可以将其 overrideAddress 设置为任何其他用户,例如具有未声明奖励的用户。然后,当他们删除其覆盖地址时,他们可以迁移现有奖励,从而耗尽受害者的奖励。

V12 生成以下正确的漏洞报告:

由于 overrideAddresses 映射未强制执行唯一性,因此多个验证器所有者可以通过 overrideReceiver 设置相同的覆盖地址。这些验证器的所有未声明奖励都累积在该单个覆盖地址下。当一个所有者调用 removeOverrideAddress(true) 时,其底层的 _migrateRewards(from=override,to=msg.sender) 调用会将整个 unclaimedRewards 余额(包括最初用于其他验证器的资金)移回调用者,从而有效地窃取了其他验证器的奖励。

验证器可以耗尽属于共享相同覆盖地址的其他验证器的未声明奖励,从而导致资金完全损失。

错误 2:Cantina 竞赛中的高危漏洞

该漏洞是 isBelowPrice 标志的检查不正确,导致用户订单在当前价格位于指定阈值的错误一侧时执行。该漏洞被评为高危等级,并且在比赛期间被其他 15 名研究人员发现。V12 在没有任何特殊提示的情况下自行发现并报告了此漏洞。

漏洞详情

易受攻击的代码如下所示:

  function executePriceDependent(PriceDependentRequest calldata req, bytes calldata signature)
        public
        payable
        returns (bytes memory)
    {
        require(verifyPriceDependent(req, signature), KuruForwarderErrors.SignatureMismatch());

        require(msg.value >= req.value, KuruForwarderErrors.InsufficientValue());
        require(allowedInterface[req.selector], KuruForwarderErrors.InterfaceNotAllowed());
        executedPriceDependentRequest[keccak256(abi.encodePacked(req.from, req.nonce))] = true;

        (uint256 _currentBidPrice,) = IOrderBook(req.market).bestBidAsk();
        require(
            (req.isBelowPrice && req.price < _currentBidPrice) || (!req.isBelowPrice && req.price > _currentBidPrice),
            PriceDependentRequestFailed(_currentBidPrice, req.price)
        );
        (bool success, bytes memory returndata) =
            req.market.call{value: req.value}(abi.encodePacked(req.selector, req.data, req.from));

        if (!success) {
            revert ExecutionFailed(returndata);
        }

        return returndata;
    }

isBelowPrice 标志的检查不正确。当 isBelowPrice 为 true 时,代码会检查 req.price < _currentBidPrice,这意味着当前价格高于阈值。相反,当 isBelowPrice 为 false 时,它会检查 req.price > _currentBidPrice,这意味着当前价格低于阈值。这颠倒了预期的逻辑,导致订单在价格位于阈值的错误一侧时执行。

V12 生成以下正确的漏洞报告:

该函数的保护措施强制执行用户指定的价格阈值,但使用了错误的比较运算符。当 isBelowPrice=true 时,它要求 req.price < currentPrice(即价格高于阈值),而当 isBelowPrice=false 时,它要求 req.price > currentPrice(即价格低于阈值)。这颠倒了预期的逻辑,因此只有当链上价格位于用户限制的另一侧时,订单才会执行。

错误 3:HackenProof 竞赛中的中危漏洞

该漏洞是 processWithdrawalQueue 函数中的拒绝服务漏洞,其中无效的 KYC 请求会阻止整个提款队列。该漏洞被评为中危等级,并获得了足够的声誉,使其在 50 名黑客中排名第 5。V12 在没有任何特殊提示的情况下自行发现并报告了此漏洞。

漏洞详情

易受攻击的代码如下所示:

  function processWithdrawalQueue(
      uint _len
  ) external onlyValidPrice onlyOperator {
      uint256 length = withdrawalQueue.length();
      require(length > 0, "empty queue!");
      require(_len <= length, "invalid len!");
      if (_len == 0) _len = length;

      uint256 totalWithdrawAssets;
      uint256 totalBurnShares;
      uint256 totalFees;

      for (uint count = 0; count < _len, ) {
          bytes memory data = withdrawalQueue.front();
          (
              address sender,
              address receiver,
              uint256 shares,
              bytes32 prevId
          ) = _decodeData(data);

          _validateKyc(sender, receiver);

          // [...]

          _withdraw(
              address(this),
              receiver,
              address(this),
              trimmedAssets,
              shares
          );
          // [...]
  }

如果 withdrawalQueue 中的某个条目具有无效或未经批准的 KYC 状态,则 _validateKyc 调用将回滚,从而中止该函数。这会将无效条目保留在队列的前面,从而永久阻止处理所有后续提款。

V12 生成以下正确的漏洞报告:

该合约循环遍历一个提款队列,并在删除每个条目之前验证其 KYC 状态。如果一个条目未能通过 KYC,则内部 _validateKyc 调用将回滚,从而中止该函数,而不会弹出无效条目。该条目将保留在队列的前面,从而永久阻止处理所有后续提款。

错误 4:Cantina 竞赛中的中危漏洞

该漏洞是 applySlashes 函数中的拒绝服务漏洞,其中恶意验证器可以阻止自己被削减。该漏洞被评为中危等级,并且被其他 23 名研究人员发现。V12 在没有任何特殊提示的情况下自行发现并报告了此漏洞。

漏洞详情

易受攻击的代码如下所示:

function applySlashes(Slash[] calldata slashes) external override onlySystemCall {
      for (uint256 i; i < slashes.length; ++i) {
          Slash calldata slash = slashes[i];
          // signed consensus header means validator is whitelisted, staked, & active
          // unless validator was forcibly retired & ejected via burn: skip
          if (isRetired(slash.validatorAddress)) continue;

          if (balances[slash.validatorAddress] > slash.amount) {
              balances[slash.validatorAddress] -= slash.amount;
          } else {
              // eject validators whose balance would reach 0
              _consensusBurn(slash.validatorAddress);
          }

          emit ValidatorSlashed(slash);
      }
}

function _consensusBurn(address validatorAddress) internal {
      // [...]
      // exit, retire, and unstake + burn validator immediately
      // [...]
      _unstake(validatorAddress, recipient, validator.stakeVersion);
}

function _unstake(
      address validatorAddress,
      address recipient,
      uint8 validatorVersion
  )
      internal
      virtual
      returns (uint256)
  {
      // [...]
      // send `bal` if `rewards == 0`, or `stakeAmt` with nonzero `rewards` added from Issuance's balance
      Issuance(issuance).distributeStakeReward{ value: unstakeAmt }(recipient, rewards);

      return unstakeAmt + rewards;
  }

function distributeStakeReward(address recipient, uint256 rewardAmount) external payable virtual onlyStakeManager {
      uint256 bal = address(this).balance;
      uint256 totalAmount = rewardAmount + msg.value;
      if (bal < totalAmount) {
          revert InsufficientBalance(bal, totalAmount);
      }

      (bool res,) = recipient.call{ value: totalAmount }("");
      if (!res) revert RewardDistributionFailure(recipient);
  }

如果应用削减导致验证器的余额达到零,则会调用 _consensusBurn 将其逐出。这会调用 Issuance::distributeStakeReward,该函数会将未抵押的金额转移到验证器。但是,如果验证器是一个恶意合约,可在接收 eth 时回滚,则转移将失败并回滚整个 applySlashes 交易。这可以防止削减验证器,还可以阻止批处理中的所有其他削减。

V12 生成以下正确的漏洞报告:

applySlashes 函数迭代多个验证器,通过调用 _consensusBurn 来削減 其权益,而 _consensusBurn 反过来又调用 StakeManager._unstake 来转移 token。由于每个 _consensusBurn 调用周围没有 try/catch 或故障隔离,如果任何外部 token 转移回滚(由于恶意或错误的 token/recipient),则整个批处理操作都会回滚。这会取消该交易中的所有先前削减,从而有效地允许单个失败阻止批处理中所有验证器的削减。

V12 在过去审计竞赛中的表现

在过去审计竞赛中测试 V12 的性能时,人工研究人员始终会复制 V12 发现的问题,包括以前审计遗漏的问题。

Phi (Code4rena, 2024 年 10 月)↗

V12 发现了人工研究人员报告的 7 个高危漏洞中的 6 个。

原始报告和V12 报告的发现

H-01: 来源 发现标题 位置
Ground Truth 在 signatureClaim 中的签名重放导致未经授权的奖励领取 PhiFactory
V12 缺失的 EIP-712 域名分隔符允许跨合约/链签名重放 PhiFactory:
来源 发现描述
Ground Truth PhiFactory::signatureClaim 解码 (expiresIn, minter, ref, verifier, artId, chainId, data),但忽略了编码的 chainId(注意 artId 之后的跳过字段)。因为该函数针对 phiSignerAddress 验证 keccak256(encodeData_),而不绑定到 block.chainid,所以在一个链上有效的签名可以在其他链上重用。由于 PhiNFT1155 -> Claimable::signatureClaim 路径正确地替换了 block.chainid,但 PhiFactory::signatureClaim 可以直接调用,攻击者可以跨链重放签名,以在不同的链上申领与同一 artId 相关的有价值的奖励,导致资金损失。
V12 _validateArtCreationSignature 验证 _recoverSigner(keccak256(signedData_), signature_) (仅限 ETH 签名消息前缀),其中 signedData_ 仅编码 (timestamp, string, bytes),并且缺少任何域名分隔符或上下文绑定(例如,合约地址或链 ID)。相同的 payload 在跨合约/链上产生相同的哈希值,从而实现跨上下文签名重放,以绕过检查并在跨部署中执行未经授权的操作。

H-02:

来源 发现标题 位置
Ground Truth 在 createArt 中的签名重放允许冒充和盗取版税 PhiFactory::createArt
V12 CreateArt 签名省略 CreateConfig 字段,允许篡改参数 PhiFactory::createArt
来源 发现描述
Ground Truth PhiFactory::createArt 验证一个未绑定到特定提交者或 CreateConfig 字段的签名。攻击者可以抢跑合法的 createArt 交易,重用相同的签名,并提供他们自己的配置(例如,将自己设置为艺术家或版税接收者,更改版税 BPS、maxSupply、endTime、mintFee)。攻击者和受害者的交易都可以成功,但版税会流向攻击者,从而实现盗窃和其他参数滥用。
V12 _validateArtCreationSignature 验证的签名仅涵盖 signedData_ (expiresIn, URI, credData),并且省略了 CreateConfig 结构体 (artist, receiver, maxSupply, mintFee, startTime, endTime, soulBounded)。由于 createArt 信任 createConfig_,而没有将其包含在签名的 payload 中,因此任何有效签名的持有者都可以使用任意的 CreateConfig 值重放它,以劫持艺术品创作(例如,将自己分配为艺术家/接收者,更改供应/费用/时间),从而导致未经授权的资产创建和经济损失。

H-03:

来源 发现标题 位置
Ground Truth shareBalance 膨胀最终阻止 curator 奖励分配 Cred::_updateCuratorShareBalance
V12 过时的零余额保留在 shareBalance 映射中 Cred::_updateCuratorShareBalance
来源 发现描述
Ground Truth Cred 在 mapping(uint256 => EnumerableMap.AddressToUintMap) shareBalance 中跟踪 curator 的 shares。当 curator 卖出所有 shares 时,_updateCuratorShareBalance 将余额设置为 0,而不是从 EnumerableMap 中删除该 key。随着时间的推移,零余额条目会累积,增加 shareBalance[credId].length()。在奖励分配期间,枚举所有条目(通过 _getCuratorDatashareBalance[credId].at(i)) 会执行许多 SLOAD,并可能达到 block gas limits,导致分配恢复,并可能对 active creds 进行 DoS curator 奖励。
V12 在 sell 分支 (isBuy == false) 中,当 currentNum - amount == 0 时,代码从 per-address tracking 中删除 credId,但调用 shareBalance.set(sender, 0) 而不是 shareBalance.remove(sender)。OpenZeppelin EnumerableMap.set 不会删除零值的 keys,因此零值条目会无限期地保留。这会膨胀枚举,错误地表示 active curators,并增加分配或治理迭代 map keys 的 gas 成本。

H-05:

来源 发现标题 位置
Ground Truth 暴露的 _removeCredIdPerAddress & _addCredIdPerAddress 让任何人都可以破坏当前和未来的 holders Cred::_addCredIdPerAddress, Cred::_removeCredIdPerAddress
V12 内部凭证管理功能缺少访问控制 Cred::_addCredIdPerAddress 和 Cred::_removeCredIdPerAddress
来源 发现描述
Ground Truth _addCredIdPerAddress_removeCredIdPerAddress 被暴露,因此任何人都可以调用它们,从而通过任意地从用户 mappings 中添加/删除 cred IDs 并扰乱 holder 状态来 grief 用户试图买入/持有/卖出 cred shares。
V12 这些函数被声明为 public,没有访问控制修饰符,允许任何外部帐户为任意地址添加或删除凭证 IDs。这破坏了 per-address 凭证跟踪的完整性,从而可以进行未经授权的分配或撤销,这可能会破坏依赖这些 mappings 的更高级别的逻辑。

H-06:

来源 发现标题 位置
Ground Truth 在 cred 创建期间的重入可以通过 share/refund 循环实现合约 Ether 的盗窃 Cred::createCred (通过 _createCredInternal / buyShareCred)
V12 createCred 中的重入导致重复的凭证 IDs Cred::createCred
来源 发现描述
Ground Truth createCred 期间,发送者会自动购买 1 share;多余的 ETH 会被退还。在此流程期间的外部调用允许重入 cred 创建和交易,在 credIdCounter 递增之前,允许攻击者覆盖 pending cred,并在 whitelisted bonding curves 之间切换(便宜地买入,然后覆盖/昂贵地卖出),从而有效地耗尽 Cred 合约的 Ether。
V12 createCred → _createCredInternal 写入 creds[credIdCounter],然后调用 buyShareCred,后者执行外部 ETH 转移(通过 safeTransferETH 退款)和 IPhiRewards.deposit,然后再递增 credIdCounter。如果没有重入保护,攻击者可以在这些外部调用期间重新进入,并重用相同的 credIdCounter 来创建重复的凭证,破坏状态,错误地分配 shares/版税,并通过退款/存款虹吸 ETH。

H-07:

来源 发现标题 位置
Ground Truth 对 token 设置的无限制更改允许艺术家更改关键功能 PhiFactory::updateArtSettings
V12 外部可变的 Soul-Bound 标志允许撤销 Soul-Bound 状态 PhiNFT1155::soulBounded
来源 发现描述
Ground Truth PhiFactory::updateArtSettings 允许艺术家随时修改关键参数(例如,URI、版税费用和 soulBounded 标志),这意味着之前假定的艺术品的不可变功能可以在部署后更改。
V12 PhiNFT1155.soulBounded(tokenId) 完全取决于 PhiFactory.artData(...).soulBounded,艺术家可以稍后通过 updateArtSettings 切换它。由于 NFT 合约不存储或强制执行不可变的快照,因此艺术家可以在 mint 后撤销或更改 soul-bound 状态,从而破坏信任和下游假设。

Munchables (Code4rena, 2024 年 7 月)↗

V12 发现了人类研究员报告的 5 个高危问题中的 4 个。

原始和 V12 报告的发现

H-01:

来源 发现标题 位置
Ground Truth 单个 plot 可以被多个 renters 占用 LandManager::transferToUnoccupiedPlot
V12 plot transfer 时缺少 plotId 更新 LandManager::transferToUnoccupiedPlot
来源 发现描述
Ground Truth transferToUnoccupiedPlot 允许用户将 staked token 移动到新的 plot,但未能更新 toilerState.plotId。合约继续认为 token 占据了原始 plot,从而导致不一致的状态,其中一个 plot 显示为已占用,而 token 已被移动,从而允许每个 plot 有多个 renters 并破坏占用逻辑。
V12 该函数在 transfer 时更新 plotOccupiedlatestTaxRate,但从不分配 toilerState[tokenId].plotId = newPlotId。 这样会留下过时的 plot 引用,并使用错误的 plotId 发出事件,从而导致依赖 toilerState.plotIdFarmPlotTaken 的使用者出现不一致的状态和误导性数据。

H-02:

来源 发现标题 位置
Ground Truth _farmPlots 函数中的无效验证允许恶意用户在没有锁定资金的情况下重复 farming LandManager::_farmPlots
V12 _farmPlots 边界检查中的 Off-by-One 错误 LandManager::_farmPlots
来源 发现描述
Ground Truth _farmPlots 使用 if (_getNumPlots(landlord) < _toiler.plotId) 来使过时的 toilers 无效。 当 landlord 解锁所有资金时,_getNumPlots 应该为 0,但是 plot 0 仍然可以 farming,因为该条件没有捕获它。 因此,用户可以在解锁资金后继续 farming 并从 plot 中收集奖励,从而产生不公平的、可重复的奖励。
V12 边界检查比较 numPlots < plotId,而不是验证 plotId >= numPlots。 由于有效的 plot IDs 是 0..numPlots-1,当 plotId == numPlots 时,toiler 不会被标记为 dirty,并继续在不存在的 plot 上积累奖励,从而可以在没有锁定资金的情况下重复 farming。

H-03:

来源 发现标题 位置
Ground Truth _farmPlots 中的计算错误可能会阻止 unstaking 所有 NFTs LandManager::_farmPlots
V12 未经检查的负奖金导致 schnibblesTotal 计算中的 uint256 下溢 LandManager::_farmPlots
来源 发现描述
Ground Truth _farmPlots 从 realm 和 rarity 奖金计算 finalBonus; 当 finalBonus < 0 时,混合的 signed/unsigned 数学运算会使中间值为负数,然后将其转换为 uint256,从而产生巨大的 schnibblesTotal。 随后的 schnibblesLandlord 乘法溢出并恢复。 由于 unstakeMunchable() 使用 forceFarmPlots(),因此恢复会阻止用户 unstaking 其 NFTs。
V12 finalBonusREALM_BONUSESRARITY_BONUSES 的总和)未被;低于 -100 的值使 (int256(schnibblesTotal) + int256(schnibblesTotal) * finalBonus) 为负数。 转换为 uint256 会下溢为一个巨大的值,从而实现任意大的奖励并耗尽资金,因为在应用奖金之前没有验证边界或。

H-04:

来源 发现标题 位置
Ground Truth 在 farmPlots() 中,边缘情况下的下溢导致资金(NFT)冻结 LandManager::_farmPlots
V12 _farmPlots 中的无符号整数下溢导致 Tenant DoS LandManager::_farmPlots
来源 发现描述
Ground Truth PRICE_PER_PLOT 增加时,_getNumPlots(landlord) 减少。 如果 renter 的 plotId 现在超出范围,_farmPlotstimestamp 设置为 plotMetadata[landlord].lastUpdated(该值可能比 stake 时设置的 toilerState.lastToilDate 更旧)。 随后的 (timestamp - lastToilDate) 下溢,导致在 farming 和 unstakeMunchable 期间永久恢复,从而有效地冻结了 NFT/资金。
V12 如果 _getNumPlots(landlord) < toiler.plotId_farmPlotstimestamp 重置为 plotMetadata[landlord].lastUpdated。 当该值小于 toilerState[tokenId].lastToilDate 时,计算 (timestamp - lastToilDate) 会下溢并恢复。 由于在 plot 计数减少时不会 lastUpdated,因此租户可能会因 farming/unstaking 而受到 DoS 攻击,直到状态得到纠正。

Virtuals (Code4rena, 2025 年 4 月)↗

V12 发现了人类研究员报告的 6 个高危问题中的 4 个。H-03 和 H-04 这两个问题之前的 audits 错过了89

原始和 V12 报告的发现

H-01:

来源 发现标题 位置
Ground Truth AgentNftV2::addValidator() 中缺少访问控制导致未经授权的验证器注入,并导致奖励核算不一致 AgentNftV2::addValidator
V12 addValidator 中缺少访问控制 AgentNftV2::addValidator
来源 发现描述
Ground Truth AgentNftV2::addValidator() 没有访问控制。 攻击者可以使用 IAgentNft(nft).nextVirtualId() 通过 AgentFactoryV2::executeApplication() 预测下一个 virtualId,并抢先调用 addValidator() 以将验证器附加到 _validators[virtualId]。 稍后,AgentNftV2::mint() 再次调用 _addValidator(),从而为同一 virtualId 复制验证器并导致奖励/分数核算不一致。
V12 addValidatorpublic,没有 role/owner 检查,因此任何帐户都可以将任何地址添加为任何 virtualId 的验证器。 该调用还会初始化该验证器的分数,从而允许任意注入和操纵验证器集,从而破坏治理和奖励分配。

H-03:

来源 发现标题 位置
Ground Truth Public ServiceNft::updateImpact 调用导致级联问题 ServiceNft::updateImpact
V12 updateImpact 中缺少访问控制允许未经授权的状态修改 ServiceNft::updateImpact
来源 发现描述
Ground Truth ServiceNft::updateImpactpublic,因此任何人都可以使用任意 virtualId/proposalId 调用它。 当管理员更改 datasetImpactWeight 时,攻击者可以抢先或重复调用 updateImpact,以使其 _impacts_maturities 朝着有利于他们的方向倾斜。 由于 AgentRewardV2::_distributeContributorRewardsMinter::mint 都读取 ServiceNft::getImpact,因此对手可以夸大他们的奖励或减少其他人的奖励,从而导致级联经济扭曲。
V12 updateImpact 缺少任何访问控制,从而允许外部调用者修改 _impacts_maturities 并为任意 IDs 发出 SetServiceScore。 这允许未经授权地操纵服务分数和成熟度,从而破坏治理、奖励分配以及依赖于准确影响值的任何下游逻辑。

H-04:

来源 发现标题 位置
Ground Truth Public ContributionNft::mint 导致级联问题 / 资金损失 ContributionNft::mint
V12 缺少存在性检查允许在 mint 中使用无效的 ParentId ContributionNft::mint
V12 mint 中未经验证的 datasetId 允许任意数据集 IDs ContributionNft::mint
来源 发现描述
Ground Truth ContributionNft::mint 是无保护的,允许提议者使用任意字段(coreIdnewTokenURIparentIdisModel_datasetId)mint 他们自己的 NFTs。 这些错误的输入会级联到 ServiceNft::mint/updateImpactAgentRewardV2::_distributeContributorRewardsMinter::mintAgentDAO::_calcMaturity 中,从而破坏 cores/models、impacts、maturities 和奖励流,从而导致资金损失和协议错误核算。
V12 该函数接受任何 parentId,并在不验证父 token 是否存在的情况下写入父/子链接,从而允许虚假的谱系,这可能会破坏下游逻辑。
V12 isModel_ == true 时,任何 datasetId 都会被记录下来,无需验证/白名单,从而允许扭曲影响计算和下游使用者的任意或不存在的数据集链接。

H-06:

来源 发现标题 位置
Ground Truth promptMulti 中缺少 prevAgentId 更新导致传输到 address(0) AgentInference::promptMulti
V12 promptMulti 中 agentTba 的不当缓存允许 Token 丢失 AgentInference::promptMulti
来源 发现描述
Ground Truth promptMulti() 基于 prevAgentId 缓存 agentTba,但从不在循环内更新 prevAgentId。 如果第一个 agentId 等于默认的 prevAgentId (0),则在第一次传输之前永远不会设置 agentTba,从而导致 safeTransferFrom(sender, address(0), amount) 和 token 丢失。 重复或重新排序的 agentId 也可以重用过时的 agentTba,从而导致传输到错误的地址。
V12 循环在获取 agentTba 后未能更新 prevAgentId,因此过时的(可能为零)agentTba 可以在迭代中重复使用。 如果包含 agentId == 0 并且 virtualInfo(0).tba 返回 address(0),则批处理中的后续传输将发送到零地址(销毁),从而导致不可逆转的 token 丢失。

Basin (Code4rena, 2024 年 7 月)↗

V12 发现了人类研究员报告的所有 2 个高危问题中的 2 个。

原始和 V12 报告的发现

H-01:

来源 发现标题 位置
Ground Truth WellUpgradeable 可以由任何人升级 WellUpgradeable::_authorizeUpgrade / upgradeToAndCall
V12 Public upgradeToAndCall 允许未经授权的升级 WellUpgradeable::upgradeToAndCall
来源 发现描述
Ground Truth 在 UUPS 模式中,_authorizeUpgrade 必须限制访问(例如,onlyOwner)。 这里它被覆盖而没有这样的访问控制,因此任何地址都可以调用 upgradeTo / upgradeToAndCall 以将代理指向任意(可能是恶意的)实现,从而危及合约。
V12 upgradeToAndCall 是公开可调用的,并且 _authorizeUpgrade 仅检查 delegatecall 上下文和注册表成员身份,而不是 msg.sender。 在没有 owner/RBAC 保护的情况下,任何外部帐户都可以触发对批准的实现的升级,从而启用恶意升级、资金盗窃或 DoS。

H-02:

来源 发现标题 位置
Ground Truth 解码时错误地分配了 decimal1 参数 Stable2::decodeWellData
V12 decodeWellData 中的小数默认错误 Stable2::decodeWellData
V12 小数默认值错误导致 decodeWellData 中的错误缩放 Stable2::decodeWellData
来源 发现描述
Ground Truth decodeWellData(bytes) 解码 (decimal0, decimal1) 并打算将零默认设置为 18。它正确地修复了 decimal0 == 0 → 18,但错误地再次检查了 decimal0 == 0 而不是 decimal1 == 0,因此永远不会默认 decimal1。 这使得 decimal1 错误地保持在其解码值(包括 0),从而导致依赖于这些小数的函数中出现下游缩放/错误定价。
V12 由于复制粘贴错误,两个保护都检查 decimal0 == 0。 当调用者提供 decimal1 == 0 时,它仍然为 0 而不是默认设置为 18; 然后储备缩放对 token1 使用 10^(18 − 0),从而大大增加了储备并破坏了费率।
V12 该错误使 decimal1 在为零时未初始化为 18,因此如果输入为 (x, 0),则所有标准化/反标准化(LP 供应、价格、储备/费率计算)都会被错误地缩放,从而导致高估/低估以及潜在的资金损失

Blackhole (2025 年 5 月)↗

V12 发现了人类研究员报告的所有 2 个高危问题中的 2 个。H-01 被之前的审计错过了10

原始和 V12 报告的发现

H-01:

来源 发现标题 位置
Ground Truth 路由器地址验证逻辑错误阻止分配有效的路由器 GenesisPoolManager::setRouter
V12 setRouter 中反转的零地址检查 GenesisPoolManager::setRouter
来源 发现描述
Ground Truth setRouter(address _router) 使用 require(_router == address(0), "ZA"),这仅允许将路由器设置为零地址。 这阻止了 owner 分配有效的非零路由器,并有效地反转了预期的检查(应该是 != address(0)),从而破坏了依赖于正确路由器的 DEX 交互。
V12 零地址保护被反转:它强制 _routeraddress(0) 而不是拒绝它。 因此,路由器无法更新为功能性的非零地址,从而禁用依赖于有效 router 的功能,并可能导致启动失败。

H-02:

来源 发现标题 位置
Ground Truth GaugeFactoryCL 中的奖励 token 可以被任何人耗尽 GaugeFactoryCL::createGauge (通过 createEternalFarming)
V12 GaugeFactory 中 createGauge 缺少访问控制 GaugeFactory::createGauge
来源 发现描述
Ground Truth createGauge 是公开可调用的。 每次调用都会通过 createEternalFarming 为新的 Algebra 永久 farming 激励提供种子,这会 safeApprove 永久 farming 合约以从 GaugeFactoryCL 中提取硬编码的 1e10_rewardToken。 如果该工厂是预先注资的,则攻击者可以重复调用 createGauge 以将 _rewardToken 耗尽到攻击者选择的 farms 中,然后从这些 farms 中 stake/claim。
V12 createGauge 缺少 onlyOwner/onlyRole 检查,从而允许任何人部署/注册 gauges。 无限制的 gauge 创建启用了任意/垃圾邮件 gauges,这些 gauges 可以转移或滥用激励措施并扰乱分配。

Lambo.win (Code4rena, 2024 年 12 月)↗

V12 发现了人类研究员报告的所有 2 个高危问题中的 2 个。这两个问题都被之前的审计错过了11

原始和 V12 报告的发现

H-01:

来源 发现标题 位置
Ground Truth 由于不正确的金额铸造,VirtualToken 的 cashIn 中用户资金损失 VirtualToken::cashIn
V12 cashIn 中对于 ERC-20 存款的不正确虚拟 token 铸造 VirtualToken::cashIn
来源 发现描述
Ground Truth cashIn(uint256 amount) 中,ERC-20 存款从用户处转移 amount,但合约使用 msg.value 而不是 amount 进行铸造。 对于 ERC-20 调用,msg.value 为 0,因此用户收到 0 个虚拟 token,而他们的 ERC-20 token 被占用,从而导致资金/信用损失。
V12 cashIn 始终调用 _mint(msg.sender, msg.value)。 在 ERC-20 分支 (underlyingToken != NATIVE_TOKEN) 中,msg.value == 0,因此尽管 _transferAssetFromUser(amount) 转移了资金,也铸造了零个虚拟 token,从而导致资产锁定和 ERC-20 存款人的 DoS。

H-03:

来源 发现标题 位置
Ground Truth directionMask 的计算不正确 LamboRebalanceOnUniwap::_getQuoteAndDirection
V12 _getQuoteAndDirection 中 WETH 交易的错误分类 LamboRebalanceOnUniswap::_getQuoteAndDirection
来源 发现描述
Ground Truth _getQuoteAndDirection 假设 WETH 始终是 token1 并相应地设置 directionMask。 由于 Uniswap 按地址按字典顺序对 token0/token1 进行排序,因此 WETH 可以是 token0,因此该函数可以选择错误的 zero-for-one 与 one-for-zero 方向,从而产生不正确的重新平衡行为和潜在的损失。
V12 该函数设置 directionMask = (tokenIn == weth) ? _BUY_MASK : _SELL_MASK,忽略了 tokenOut == weth 的情况。 因此,WETH 买入交易被错误地分类为 SELL 并被路由到错误的执行路径,从而导致交换失败或不良结果。

TraitForge (Code4rena, 2024 年 7 月)↗

V12 发现了人类研究员报告的 6 个高危问题中的 1 个。

原始和 V12 报告的发现

H-01:

来源 发现标题 位置
Ground Truth 基于各代 token 总数的错误铸造逻辑 TraitForgeNft::mintWithBudget
V12 mintWithBudget 强制执行全局计数器上的 mint cap,而不是每个生成计数器 TraitForgeNft::mintWithBudget
来源 发现描述
Ground Truth mintWithBudgetwhile (budgetLeft >= mintPrice && _tokenIds < maxTokensPerGen) 中使用全局 _tokenIds(有史以来铸造的总数)。 由于 _tokenIds 不会按生成重置,因此一旦总 mints 达到 maxTokensPerGen,即使当前生成尚未达到其自身的上限,也会阻止进一步的 mints,从而破坏每个生成的供应逻辑。
V12 循环防止 _tokenIds < maxTokensPerGen 而不是每个生成计数器(例如,generationMintCounts[currentGeneration])。 在全局计数达到上限后,新生成无法通过 mintWithBudget 进行 mint,从而导致拒绝 minting 和潜在的收入损失,尽管当前生成中存在未使用的容量。

DODO Cross-Chain DEX (Sherlock, 2025 年 6 月)↗

V12 发现了人类研究员报告的 5 个高危问题中的 2 个。

原始和 V12 报告的发现

H-02:

来源 发现标题 位置
Ground Truth 任何攻击者都可以从 GatewayTransferNative 中窃取累积的 ZRC20 token GatewayTransferNative::withdrawToNativeChain
V12 withdrawToNativeChain 中潜在的缺失 msg.value 检查 GatewayTransferNative::withdrawToNativeChain
来源 发现描述
Ground Truth zrc20 == _ETH_ADDRESS_ 时,withdrawToNativeChain 跳过验证 msg.value。 攻击者可以精心设计绕过 transferFrom(本机路径)的调用,并在不存入 ETH 的情况下声明任意本机 token 数量,从而可以通过恶意消息制作窃取累积的 ZRC20。
V12 当桥接本机 ETH (zrc20 == _ETH_ADDRESS_) 时,该函数不强制执行 msg.value == amount。 攻击者可以传递一个非零 amount 但发送零 ETH,但下游逻辑使用 amount,从而有效地 mint/误分配桥接价值并导致资金损失。

H-05:

来源 发现标题 位置
Ground Truth Unauthorized Claim of Non-EVM Chain Refunds in claimRefund GatewayTransferNative::claimRefund, GatewayCrossChain::claimRefund
V12 ClaimRefund 授权绕过 GatewayTransferNative::claimRefund
来源 发现描述
Ground Truth claimRefund(bytes32 externalId) 中,如果 refundInfo.walletAddress.length != 20(即,非 EVM 地址,如 BTC),则 receiver 默认为 msg.sender。 授权检查 `require(bots[msg.sender]
V12 该函数在 walletAddress.length == 20 上分支以派生接收者; 否则,它将 receiver = msg.sender 保持不变,因此 require(msg.sender == receiver) 微不足道地成立。 由于回调处理程序存储未经验证的 walletAddress 字节,因此攻击者可以确保非 20 字节值,然后调用 claimRefund 以虹吸退款。

Lend (Sherlock, 2025 年 6 月)↗

V12 发现了人类研究员报告的 28 个高危问题中的 10 个。

原始和 V12 报告的发现

H-01:

来源 发现标题 位置
Ground Truth 通过重复声明相同的奖励来耗尽 LEND token 储备 CoreRouter::claimLend
V12 由于未清除的 lendAccrued 导致 LEND 奖励的重复声明 CoreRouter::claimLend
来源 发现描述
Ground Truth claimLend() 通过 grantLendInternal(holder, accrued) 转移应计的 LEND,但从不重置 lendStorage.lendAccrued[holder]。 由于来自 grantLendInternal(剩余金额)的返回值被忽略,因此可以在后续调用中再次声明相同的应计余额,从而允许永久耗尽 LEND 储备。
V12 claimLend 将奖励累积到 lendAccrued 中(通过 distributeBorrowerLend / distributeSupplierLend),然后使用 grantLendInternal 支付它们,但 lendAccrued 从未清除或减少。 在后续调用中,旧值保持并再次支付(加上新的应计),从而实现重复声明,从而从协议中窃取过多的 LEND。

H-03:

来源 发现标题 位置
Ground Truth 用户可以通过利用跨链借款/抵押品不变性来逃避清算和桥接资金 LendStorage::borrowWithInterest
V12 通过跨链借款/抵押品不变性违反的拒绝服务 LendStorage::getMaxLiquidationRepayAmount
来源 发现描述
Ground Truth 用户在 Chain A 上提供抵押品,并在 Chain B 上借款,然后在 B 上提供灰尘并在 A 上借入灰尘。 这会在两个链上为同一用户资产填充 crossChainBorrowscrossChainCollaterals。 不变性 `borrows.length == 0
V12 addCrossChainBorrowaddCrossChainCollateral 都可以为同一用户-token 对留下非空数组。 由于未发生相互清除,因此在 borrowWithInterest 中检查的不变性失败,从而使下游调用(如 getMaxLiquidationRepayAmount)恢复,并对该仓位进行 DoS 清算。

H-07:

来源 发现标题 位置
事实依据 由于错误的赎回支付计算,CoreRouter容易发生资金耗尽或滞留 CoreRouter::redeem
V12 CoreRouter.redeem 中不匹配的汇率使用导致不正确的底层转账 CoreRouter::redeem
来源 发现描述
事实依据 redeem 使用 _lToken.exchangeRateStored() 预先计算 expectedUnderlying,然后调用 _lToken.redeem(_amount)。它盲目地将 expectedUnderlying 转给用户,而不检查 LToken 实际发送了多少底层资产。如果实际赎回的金额更少,CoreRouter 会超额支付(储备金耗尽);如果更多,多余的 token 会卡在 CoreRouter 中。
V12 redeemredeem() 之前使用可能过时的 exchangeRateStored() (这将累积利息并使用更新的内部利率)。预先计算的 expectedUnderlying 可能与实际赎回的金额不同,导致用户获得的报酬不足,或者 CoreRouter 由于不匹配而泄漏/滞留资金。

H-08:

来源 发现标题 位置
事实依据 由于不正确的 maxLiquidationAmount 计算导致跨链清算被阻止 LendStorage::getMaxLiquidationRepayAmount (使用 ::borrowWithInterest)
V12 过度严格的抵押品过滤器导致 borrowWithInterest 中的低估 LendStorage::borrowWithInterest
来源 发现描述
事实依据 getMaxLiquidationRepayAmount 使用 borrowWithInterest 计算上限,它只对来自当前链的借款进行求和。它省略了目的地是当前链的借款。这低估了用户未偿还的借款,并使 _validateAndPrepareLiquidation 拒绝有效的跨链清算,因为 repayAmount 似乎超过了 maxLiquidationAmount
V12 borrowWithInterest 的抵押品路径中,过滤器要求 destEid == currentEid srcEid == currentEid,因此来自其他链的抵押品条目(srcEid != currentEid) 被跳过。这低估了本地(目的地)抵押品支持的债务,导致不准确的借款总额,并导致依赖此值的清算逻辑失败。

H-09:

来源 发现标题 位置
事实依据 过时的汇率利用 CoreRouter::supply
V12 由于过时的 exchangeRateStored 导致过度贷记 lToken CoreRouter::supply
来源 发现描述
事实依据 supply() 在调用 mint() 之前使用 _lToken.exchangeRateStored() 读取过时的汇率。由于 mint() 会累积利息并更新汇率,因此使用 mint 之前的汇率来计算 mintTokens 会过度贷记用户,使其获得比实际铸造的更多的 lToken,从而造成价值泄漏。
V12 supply() 计算 mintTokens = (_amount * 1e18) / exchangeRateStored(),该值取自 LErc20.mint() 之前 (这将累积利息并更新利率)。这种不匹配将过多的 lToken 贷记给用户,随着时间的推移,会增加余额并耗尽协议价值。

H-18:

来源 发现标题 位置
事实依据 borrowWithInterest() 中不正确的 srcEid 检查 LendStorage::borrowWithInterest
V12 由于不正确的 EID 过滤器,borrowWithInterest 忽略了跨链抵押品 LendStorage::borrowWithInterest
来源 发现描述
事实依据 源链 (A) 上,borrowWithInterest 使用 borrows[i].srcEid == currentEid 过滤借款。但是链 A 上的借款记录存储 destEid = currentEidsrcEid 设置为远程链 B)。这种不匹配导致该函数忽略链 A 上现有的跨链借款并返回零,从而破坏了会计和下游逻辑。
V12 在目标链上的抵押品分支中,该函数要求 srcEid == currentEid && destEid == currentEid。有效的、以本地链为目的地的跨链抵押品具有 destEid == currentEidsrcEid != currentEid,因此会被错误地跳过,从而低估了借款金额并面临不正确的清算风险。

H-21:

来源 发现标题 位置
事实依据 用户可以在发起借款后立即赎回抵押品,导致抵押不足 CrossChainRouter::borrowCrossChain; CoreRouter::redeem
V12 由于缺乏抵押品托管导致的抵押不足的跨链借款 CrossChainRouter::borrowCrossChain
来源 发现描述
事实依据 borrowCrossChain 添加抵押品跟踪并发送跨链消息后,它不会锁定源链上用户的抵押品。在远程借款最终确定之前,用户可以调用 CoreRouter::redeem 并提取抵押品,因为流动性检查仅考虑记录的借款(待处理的跨链借款尚未反映出来)。这使得用户在目标链借款执行后最终抵押不足。
V12 borrowCrossChain 依赖于仅查看的抵押品计算 (getHypotheticalAccountLiquidityCollateral) 并发送消息而不质押/锁定资产。在异步窗口中,用户可以移除或转移抵押品,因此目标链会针对不再存在的抵押品铸造债务,从而产生抵押不足的头寸和偿付能力风险。

H-22:

来源 发现标题 位置
事实依据 清算验证逻辑错误 CrossChainRouter::_checkLiquidationValid
V12 _checkLiquidationValid 中的清算模拟符号错误 CrossChainRouter::_checkLiquidationValid
来源 发现描述
事实依据 在 Cross-chain 清算中,链 B 计算 seizeTokens(要获取的抵押品),并将其作为 payload.amount 发送到链 A。链 A 的 _checkLiquidationValid 然后调用 getHypotheticalAccountLiquidityCollateral(sender, destlToken, 0, payload.amount),将 payload.amount 视为额外的借款而不是被扣押的抵押品。这滥用了参数语义,并且可能错误地将健康的头寸标记为可清算,因为它询问的是“如果用户借入更多,会发生什么?”,而不是模拟抵押品扣押。
V12 _checkLiquidationValid 将偿还/扣押金额作为 borrowAmount 传递给 getHypotheticalAccountLiquidityCollateral,这会增加假设的债务而不是减少它。这种符号反转会增加 sumBorrowPlusEffects,可能将有偿付能力的账户标记为抵押不足,并启用不必要的清算。

H-25:

来源 发现标题 位置
事实依据 _handleLiquidationSuccess 中不正确的 destEid 值阻止了清算完成 CrossChainRouter::_handleLiquidationSuccess
V12 不正确的 EID 参数导致抵押品查找不匹配 CrossChainRouter::_handleLiquidationSuccess
来源 发现描述
事实依据 _handleLiquidationSuccess 调用 lendStorage.findCrossChainCollateral(...),其中 destEid 硬编码为 0,因此查找永远不会与使用真实来源/目的地EID存储的记录匹配。找不到抵押品记录,从而阻止了清算最终确定,并将债务/抵押品留在悬而未决的状态。
V12 该函数在搜索跨链抵押品条目时,提供了不正确的链标识符(使用本地EID和destEid=0)。由于记录是用实际的srcEid/destEid保存的,因此查找失败,导致清算完成恢复/中止,并导致拒绝服务和潜在的财务损失。

H-27:

来源 发现标题 位置
事实依据 CoreRouter.sol#borrow() 中不正确的抵押品检查逻辑 CoreRouter::borrow
V12 没有抵押品的无限制初始借款 CoreRouter::borrow
来源 发现描述
事实依据 borrow() 正确地从 getHypotheticalAccountLiquidityCollateral(msg.sender, LToken(_lToken), 0, _amount) 获取 (borrowed, collateral),然后使用利息指数替换重新计算的 borrowAmount 进行偿付能力检查。如果 currentBorrow.borrowIndex == 0(该市场的首次借款),它将 borrowAmount = 0,从而将检查变为 require(collateral >= 0),这始终会通过——允许抵押不足的借款。
V12 对于新的借款人,currentBorrow.borrowIndex 为零,因此 borrowAmount 被强制设置为 0,并且 require(collateral >= borrowAmount) 很容易成功。此绕过允许在没有足够抵押品的情况下进行初始借款,从而导致协议资金耗尽和不良债务的风险。

Burve (Sherlock, 2025 年 4 月)↗

V12 发现了人工研究人员报告的 9 个高危问题中的 2 个。

原始和 V12 报告的发现

H-03:

来源 发现标题 位置
事实依据 不正确的净额结算逻辑导致过多的提款金额 VaultE4626Impl::commit
V12 不正确的减法顺序导致总提款 VaultE4626Impl::commit
来源 发现描述
事实依据 trimBalance 期间,可以对存款和提款进行排队,然后 commit 尝试将它们进行净额结算。在 assetsToWithdraw > assetsToDeposit 的分支中,代码在从 assetsToWithdraw 中减去 之前assetsToDeposit 归零化,从而使提款无法进行净额结算。然后,保管库提取总额并过度减少 totalVaultShares,导致用户收到过多金额并打破会计核算。
V12 当存款和提款都处于待处理状态时,commit 净额结算逻辑设置 assetsToDeposit = 0,然后从 assetsToWithdraw 中减去它,因此不会发生净额结算。保管库处理全部提款金额,违反了净额结算不变性,并允许过度提款,从而耗尽资金并破坏 totalVaultShares

H-06:

来源 发现标题 位置
事实依据 ValueFacet.removeValueSingle 中的费用绕过 ValueFacet::removeValueSingle
V12 不正确的费用计算导致提款时税收为零 ValueFacet::removeValueSingle
来源 发现描述
事实依据 removeValueSingle 中,在分配 removedBalance(默认为 0之前,使用 removedBalance 计算费用 realTax。正确的分子应该是 realRemoved。因为此时 removedBalance 为零,所以 realTax 变为 0,允许用户在不支付预期费用的情况下提款,从而导致协议收入损失。
V12 该函数计算 realTax = mulDiv(removedBalance, nominalTax, removedNominal),而 removedBalance 仍然为零,而不是使用 realRemoved。这使得费用始终为零,因此永远不会收取提款费用,从而实现了费用绕过和经济泄漏。

Crestial (Sherlock, 2025 年 3 月)↗

V12 发现了人工研究人员报告的唯一高危问题。

原始和 V12 报告的发现

H-01:

来源 发现标题 位置
事实依据 任何批准 BlueprintV5 的人都可能通过公共 payWithERC20 被耗尽资金 Payment::payWithERC20
V12 无限制的 payWithERC20 允许任意 safeTransferFrom Payment::payWithERC20
来源 发现描述
事实依据 payWithERC20public 并且执行 token.safeTransferFrom(fromAddress, toAddress, amount),没有将 msg.sender 绑定到 fromAddress 的授权。任何攻击者都可以调用它并从任何已批准 BlueprintV5/Payment 合约的地址转移 token,从而耗尽已批准用户的资金。
V12 该函数不对 msg.sender 施加任何限制,允许任何人指定任意的 fromAddresstoAddress。如果 fromAddress 已授予合约配额,则该调用将通过 safeTransferFrom 转移资金,从而由于缺少访问控制而导致未经授权的余额耗尽。

Mellow (Sherlock, 2025 年 7 月)↗

V12 发现了人工研究人员报告的 6 个高危问题中的 2 个。

原始和 V12 报告的发现

H-01:

来源 发现标题 位置
事实依据 Consensus.checkSignatures 不检查签名者的重复性 Consensus::checkSignatures
V12 重复签名利用绕过多重签名阈值 Consensus::checkSignatures
来源 发现描述
事实依据 checkSignatures 仅检查 signatures.length >= threshold 并根据注册的签名者集合验证每个签名,但从不强制签名者地址是唯一的。攻击者可以多次提交同一签名者的有效签名以满足阈值,并绕过存款/赎回的预期多重签名要求。
V12 该函数独立验证每个签名,而不跟踪已看到的签名者,因此来自单个授权密钥的重复签名满足基于计数的阈值。此遗漏允许单个签名者执行原本需要多个不同签名者的操作,从而打破了多重签名安全模型。

H-05:

来源 发现标题 位置
事实依据 FeeManager 中不正确的性能费用计算 FeeManager::calculateFee
V12 性能费用计算溢出允许费用份额超过总份额 FeeManager::calculateFee
来源 发现描述
事实依据 calculateFee 将费用份额计算为 shares = mulDiv((minPriceD18_ - priceD18), performanceFeeD6 * totalShares, 1e24),错误地将价格差异视为可直接转换为份额。这错误地应用了单位(价格与份额),产生了无意义的结果(例如,费用份额等于总份额),具体取决于价格规模。
V12 该公式将 (minPriceD18 - priceD18)(缩放 1e18+)乘以 performanceFeeD6 * totalShares,然后除以 1e24。对于较大的价格差,固定除数不足,因此计算出的费用份额可能超过 totalShares,从而能够过度收集费用并打破份额会计。

Notional Exponent (Sherlock, 2025 年 7 月)↗

V12 发现了人工研究人员报告的 11 个高危问题中的 2 个。

原始和 V12 报告的发现

H-06:

来源 发现标题 位置
事实依据 由于 ++s_batchNonce 上的溢出导致 _initiateWithdrawImpl 中的 DoS DineroWithdrawRequestManager::_initiateWithdrawImpl
V12 Nonce 溢出导致 RequestId 冲突 DineroWithdrawRequestManager::_initiateWithdrawImpl
来源 发现描述
事实依据 _initiateWithdrawImpl 使用 nonce = ++s_batchNonce 构建 requestId,其中 s_batchNonce 是一个 uint16。一旦达到 65535,预递增就会溢出并恢复,导致 initiateWithdraw 对所有人失败——锁定了通过此管理器路由的 WETH 的提款(拒绝服务)。
V12 该函数将 nonce 打包到 requestId 的前 16 位中,但没有将 s_batchNonce 限制为 16 位。当 s_batchNonce 超过 2^16-1 时,高位在打包期间会被截断,导致不同的 nonce 映射到相同的 requestId,从而破坏了唯一性和请求跟踪。

H-11:

来源 发现标题 位置
事实依据 过期 PT 赎回中缺少滑点保护导致用户资金损失 PendlePTLib::redeemExpiredPT
V12 PendlePTLib.redeemExpiredPT 中硬编码的零滑点界限 PendlePTLib::redeemExpiredPT
来源 发现描述
事实依据 当 PT 过期时,_redeemPT 调用 PendlePTLib.redeemExpiredPT,后者调用 sy.redeem(..., minTokenOut = 0, ...)。在没有滑点保护的情况下,由于价格影响、MEV 或不利的汇率,执行外部交换的 SY 合约可能会返回更少的 token——影响即时赎回和提款启动流程,并导致用户直接损失。
V12 redeemExpiredPTIStandardizedYield.redeem 中将 minAmountOut 硬编码为 0,从而禁用滑点保护。这使得价格操纵或不利的市场变动能够以非常不利的汇率强制赎回,从而提供任意低的输出,并使用户面临价值损失。

Superfluid (Sherlock, 2025 年 6 月)↗

V12 发现了人工研究人员报告的 2 个高危问题中的 1 个。

原始和 V12 报告的发现

来源 发现标题 位置
事实依据 无需调用 Unstake 即可提取 FluidLocker 中的质押 token FluidLocker::provideLiquidity
V12 未经检查的 FLUID 提款导致 getAvailableBalance 中下溢 FluidLocker::getAvailableBalance (受 ::provideLiquidity 影响)
来源 发现描述
事实依据 provideLiquidity 未根据 getAvailableBalance() 进行验证,从而允许将质押的 token 移至 DEX 头寸而无需调用 unstake。在 6 个月的免税期后,所有者提取该流动性,但 Locker 仍然将这些 token 视为已质押——因此奖励会继续累积在不再持有的 token 上,从而破坏了奖励分配的完整性。
V12 getAvailableBalance() 返回 FLUID.balanceOf(this) - _stakedBalance,但 provideLiquidity 可以在不减少 _stakedBalance 或检查 supAmount ≤ getAvailableBalance() 的情况下提取 FLUID。如果 FLUID 余额低于 _stakedBalance,则减法会下溢(Solidity ≥0.8),导致函数恢复,并可能导致依赖此视图的函数出现拒绝服务。

Zero Lend (ImmuneFi, 2024 年 2 月)↗

V12 发现了人工研究人员报告的 3 个严重问题中的 2 个,以及 9 个高危问题中的 5 个。

原始和 V12 报告的发现

29031 - [SC - 严重]

来源 发现标题 位置
事实依据 VestedZeroNFT token 可以通过未经检查的 split() 直接被盗 VestedZeroNFT::split
V12 未经授权的 NFT 拆分,没有所有权验证 VestedZeroNFT::split
来源 发现描述
事实依据 split() 允许任何调用者拆分现有的 tokenId,并将任意比例的新 token 铸造给 msg.sender,而无需验证 tokenId 的所有权。攻击者可以传递其他人的 tokenId(例如,fraction = 1)以从受害者的 vesting NFT 中窃取几乎所有价值。
V12 该函数仅调用 _requireOwned(tokenId) 以检查是否存在,并忽略其返回的所有者,从不将其与 msg.sender 进行比较。此缺少的所有权检查允许任何人拆分另一个用户的 NFT 并接收新铸造的部分 token,从而可以直接窃取 vesting 的价值。

29062 - [SC - 严重]

来源 发现标题 位置
事实依据 攻击者可以通过未经授权的取消质押来窃取质押 NFT 的锁定余额 OmnichainStaking::unstakeToken
V12 缺少所有权检查允许未经授权的 NFT 取消质押 OmnichainStaking::unstakeToken
来源 发现描述
事实依据 unstakeToken 允许任何人燃烧等于 NFT 的 tokenPower 的投票权,然后从质押合约中提取该 NFT,而无需验证调用者是否是原始质押者/所有者。由于不同的锁定配置可以产生相同的算力,攻击者可以锁定更少的 token 更长时间以匹配算力,燃烧该金额,并取消质押受害者余额更高、持续时间更短的 NFT——窃取更大的锁定余额,并迫使受害者处于攻击者较差的位置。
V12 该函数燃烧等于 tokenPower[tokenId] 的 ERC20 质押 token,并将 NFT 转移到 msg.sender,但从不检查 msg.sender 是否质押或拥有 tokenId。不存在 tokenId → staker 的映射,因此任何持有必要质押 token 的地址都可以燃烧它们并提取他们从未存入的任意 NFT,从而可以窃取质押 NFT。

28910 - [SC - 高]

来源 发现标题 位置
事实依据 registerGauge 中错误的 Bool 检查阻止了池注册 PoolVoter::registerGauge
V12 反转的 isPool 条件阻止了新的池注册 PoolVoter::registerGauge
来源 发现描述
事实依据 registerGauge 函数在将新的 _asset 推送到 _pools 中并设置 isPool[_asset] = true 的代码周围使用了不正确的布尔检查。由于条件错误,初始化块永远不会为新的池运行,因此它们永远不会被注册。
V12 registerGauge 使用 if (isPool[_asset]) 而不是 if (!isPool[_asset]) 来保护 _pools.push(_asset)isPool[_asset] = true。由于新资产默认为 false,因此该块永远不会执行,从而使新的池未被记录,并破坏了依赖于池列表的下游逻辑。

29101 - [SC - 高]

来源 发现标题 位置
事实依据 BaseLocker 中的质押已损坏 BaseLocker::_createLock
V12 不合格的 safeTransferFrom 调用保留了 msg.sender,导致恢复 BaseLocker::_createLock
来源 发现描述
事实依据 _stakeNFT 为 true 时,locker 将 NFT 铸造给自己,然后调用 safeTransferFrom(address(this), address(staking), tokenId, data)。由于这是内部调用,因此 msg.sender 仍然是用户,因此 ERC721 的授权检查失败(locker合约,而不是用户,拥有 NFT)。因此,在锁定创建期间,对于普通用户来说,质押会恢复,从而破坏了质押流程,并可能冻结集成商中的资金。
V12 _createLock 调用公共 safeTransferFrom 而没有对其进行限定(内部跳转),因此 msg.sender 仍然是 EOA 而不是合约。ERC721 的 _checkAuthorized 然后恢复,因为 EOA 未被批准用于合约拥有的 token。这使得“创建和质押”锁定操作始终失败。

29012 - [SC - 高]

来源 发现标题 位置
事实依据 通过重复的池条目操纵 PoolVoter 中的投票 PoolVoter::vote / PoolVoter::_vote / PoolVoter::reset
V12 缺少唯一性验证允许重复的池投票泄漏权重 PoolVoter::vote
来源 发现描述
事实依据 vote() 允许投票者多次包括同一池。每个重复项都会增加 totalWeightweights[_pool],但 votes[_who][_pool] 仅被最后权重覆盖。当调用 reset() 时,它仅减去最后存储的权重,从而使先前的递增卡住——允许攻击者循环投票/重置,并人为地膨胀池的权重并倾斜奖励/治理。
V12 池数组未去重。_vote() 为每个出现次数增加权重,而 votes[user][pool] 仅保留最后一个值;然后 reset() 减去一次,因此剩余额外的权重。攻击者可以使用重复的池条目重复投票,以永久地增加 weights[pool]totalWeight

29189 - [SC - 高]

来源 发现标题 位置
事实依据 ZeroLendToken 不允许白名单用户转移 ZeroLendToken::_update
V12 暂停机制中的逻辑反转冻结了白名单发送者 ZeroLend::_update
来源 发现描述
事实依据 _update(from, to, value) 使用 require(!paused && !whitelisted[from], "paused");。只要 whitelisted[from] 为 true,此条件就会恢复,从而阻止白名单用户完全转移(即使在未暂停时)。预期的行为是——允许白名单发送者在暂停时转移——应该使用不阻止他们的逻辑,例如 if (paused) require(whitelisted[from], "paused");
V12 暂停门是 require(!paused && !whitelisted[from]),它反转了白名单豁免。由于该条件要求同时“未暂停”和“非白名单”,因此任何白名单发送者总是无法通过检查。预期的逻辑是 `require(!paused

29095 - [SC - 高]

来源 发现标题 位置
事实依据 locker 供应可以通过 merge() 任意膨胀 BaseLocker::merge (通过 _depositFor)
V12 BaseLocker 中通过 Merge 膨胀供应 BaseLocker::merge
来源 发现描述
事实依据 BaseLocker 跟踪全局 supply 并在 _depositFor 中递增它。当合并两个 locker (merge()DepositType.MERGE_TYPE) 时,即使它只是合并现有锁(没有存入新的 token),代码仍然执行 supply += _value。重复的合并使攻击者能够无限期地向上漂移 supply,从而导致会计核算不同步(例如,基于 supply 的奖励),并可能导致溢出或经济操纵。
V12 merge() 使用 MERGE_TYPE 调用 _depositFor,这会无条件地增加 supply,而 MERGE 路径会跳过任何 token 转移。通过重复将余额合并到目标 NFT 中,攻击者可以膨胀报告的总 supply,而无需添加 token,从而破坏了依赖于准确 supply 的不变量和奖励/治理计算。

V12 在 Zellic 审计期间发现错误

V12 发现了一些,但不是大多数,Zellic 在审计期间发现的错误。其中包括 V12 和 Zellic 的安全研究人员独立发现的严重和高危问题。

为了评估 V12 的错误发现能力,我们在 Zellic 的 EVM 审计期间独立于人工研究人员运行 V12。然后,我们在每次审计结束时比较 V12 和人工审计人员的结果。我们将 Zellic 研究人员的绩效视为事实依据预言机;也就是说,我们假设他们不会遗漏错误。(对于我们的团队来说,这是一个相当不错的假设!)我们评估了审计中发现的每个错误,V12 是否发现了它。

V12 识别出 24 个已验证的漏洞:2 个严重、6 个高危、8 个中危,其余为低危或信息性。这些错误都是 V12 独立发现的,没有特殊的提示或人工协助。

下面,我们重点介绍最近审计中发现的三个具有代表性的漏洞,其中 V12 的分析与人工审计人员的发现相匹配。由于客户的保密限制,并非所有错误都可以讨论。以下示例均来自公开披露的报告。

错误 1:(严重)token 合约中损坏的 ownable 功能

存在漏洞的合约是一个token,它实现了 Ownable 和 AccessControl。Ownable 的内部所有权检查被覆盖,以使用 AccessControl 的管理角色作为事实来源,从而造成拒绝服务风险。

/**
 * @dev Implementation of the _checkOwner function required by Ownable.
 * Uses the existing access control mechanism to check if the caller has the DEFAULT_ADMIN_ROLE.
 */
function _checkOwner() internal view virtual override {
    _checkRole(DEFAULT_ADMIN_ROLE);
}

V12 独立发现了一个漏洞,如果合约的所有权被转移,该漏洞会破坏仅限管理的功能。人工审计人员同时发现了这个问题。审计结束后,客户最终实施了代码更改以解决此漏洞,这表明该问题是有效的,无论它是人工还是 V12 发现的。下面,我们展示了两个描述(一个由人工编写,另一个由 V12 编写),以进行比较。

原始和 V12 报告的发现

来源 发现标题 位置
事实依据 被覆盖的 ownable 功能可能导致管理员锁定 NameToken
V12 Ownable 和 AccessControl 之间的不同步导致管理员锁定 NameToken
来源 发现描述
事实依据 NameToken 合约覆盖了 Ownable 合约的内部所有权检查 (_requireCallerIsContractOwner()),转而使用 AccessControl 合约的 DEFAULT_ADMIN_ROLE 检查。但是,当通过 Ownable::transferOwnership 转移所有权时,新所有者不会自动收到 DEFAULT_ADMIN_ROLE
V12 该合约覆盖了 Ownable 的内部所有权检查 (_requireCallerIsContractOwner),转而使用 AccessControl 的 DEFAULT_ADMIN_ROLE 检查。但是,当通过 Ownable.transferOwnership 转移所有权时,新所有者不会自动收到 DEFAULT_ADMIN_ROLE。因此,新所有者无法满足角色检查,并且仅限所有者的函数变得无法访问。同样,如果原始管理员放弃 DEFAULT_ADMIN_ROLE 或所有权,则没有帐户持有 DEFAULT_ADMIN_ROLE,并且所有受保护的功能都将被永久锁定。
来源 发现影响
事实依据 这将导致新所有者无法满足角色检查,从而使仅限所有者的函数无法访问。在最坏的情况下,如果原始管理员在转移所有权后立即放弃 DEFAULT_ADMIN_ROLE,则没有帐户将持有 DEFAULT_ADMIN_ROLE,因此所有受保护的功能都将被永久锁定。我们认为此问题发生的可能性很高,因此影响是严重的。
V12 严重:仅限所有者的函数在所有权转移或角色放弃后可能变得永久不可访问,从而有效地锁定了所有管理功能,并可能使合约停滞。

V12 正确识别了 bug 的位置和影响,并恰当地描述了该漏洞。

错误 2:(严重)订单填充机制中的重入漏洞

存在漏洞的合约是一个 DeFi 协议,允许用户填充合成头寸的订单。存在漏洞的代码由于在状态更新之前进行外部调用而存在重入风险。

function _fillOrder(IPools.Order memory order, bytes memory signature, uint256 fraction, bytes32 orderHash) internal {
    //...
    _validateOrderFillConditions(order, orderHash, fraction);
    //...
    uint256 sharesToFill = (order.shares * fraction / FULL_FILL);
    _mint(longRecipient, longTokenId, sharesToFill, ""); // 外部调用
    _mint(shortRecipient, shortTokenId, sharesToFill, ""); // 外部调用
    //...
    orderFillPercentages[orderHash] += fraction; // 外部调用后的状态更新
}

V12 独立发现了一个重入漏洞,该漏洞可能允许攻击者过度填充超过 100% 的订单。人工审计人员同时发现了这个问题,并且客户实施了代码更改以解决它。我们在下面展示了两个描述(一个由人工编写,另一个由 V12 编写)以进行比较。

原始和 V12 报告的发现 来源 发现标题 位置
Ground Truth 通过 ERC-1155 的重入 Pools
V12 由于状态更新前的外部调用导致的重入风险 _fillOrder
来源 发现描述
Ground Truth 接收者可以使用fillOrderFOKfillOrdersFOK函数来填充由制造者签名的订单,使得订单的成交百分比始终小于或等于100%。这些函数在内部调用_fillOrder,该函数在调用_mint(铸造代表该位置的ERC-1155)之前验证百分比,最后更新订单成交百分比。_mint函数在内部对接收者的地址调用IERC1155Receiver(to).onERC1155Received。这可能被滥用以重入fillOrderFOKfillOrdersFOK函数。
V12 该函数执行多个外部调用(例如通过_mint铸造ERC1155代币,这会触发接收合约上的接受检查,以及使用SafeERC20.safeTransferFrom转移抵押品和费用代币),然后再更新关键状态(orderFillPercentages)。这种排序可能允许充当接收者的恶意合约在其中一个外部调用期间重入_fillOrder函数(或另一个相关函数),从而可能实现双重填充或操纵抵押品转移。
来源 发现影响
Ground Truth 由于orderFillPercentages的值在铸造函数之后更新,并在铸造之前验证,因此重入可用于创建成交百分比超过100%的订单,因此,如果制造者已批准足够的资金给合约,他们将为订单支付超出预期的费用。
V12 攻击者可以利用此漏洞重复填充同一订单,多次耗尽抵押品或费用,从而打破订单成交百分比和资产核算的预期不变性。

V12 正确地识别了重入漏洞,并提供了关于攻击如何运作的全面推理。两种分析都认识到,核心问题是违反了检查-效果-交互模式,即状态更新发生在可能触发重入的外部调用之后。

Bug 3: (高) 部分状态更新导致订单结算中的代币损失

漏洞代码是跨链意图网络的一部分。漏洞代码如下所示:

function _settleOrder(bytes32 orderId, address filler) internal {
    // [...]
    bool successLock = balances[order.offerer][order.inputToken].decreaseLockedNoRevert(
        uint128(order.inputAmount)
    );
    bool successUnlock = balances[filler][order.inputToken].increaseUnlockedNoRevert(
        uint128(order.inputAmount)
    );
    if (!successLock || !successUnlock) {
        return;
    }
    // [...]
}

V12 独立地发现了一个漏洞,其中失败的余额更新可能导致不一致的状态和/或代币损失。人工审计员也同时发现了这个问题,并且客户端实施了代码更改来解决它。我们展示了两个描述(一个由人编写,另一个由 V12 编写)以进行比较,如下所示。

原始和 V12 报告的发现

来源 发现标题 位置
Ground Truth 结算失败时状态更新不正确 _settleOrder
V12 当填充者信用失败时,部分状态更新导致永久的代币损失 _settleOrder
来源 发现描述
Ground Truth _settleOrder 函数结算单个订单并更新余额。当余额更新失败时,它会通过提前返回来跳过结算当前订单。但是,在这种情况下可能会发生不正确的余额状态——如果 successLock 为 false 但 successUnlock 为 true,则该函数在增加填充者的解锁余额后返回,从而创建一个不一致的状态,其中填充者的解锁金额增加,提供者的锁定金额保持不变, 并且订单状态保持不变。
V12 _settleOrder 函数调用两个非还原的 BalanceUtils 助手:对提供者执行 decreaseLockedNoRevert,对填充者执行 increaseUnlockedNoRevert。如果锁定代币减少成功但解锁代币增加失败,则该函数会提前返回而不还原状态。因此,提供者的锁定代币被移除,并且永远不会返还或转发给填充者,从而导致永久的代币损失。
来源 发现影响
Ground Truth _settleOrder 函数中结算失败时的不正确状态更新可能导致不一致的余额核算。
V12 攻击者或善意的填充者可能会触发代币信用步骤中的失败(例如,溢出检查),并导致提供者永久损失锁定的代币。这会导致直接的经济损失并破坏对合约的信任。

V12 正确地识别了根本原因,并提供了对攻击媒介的更详细分析,尽管它描述了与人工审计员相反的失败情况(减少成功但增加失败,而人工审计员认为是减少失败但增加成功)。两者都识别了导致余额不一致的非原子状态更新的核心问题。

V12 在未经审计的代码中发现了导致黑客攻击的漏洞

V12 能够发现导致过去重大黑客攻击的漏洞。虽然一些黑客攻击依赖于极其复杂的漏洞,但很大一部分黑客攻击源于相对简单的编码错误。我们在下面展示了 V12 能够检测到的真实漏洞利用向量的代表性示例。

Uranium: $57,200,000

该漏洞是一个编码错误,其中一个常量未更新,导致不正确的常数乘积检查。这导致 5720 万美元被盗12。V12 检测并报告此错误,无需任何特殊提示或帮助。

漏洞详细信息

UraniumPair 合约是从 Uniswap V2 分叉而来的,并且 swap 函数被修改以更改费用结构。但是,常数乘积检查未更新以反映新的费用结构,并且保持为 1000**2 而不是 10000**2。这个不正确的指数使不变量检查小了 100 倍,允许在单个交易中耗尽 98% 的流动性。漏洞代码如下所示:

    function swap(uint amount0Out, uint amount1Out, address to, bytes calldata data) external lock {
      require(amount0Out > 0 || amount1Out > 0, 'UraniumSwap: INSUFFICIENT_OUTPUT_AMOUNT');
      // [...]
      { // scope for reserve{0,1}Adjusted, avoids stack too deep errors
      uint balance0Adjusted = balance0.mul(10000).sub(amount0In.mul(16));
      uint balance1Adjusted = balance1.mul(10000).sub(amount1In.mul(16));
      require(balance0Adjusted.mul(balance1Adjusted) >= uint(_reserve0).mul(_reserve1).mul(1000**2), 'UraniumSwap: K');
      }

V12 报告了此漏洞,描述如下:

swap 函数使用 10000 的因子应用费用调整(在将余额乘以 10000 后减去 amountIn·16),但随后针对 reserve0·reserve1·(1000²) 而不是 (10000²) 验证常数乘积不变量。这个不正确的指数使不变量检查过于宽松 100 倍,允许攻击者闪电兑换几乎所有流动性,并且仅返回约 1% 的所需金额以满足错误缩放的 K 检查。

Eleven: $4,500,000

该漏洞是 emergencyBurn 函数中的一个逻辑错误,其中合约允许用户提取他们的代币而不燃烧他们的份额。这导致 450 万美元被盗 13。V12 检测并报告此错误,无需任何特殊提示或帮助。

漏洞详细信息

当调用 emergencyBurn 函数时,它会将与调用者的份额对应的完整代币余额转移回给用户,但不会燃烧这些份额或更新用户的债务。这允许用户调用 emergencyBurn,然后继续进行常规的 withdraw 来再次提取相同的代币,从而有效地耗尽金库。漏洞代码如下所示:

    /**
   * @dev Function to exit the system. The vault will withdraw the required tokens
   * from the strategy and pay up the token holder. A proportional number of IOU
   * tokens are burned in the process.
   */
  function withdraw(uint256 _shares) public {
      claim(msg.sender);//TODO double check inhereted correctly
      _burn(msg.sender, _shares);
      uint avai = available();
      if(avai<_shares) IMasterMind(mastermind).withdraw(nrvPid, (_shares.sub(avai)));
      token.safeTransfer(msg.sender, _shares);
      emit Withdrawn(msg.sender, _shares, block.number);
      updateDebt(msg.sender);
  }

  function emergencyBurn() public {
      uint balan = balanceOf(msg.sender);
      uint avai = available();
      if(avai<balan) IMasterMind(mastermind).withdraw(nrvPid, (balan.sub(avai)));
      token.safeTransfer(msg.sender, balan);
      emit Withdrawn(msg.sender, balan, block.number);
  }

V12 报告了此漏洞,描述如下:

emergencyBurn 函数将与调用者的份额对应的完整代币余额转移回给用户,但不会销毁这些份额。用户在提款后保留了他们的金库份额,允许他们重复调用 emergencyBurn 并无限期地耗尽代币。

SuperRare: $710,000

该漏洞是 updateMerkleRoot 函数中不正确的访问控制检查,允许任何人调用它并设置新的 Merkle 根。这导致 710,000 美元被盗。V12 检测并报告此错误,无需任何特殊提示或帮助。

漏洞详细信息

updateMerkleRoot 函数检查调用者不是所有者或特定地址,而不是检查调用者是所有者或该特定地址。这种逻辑错误允许任何人调用 updateMerkleRoot 并设置新的 Merkle 根,从而允许接受欺诈性证明。漏洞代码如下所示:

  function updateMerkleRoot(bytes32 newRoot) external override {
      require((msg.sender != owner() || msg.sender != address(0xc2F394a45e994bc81EfF678bDE9172e10f7c8ddc)), "Not authorized to update merkle root");
      if (newRoot == bytes32(0)) revert EmptyMerkleRoot();
      currentClaimRoot = newRoot;
      currentRound++;
      emit NewClaimRootAdded(newRoot, currentRound, block.timestamp);
  }

V12 报告了此漏洞,描述如下:

updateMerkleRoot 函数使用不正确的访问控制检查,因此任何人都可以调用它来增加 currentRound 并设置新的 currentClaimRoot。攻击者可以重复创建一个仅包含他们自己的地址和大量代币分配的恶意 Merkle 根,调用 updateMerkleRoot 来推进回合,然后使用有效的证明调用 claim 并耗尽代币。由于 lastClaimedRound 仅与 currentRound 进行比较,因此每个新回合都允许攻击者再次声明,从而使他们能够耗尽合约的整个代币余额。

Dexodus Finance: $300,000

该漏洞是 performUpkeep 函数中的一个验证错误,其中合约在使用价格数据之前不检查其新鲜度。这导致 300,000 美元被盗 14。V12 检测并报告此错误,无需任何特殊提示或帮助。

漏洞详细信息

performUpkeep 函数解码并验证签名的 Chainlink 报告,但在使用价格数据之前,从不检查报告的 expiresAt(或任何时间戳)。这允许攻击者重放旧的有效签名以操纵价格。漏洞代码如下所示:

  function performUpkeep(bytes calldata performData) external {
      // Decode incoming performData
      (bytes[] memory signedReports, bytes memory extraData) = abi.decode(
          performData,
          (bytes[], bytes)
      );

      // [...]
          // Verify the report
          bytes memory verifiedReportData = verifier.verify(report, abi.encode(feeTokenAddress));

          uint256 _currentPrice;
          if (dxMarkets[key].specificTimeFrame) {
              // Decode verified report data into PremiumReportRWA struct
              PremiumReportRWA memory verifiedReport = abi.decode(
                  verifiedReportData,
                  (PremiumReportRWA)
              );
              require(verifiedReport.status == MarketStatus.Open);

              _currentPrice = _scalePriceToTokenDecimals(
                  USDC,
                  verifiedReport.price
              );
        // [...]
  }

V12 报告了此漏洞,描述如下:

performUpkeep 函数解码并验证签名的 Chainlink 报告,但在使用价格数据之前,从不检查报告的 expiresAt (或任何时间戳)。它完全依赖于外部 IVerifierProxy 实现,其新鲜度加强情况未知。如果该验证器不严格拒绝过时的报告,攻击者可以重放旧的有效签名以操纵传递到 futuresCore.modifyPosition 的价格。

Nitron: $950,000

该漏洞是一个预言机操纵漏洞,其中捐赠的代币可用于膨胀赎回率。这导致 950,000 美元被盗 15。V12 检测并报告此错误,无需任何特殊提示或帮助。

漏洞详细信息

totalAssets 函数返回金库持有的 fsGLP 余额,包括直接捐赠给合约的任何代币。由于金库被用作定价 dGLP 的预言机,攻击者可以捐赠 fsGLP 以膨胀赎回率并操纵预言机价格。漏洞代码如下所示:

  /** @dev See {IERC4626-totalAssets}. */
function totalAssets() public view virtual returns (uint256) {
    return fsGLP.balanceOf(address(this));
}

V12 报告了此漏洞,描述如下:

CarbonWrappedGLP 使用 totalAssets() 计算每股资产价值,该函数返回 fsGLP.balanceOf(address(this))。由于没有内部会计来区分官方存款和任意代币转账,因此任何人都可以直接将 fsGLP 发送到合约。这会膨胀 totalAssets,从而膨胀转换函数使用的每股价值。

讨论

V12 与安全专业人士相比如何?

V12 擅长发现直接的、表面级别的编码错误。V12 并非旨在发现我们预测将成为人工研究人员主要关注点的深入、复杂的漏洞。

V12 擅长简单的错误类别,例如:访问控制破坏、输入验证、重入、未检查的算术和拒绝服务。另一方面,V12 难以发现诸如逻辑错误、经济问题、设计缺陷或需要特定见解的错误,例如:密码学错误、L1 实现细节或跨链或跨协议交互。

我们认为,合格的安全专业人员应该始终胜过 V12。尽管如此,我们的示例表明,V12 发现的这类错误经常被遗漏,但顶尖的审计员不会。V12 还经常发现审计竞赛中发现的许多错误。简而言之,V12 不会取代人工研究人员,但我们相信它会提高标准。

总的来说,鉴于计算机程序和数学证明之间的对应关系↗,我们认为漏洞研究的问题很可能 “AGI-complete”,这意味着,除非实现 AGI,否则总会有 AI 系统无法触及的错误。更实际的是,即使在今天,我们也发现存在一整类漏洞,即使对于最好的 AI 系统(包括 V12)来说也是遥不可及的。

安全专业人员应该如何使用 V12?

由最好的研究人员使用时,V12 可以增强他们现有的专业知识和技能。我们建议先手动完整地审查代码,最后再使用 V12。这可以防止研究人员的思维过程受到 V12 的影响;例如,不会发明否则会出现的想法(如攻击向量)。V12 充当 (1) 额外的保证层和 (2) 额外的灵感来源。

由平庸或缺乏经验的研究人员使用时,V12 主要是一种拐杖。我们不建议初级研究人员使用 V12。它可能导致过度依赖,并在长期内阻碍内在安全研究技能的发展。

在 Zellic,我们基于这些原则开发了一种在我们的 EVM 审计中使用 V12 的协议。审计正常进行,没有 V12 的帮助,直到大约中途,我们才在代码库上运行 V12。这个时间安排是经过深思熟虑的。它使人工审计员有时间熟悉代码库(以便他们可以快速评估哪些 V12 发现值得调查),同时避免其创造过程受到“污染”↗,从而保留了他们依靠内在直觉发现错误的能力。

以这种方式使用时,V12 会增强而不是取代人工审计员。V12 可能会提出一些问题,这些问题本身不是漏洞,但会激发导致发现真正漏洞的思路。这为我们的客户提供了额外的保护层,同时确保在使用 V12 时审计质量不会比没有 V12 时更差。

路线图

我们计划将来继续向 V12 添加功能。这些功能包括 POC 生成(这将进一步消除误报)、CI/CD 集成、对 Rust 和 Solana 的支持以及模糊测试。

结论

我们希望你喜欢这篇文章。如果你还没有尝试过,请在此处尝试 V12!↗ V12 目前处于封闭测试阶段,我们正在根据设计合作伙伴的反馈进行额外的测试和改进体验。我们优先考虑为 Zellic 的现有客户提供访问权限,但如果你想尝试 V12,请随时联系↗以获取早期访问权限。我们计划在 2025 年第四季度全面发布 V12。最后,如果你对我们的使命(提高安全性标准)感到兴奋,并希望与世界上最好的黑客合作:我们正在招聘!↗

尾注

  1. 我们定期发布这些统计数据作为我们新闻通讯的一部分,你可以在此处↗找到这些数据。

  2. 链接到原始文章↗

  3. 链接到审计报告↗

  4. 链接到原始文章↗

  5. 链接到审计报告↗

  6. 链接到原始文章↗

  7. 链接到审计报告↗

  8. 链接到原始文章↗

  9. 链接到审计报告↗

  10. 链接到审计报告↗

  11. 链接到审计报告↗

  12. 链接到原始文章↗

  13. 链接到原始文章↗

  14. 链接到原始文章↗

  15. 链接到原始文章↗

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

0 条评论

请先 登录 后评论
zellic
zellic
Security reviews and research that keep winners winning. https://www.zellic.io/