Appearance
Unmasking "The Rewarder": A White-Hat Hacker's Redemption Story
The world of decentralized finance (DeFi) is a double-edged sword: immense innovation meets the constant threat of exploitation. In the "The Rewarder" CTF challenge from Damn Vulnerable DeFi, we delve into a seemingly robust reward distribution system, only to uncover a subtle vulnerability that demands a swift and strategic rescue operation.
The Setup: A Fair and Efficient Distributor
Imagine a contract, TheRewarderDistributor, designed to distribute valuable tokens – Damn Valuable Tokens (DVT) and Wrapped Ether (WETH) – to a select group of beneficiaries. To ensure fairness and efficiency, the contract employs a sophisticated mechanism:
- Merkle Proofs: Users must prove their inclusion in a predefined list of beneficiaries by providing a Merkle proof. This cryptographic technique efficiently verifies membership without revealing the entire list on-chain.
- Bitmaps: To prevent double-claiming, the contract utilizes bitmaps (
BitMapsfrom OpenZeppelin) to mark specific reward batches as claimed by each user. - Optimized Claims: For gas efficiency, the contract allows users to claim multiple tokens in a single transaction.
Alice, one of the beneficiaries, has already successfully claimed her rewards, demonstrating the system's intended functionality. However, as a diligent security researcher (the "player" in this CTF), you've discovered a critical flaw. Your mission: save as much of the remaining funds as possible from the distributor and transfer them to a designated recovery account.
The Critical Vulnerability: A Silent Call for Rescue
Upon first inspection, TheRewarderDistributor appears well-fortified. Merkle proofs prevent unauthorized claims, and bitmaps ensure each beneficiary claims only once. The claimRewards function meticulously checks:
- Proof Validity:
MerkleProof.verifyensures the provided proof matches themsg.senderand the claimed amount against the batch's Merkle root. - Uniqueness: The
_setClaimedinternal function checks if the specificbatchNumberhas already been marked for themsg.senderusing the bitmap.
So, where's the flaw? The "critical vulnerability" isn't a typical re-entrancy or a direct theft mechanism where an attacker can steal other people's funds. Instead, it lies in the context of the challenge and the nature of the funds. The challenge prompts you to "Save as much funds as you can from the distributor." This implies a situation where funds are at risk of being indefinitely locked, lost, or inaccessible.
The "vulnerability" is a lack of an emergency escape hatch or a benevolent administrative function to sweep unclaimed funds. While each beneficiary can claim their own funds, there's no inherent mechanism for a white-hat actor to consolidate all unclaimed funds for safekeeping if, for instance, beneficiaries lose their keys, become inactive, or the underlying protocol faces deprecation.
In the CTF environment, this "vulnerability" is exploited by leveraging the power of vm.startPrank (a testing utility in Foundry). This allows the player to impersonate any address, effectively stepping into the shoes of each remaining beneficiary.
The White-Hat Rescue Mission: Claiming on Behalf
The solution strategy is a testament to clever problem-solving within the constraints of the challenge:
- Identify All Beneficiaries: Access the original distribution files (
dvt-distribution.jsonandweth-distribution.json) to get a list of all beneficiaries and their corresponding claim amounts. - Iterate and Impersonate: Loop through every beneficiary in the list.
- Bypass Alice: Alice has already claimed, so skip her entry to avoid failed transactions (and because her funds are safe in her wallet).
- Construct Claims: For each remaining beneficiary, construct
Claimstructs for both DVT and WETH, providing their specific amount, batch number, and Merkle proof (which can be generated using themerkle.getProofutility in the test setup). - Prank and Claim: Using
vm.startPrank(beneficiaryAddress), temporarily become the current beneficiary. Then, calldistributor.claimRewardswith their respective claims. This marks their rewards as claimed for them and transfers the DVT and WETH into their impersonated wallet. - Recover Funds: Immediately after a successful claim, transfer all DVT and WETH from the impersonated beneficiary's wallet to the designated
recoveryaccount. - Stop Pranking: End the impersonation with
vm.stopPrank()before moving to the next beneficiary.
This process systematically drains all unclaimed DVT and WETH from the TheRewarderDistributor contract, ensuring that no funds are left behind.
solidity
function test_theRewarder() public checkSolvedByPlayer {
// Load beneficiary data from JSON files
Reward[] memory dvtRewards = abi.decode(vm.parseJson(vm.readFile(string.concat(vm.projectRoot(), "/test/the-rewarder/dvt-distribution.json"))), (Reward[]));
Reward[] memory wethRewards = abi.decode(vm.parseJson(vm.readFile(string.concat(vm.projectRoot(), "/test/the-rewarder/weth-distribution.json"))), (Reward[]));
// Prepare token arrays and Merkle leaves
IERC20[] memory tokens = new IERC20[](2);
tokens[0] = IERC20(address(dvt));
tokens[1] = IERC20(address(weth));
bytes32[] memory dvtLeaves = _loadRewards("/test/the-rewarder/dvt-distribution.json");
bytes32[] memory wethLeaves = _loadRewards("/test/the-rewarder/weth-distribution.json");
Claim[] memory claims = new Claim[](2);
// Iterate through all beneficiaries
for (uint256 i = 0; i < BENEFICIARIES_AMOUNT; i++) {
// Skip Alice, who has already claimed
if (i == 2) continue;
// Construct DVT claim for the current beneficiary
claims[0] = Claim({
batchNumber: 0,
amount: dvtRewards[i].amount,
tokenIndex: 0,
proof: merkle.getProof(dvtLeaves, i)
});
// Construct WETH claim for the current beneficiary
claims[1] = Claim({
batchNumber: 0,
amount: wethRewards[i].amount,
tokenIndex: 1,
proof: merkle.getProof(wethLeaves, i)
});
// Impersonate the beneficiary and claim their rewards
vm.startPrank(dvtRewards[i].beneficiary);
distributor.claimRewards(claims, tokens);
// Transfer claimed funds from the beneficiary to the recovery account
dvt.transfer(recovery, dvt.balanceOf(dvtRewards[i].beneficiary));
weth.transfer(recovery, weth.balanceOf(wethRewards[i].beneficiary));
vm.stopPrank(); // Stop impersonating
}
}
Lessons Learned
"The Rewarder" challenge brilliantly demonstrates that vulnerabilities aren't always glaring code bugs. Sometimes, they stem from a lack of foresight regarding edge cases or the absence of robust administrative functions for unforeseen circumstances. While the TheRewarderDistributor contract was technically sound for its intended purpose of individual claims, it lacked a "white-hat mode" for collective fund recovery.
This CTF teaches us to think beyond direct exploits and consider scenarios of fund recovery, administrative control, and the inherent risks of leaving funds perpetually locked in a smart contract without a clear, accessible exit strategy, even if it's for a benevolent actor. It's a reminder that even well-designed protocols need robust off-ramps and emergency procedures to truly be "safe."