Bid Beasts

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

Arbitrary withdrawal of another address' failed-transfer credits

Short summary

A mismatch between the mapping key read and the mapping key cleared in withdrawAllFailedCredits allows any attacker to withdraw another address’ failedTransferCredits repeatedly, draining contract funds.

Description

  • Normal behaviour:

    When a low-level transfer to a recipient fails, the contract should record that recipient’s credit in failedTransferCredits[recipient]. Only the credited recipient (or a clearly authorized party) should be able to withdraw those funds, and the withdrawn credit must be removed (atomically) from the mapping.

  • Observed behaviour (issue):

    withdrawAllFailedCredits(address receiver) reads amount = failedTransferCredits[receiver] but then clears failedTransferCredits[msg.sender] (or otherwise does not atomically clear the same mapping entry), and pays msg.sender. Because the mapping entry for receiver is left unchanged, an attacker may repeatedly call withdrawAllFailedCredits(receiver) and receive the same amount each time — until the contract’s ETH is exhausted. This results in direct theft from the contract.

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

Root cause

Key mismatch between read and clear/write operations:

  • read uses receiver as the key,

  • clear/write uses msg.sender (or some other key),

  • pay goes to msg.sender.

This is a logic bug (indexing bug) that leaves the victim’s credit record intact while paying the attacker.

Risk

Likelihood:

  • The attack requires only the existence of a nonzero failedTransferCredits[Victim] entry and the ability to call withdrawAllFailedCredits(receiver).

  • No privileges are required — any EOA can initiate the withdraw call — so the attack is trivial to trigger.

  • An attacker can bootstrap the exploit themselves — e.g., place bids from a contract that deliberately rejects Ether (so refunds fail and are credited), then repeatedly call withdrawAllFailedCredits with that contract as the receiver to siphon funds, without waiting for other users.

Impact:

  • An attacker can repeatedly withdraw the same credited amount while the mapping entry remains, drawing repeated transfers from the contract.

  • The contract balance is the limiting factor; attacker can extract floor(contract_balance / credit_amount) * credit_amount. If credits and contract balance are large, this is direct theft of funds (complete or partial draining).

  • Financial loss to users and protocol (seller refunds stolen, funds missing), reputational damage, potential legal exposure.

Proof of Concept

Place the following into BidBeastsMarketPlaceTest.t.sol.

function test_withdrawOtherFunds_PoC() public {
// 1) setup: mint & list NFT
_mintNFT();
_listNFT();
// 2) prepare a contract that cannot receive ETH
// RejectEther has no payable receive/fallback
vm.deal(address(rejector), STARTING_BALANCE);
// 3) make first bid from rejector (non-payable recipient)
uint256 bidToSteal = MIN_PRICE + 1;
vm.prank(address(rejector));
market.placeBid{value: bidToSteal}(TOKEN_ID);
// 4) make a higher bid from another account to trigger refund to rejector
vm.prank(BIDDER_1);
market.placeBid{value: 3 ether}(TOKEN_ID);
// now failedTransferCredits[rejector] == bidToSteal
assertEq(market.failedTransferCredits(address(rejector)), bidToSteal);
// 5) attacker withdraws other user's credits (bug)
uint256 attackerBefore = BIDDER_2.balance;
vm.prank(BIDDER_2);
market.withdrawAllFailedCredits(address(rejector));
// attacker received the credit
assertEq(BIDDER_2.balance, attackerBefore + bidToSteal);
// victim's mapping was NOT cleared -> attacker can repeat
assertEq(market.failedTransferCredits(address(rejector)), bidToSteal);
// repeat attack (still succeeds in buggy version while contract has funds)
vm.prank(BIDDER_2);
market.withdrawAllFailedCredits(address(rejector));
assertEq(BIDDER_2.balance, attackerBefore + bidToSteal * 2);
}

This PoC demonstrates both theft and repeatability.

Recommended Mitigation

Allow only the credited address to withdraw its own credits. This is the best-practice unless you have a strong reason to let third parties withdraw on behalf of someone.

- function withdrawAllFailedCredits(address _receiver) external {
+ function withdrawAllFailedCredits(address _receiver) external nonReentrant {
+ require(_receiver == msg.sender, "Only receiver can withdraw");
uint256 amount = failedTransferCredits[_receiver];
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");
}

Why this works: read/clear/pay all use the same key (receiver), and only the intended owner can trigger the withdraw.

Additional protections (apply in all fixes)

  • Add nonReentrant (OpenZeppelin ReentrancyGuard) to withdrawAllFailedCredits and other money-moving functions: placeBid, takeHighestBid, settleAuction, withdrawFee.

Updates

Lead Judging Commences

cryptoghost Lead Judge 2 months 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.

Give us feedback!