Platform: Code4rena
Start Date: 09/02/2024
Pot Size: $60,500 USDC
Total HM: 17
Participants: 283
Period: 12 days
Judge:
Id: 328
League: ETH
Rank: 83/283
Findings: 5
Award: $67.08
π Selected for report: 0
π Solo Findings: 0
π Selected for report: Aamir
Also found by: 0rpse, 0x11singh99, 0x13, 0xAlix2, 0xAsen, 0xBinChook, 0xCiphky, 0xE1, 0xKowalski, 0xLogos, 0xWallSecurity, 0xaghas, 0xbranded, 0xlemon, 0xlyov, 0xpoor4ever, 0xprinc, 0xvj, ADM, Aymen0909, BARW, Bauchibred, Breeje, CodeWasp, DMoore, DeFiHackLabs, Draiakoo, Fulum, GhK3Ndf, Greed, Jorgect, Josh4324, Kalogerone, KmanOfficial, Krace, Limbooo, McToady, MidgarAudits, MrPotatoMagic, PedroZurdo, Pelz, Ryonen, SovaSlava, SpicyMeatball, Tendency, Timenov, ZanyBonzy, _eperezok, al88nsk, alexxander, alexzoid, aslanbek, blutorque, btk, cartlex_, cats, csanuragjain, deadrxsezzz, denzi_, devblixt, dimulski, djxploit, erosjohn, evmboi32, fnanni, grearlake, haxatron, hulkvision, immeas, israeladelaja, jaydhales, jesjupyter, jnforja, josephdara, juancito, kiqo, klau5, korok, krikolkk, ktg, kutugu, ladboy233, lil_eth, m4ttm, matejdb, merlinboii, n0kto, ni8mare, novamanbg, nuthan2x, oualidpro, pa6kuda, peter, petro_1912, pkqs90, pynschon, sandy, sashik_eth, shaflow2, shaka, sobieski, soliditywala, solmaxis69, stackachu, tallo, thank_you, tpiliposian, ubl4nk, visualbits, vnavascues, web3pwn, xchen1130, zhaojohnson
0.0037 USDC - $0.00
https://github.com/code-423n4/2024-02-ai-arena/blob/main/src/GameItems.sol#L291-L303 https://github.com/code-423n4/2024-02-ai-arena/blob/main/src/GameItems.sol#L5
In the GameItems
contract, the owner can modify the transferability state of a given item NFT using the adjustTransferability
function. When allGameItemAttributes[tokenId].transferable
is set to false, all the tokens associated with that tokenId
cannot be transferred by users. This restriction is ensured by the check inside the safeTransferFrom
function:
function safeTransferFrom( address from, address to, uint256 tokenId, uint256 amount, bytes memory data ) public override(ERC1155) { require(allGameItemAttributes[tokenId].transferable); super.safeTransferFrom(from, to, tokenId, amount, data); }
However, the OpenZeppelin ERC1155 token implementation (which the contract inherits from) contains another transfer function, safeBatchTransferFrom
, which was not overridden in GameItems
. Therefore, token holders can use it directly to transfer their tokens regardless of the transferability state set by the owner.
This loophole can pose significant problems for the protocol and the underlying game, as the owner will have no control over the transferability of game items.
Users can bypass the game item transferability check using the safeBatchTransferFrom
function, allowing them to transfer game items regardless of the owner's decision. This could potentially enable users to manipulate the protocol or exploit vulnerabilities in the game logic.
Manual review, VS Code
To address this issue, the safeBatchTransferFrom
function must also be overridden and include the transferability check, similar to the safeTransferFrom
function.
Token-Transfer
#0 - c4-pre-sort
2024-02-22T04:19:53Z
raymondfam marked the issue as sufficient quality report
#1 - c4-pre-sort
2024-02-22T04:20:00Z
raymondfam marked the issue as duplicate of #18
#2 - c4-pre-sort
2024-02-26T00:29:11Z
raymondfam marked the issue as duplicate of #575
#3 - c4-judge
2024-03-05T04:56:42Z
HickupHH3 marked the issue as satisfactory
π Selected for report: klau5
Also found by: 0xAleko, 0xAlix2, 0xAsen, 0xCiphky, 0xKowalski, 0xlemon, 0xvj, 14si2o_Flint, Aamir, AlexCzm, Aymen0909, BARW, Blank_Space, DanielArmstrong, Davide, Draiakoo, Giorgio, McToady, MrPotatoMagic, PoeAudits, Ryonen, Silvermist, SpicyMeatball, Tychai0s, VAD37, Varun_05, alexxander, alexzoid, aslanbek, blutorque, btk, cats, d3e4, denzi_, evmboi32, fnanni, givn, grearlake, haxatron, jesjupyter, juancito, ke1caM, ktg, lanrebayode77, linmiaomiao, matejdb, merlinboii, n0kto, novamanbg, nuthan2x, petro_1912, pynschon, radin100, sashik_eth, shaka, sl1, soliditywala, solmaxis69, t0x1c, ubl4nk, vnavascues, xchen1130, yotov721, zhaojohnson
1.1225 USDC - $1.12
https://github.com/code-423n4/2024-02-ai-arena/blob/main/src/FighterFarm.sol#L370-L391
The reRoll
function allows a fighter owner to randomly change (reroll) the traits of their fighter NFT. Within the FighterFarm
contract, the numRerolls
mapping tracks and limits the number of times a user can reroll their fighter, with the maximum rerolls allowed for each fighter type denoted by maxRerollsAllowed
.
The reRoll
function includes the following check to ensure the maximum rerolls are not exceeded:
require(numRerolls[tokenId] < maxRerollsAllowed[fighterType]);
However, when calling the reRoll
function, the token owner must provide two arguments: the NFT identifier tokenId
and the NFT fighter type fighterType
.
The issue arises because the function does not verify that the provided fighterType
matches the one associated with the tokenId
NFT. Since the reroll maximum limits for each fighter type are handled separately (number of generation is different for both fighter types as they arn't increase at the same time see the incrementGeneration
function), users can bypass the limit for their current tokenId
fighter type by providing a different fighter type as input to the function.
This loophole enables fighter NFT owners to potentially reroll their fighters more times than intended by the protocol, which contradicts the protocol's intent.
Let's illustrate this issue with a simple scenario:
Bob owns a fighter NFT with tokenId = 9
and fighterType = 1
.
Before Bob calls reRoll
function, we had the following states in the contract: numRerolls[tokenId = 9] = 3
& maxRerollsAllowed = [7, 3]
In the normal condition, Bob shouldn't be allowed to reroll his fighter as he has reached the maximum allowed rerolls, i.e., numRerolls[tokenId = 9] == maxRerollsAllowed[fighterType = 1]
.
However, Bob still calls the reRoll
function and provides the wrong fighter type fighterType = 0
. He bypasses the maximum reroll check as numRerolls[tokenId = 9] = 3 < maxRerollsAllowed[0] = 7
.
Since the reRoll
function doesn't validate that the provided fighterType
matches the fighter NFT's type, Bob's call succeeds, allowing him to reroll his NFT. This results in the following state after the call: numRerolls[tokenId = 9] = 4 > maxRerollsAllowed[fighterType = 1] = 3
.
Bob has successfully rerolled his fighter NFT, bypassing the maximum reroll check due to the mentioned issue.
Users may bypass the maximum reroll allowed check in the reRoll
function, potentially rerolling their fighter NFTs more times than intended by the protocol.
Manual review, VS Code
To address this issue, two options are available:
Remove the fighterType
argument from the reRoll
function and directly use the type stored in storage.
Add a check to ensure that the provided fighterType
in the reRoll
function matches the one associated with the provided fighter tokenId
.
Context
#0 - c4-pre-sort
2024-02-22T02:15:13Z
raymondfam marked the issue as sufficient quality report
#1 - c4-pre-sort
2024-02-22T02:15:19Z
raymondfam marked the issue as duplicate of #306
#2 - c4-judge
2024-03-05T04:35:27Z
HickupHH3 marked the issue as satisfactory
#3 - c4-judge
2024-03-19T09:05:07Z
HickupHH3 changed the severity to 3 (High Risk)
π Selected for report: haxatron
Also found by: 0xAlix2, 0xCiphky, 0xStriker, 0xaghas, 0xbranded, 0xlamide, 0xmystery, 0xvj, 14si2o_Flint, Aamir, AgileJune, Aymen0909, DanielArmstrong, DarkTower, Draiakoo, EagleSecurity, Giorgio, Krace, KupiaSec, MidgarAudits, MrPotatoMagic, PoeAudits, Ryonen, SpicyMeatball, Topmark, Tychai0s, VAD37, Varun_05, VrONTg, WoolCentaur, _eperezok, alexxander, alexzoid, aslanbek, blutorque, btk, cartlex_, d3e4, devblixt, evmboi32, fnanni, immeas, jesjupyter, juancito, ke1caM, klau5, ktg, lil_eth, merlinboii, nuthan2x, peter, petro_1912, pkqs90, pynschon, radin100, sandy, sashik_eth, shaka, sl1, soliditywala, t0x1c, ubl4nk, visualbits, vnavascues
0.5612 USDC - $0.56
https://github.com/code-423n4/2024-02-ai-arena/blob/main/src/FighterFarm.sol#L129-L134 https://github.com/code-423n4/2024-02-ai-arena/blob/main/src/FighterFarm.sol#L470 https://github.com/code-423n4/2024-02-ai-arena/blob/main/src/FighterFarm.sol#L110
In the FighterFarm
contract, the numElements
mapping represents the number of elements in each generation and is initialized in the constructor for the first generation as numElements[0] = 3
.
However, when the generation is increased in the incrementGeneration
function, the numElements
mapping is not updated:
function incrementGeneration(uint8 fighterType) external returns (uint8) { require(msg.sender == _ownerAddress); generation[fighterType] += 1; maxRerollsAllowed[fighterType] += 1; //@audit numElements not updated return generation[fighterType]; }
This causes the numElements
mapping to always remain equal to 0 from generation 1 onward (numElements[generation[fighterType] > 0] == 0
). Consequently, the _createFighterBase
function always returns a zero value for the element
field, which is essential for the fighter NFT. This issue is apparent in the following code:
function _createFighterBase( uint256 dna, uint8 fighterType ) private view returns (uint256, uint256, uint256) { //@audit numElements[generation[fighterType]] will be 0 when generation[fighterType] > 0 uint256 element = dna % numElements[generation[fighterType]]; uint256 weight = (dna % 31) + 65; uint256 newDna = fighterType == 0 ? dna : uint256(fighterType); return (element, weight, newDna); }
Since numElements[generation[fighterType] > 0] == 0
we will have element = dna % 0 = 0
, resulting in the element
field always being 0 for generations 1, 2, and so forth. This incorrect feature representation for the fighter NFTs can potentially disrupt the protocol and the game as all the NFT minting function _createNewFighter
always call under the hood _createFighterBase
.
The _createFighterBase
function will return incorrect values for the fighter NFT element
field, leading to inaccurate NFT representation and potentially causing issues for the protocol and the game.
Manual review, VS Code
To resolve this issue, ensure that the numElements
mapping is updated when increasing the fighter generation in the incrementGeneration
function.
Context
#0 - c4-pre-sort
2024-02-22T18:53:58Z
raymondfam marked the issue as sufficient quality report
#1 - c4-pre-sort
2024-02-22T18:54:10Z
raymondfam marked the issue as duplicate of #45
#2 - c4-judge
2024-03-07T06:53:30Z
HickupHH3 changed the severity to 3 (High Risk)
#3 - c4-judge
2024-03-08T03:16:13Z
HickupHH3 marked the issue as partial-50
π Selected for report: DarkTower
Also found by: 0brxce, 0xBinChook, 0xCiphky, 0xDetermination, 0xLogos, Aymen0909, BARW, BoRonGod, Kow, Krace, MrPotatoMagic, PedroZurdo, Tricko, Zac, ZanyBonzy, alexxander, bhilare_, djxploit, evmboi32, grearlake, haxatron, immeas, jnforja, ke1caM, klau5, rouhsamad, sashik_eth, sl1, solmaxis69, ubl4nk, web3pwn, zxriptor
64.3894 USDC - $64.39
https://github.com/code-423n4/2024-02-ai-arena/blob/main/src/MergingPool.sol#L139-L167 https://github.com/code-423n4/2024-02-ai-arena/blob/main/src/FighterFarm.sol#L313-L331 https://github.com/code-423n4/2024-02-ai-arena/blob/main/src/FighterFarm.sol#L529
In the MergingPool
contract, the claimRewards
function allows users who have been selected as fighter NFT winners in pickWinner
to claim their won NFTs, which are minted through the _fighterFarmInstance.mintFromMergingPool
call within claimRewards
.
The claimRewards
function iterates over each round and mints NFTs to users who are included in the round's winners array:
function claimRewards( string[] calldata modelURIs, string[] calldata modelTypes, uint256[2][] calldata customAttributes ) external { uint256 winnersLength; uint32 claimIndex = 0; uint32 lowerBound = numRoundsClaimed[msg.sender]; for (uint32 currentRound = lowerBound; currentRound < roundId; currentRound++) { numRoundsClaimed[msg.sender] += 1; winnersLength = winnerAddresses[currentRound].length; //@audit reentrancy possible for (uint32 j = 0; j < winnersLength; j++) { if (msg.sender == winnerAddresses[currentRound][j]) { _fighterFarmInstance.mintFromMergingPool( msg.sender, modelURIs[claimIndex], modelTypes[claimIndex], customAttributes[claimIndex] ); claimIndex += 1; } } } if (claimIndex > 0) { emit Claimed(msg.sender, claimIndex); } }
The issue arises because the _fighterFarmInstance.mintFromMergingPool
function calls _safeMint
internally to mint the NFT to the user (invoked in the _createNewFighter
internal function), which triggers the onERC721Received
callback. This callback enables the user to reenter the claimRewards
function, potentially claiming more NFTs than intended :
function _createNewFighter( address to, uint256 dna, string memory modelHash, string memory modelType, uint8 fighterType, uint8 iconsType, uint256[2] memory customAttributes ) private { ... //@audit will trigger `onERC721Received` callback _safeMint(to, newId); FighterOps.fighterCreatedEmitter( newId, weight, element, generation[fighterType] ); }
A reentrancy attack can occur if a user has has two or more unclaimed NFTs, in that case the following steps outline how such an attack can be executed:
claimRewards
function to claim their won NFTs.claimRewards
mints the first NFT won by the user, triggering the onERC721Received
callback.claimRewards
again, he won't be able to claim the first token again as numRoundsClaimed
is incremented beyond that round but he can mint the second (and third,fourth,...) NFT that he has won in the rounds that followed.claimRewards
call resumes. As the numRoundsClaimed
is not checked within the loop, the function continues to increment it beyond the current roundId
.numRoundsClaimed
tracker has been incremented beyond the current roundId so he will no be able to claim again for some rounds but this is not relevant as he was able to claim an NFT for free.This vulnerability can potentially be exploited by winners to obtain more NFTs than intended, resulting in the minting of NFTs that were not supposed to be created.
Consider the following scenario:
roundId = 15
, and Bob has been selected as an NFT winner in both round 2 and round 11.numRoundsClaimed[Bob] = 0
.claimRewards
function, which will be looping through rounds [0, 14] to mint the NFTs.currentRound = 2
, _fighterFarmInstance.mintFromMergingPool
is called, minting an NFT to Bob. At this point, numRoundsClaimed[Bob] = 3
._safeMint
(which triggers the onERC721Received
callback) to reenter claimRewards
, and this time, the function loop iterates over rounds [3, 14].currentRound = 11
, the second NFT won by Bob is minted. Then the loop completes, and the call ends with numRoundsClaimed[Bob] = 15
.claimRewards
call resumes, continuing from currentRound = 3
(as currentRound
is indepandent from numRoundsClaimed
after the loop has started). At currentRound = 11
, the second NFT is again minted to Bob, and the function call ends with numRoundsClaimed[Bob] = 26
.numRoundsClaimed
tracker is set to a future round meaning that Bob want be able to claim in those future rounds but this is irrelevant as Bob was able to take a free NFT.This attack can be done whenever a user has won multiple times and didn't claim his NFTs, and the more he has won the more he can take free NFTs with this attack.
Winners in the Merging Pool can exploit reentrancy to fraudulently claim additional NFTs when using the claimRewards
function.
Manual review, VS Code
Implement a nonReentrant
modifier to prevent reentrancy attacks in the claimRewards
function.
Reentrancy
#0 - c4-pre-sort
2024-02-22T09:07:31Z
raymondfam marked the issue as sufficient quality report
#1 - c4-pre-sort
2024-02-22T09:07:39Z
raymondfam marked the issue as duplicate of #37
#2 - c4-judge
2024-03-07T02:44:22Z
HickupHH3 marked the issue as satisfactory
π Selected for report: klau5
Also found by: 0xAlix2, 0xCiphky, 0xDetermination, 0xG0P1, 0xMosh, 0xabhay, 14si2o_Flint, AlexCzm, Aymen0909, CodeWasp, DanielArmstrong, FloatingPragma, Giorgio, JCN, Jorgect, Kalogerone, KmanOfficial, Kow, KupiaSec, McToady, SpicyMeatball, VAD37, WoolCentaur, ZanyBonzy, alexxander, alexzoid, almurhasan, blutorque, csanuragjain, denzi_, dipp, djxploit, evmboi32, handsomegiraffe, haxatron, immeas, jesjupyter, ke1caM, klau5, lanrebayode77, lil_eth, merlin, merlinboii, nuthan2x, peanuts, shaflow2, shaka, sl1, solmaxis69, stakog, swizz, t0x1c, tallo, ubermensch, vnavascues, yotov721
1.0089 USDC - $1.01
https://github.com/code-423n4/2024-02-ai-arena/blob/main/src/RankedBattle.sol#L285-L287
In the RankedBattle
contract, users are required to stake their fighter NFT and a specified amount of NRN tokens to earn NRN rewards generated from won battles. Staking is facilitated by the stakeNRN
function, which locks both the fighter NFT (changes state to staked in the FighterFarm
contract) and NRN stake amount.
The unstakeNRN
function permits users to unstake their staked NRN tokens at their discretion. If all the staked NRN tokens are withdrawn, users can retrieve their fighter NFT.
However, the function contains a loophole that enables users to claim back their NFT (unstake it) while continuing to earn points from battles, and consequently, NRN rewards. This occurs because the function only verifies that amountStaked[tokenId]
is zero when unstaking the NFT and does not consider the stakeAtRisk
amount:
function unstakeNRN(uint256 amount, uint256 tokenId) external { ... bool success = _neuronInstance.transfer(msg.sender, amount); if (success) { //@audit doesn't account for StakeAtRisk amount if (amountStaked[tokenId] == 0) { _fighterFarmInstance.updateFighterStaking(tokenId, false); } emit Unstaked(msg.sender, amount); } }
As observed, the function solely checks the current staked amount for the corresponding token ID amountStaked[tokenId]
. However, in this game, a portion of the staked amount can be temporarily put at risk when a user loses a game, this amount is deducted from the initial staked amount, and held in the StakeAtRisk
contract. The actual at-risk amount can be retrieved using _stakeAtRiskInstance.getStakeAtRisk(tokenId)
.
Therefore, at any given timestamp, the user's actual stake amount is amountStaked[tokenId] + _stakeAtRiskInstance.getStakeAtRisk(tokenId)
, where the at-risk stake can be zero if the user hasn't lost games.
In essence, a user could strategically lose games to place a portion of the staked amount at risk, then unstake all remaining amounts until amountStaked[tokenId] == 0
, allowing them to claim back the locked NFT. Subsequently, the user can proceed to play again to recover the at-risk amount and continue playing the game, earning points, and therefore NRN rewards without having an NFT staked. And this is possible because the updateBattleRecord
function does account for the stakeAtRisk
amount when adding game result and points as shown below :
function updateBattleRecord( uint256 tokenId, uint256 mergingPortion, uint8 battleResult, uint256 eloFactor, bool initiatorBool ) external { ... _updateRecord(tokenId, battleResult); uint256 stakeAtRisk = _stakeAtRiskInstance.getStakeAtRisk(tokenId); //@audit stakeAtRisk is checked if (amountStaked[tokenId] + stakeAtRisk > 0) { _addResultPoints(battleResult, tokenId, eloFactor, mergingPortion, fighterOwner); } ... }
The loophole will potentially allow users to play the games and earn NRN rewards without staking any fighter NFT which goes against the intent of the protocol.
Let's outline the scenario demonstrating this issue:
Bob initially stakes his fighter NFT and 1000 NRN tokens, resulting in amountStaked[tokenId] = 1000
.
Bob participates in battles and loses some, leading to a portion of his stake being put at risk. At this point, the contract state is:
amountStaked[tokenId] = 800 _stakeAtRiskInstance.getStakeAtRisk(tokenId) = 200
Bob calls the unstakeNRN
function to withdraw his remaining 800 NRN tokens. Since amountStaked[tokenId] = 0
at the end, the function unstakes the fighter NFT, freeing it for Bob (can transfer it freely).
Later, Bob participates in more battles and wins, allowing him to earn points and reclaim his at-risk stake amount. After winning some games, the contract reaches the state:
amountStaked[tokenId] = 200 _stakeAtRiskInstance.getStakeAtRisk(tokenId) = 0
Users can claim NRN rewards without staking their fighter NFTs, undermining the protocol's intended mechanics.
Manual review, VS Code
To address this issue, the unstakeNRN
function must incorporate the at-risk amount into the final staked amount check before unstaking the NFT. The following modification achieves this:
function unstakeNRN(uint256 amount, uint256 tokenId) external { require(_fighterFarmInstance.ownerOf(tokenId) == msg.sender, "Caller does not own fighter"); if (amount > amountStaked[tokenId]) { amount = amountStaked[tokenId]; } amountStaked[tokenId] -= amount; globalStakedAmount -= amount; stakingFactor[tokenId] = _getStakingFactor( tokenId, _stakeAtRiskInstance.getStakeAtRisk(tokenId) ); _calculatedStakingFactor[tokenId][roundId] = true; hasUnstaked[tokenId][roundId] = true; bool success = _neuronInstance.transfer(msg.sender, amount); if (success) { -- if (amountStaked[tokenId] == 0) { ++ uint256 stakeAtRisk = _stakeAtRiskInstance.getStakeAtRisk(tokenId); ++ if (amountStaked[tokenId] + stakeAtRisk == 0) { _fighterFarmInstance.updateFighterStaking(tokenId, false); } emit Unstaked(msg.sender, amount); } }
Context
#0 - c4-pre-sort
2024-02-24T04:51:18Z
raymondfam marked the issue as sufficient quality report
#1 - c4-pre-sort
2024-02-24T05:18:38Z
raymondfam marked the issue as duplicate of #1641
#2 - c4-pre-sort
2024-02-24T06:09:09Z
raymondfam marked the issue as not a duplicate
#3 - c4-pre-sort
2024-02-24T06:09:45Z
raymondfam marked the issue as duplicate of #833
#4 - c4-judge
2024-03-13T11:32:40Z
HickupHH3 marked the issue as duplicate of #1641
#5 - c4-judge
2024-03-14T06:21:48Z
HickupHH3 marked the issue as satisfactory