智能合约常见漏洞:流动性质押协议

本文深入探讨了流动性再质押协议中的常见漏洞,包括重入攻击、拒绝服务、不正确的奖励分配以及经济攻击。文章通过Sigma Prime的审计案例,详细分析了EigenLayer集成及其他质押协议中存在的具体问题,旨在为DeFi开发者和安全研究人员提供有价值的安全指导,以提升系统的安全性,保护用户资产。

介绍

流动性再质押协议在 DeFi 领域迅速获得了关注,改变了用户管理和最大化其质押策略的方式。通过使资产能够在多个平台上重复使用,这些协议提供了更高的灵活性和资本效率。然而,伴随这种创新而来的是一些可被利用的风险。

开发者和安全研究人员必须对这些漏洞保持警惕,以加强其系统的安全性。在本文中,我们将探讨流动性再质押协议中可能出现的普遍问题。我们将研究 EigenLayer 集成以及其他形式的质押协议的最新挑战,以强调为什么谨慎的实现对于避免诸如重入攻击、拒绝服务(DoS)漏洞和不正确的奖励分配等问题如此重要。

我们将研究典型的安全缺陷和技术挑战,并以 Sigma Prime 审查中的例子作为支撑。我们的目标是提供一个简洁而全面的流动性(再)质押协议中常见漏洞的概述,为 DeFi 领域的开发人员和安全研究人员提供有价值的资源。

什么是流动性质押/再质押/EigenLayer 协议?

流动性质押、再质押和 EigenLayer 协议是在权益证明(PoS)区块链网络中使用的先进机制,旨在增强质押资产的流动性、安全性和可用性。这三种协议在目标上是相似的,但在一些关键方面有所不同。

流动性质押

流动性质押允许用户在保持流动性的同时质押其代币。用户无需将代币锁定在质押合约中,而是会收到代表其质押资产的衍生代币。这些衍生代币可以进行交易、用作抵押品或部署在其他 DeFi 协议中。这增加了你质押价值的灵活性。

再质押

再质押是指跨多个协议或服务质押资产的做法。此过程涉及利用同一组质押代币来保护区块链生态系统中的其他层或服务。再质押增强了质押代币的经济效用,而无需质押者投入额外的资本。

EigenLayer 协议

EigenLayer 协议旨在提高各种区块链服务的安全性,例如Layer2解决方案、桥和预言机,这些服务统称为积极验证服务(AVS)。借助 EigenLayer,用户可以存入原生 ETH 或流动性质押代币(LST),然后将其质押委托给受信任的运算符。这些由 AVS 批准的运营商使用委托的质押来验证不同的服务。获得的奖励与委托其质押的用户分享,但如果运营商行为不诚实,则质押金额可能会被削减。

了解这些协议如何工作以及它们如何相互交互是发现和修复潜在问题的关键。在下一节中,我们将研究这些协议中的常见漏洞,使用 Sigma Prime 最近的审查中的真实示例来展示其影响。在本文的其余部分中,我们将统称所有这些原语为流动性质押协议。

EigenLayer 再质押协议是相当新的,涉及几个重要的组件和工作流程,以使系统正常工作。以下是一个分解,以帮助理解它们如何运作以及可能出现问题的潜在位置。

关键实体

  • 质押者: 他们可以将自己的代币委托给受信任的验证者,也可以通过 EigenPods 原生质押。
  • 验证者: 接收来自质押者的委托质押,并参与验证服务。他们根据自己的表现获得奖励或面临惩罚。
  • 节点运营商: 管理他们的节点,处理验证器操作,并确保与底层区块链网络正确交互。
  • 委托管理器: 跟踪委托的份额,处理取消委托,并管理质押者的提款队列。
  • 提款路由器: 管理延迟提款流程,确保资产从系统中正确退出和转移。

