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: 113/283
Findings: 6
Award: $32.48
π Selected for report: 0
π Solo Findings: 0
π Selected for report: CodeWasp
Also found by: 0x13, 0xAlix2, 0xAsen, 0xCiphky, 0xE1, 0xLogos, 0xaghas, 0xlemon, 0xlyov, 0xvj, ADM, Aamir, BARW, Bauchibred, DMoore, DanielArmstrong, Draiakoo, Fulum, GhK3Ndf, Josh4324, Kalogerone, KmanOfficial, Krace, KupiaSec, Limbooo, MidgarAudits, MrPotatoMagic, PedroZurdo, Pelz, Tendency, _eperezok, adam-idarrha, al88nsk, alexxander, alexzoid, aslanbek, blutorque, btk, c0pp3rscr3w3r, cartlex_, cats, d3e4, deadrxsezzz, denzi_, devblixt, dimulski, erosjohn, evmboi32, fnanni, grearlake, hulkvision, immeas, israeladelaja, jaydhales, jesjupyter, jnforja, juancito, klau5, korok, ktg, ladboy233, matejdb, merlinboii, novamanbg, nuthan2x, oualidpro, peanuts, petro_1912, pkqs90, pynschon, radev_sw, rouhsamad, sashik_eth, shaka, sobieski, soliditywala, stackachu, tallo, thank_you, ubl4nk, vnavascues, web3pwn, xchen1130, zhaojohnson
0.1044 USDC - $0.10
https://github.com/code-423n4/2024-02-ai-arena/blob/main/src/FighterFarm.sol#L333-L365
Fighter NFTs are always transferable, even if they are staked or if the recipient has reached the maximum fighters allowed.
FighterFarm::_ableToTransfer
defines the custom logic to determine whether a token is transferable or not:
function _ableToTransfer(uint256 tokenId, address to) private view returns(bool) { return ( _isApprovedOrOwner(msg.sender, tokenId) && balanceOf(to) < MAX_FIGHTERS_ALLOWED && !fighterStaked[tokenId] ); }
To enforce this behavior, the transferFrom(address,address,uint256)
and safeTransferFrom(address,address,uint256)
functions from the base ERC721
contract are overriden.
function transferFrom( address from, address to, uint256 tokenId ) public override(ERC721, IERC721) { require(_ableToTransfer(tokenId, to)); _transfer(from, to, tokenId); } function safeTransferFrom( address from, address to, uint256 tokenId ) public override(ERC721, IERC721) { require(_ableToTransfer(tokenId, to)); _safeTransfer(from, to, tokenId, ""); }
However, the safeTransferFrom(address,address,uint256,bytes memory)
function inherited from the base ERC721
contract is not overriden, and hence it can be used to freely transfer tokens without the custom logic described above.
function safeTransferFrom( address from, address to, uint256 tokenId, bytes memory data ) public virtual override { require(_isApprovedOrOwner(_msgSender(), tokenId), "ERC721: caller is not token owner nor approved"); _safeTransfer(from, to, tokenId, data); }
Players will be able to have more fighters than the maximum allowed, and transfer their fighters even when they are staked.
Manual review.
Either override the safeTransferFrom(address,address,uint256,bytes memory)
function from the base ERC721
contract as well, or even better, override just the _beforeTokenTransfer
hook to apply the custom transfer logic in all kind of transfers at the same time (careful with mints and burns).
Token-Transfer
#0 - c4-pre-sort
2024-02-23T05:06:46Z
raymondfam marked the issue as sufficient quality report
#1 - c4-pre-sort
2024-02-23T05:06:54Z
raymondfam marked the issue as duplicate of #739
#2 - c4-judge
2024-03-11T02:42:04Z
HickupHH3 marked the issue as satisfactory
π 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#L289-L303
Game items are always transferable, even if the owner set the transferable
attribute to false.
Each GameItem
has a boolean transferable
attribute, which can be modified by the contract owner.
struct GameItemAttributes { string name; bool finiteSupply; bool transferable; uint256 itemsRemaining; uint256 itemPrice; uint256 dailyAllowance; }
To only allow token transfers for tokenId
's whose transferable
attribute is set to true, the safeTransferFrom
function inherited from the base ERC1155
contract is overriden:
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 safeBatchTransferFrom
function inherited from the base ERC1155
contract is not overriden, and hence it can be used to freely transfer tokens without the custom logic described above.
function safeBatchTransferFrom( address from, address to, uint256[] memory ids, uint256[] memory amounts, bytes memory data ) public virtual override { require( from == _msgSender() || isApprovedForAll(from, _msgSender()), "ERC1155: caller is not token owner nor approved" ); _safeBatchTransferFrom(from, to, ids, amounts, data); }
Players will be able to transfer any game item at will, despite the owner of the contract disallowing it.
Manual review.
Either override the safeBatchTransferFrom
function from the base ERC1155
contract as well, or even better, override just the _beforeTokenTransfer
hook to apply the custom transfer logic in all kind of transfers at the same time (careful with mints and burns).
Token-Transfer
#0 - c4-pre-sort
2024-02-22T03:59:32Z
raymondfam marked the issue as sufficient quality report
#1 - c4-pre-sort
2024-02-22T03:59:39Z
raymondfam marked the issue as duplicate of #18
#2 - c4-pre-sort
2024-02-26T00:28:15Z
raymondfam marked the issue as duplicate of #575
#3 - c4-judge
2024-03-05T04:54:19Z
HickupHH3 marked the issue as satisfactory
π 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
1.1225 USDC - $1.12
https://github.com/code-423n4/2024-02-ai-arena/blob/main/src/FighterFarm.sol#L470
FighterFarm::_createFighterBase
will be unusable after the first generation of Fighters.
FighterFarm::_createFighterBase
computes the element
, weight
and newDna
of a Fighter.
function _createFighterBase( uint256 dna, uint8 fighterType ) private view returns (uint256, uint256, uint256) { uint256 element = dna % numElements[generation[fighterType]]; uint256 weight = dna % 31 + 65; uint256 newDna = fighterType == 0 ? dna : uint256(fighterType); return (element, weight, newDna); }
To compute the element
, it uses the numElements
mapping to get the amount of elements for the current generation of Fighters.
mapping(uint8 => uint8) public numElements;
Initially, the number of elements for generation 0 is set in the constructor:
constructor(address ownerAddress, address delegatedAddress, address treasuryAddress_) ERC721("AI Arena Fighter", "FTR") { _ownerAddress = ownerAddress; _delegatedAddress = delegatedAddress; treasuryAddress = treasuryAddress_; numElements[0] = 3; }
However, there isnβt any other function in the contract to set the number of elements for any other generation, and thus it will be 0.
Back to the _createFighterBase
function, when the current generation of Fighters is greater than 0, numElements[generation[fighterType]]
will be 0, and a division by 0 will happen when computing the modulo operation:
uint256 element = dna % numElements[generation[fighterType]];
This will make the transaction revert, rendering the _createFighterBase
function unusable.
After the first generation of Fighters (ie. from generation 1 onwards), some of the core functions of the FighterFarm
contract will be unusable. The affected functions will be:
FighterFarm::claimFighters
FighterFarm::redeemMintPass
FighterFarm::mintFromMergingPool
FighterFarm::reRoll
Manual review.
Create a setter function for the numElements
mapping.
Error
#0 - c4-pre-sort
2024-02-22T18:36:06Z
raymondfam marked the issue as sufficient quality report
#1 - c4-pre-sort
2024-02-22T18:36:14Z
raymondfam marked the issue as duplicate of #45
#2 - c4-judge
2024-03-07T07:00:31Z
HickupHH3 marked the issue as satisfactory
π Selected for report: nuthan2x
Also found by: 0xE1, 0xblackskull, 0xgrbr, 0xvj, Greed, McToady, MidgarAudits, PetarTolev, Sabit, SovaSlava, SpicyMeatball, Timenov, Tychai0s, _eperezok, alexxander, btk, c0pp3rscr3w3r, favelanky, jesjupyter, josephdara, juancito, klau5, kutugu, lil_eth, merlinboii, pynschon, sandy, shaflow2, zaevlad
29.1474 USDC - $29.15
https://github.com/code-423n4/2024-02-ai-arena/blob/main/src/Neuron.sol#L90-L112
Accounts that have been granted a role in the Neuron
token contract, will keep that role forever. This can be an issue if those accounts ever get compromised, either through a vulnerability if they are contract accounts, or through a private key leak if they are EOAs.
The Neuron
token uses OpenZeppelinβs AccessControl
contract to manage roles.
By default, the admin role for all roles is
DEFAULT_ADMIN_ROLE
, which means that only accounts with this role will be able to grant or revoke other roles.
However, the DEFAULT_ADMIN_ROLE
is never assigned, and instead the contract defines custom functions to grant roles.
function addMinter(address newMinterAddress) external { require(msg.sender == _ownerAddress); _setupRole(MINTER_ROLE, newMinterAddress); } function addStaker(address newStakerAddress) external { require(msg.sender == _ownerAddress); _setupRole(STAKER_ROLE, newStakerAddress); } function addSpender(address newSpenderAddress) external { require(msg.sender == _ownerAddress); _setupRole(SPENDER_ROLE, newSpenderAddress); }
Since there arenβt any wrappers around revoking roles, this functionality will be unavailable.
If an account with a certain role gets compromised, there will be no way of revoking its role, which is a risk for the protocol.
Manual review.
Assign the DEFAULT_ADMIN_ROLE
to the ownerAddress
in the constructor of the Neuron
contract.
Access Control
#0 - c4-pre-sort
2024-02-22T05:09:16Z
raymondfam marked the issue as insufficient quality report
#1 - c4-pre-sort
2024-02-22T05:09:22Z
raymondfam marked the issue as duplicate of #20
#2 - c4-judge
2024-03-05T07:35:30Z
HickupHH3 marked the issue as satisfactory
π Selected for report: ktg
Also found by: 0xCiphky, 0xDetermination, 0xRiO, 0xWallSecurity, 0xlemon, 0xvj, AlexCzm, BARW, Blank_Space, Draiakoo, FloatingPragma, Giorgio, Matue, McToady, MrPotatoMagic, Silvermist, SpicyMeatball, Tendency, Topmark, Tumelo_Crypto, _eperezok, agadzhalov, ahmedaghadi, alexxander, aslanbek, cats, d3e4, denzi_, dutra, evmboi32, fnanni, givn, handsomegiraffe, haxatron, immeas, juancito, ke1caM, kiqo, klau5, krikolkk, niser93, peter, petro_1912, pkqs90, rspadi, sl1, stakog, visualbits, vnavascues, yotov721
1.876 USDC - $1.88
https://github.com/code-423n4/2024-02-ai-arena/blob/main/src/MergingPool.sol#L142 https://github.com/code-423n4/2024-02-ai-arena/blob/main/src/MergingPool.sol#L158 https://github.com/code-423n4/2024-02-ai-arena/blob/main/src/FighterFarm.sol#L329
Users can pass arbitrary customAttributes
to MergingPool::claimRewards
, which are then used to mint Fighters through FighterFarm::mintFromMergingPool
. This results in Fighters having element
and weight
attributes outside of the expected range.
Fighters are minted using the FighterFarm::_createNewFighter
function.
function _createNewFighter( address to, uint256 dna, string memory modelHash, string memory modelType, uint8 fighterType, uint8 iconsType, uint256[2] memory customAttributes ) private { require(balanceOf(to) < MAX_FIGHTERS_ALLOWED); uint256 element; uint256 weight; uint256 newDna; if (customAttributes[0] == 100) { (element, weight, newDna) = _createFighterBase(dna, fighterType); } else { element = customAttributes[0]; weight = customAttributes[1]; newDna = dna; } uint256 newId = fighters.length; // .... }
We can see that if customAttributes[0] == 100
, the element
and weight
attributes of the fighter are generated by the _createFighterBase
function; otherwise they are directly taken from the customAttributes
tuple.
function _createFighterBase( uint256 dna, uint8 fighterType ) private view returns (uint256, uint256, uint256) { uint256 element = dna % numElements[generation[fighterType]]; uint256 weight = dna % 31 + 65; uint256 newDna = fighterType == 0 ? dna : uint256(fighterType); return (element, weight, newDna); }
The sponsor team confirmed that the weight
attribute should always be a value between 65 and 95, which is what we see implemented in the code above. Moreover, the range for the element
attribute depends on the current generation of Fighters.
To enforce these restrictions on the attributes, all functions that mint Fighters in the FighterFarm
contract set the customAttributes[0]
to 100, except for mintFromMergingPool
.
function mintFromMergingPool( address to, string calldata modelHash, string calldata modelType, uint256[2] calldata customAttributes ) public { require(msg.sender == _mergingPoolAddress); _createNewFighter( to, uint256(keccak256(abi.encode(msg.sender, fighters.length))), modelHash, modelType, 0, 0, customAttributes ); }
This function receives arbitrary customAttributes
that come from MergingPool::claimRewards
, which is an external function that any player can call when they win a reward.
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; for (uint32 j = 0; j < winnersLength; j++) { if (msg.sender == winnerAddresses[currentRound][j]) { _fighterFarmInstance.mintFromMergingPool( msg.sender, modelURIs[claimIndex], modelTypes[claimIndex], // @audit-issue -> arbitrary values sent customAttributes[claimIndex] ); claimIndex += 1; } } } // ... }
If the winner calls claimRewards
with customAttributes[0] != 100
, then unexpected values will be set as the weight
and element
attributes of the new Fighter.
Players can mint Fighters with arbitrary weight
and element
attributes, possibly outside of the expected range, which could break some of the core functionalities of the game.
Manual review.
Validate the values present in the customAttributes
parameter of the MergingPool::claimRewards
function.
Invalid Validation
#0 - c4-pre-sort
2024-02-24T09:02:40Z
raymondfam marked the issue as insufficient quality report
#1 - c4-pre-sort
2024-02-24T09:02:49Z
raymondfam marked the issue as duplicate of #226
#2 - c4-judge
2024-03-11T10:28:04Z
HickupHH3 marked the issue as satisfactory
π Selected for report: ahmedaghadi
Also found by: 0x13, 0xAleko, 0xDetermination, 0xKowalski, 0xPluto, 0xRiO, 0xvj, AlexCzm, Avci, BARW, BigVeezus, Cryptor, DeFiHackLabs, Draiakoo, Fitro, Giorgio, GoSlang, Greed, Honour, Kalogerone, KmanOfficial, Krace, McToady, MidgarAudits, MrPotatoMagic, Nyxaris, ReadyPlayer2, Ryonen, SovaSlava, SpicyMeatball, VAD37, _eperezok, alexzoid, almurhasan, btk, cu5t0mpeo, deadrxsezzz, djxploit, dvrkzy, emrekocak, erosjohn, evmboi32, fnanni, grearlake, inzinko, jesjupyter, jesusrod15, josephdara, ke1caM, klau5, ktg, ladboy233, merlinboii, nuthan2x, peanuts, pipidu83, pontifex, radev_sw, sl1, sobieski, soliditywala, t0x1c, taner2344, vnavascues, y4y, yovchev_yoan, zaevlad
0.2347 USDC - $0.23
https://github.com/code-423n4/2024-02-ai-arena/blob/main/src/MergingPool.sol#L149-L163
Claiming rewards in the MergingPool
will become increasingly costly over rounds, to the point it might be too expensive for users to even want to claim them.
When a user calls MergingPool::claimRewards
, the contract has to iterate over all the rounds since the last claim of the user. For users that have never claimed rewards before, this will mean iterating from the very first round to the last one.
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; 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; } } } // ... }
Note that for each iteration, there are several storage read/writes, which contributes to the very high cost of calling this function.
Claiming rewards in the MergingPool
contract will unnecessarily imply a significant monetary cost for users over rounds, which are expected to have a 1-week duration.
Over time, the cost of claiming the reward might even be greater than the value of the reward itself, so users will stop using this functionality of the protocol.
Manual review.
Consider storing the unclaimed rewards for each user in a more efficient way. For instance, a mapping(address user => uint256[] roundIds)
could be used, where all the rounds in which the user won a reward are saved and they only have to iterate over those rounds, removing them from the list as the rewards are claimed.
Loop
#0 - c4-pre-sort
2024-02-23T23:49:39Z
raymondfam marked the issue as sufficient quality report
#1 - c4-pre-sort
2024-02-23T23:49:47Z
raymondfam marked the issue as duplicate of #1541
#2 - c4-judge
2024-03-11T13:00:37Z
HickupHH3 marked the issue as duplicate of #216
#3 - c4-judge
2024-03-12T02:34:49Z
HickupHH3 marked the issue as partial-50
#4 - c4-judge
2024-03-21T02:10:42Z
HickupHH3 marked the issue as satisfactory
π Selected for report: ahmedaghadi
Also found by: 0x13, 0xAleko, 0xDetermination, 0xKowalski, 0xPluto, 0xRiO, 0xvj, AlexCzm, Avci, BARW, BigVeezus, Cryptor, DeFiHackLabs, Draiakoo, Fitro, Giorgio, GoSlang, Greed, Honour, Kalogerone, KmanOfficial, Krace, McToady, MidgarAudits, MrPotatoMagic, Nyxaris, ReadyPlayer2, Ryonen, SovaSlava, SpicyMeatball, VAD37, _eperezok, alexzoid, almurhasan, btk, cu5t0mpeo, deadrxsezzz, djxploit, dvrkzy, emrekocak, erosjohn, evmboi32, fnanni, grearlake, inzinko, jesjupyter, jesusrod15, josephdara, ke1caM, klau5, ktg, ladboy233, merlinboii, nuthan2x, peanuts, pipidu83, pontifex, radev_sw, sl1, sobieski, soliditywala, t0x1c, taner2344, vnavascues, y4y, yovchev_yoan, zaevlad
0.2347 USDC - $0.23
https://github.com/code-423n4/2024-02-ai-arena/blob/main/src/RankedBattle.sol#L299-L305
Claiming NRN in RankedBattle
for the first time will be increasingly costly for new players over rounds. This will make them incur large gas fees unnecessarily, which is unfair.
When a user calls RankedBattle::claimNRN
, the contract has to iterate over all the rounds since the last claim of the user. For users that have never claimed NRN before, this will mean iterating from the very first round to the last one.
function claimNRN() external { require(numRoundsClaimed[msg.sender] < roundId, "Already claimed NRNs for this period"); uint256 claimableNRN = 0; uint256 nrnDistribution; uint32 lowerBound = numRoundsClaimed[msg.sender]; for (uint32 currentRound = lowerBound; currentRound < roundId; currentRound++) { nrnDistribution = getNrnDistribution(currentRound); claimableNRN += ( accumulatedPointsPerAddress[msg.sender][currentRound] * nrnDistribution ) / totalAccumulatedPoints[currentRound]; numRoundsClaimed[msg.sender] += 1; } if (claimableNRN > 0) { amountClaimed[msg.sender] += claimableNRN; _neuronInstance.mint(msg.sender, claimableNRN); emit Claimed(msg.sender, claimableNRN); } }
Note that for each iteration, there are several storage read/writes, which contributes to the very high cost of calling this function for the first time.
Claiming NRN in the RankedBattle
contract will imply a significant monetary cost for users over rounds, which are expected to have a 1-week duration. This will particularly affect new players, who will need to iterate over all the previous rounds unnecessarily, since they donβt have any NRN available to claim for those rounds.
Over time, the cost of claiming NRN for the first time might be so high that new players will be discouraged from playing the game.
Manual review.
Consider adding a fromRound
parameter to RankedBattle::claimNRN
, so that users can start claiming NRN from a given round (where they actually started playing the game) instead of from the game launch. Ideally, keep track of the rounds a user has NRN available to claim either on-chain or off-chain, so that they only need to iterate over those rounds.
Loop
#0 - c4-pre-sort
2024-02-25T02:23:25Z
raymondfam marked the issue as sufficient quality report
#1 - c4-pre-sort
2024-02-25T02:23:35Z
raymondfam marked the issue as duplicate of #1541
#2 - c4-judge
2024-03-11T13:00:38Z
HickupHH3 marked the issue as duplicate of #216
#3 - c4-judge
2024-03-12T02:43:55Z
HickupHH3 marked the issue as partial-50
#4 - c4-judge
2024-03-21T02:10:45Z
HickupHH3 marked the issue as satisfactory