本文对F Foundation的FCHAIN智能合约进行了审计,重点分析了其验证者注册和质押机制,揭示了一些关键的安全漏洞和优化建议。本文还详细描述了相关的重大问题和审计过程中的信任假设,建议F Foundation团队关注Ava Labs的最新进展,以便及时调整和改进其合约代码。
类型时间线 从 2025-03-03 到 2025-03-19 语言 Solidity 总问题 18 (6 已解决) 严重性关键问题 3 (2 已解决) 高严重性问题 1 (1 已解决) 中等严重性问题 2 (2 已解决) 低严重性问题 4 (1 已解决) 备注与附加信息 8 (0 已解决)
我们审核了 0xFFoundation/fchain-contracts 仓库中的提交 11ffd45。
范围内的文件如下:
fchain-contracts/
└── src/
├── StakeManager.sol
└── ValidatorRegistry.sol
审核的代码库管理着 F Foundation 的“FCHAIN”的验证者集合,这是一个遵循 ACP-77 规范的子网,位于 Avalanche 生态系统中。范围内的合约是基于 Ava Lab 的 ICM 合约 的分支。代码库使用 Warp Messenger,这是一个使用预编译功能在子网和 Avalanche P-chain 之间发送消息的标准。消息可以由 P-chain 验证者签名,以管理 ACP-77 子网的官方验证者集合,例如 FCHAIN。
验证方案的设置涉及“许可证”,这些许可证是将在 FCHAIN 首次启动时出售或分配的 NFT。这些许可证将使用户能够成为 FCHAIN 的验证者。为了做到这一点,用户需要锁定他们的许可证 NFT。一旦完成,验证者可以开始为每个“周期”赚取奖励,只要他们有足够的正常运行时间。奖励将以 FCHAIN 的原生代币支付并自动重新质押。委托者也可以向验证者质押, earning some rewards 为自己和验证者,而无需进行验证的工作。对验证者和委托者的质押都需要至少一个已质押的许可证,但可以通过质押额外的许可证或原生代币来增加奖励。
为了防止对验证者方案的操控,加入和退出验证者或委托者集合的操作都有时间延迟。这为 P-chain 验证者提供了时间,以签署对 FCHAIN 验证者集合的任何更改,并防止验证者在部分周期内获得奖励。这通常通过对大多数状态转换具有“初始化”和“完成”功能来实现,在这两个阶段之间,P-chain 生成一条带签名的消息来验证转换。
此外,为了稳定系统,有一个为期一年的时间,在此期间,委托者无法提取他们的质押。这一时期对所有人而言均相同,并在 FCHAIN 启动后的一年结束。值得注意的是,此期间不适用于验证者,允许他们在停止验证后立即移除其质押。尽管委托者无法提取,但他们在此期间可以更改所选验证者并存入更多质押。
在审核过程中,作出了以下信任假设:
updateBalances
或 updateMultipleBalances
函数将被调用,但在周期结束之前,针对每个 stakeID
进行活动验证。stakeID
签署正常运行时间证明。类似地,假设在每个周期的宽限期内,将针对每个注册的 stakeID
调用 submitUptimeProof
或 submitMultipleUptimeProofs
。虽然正常运行时间和奖励积累方案是脆弱的,但它已被激励正常运作。计量正常运行时间和积累奖励的功能是开放可调用的,可以在多个周期内调用,也可能不会在每个周期内为每个验证者或委托者调用。然而,我们理解 F Foundation 团队意图使调用适用的功能自动化。因此,我们在安全评估中做出了以下假设:
在整个代码库中,识别出了以下特权角色:
StakeManager
合约中,仅 DEFAULT_ADMIN_ROLE
被授权升级合约。在 ValidatorRegistry
合约中,VALIDATOR_ADMIN_ROLE
可以:
ValidatorRegistry
合约中,DEFAULT_ADMIN_ROLE
可以授予或撤销 VALIDATOR_ADMIN_ROLE
。需要注意的是,审核的代码库的分支来自 ICM 合约,这些合约仍在积极开发中。因此,建议 F Foundation 团队关注 ICM 合约代码库的最新发展,并审核任何变更以便与审核的代码库集成。F Foundation 团队应检查 ICM 合约的新版本及其相关讨论。此外,他们还应监控与 ACP-77 和 ACP-99 相关的讨论。
持有 FNode
许可证是成为 FCHAIN 验证者的必要条件。验证者必须 锁定 他们的 ERC-721 兼容的 FNode
代币到 StakeManager
合约中,才能被认定为活动验证者并获得激励,而委托者也可以 锁定 并将其 FNode
代币委托给活动验证者以获得奖励。
在 StakeManager
合约中,验证者和委托者通过调用 internal
_lockLicenses
函数 在验证者注册、委托者注册或添加新质押的初始化阶段锁定其 FNode
许可证。此函数验证 调用者 (_msgSender
) 是 ERC-721 代币的所有者,然后通过 更新 tokenLockedBy[tokenID]
映射来“锁定”代币。值得注意的是,只有 internal
映射被修改,代币本身并未转移到合约中。
这允许 FNode
代币的所有者通过调用 IERC721
接口的 transferFrom
或 safeTransferFrom
函数转让其“锁定”许可证的所有权,这实际上取消了他们作为网络验证者的资格。然而,StakeManager
中的原始质押仍然继续获得奖励,因为许可证仍然被计算在验证者的总体权重中。此外,许可证的新所有者 无法重新质押 代币,因为它已在 tokenLockedBy
映射中记录。
考虑在锁定过程中将 FNode
代币转账到 StakeManager
合约中,并在解锁时将其返回给验证者或委托者。或者,考虑使 FNode
代币在作为活动验证者的权重的一部分时不可转让。
更新:已确认,将解决。团队表示:
NFT将在一年内不可转让,因此我们目前不会更改这一部分。
StakeManager
中锁定initializeDeposit
函数 旨在接受许可证和质押金额,作为系统中验证者的质押。验证者可以调用此函数以增加自己的权重,或委托者可以调用此函数以增加他们所委托验证者的权重。然而,该函数在执行存款之前并未验证验证者的活动状态,导致质押者可能不小心将资金存入不活跃的验证者。该函数随后 锁定 存入的许可证和价值于 StakeManager
合约中,将新增加的权重添加到验证者的现有权重中,并 向 P-Chain 发送相应的设置权重消息。它还 在 pendingDeposits
映射中创建了一项条目,这样在调用 completeDeposit
函数时,这些 质押将被添加到 nextEpochDeposit
映射中,该映射会在质押者的余额更新时被 处理。
鉴于验证者已经不活跃,P-Chain 将拒绝添加权重的消息,并将没有相应的 messageIndex
可用于调用 completeDeposit
函数。因此,pendingDeposits
永远不会被计算为质押者的余额的一部分。此外,由于锁定的质押不属于质押者的余额,质押者将无法由于 createWithdrawalRequest
函数中的还原 而提取这些质押,从而不可避免地将这些质押锁定在 StakeManager
合约中。
考虑在 initializeDeposit
函数中检查验证者是否活跃,然后再接受存款。
更新:在 pull request #46 中已解决。initializeDeposit
函数在接受质押之前正确检查验证者是否活跃。此外,引入了一个 cancelDeposit
函数,允许用户在存款完成之前,如果验证者变得不活跃,可以提取任何已存入的质押。
createEndRequest
并快速重新注册在 ACP-77 规范中,明确指出验证者只能注册一次:
当知道特定的 validationID 未被注册且将不会被注册时,P-Chain 必须愿意为该 validationID 提交一个 L1ValidatorRegistrationMessage,标记为 false,这可能在消息过期后而未完成,或者验证者曾成功注册后被移除。
然而,验证者可以仅在 FCHAIN 上重新注册,这导致 FCHAIN 和 P-chain 上记录的验证者集之间存在差异。这是因为一旦验证者被注销,P-chain 必须拒绝再次注册它。
要做到这一点,验证者必须首先调用 initializeValidatorRegistration
。一旦收到来自 P-chain 的 L1ValidatorRegistrationMessage
,验证者可以调用 completeValidatorRegistration
。在这一过程中,验证者可以快速调用 initializeEndValidation
和 completeEndValidation
,然后再次调用 initializeValidatorRegistration
和 completeValidatorRegistration
,使用与原始注册相同的 messageIndex
。这必须在调用 initializeEndValidation
之前未调用 _updateBalances
的情况下进行,因为这样会影响质押的 fNodesTokenIDs
映射。
此外,所有操作都必须在 registrationExpiry
时间 到达之前完成。完成此过程后,验证者的 isValidationEnded
布尔值将被设置为 true
。这将导致 该验证者的结束被永久阻止,阻止 正常运行时间证明的处理,导致 P-chain 和 FCHAIN 上记录的验证者集之间的差异。需要注意的是,这一漏洞的另一个特性是,验证者能够完全跳过调用 createEndRequest
。这显然不是预期的功能。
考虑在验证者注册流程中检查 isValidationEnded
的状态。同时,考虑在所有情况下强制要求在验证者可以初始化结束验证之前,至少调用一次 _updateBalances
。可以通过标记任何新的验证者为“需要余额更新”,或者检查该验证者的 startEpoch
和 nextEpochDeposit
值的方式来实施这一点。
更新:在 pull request #43 中已解决。
在 completeValidatorChange
和 completeDelegatorRegistration
函数中,Warp 消息被吸收并用于验证逻辑。然而,这些 Warp 消息并没有经过充分的检查。
在 completeValidatorChange
中,任何值都可以指定为 oldNonceTarget
和 newNonceTarget
,但这些值仅被 使用一次,用于与 oldNonceMessage
和 newNonceMessage
的检查。因此,可以指定任何消息索引,这使得该函数在没有预期的 Warp 消息的情况下轻松执行。这可能允许某些委托者快速更换其验证者,而无需 P-chain 的批准。这同样可以被利用来触发调用者不控制的验证者的更改,从而影响其他用户的奖励。
在 completeDelegatorRegistration
中,Warp 消息中唯一使用的值是 validationID
。因此,任何包含相同 validationID
的错误消息都可能被用来使函数成功执行。这可能允许任何委托者在没有 P-chain 批准的情况下向验证者注册。
在这两种情况下,这位委托者所积累的奖励都可以被轻松操控。因此,考虑在 initiateValidatorChange
中记录验证者更改的随机数,并在 completeValidatorChange
中使用这些随机数来确保使用的消息比初始请求更新。这也可以用于防止用户更换其不知晓的委托者的验证者。类似地,考虑在 initializeDelegatorRegistration
中存储获取的 nonce
并记录从 Warp 消息中获得的 nonce
在 completeDelegatorRegistration
中解析。这些值应进行相互比较,以确保 P-Chain Warp 消息比初始化委任请求更新。
更新:在 pull request #45 中已解决。
StakeManager
合约促进 委托者的注册过程,以便对活动验证者进行质押。它还包括 createEndRequest
函数,允许验证者发起从网络中的退出。当委托者在通过 createEndRequest
开始退出过程但尚未更新其余额的验证者上启用质押时,将会产生一个问题,导致其 isValidationActive
布尔值保持为 true
。此标志仅在创建验证结束请求后通过余额更新设为 false
。
在此期间,委托者可以执行 initializeDelegatorRegistration
函数,这 将质押的权重添加到验证者, 向 P-Chain 广播消息,并通过 _lockStakesAndLicensesSetTotalStats
internal
函数 在合约内锁定质押。这些质押预计将在下一个周期调用 completeDelegatorRegistration
时反映在质押者的余额中,该余额经过更新。
然而,如果验证者在委托者完成注册之前继续更新其余额并结束其验证,那么验证者的权重可能降至零。这样一系列操作引入了两个重要问题:
initializeDelegatorRegistration
的权重更新消息在验证者退出后因为中继器延迟采集或 P-Chain 顺序处理问题而被处理,它可能会被拒绝,因为验证者在 P-Chain 上被标记为不活跃。这意味着将没有 messageIndex
完成委托者的注册,从而有效地使质押资金锁定于 StakeManager
合约中,无处可去。initializeDelegatorRegistration
消息及时处理,提供有效的 messageIndex
,但验证者在执行 completeDelegatorRegistration
之前退出,则系统可能仍会完成质押登记。这将导致不一致,因为验证者的权重为零,但系统承认存在一个质押注册。因此,委托者由于无法验证验证者的正常运行时间而无法赚取奖励,也无法解锁其代币,除非 delegationLockDeadline
已过,迫使他们重新分配其委任。此漏洞还涉及到 initializeDeposit
和 completeDeposit
函数,从而在质押机制中呈现出更大的攻击向量。
考虑为正在退出的验证者添加一个 PendingRemoved
状态,并在 completeDelegatorRegistration
和 completeDeposit
函数中实施条件检查,以自行释放质押,如果验证者处于这种过渡状态。这样做有助于保护防止意外的质押锁定,并确保验证者和委托者状态之间的一致性。
更新:在 pull request #47 中已解决。
如果 Warp 消息未被至少 67% 的 FCHAIN 验证者签名,则发送到 P-chain 的 Warp 消息可能会被视为无效 ,从而导致问题。在 StakeManager
中存在很多依赖于 L1ValidatorWeightMessage
的操作。然而,如果这些消息被 P-chain 丢弃,这些流程可能会冻结,从而给用户带来问题。例如,如果 L1ValidatorWeightMessage
从未发送,则 completeWithdrawal
函数将无法使用,令代币锁定在 StakeManager
合约中。
考虑创建一个类似于 resendRegisterValidatorMessage
和 resendEndValidatorMessage
函数的函数,用于 L1ValidatorWeightMessage
。这将允许在重要的验证者权重消息被 P-chain 丢弃的情况下采取行动。
更新:在 pull request #44 中已解决。
在 StakeManager.sol
中的 createWithdrawalRequest
函数文档字符串中,stakeID
、amount
、fNodesTokenIDs
参数没有被文档化。
在 ValidatorRegistry.sol
中的 addValidatorAdmin
函数文档字符串中,account
参数没有被文档化。
在 ValidatorRegistry.sol
中的 removeValidatorAdmin
函数文档字符串中,account
参数没有被文档化。
请务必全面文档化合约公共 API 中所有函数/事件(及其参数或返回值)。撰写文档字符串时,请考虑遵循 Ethereum Natural Specification Format(NatSpec)。
更新: 已确认,将解决。
initializeDeposit
中验证不足initializeDeposit
函数 允许存入原生代币和许可证的锁定。然而,在 fNodesTokenIDs
为空 且 msg.value == 0
的情况下,该函数将成功执行,而不会对验证者或委托者的股份做出任何更改,从而导致不必要的Gas消耗。
遵循“尽早和大声失败”的原则,请考虑检查 fNodesTokenIDs
和 msg.value
的长度,如果这两个值均为 0,则回退并给出描述性错误。
更新: 在 pull request #48 中已解决。
StakeManager
和 ValidatorRegistry
合约在内联文档中存在显著不足,尤其是许多函数及其输入变量缺少文档字符串。此缺陷不仅妨碍代码库的可读性和可维护性,还对确保其安全性和功能性带来了重大的挑战。
请考虑在整个代码库中添加内联文档,以确保所有函数,特别是那些是公共或外部的,都附有详细的文档字符串。这些应包括对函数目的、参数、返回值、发出的事件以及其行为的任何副作用或重要说明的描述。撰写文档字符串时,请考虑遵循 Ethereum Natural Specification Format(NatSpec)。
更新: 已确认,将解决。
initialize...
函数名称更新为 initiate...
在 StakeManager
合约 中,有 5 个函数遵循 initialize...
的命名约定,例如:
initializeValidatorRegistration
initializeDelegatorRegistration
initializeDeposit
initializeEndValidation
initializeWithdraw
这些函数都有相应的 complete...
函数。然而,还有一个名称类似而不遵循此约定的函数:initiateValidatorChange
,它有一个类似的 complete...
函数。
请考虑将这 5 个已识别的函数名称更改为 initiate...
。这将有助于保持代码库的一致性并便于理解。此外,这样做将有助于将这些函数与 initialize
函数 及与初始化相关的任何关联函数区分开来。最后,这将帮助与 最新版本的 icm-contracts
ValidatorManager.sol
相匹配,该版本已经做出了相同的更改。
更新: 已确认,将解决。
在开发过程中,有描述良好的 TODO 注释将使跟踪和解决它们的过程更加容易。然而,如果未解决,这些注释可能会变老,开发完成时可能会遗忘与系统安全相关的重要信息。因此,TODO 注释应在项目的问题待办列表中跟踪,并在系统部署之前解决。
在 StakeManager.sol
的第 543 行中识别到一个 TODO 注释。
请考虑解决所有 TODO 注释的实例并在问题待办列表中跟踪它们。
更新: 已确认,将解决。
StakeManager
和 ValidatorRegistry
合约由于函数顺序不一致,违反了 Solidity 风格指南。例如,在 StakeManager
合约中,submitUptimeProof
外部函数 被放置在 _processUptimeProof
内部函数 之后。
为了提高项目的整体可读性,请考虑按照 Solidity Style Guide(函数顺序) 推荐的标准化顺序。
更新: 已确认,将解决。
自 Solidity 0.8.18 起,开发者可以在映射中使用命名参数。这意味着映射可以采用形式 mapping(KeyType KeyName? => ValueType ValueName?)
。这种更新的语法能更清晰地表示映射目的。
在 StakeManager.sol
中,tokenExists
映射没有命名参数。
请考虑在映射中添加命名参数,以提高代码库的可读性和可维护性。
更新: 已确认,将解决。
命名返回变量是一种声明在函数体内使用并作为该函数输出返回的变量的方式。它们是明确的行内 return
语句的替代品。
在 StakeManager.sol
中发现多个未使用的命名返回变量的实例:
_processPendingValidatorChange
函数 的 [validationID
返回变量_getEpochRewardForDelegator
函数 的 [result
返回变量请考虑使用或移除任何未使用的命名返回变量。
更新: 已确认,将解决。
在智能合约中提供特定的安全联系人(如电子邮件或 ENS 名称)可以大大简化他人识别代码漏洞时的沟通流程。这种做法非常有益,因为它允许代码所有者指定漏洞披露的沟通渠道,消除由于缺乏知识而导致的错误或未报告的风险。此外,如果合约包含第三方库,而在这些库中出现错误,维护者也可以更方便地联系适当的人员并提供缓解指令。
StakeManager
和 ValidatorRegistry
合约中均未设置安全联系人。此外,ValidatorManager
合约当前 指定 的安全联系人为 ava-labs/icm-contracts
,而非 0xFFoundation/fchain-contracts
的安全联系人。
请考虑在每个合约定义上方添加包含正确安全联系人的 NatSpec 注释。建议使用 @custom:security-contact
的约定,因为这一约定已经被 OpenZeppelin Wizard 和 ethereum-lists 采用。
更新: 已确认,将解决。
在 StakeManager
合约中发现多个打字错误的实例:
请考虑纠正任何打字错误,以提高代码库的清晰度和可读性。
更新: 已确认,将解决。
_disableInitializers
未被初始合约构造函数调用在代理模式中,实施合约允许任何人调用其 initialize
函数。虽然这不是直接的安全问题,但防止实施合约初始化非常重要,因为这可能允许攻击者接管合约。这不会影响代理合约的功能,因为只有实施合约的存储会受到影响。
在代码库中发现多个初始合约,其中在构造函数中没有调用 _disableInitializers()
:
StakeManager
,在 StakeManager.sol
文件中ValidatorRegistry
,在 ValidatorRegistry.sol
文件中请考虑在可初始合约的构造函数内部调用 _disableInitializers()
以防止恶意行为者抢先初始化。
更新: 已确认,将解决。
在代码库中,有多个函数的可见性不必要地宽松:
StakeManager.sol
中的 _validateEpoch
函数,internal
可见性可以限制为 private
。
StakeManager.sol
中的 _processUptimeProof
函数,internal
可见性可以限制为 private
。
StakeManager.sol
中的 _hasGracePeriodPassed
函数,internal
可见性可以限制为 private
。
StakeManager.sol
中的 _getEpochEndTime
函数,internal
可见性可以限制为 private
。
StakeManager.sol
中的 _getPChainWarpMessage
函数,internal
可见性可以限制为 private
。
StakeManager.sol
中的 _getWeight
函数,internal
可见性可以限制为 private
。
StakeManager.sol
中的 _getWeightSetLosses
函数,internal
可见性可以限制为 private
。
StakeManager.sol
中的 _lockStakesAndLicensesSetTotalStats
函数,internal
可见性可以限制为 private
。
StakeManager.sol
中的 _lock
函数,internal
可见性可以限制为 private
。
StakeManager.sol
中的 _lockLicenses
函数,internal
可见性可以限制为 private
。
StakeManager.sol
中的 _validateStakeAmount
函数,internal
可见性可以限制为 private
。
StakeManager.sol
中的 _unlock
函数,internal
可见性可以限制为 private
。
StakeManager.sol
中的 _unlockLicenses
函数,internal
可见性可以限制为 private
。
StakeManager.sol
中的 _processPendingValidatorChange
函数,internal
可见性可以限制为 private
。
StakeManager.sol
中的 _processPendingBalanceChanges
函数,internal
可见性可以限制为 private
。
StakeManager.sol
中的 _getEpochReward
函数,internal
可见性可以限制为 private
。
StakeManager.sol
中的 _getEpochRewardForValidator
函数,internal
可见性可以限制为 private
。
StakeManager.sol
中的 _getEpochRewardForDelegator
函数,internal
可见性可以限制为 private
。
StakeManager.sol
中的 _updateBalances
函数,internal
可见性可以限制为 private
。
StakeManager.sol
中的 _revertIfDeadlineNotPassed
函数,internal
可见性可以限制为 private
。
ValidatorRegistry.sol
中的 completeValidatorRegistration
函数,public
可见性可以限制为 external
。
为了更好地传达函数的预期用途,并可能实现一些额外的Gas节省,请考虑将函数的可见性更改为仅限于所需的权限。
更新: 已确认,将解决。
经审核的代码库管理着 F Foundation 的 FCHAIN 验证者集,这是一个在 Avalanche 生态系统下运行的 ACP-77 规范的子网。审核的代码与 Ava Labs 的 ICM 合约 ValidatorManager
合约交互,该合约未包含在此次审计范围内。
发现了多个关键和高严重性的问题,主要与用于注册验证者和委托者的关键参数的验证有关。虽然在审核代码库的文档文件夹中提供了一些文档,但在合约中添加更多内联文档可以进一步增强清晰度。由于 ICM 合约仍在 Ava Labs 积极开发中,我们鼓励 F Foundation 团队保持对可能影响该代码库的任何更新的关注,并根据需要整合相关更改。
在审计过程中,客户反应非常迅速,及时解决疑虑,并帮助优先处理潜在问题。
- 原文链接: blog.openzeppelin.com/fc...
- 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!