存款路径

  1. 流动性质押: 质押者将 LST 存入由 StrategyManager 管理的策略中,然后将其添加到 DelegationManager 中的当前代理人。
  2. 原生质押: 涉及运行验证器。质押者部署一个 EigenPod 并在 Beacon Chain 上激活验证器。验证器激活通过 verifyWithdrawalCredentials() 验证,并且份额被分配给 DelegationManager 中的当前代理人。

委托流程

份额默认不被委托,可以通过两种方式委托:

  1. 注册为运营商: 运营商将其份额永久委托给自己。
  2. 委托给运营商: 质押者将份额委托给受信任的运营商。要更改代理人,质押者必须首先取消委托。强制取消委托可以由质押者、运营商或由运营商设置的委托批准人发起。

提款流程

提款可以从 EigenLayer 或 Beacon Chain 发起:

  1. Beacon Chain 提款:
    • 完全提款: 验证器请求完全提款,并且 podOwner 在 EigenLayer 上对其进行验证。提款金额包括全部余额。
    • 部分提款: 验证器请求部分提款,并且资金通过 DelayedWithdrawalRouter 退出。提款金额包括高于 32 ETH 的请求盈余余额。
  2. EigenLayer 提款:
    • 取消委托和提款: 质押者取消委托并以代币形式请求提款。
    • 队列提款: 质押者指定从哪些策略中提款,并且份额进入提款队列。

惩罚机制

EigenLayer 的惩罚机制尚未启用。集成商应做好准备,确保他们的代码库可以处理未来的惩罚事件。但是,Beacon Chain 确实启用了启动时就启用的惩罚机制

常见漏洞

我们将简要介绍流动性质押协议中发现的一些常见问题。这些例子来自我们的审查,以突出我们通常寻找和自问的内容。

重入

  • 始终在进行任何外部调用之前更新合约状态。使用 nonReentrant 修饰符来阻止对同一函数的多次调用,并遵循 Checks-Effects-Interactions (CEI) 模式,以确保状态更改发生在交互之前。
  • 请记住,重入保护也有限制,尤其是在处理彼此交互的多个合约时。保护在正常情况下可以正常工作,但它不会阻止具有执行控制权的人通过不同的合约重新进入。我们还应该小心共享状态的合约,因为这也可以规避重入保护。

循环

  • 无界循环可能导致 DoS 攻击或过多的 gas 使用。小心在单个交易中添加过多的代币、委托或提款。我们将在下一节中提供一个真实的例子。它展示了一个优雅的例子,说明在使用多个循环时可能发生的错误以及它们可能造成的破坏。
  • 确保 msg.value 不在循环中重放,因为它可能导致多次不期望的付款。

确定性的提款金库地址计算

  • 我们的一位安全工程师在 Secureum 2023 上发表了关于确定性的提款金库地址计算的演讲。在本次演讲中,他回顾了 create2(),它的一些复杂性以及合约元数据所包含的内容。
  • 元数据本质上是关于已部署合约的信息。例如,它包括诸如 EVM 版本、编译器设置、编译源文件/源单元、使用的语言等信息。存在此数据的原因是为了能够严格验证合约的源代码。元数据作为标签存储在合约的字节码中,位于字节码的末尾。此标签称为元数据哈希。
  • 合约元数据的最小更改会导致字节码发生更改。因此,更改字节码将导致由 create2() 创建的不同合约地址。如果合约地址略有不同,这可能导致用户无法提取资金。

Beacon Chain 状态根操纵

  • 由于智能合约无法直接访问 Beacon Chain 数据(除了通过 EIP-4788 的 Beacon 区块根之外),它们必须依赖预言机或其他机制来提供此信息。这种依赖性增加了可能的漏洞的另一层,因为这些预言机本身可能会受到损害或意外或恶意地提供不正确的数据。
  • 基于不正确的状态根,它将允许处理和接受无效的存款和提款证明,这将使未经授权的提款或存款被识别为合法的。

对代币价值的经济攻击

  • 攻击者可以使用诸如闪电贷之类的方法来扭曲池余额,从而使代币贬值或高估。
  • 我们将在下一节中展示此漏洞的真实示例。此示例说明了在强制委托之后对份额的跟踪中的缺陷如何导致 TVL 的操纵,进而可以利用该漏洞来攻击代币价值。

防止惩罚

  • 恶意用户可能会尝试通过交易干扰、out-of-gas 错误或及时提款来阻止惩罚或清算。
  • 在我们的 Mantle Network 审查 中可以找到这样一个例子。在其中,节点可以通过操纵其存款储备来避免惩罚。在 Mantle 中,节点必须提供存款作为其业绩的保险。存款大小仅在存款时检查。因此,减少此金额不会影响节点的有效性。因此,节点可以将其存款减少到零并避免惩罚。

Merkle 证明欺骗

  • 我们的一位安全评估经理在 Secureum 2023 上发表了关于Merkle 树及其常见陷阱的演讲。这是一个很好的资源,可以了解 Merkle 树的基础知识以及如何利用它们。
  • Merkle 树是许多质押协议的基础,它们的可预测值可能是操纵的目标。如果可以更改 Merkle 树的任何部分(例如,高度、叶子或根),则恶意参与者可能能够欺骗 Merkle 证明。

处理计算中的小数

  • 在财务计算中不正确地管理小数可能会导致严重的舍入误差,从而严重影响质押结果。
  • 程序通常使用固定数量的位来表示数字。这意味着可以表示的位数是有限的。通常在执行除法运算时需要进行舍入,以在固定数量的位中表示可能无限的结果。
  • 以下代码段中有一个错误的例子,它表示用于计算铸币金额的公式。我们观察到 inflationPercentage 是通过执行乘法和除法的两个单独案例来计算的。这种疏忽会导致双重舍入损失(每个除法运算一次),从而减少铸币金额。
// 计算存款后价值的百分比
uint256 inflationPercentaage = (SCALE_FACTOR * _newValueAdded) / (_currentValueInProtocol + _newValueAdded);

// 计算新的供应量
uint256 newEzETHSupply = (_existingEzETHSupply * SCALE_FACTOR) / (SCALE_FACTOR - inflationPercentaage);

// 从新的供应量中减去旧的供应量以获得要铸造的金额
uint256 mintAmount = newEzETHSupply - _existingEzETHSupply;

访问控制和状态完整性

  • 使用基于角色的访问、限制特权角色的权力、实施多重签名控制以及采用去中心化治理实践降低了滥用的可能性。即使存在修饰符,也务必仔细检查逻辑以确保已正确实施。
  • 可以在 这里 找到有关访问限制的良好资源。

冷却期利用

  • 冷却期旨在管理提款并防止滥用。但是,这些期间的漏洞可能允许质押者逃避处罚,从而破坏系统的完整性。

赌注最终确定过程的安全性

  • 质押机制中的最终确定过程必须是稳健且透明的。此处的漏洞可能导致操纵,从而阻止适当的奖励分配或质押最终确定。

质押时间范围的执行

  • 验证所有质押操作上的时间戳,并确保它们落在适当的时间段内,以维护协议的规范。

Sigma Prime的审查中的漏洞示例

为了提供对上述漏洞更实际的理解,我们提供了自己对流动性质押协议的审查中的一些示例。这些示例重点介绍了现实世界的错误及其对这些系统的安全性和功能的影响。

stakedButUnverifiedNativeETH 的错误会计处理

在我们最近对 Kelp 的 LRT 智能合约更新 (KLP2-01) 的安全审查中,我们发现了一个问题,即协议如何核算尚未验证的已质押 ETH。像这样的会计问题太常见了,但在像质押这样高风险的环境中尤其重要。

当质押 32 ETH 时,名为 stakedButUnverifiedNativeETH 的变量会增加 32 ETH。验证股权后,应减少此金额,以反映 ETH 现在已验证并在 EigenLayer 中核算。但是,在这种情况下,问题出现在验证过程中。协议不是减去 32 ETH 总额,而是减去验证器的“有效余额”。

验证器的有效余额可能低于 32 ETH。如果发生这种情况,变量 stakedButUnverifiedNativeETH 最终会留下一些实际上不属于协议的 ETH。这种差异意味着协议的总资金被高估,并且其代币价格可能计算不正确。

在以下代码段中,我们可以看到 32 ETH 在调用 stake() 后递增。但是,我们可以看到在第二个代码段中,它减去的是有效余额,而不是质押的 32 ETH。

IEigenPodManager eigenPodManager = IEigenPodManager(lrtConfig.getContract(LRTConstants.EIGEN_POD_MANAGER));
eigenPodManager.stake{ value: 32 ether }(pubkey, signature, depositDataRoot);

// 跟踪已质押但未验证的原生 ETH
stakedButUnverifiedNativeETH += 32 ether;
eigenPod.verifyWithdrawalCredentials(
    oracleTimestamp, stateRootProof, validatorIndices, withdrawalCredentialProofs, validatorFields
);

uint256 totalVerifiedEthGwei = 0;
for (uint256 i = 0; i < validatorFields.length;) {
    uint64 validatorCurrentBalanceGwei = BeaconChainProofs.getEffectiveBalanceGwei(validatorFields[i]);
    totalVerifiedEthGwei += validatorCurrentBalanceGwei;
    unchecked {
        ++i;
    }
}

// 减少已验证的 eth 金额
stakedButUnverifiedNativeETH -= (totalVerifiedEthGwei * LRTConstants.ONE_E_9);

自委托函数中的无限循环漏洞

我们在 Omni 代币和 AVS 审查 (OMNI-01) 中发现了一个问题,涉及其 _getSelfDelegations() 函数,该函数容易受到拒绝服务 (DoS) 状况的影响。当运营商在未包含在 _strategyParams 数组中的策略中进行质押时,就会出现此漏洞。因此,如果任何运营商在不兼容的策略中进行了质押,则 syncWithOmni() 函数将失败。

该错误在以下代码段中进行了说明:

for (uint256 i = 0; i < strategies.length;) {
    IStrategy strat = strategies[i];

    // 查找策略的策略参数
    StrategyParam memory params;
    for (uint256 j = 0; j < _strategyParams.length;) {
        if (address(_strategyParams[j].strategy) == address(strat)) {
            params = _strategyParams[j];
            break;
        }
        unchecked {
            j++;
        }
    }

    // 如果未找到策略,请勿在质押中考虑它
    if (address(params.strategy) == address(0)) continue;

    staked += _weight(shares[i], params.multiplier);
    unchecked {
        i++;
    }
}

如果当前策略在 _strategyParams 中未找到,则循环将继续,而不会递增 i 迭代器。因此,将重复检查相同的不兼容策略,从而导致无限循环,该循环仅在交易耗尽 gas 时才结束,最终导致拒绝服务。

在 Deneb 之后验证 Beacon Chain 提款中断

在我们对 EigenLayer (EGN3-01) 的审查中,我们发现 BeaconChainProof.verifyWithdrawal() 函数中存在缺陷。我们发现它不支持在 Ethereum Deneb 升级 之后发生的提款。这导致提款卡在 EigenLayer 的 EigenPod 合约中或被恶意伪造。

BeaconChainProofs 库假定树的高度为 4。但是,Deneb 升级向 ExecutionPayload 容器添加了两个新字段,以容纳 blob,作为 EIP-4844 的一部分。这使字段数从 15 个增加到 17 个,因此也使树的高度从 4 个增加到 5 个。

这导致了两个结果:

  1. 在 Deneb 之后生成的有效提款证明被错误地验证为无效。普通用户将无法通过 EigenPod 合约提取资金。
  2. 它为第二次预映像攻击打开了可能性,恶意用户可以冒充中间叶节点以伪造提款证明。

恶意验证器抢先交易攻击

此示例将介绍本文 RocketPool 和 Lido 抢先交易 Bugfix 审查 中的发现。

根据 Beacon Chain 的设计,存款合约仅需要 1 ETH 作为存款实例化的最小值,并且这第一个存款是设置提款凭据的存款。其目的是允许存款人进行部分付款,而不是 32 ETH 的一次性付款。但是,这为池化质押打开了一个抢先交易攻击向量。

为了利用此问题,节点运营商使用相同的公钥但不同的提款凭据生成两个存款数据实例。一个实例是真正的 32 ETH 存款,将由质押池处理并发送到存款合约。另一个实例是恶意实现,仅存入 1 ETH,并且提款凭据已替换为攻击者控制中的地址。

然后,攻击者等待质押池准备使用真正的存款数据将其提交到存款合约的 32 ETH。但是,在存入真正的 32 ETH 之前,攻击者直接使用他们自己的存款数据抢先交易至存款合约。

以下是 Beacon Chain 逻辑的样子:

def process_deposit(state: BeaconState, deposit: Deposit) -> None:
  # Verify the Merkle branch
  assert is_valid_merkle_branch(
    leaf=hash_tree_root(deposit.data),
    branch=deposit.proof,
    depth=DEPOSIT_CONTRACT_TREE_DEPTH + 1, # Add 1 for the List length mix-in
    index=state.eth1_deposit_index,
    root=state.eth1_data.deposit_root,
)
  # Deposits must be processed in order
  state.eth1_deposit_index += 1
  pubkey = deposit.data.pubkey
  amount = deposit.data.amount
  validator_pubkeys = [v.pubkey for v in state.validators]
  if pubkey not in validator_pubkeys:
    # Verify the deposit signature (proof of possession) which is not checked by the deposit contract_
    deposit_message = DepositMessage(
      pubkey=deposit.data.pubkey,
      withdrawal_credentials=deposit.data.withdrawal_credentials,
      amount=deposit.data.amount,
)
    domain = compute_domain(DOMAIN_DEPOSIT) # Fork-agnostic domain since deposits are valid across forks
    signing_root = compute_signing_root(deposit_message, domain)
    if not bls.Verify(pubkey, signing_root, deposit.data.signature):
      return
   # Add validator and balance entries
   state.validators.append(get_validator_from_deposit(state, deposit))
   state.balances.append(amount)
else:
   # Increase balance by deposit amount
   index = ValidatorIndex(validator_pubkeys.index(pubkey))
   increase_balance(state, index, amount)

现在,由于 Beacon Chain 仅在第一次有效提交时注册提款凭据,因此它是攻击者为此公钥指定的提款凭据。来自存款池的第二个 32 ETH 存款然后触发最终的 else 语句(在上面的代码段中),以简单地将验证器的余额增加 32 ETH,而忽略了真正的提款凭据。

然后,攻击者只需等待他们的验证器被激活,然后退出,他们将收到 33 ETH 到他们的提款地址。

结论

本文介绍了流动性质押协议的一些细节,包括流动性质押、再质押和 EigenLayer 集成。我们回顾了组件和工作流程、常见漏洞(如汇率操纵、不正确的访问控制、拒绝服务攻击等)。Sigma Prime 审查中的真实示例展示了其中一些漏洞的实际作用。

理解和缓解这些漏洞对于开发人员和安全审查人员来说非常重要。它可以提高协议的安全性并保护用户的资产,并保持整个生态系统的安全和可信。我们鼓励所有开发人员和安全审查人员保持知情,并积极主动地发现和缓解这些类型的威胁。

在 Sigma Prime,我们致力于保护和加强区块链网络和协议。如果你正在构建流动性再质押解决方案,并希望利用我们在该领域的前沿安全专业知识,请 联系我们

进一步阅读

以下是由 Sigma Prime 执行的一些质押审查的列表。此列表包含已公开的报告,也可以在我们的 公共报告存储库 中找到。

以下是来自其他安全研究人员和开发人员的一些其他文章:

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

0 条评论

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