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: 98/283
Findings: 7
Award: $63.64
π 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/cd1a0e6d1b40168657d1aaee8223dc050e15f8cc/src/FighterFarm.sol#L539-L545 https://github.com/code-423n4/2024-02-ai-arena/blob/cd1a0e6d1b40168657d1aaee8223dc050e15f8cc/src/FighterFarm.sol#L338-L348 https://github.com/code-423n4/2024-02-ai-arena/blob/cd1a0e6d1b40168657d1aaee8223dc050e15f8cc/src/FighterFarm.sol#L355-L365 https://github.com/OpenZeppelin/openzeppelin-contracts/blob/ecd2ca2cd7cac116f7a37d0e474bbb3d7d5e1c4d/contracts/token/ERC721/ERC721.sol#L175-L183
Fighters in the protocol are represented as NFTs and can be traded between players. There is a function with custom logic whether a transfer of a fighter is currently allowed:
function _ableToTransfer(uint256 tokenId, address to) private view returns(bool) { return ( _isApprovedOrOwner(msg.sender, tokenId) && balanceOf(to) < MAX_FIGHTERS_ALLOWED && !fighterStaked[tokenId] ); }
It is invoked when a player tries to call the overridden transferFrom or safeTransferFrom.
The issue is that the ERC721 contract has an additional safeTransferFrom function that includes the bytes param, and it is not overridden in FighterFarm
, so it can be called freely.
This allows Alice to bypass the require
statements if she wants to.
Fighters can be staked and still transferred, as well as the receiver of a transfer can have a balance > MAX_FIGHTERS_ALLOWED
. This should not be possible. I have classified the issue as High severity as one of the main invariants that should not be broken is to be able to stake and then transfer a fighter.
Add the code below to RankedBattle.t.sol
and run with forge test --mt testBypassCustomLogic -vvvv
function testBypassCustomLogic() public { // Creating instance of Alice and Bob, funding Alice with $NRN for staking later address alice = makeAddr("alice"); address bob = makeAddr("bob"); _fundUserWith4kNeuronByTreasury(alice); // Minting Alice 10 fighter NFTs to fill up her MAX_FIGHTERS_ALLOWED for (uint8 i = 0; i < 10; i++) { _mintFromMergingPool(alice); assertEq(_fighterFarmContract.ownerOf(i), alice); } // Expecting revert to mint a new one for Alice vm.expectRevert(); _mintFromMergingPool(alice); // Minting one to Bob and expecting revert on transfer to Alice using the intended function _mintFromMergingPool(bob); assertEq(_fighterFarmContract.ownerOf(10), bob); vm.startPrank(bob); vm.expectRevert(); _fighterFarmContract.transferFrom(bob, alice, 10); // Now transferring with the function that has not been overridden - successful _fighterFarmContract.safeTransferFrom(bob, alice, 10, ""); assertEq(_fighterFarmContract.ownerOf(10), alice); assertEq(_fighterFarmContract.balanceOf(alice), 11); // Now let's try staking one of Alice's fighters and transferring it to Bob while staked (not allowed) vm.startPrank(alice); _rankedBattleContract.stakeNRN(100e18, 0); // Expecting revert with intended transfer func vm.expectRevert(); _fighterFarmContract.transferFrom(alice, bob, 0); // Works with the other function though _fighterFarmContract.safeTransferFrom(alice, bob, 0, ""); assertEq(_fighterFarmContract.ownerOf(0), bob); }
Manual Review/Foundry
Override the other safeTransferFrom
function as well, or better yet, override the internal transfer function of ERC721.
ERC721
#0 - c4-pre-sort
2024-02-23T05:11:46Z
raymondfam marked the issue as sufficient quality report
#1 - c4-pre-sort
2024-02-23T05:11:57Z
raymondfam marked the issue as duplicate of #739
#2 - c4-judge
2024-03-11T02:44:53Z
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/cd1a0e6d1b40168657d1aaee8223dc050e15f8cc/src/GameItems.sol#L212 https://github.com/code-423n4/2024-02-ai-arena/blob/cd1a0e6d1b40168657d1aaee8223dc050e15f8cc/src/GameItems.sol#L291-L303 https://github.com/OpenZeppelin/openzeppelin-contracts/blob/ecd2ca2cd7cac116f7a37d0e474bbb3d7d5e1c4d/contracts/token/ERC1155/ERC1155.sol#L134-L146
When an admin creates an item they pass a bool if the item is allowed to be transferred:
require(isAdmin[msg.sender]); allGameItemAttributes.push( GameItemAttributes( name_, finiteSupply, @> transferable, itemsRemaining, itemPrice, dailyAllowance ) );
This dictates whether an item is allowed to be transferred or not. Items are minted in ERC1155 standard, and later if a user wants to transfer a transferrable item, the safeTransferFrom
function is overridden with the following require statement:
require(allGameItemAttributes[tokenId].transferable);
The issue is that the ERC1155 contract that is inherited has an additional function for making transfers which has not been overridden. The function allows the exact same logic except it skips the custom require statement implemented by the protocol to check if an item is transferrable. Due to this, players can freely transfer items even if they're not allowed to be transferred.
Players can transfer items that are created as non-transferrable.
Add the code below to GameItems.t.sol
and run with forge test --mt testBypassTransferBool -vvvv
function testBypassTransferBool() public { // Creating instance of Alice and Bob, funding Alice with $NRN to buy address alice = makeAddr("alice"); address bob = makeAddr("bob"); _fundUserWith4kNeuronByTreasury(alice); // Creating non-transferrable game item _gameItemsContract.createGameItem("Non Transferrable Item", "", true, false, 10_000, 1 * 10 ** 18, 10); // Buying an item with Alice, item is with id 1 since another one already exists at 0 that is transferrable vm.startPrank(alice); _gameItemsContract.mint(1, 1); assertEq(_gameItemsContract.balanceOf(alice, 1), 1); // This should now fail since item is set as non-transferrable vm.expectRevert(); _gameItemsContract.safeTransferFrom(alice, bob, 1, 1, ""); uint256[] memory ids = new uint256[](1); ids[0] = 1; uint256[] memory amounts = new uint256[](1); amounts[0] = 1; // This will succeed though _gameItemsContract.safeBatchTransferFrom(alice, bob, ids, amounts, ""); assertEq(_gameItemsContract.balanceOf(bob, 1), 1); assertEq(_gameItemsContract.balanceOf(alice, 1), 0); }
Manual Review/Foundry
Override both functions, or better yet, override the internal transfer function.
Token-Transfer
#0 - c4-pre-sort
2024-02-22T04:04:41Z
raymondfam marked the issue as sufficient quality report
#1 - c4-pre-sort
2024-02-22T04:04:48Z
raymondfam marked the issue as duplicate of #18
#2 - c4-pre-sort
2024-02-26T00:28:23Z
raymondfam marked the issue as duplicate of #575
#3 - c4-judge
2024-03-05T04:47:39Z
HickupHH3 changed the severity to 3 (High Risk)
#4 - c4-judge
2024-03-05T04:54:32Z
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/cd1a0e6d1b40168657d1aaee8223dc050e15f8cc/src/FighterFarm.sol#L233-L262 https://github.com/code-423n4/2024-02-ai-arena/blob/cd1a0e6d1b40168657d1aaee8223dc050e15f8cc/src/AiArenaHelper.sol#L100-L104 https://github.com/code-423n4/2024-02-ai-arena/blob/cd1a0e6d1b40168657d1aaee8223dc050e15f8cc/src/FighterFarm.sol#L509
There are numerous ways for users to mint fighters in the system, one of which is to redeem a Mint Pass. Mint passes are given to the community pre-launch. The issue is that the current logic in the function to redeem a mint pass allows the user redeeming it to customize every single parameter passed to create a new fighter.
function redeemMintPass( uint256[] calldata mintpassIdsToBurn, uint8[] calldata fighterTypes, uint8[] calldata iconsTypes, string[] calldata mintPassDnas, string[] calldata modelHashes, string[] calldata modelTypes )
Being able to influence all the parameters gives the user the ability to mint the rarest most valued NFT possible, ticking all different rarity checks in the protocol:
It seems to me this is not the intended function logic as it's counter-intuitive to allow anyone with a mint pass to redeem the rarest and most high valued possible NFT arbitrarily.
Users with mint pass can always redeem with accuracy the rarest most high valued NFT, which in turn will just make it less valuable the more people have it.
Simple issue.
Manual Review
Owners to pre-randomize mint passes and hash them along with the receiver's address. Use merkle tree for validation when users claiming. This way if the mint passes are still to have some rare quality for early community members, admins have control over how many ensured rare attributes per fighter are assigned.
That or the function to randomize the stats upon the moment of redeeming, could potentially user Chainlink VRF or some similar service for true randomness.
Other
#0 - c4-pre-sort
2024-02-22T07:40:03Z
raymondfam marked the issue as sufficient quality report
#1 - c4-pre-sort
2024-02-22T07:40:35Z
raymondfam marked the issue as duplicate of #33
#2 - c4-pre-sort
2024-02-26T00:53:13Z
raymondfam marked the issue as duplicate of #1626
#3 - c4-judge
2024-03-06T03:09:06Z
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/cd1a0e6d1b40168657d1aaee8223dc050e15f8cc/src/FighterFarm.sol#L370-L391 https://github.com/code-423n4/2024-02-ai-arena/blob/cd1a0e6d1b40168657d1aaee8223dc050e15f8cc/src/FighterFarm.sol#L380 https://github.com/code-423n4/2024-02-ai-arena/blob/cd1a0e6d1b40168657d1aaee8223dc050e15f8cc/src/FighterFarm.sol#L385 https://github.com/code-423n4/2024-02-ai-arena/blob/cd1a0e6d1b40168657d1aaee8223dc050e15f8cc/src/FighterFarm.sol#L470-L472 https://github.com/code-423n4/2024-02-ai-arena/blob/cd1a0e6d1b40168657d1aaee8223dc050e15f8cc/src/FighterFarm.sol#L383-L385 https://github.com/code-423n4/2024-02-ai-arena/blob/cd1a0e6d1b40168657d1aaee8223dc050e15f8cc/src/AiArenaHelper.sol#L107-L108
Users are allowed to reroll their fighters a number of times to generate new stats like physical attributes, weight and element. One of the parameters that is supplied by the player is the fighterType
. Fighter type in the protocol (currently) is a way to determine whether the creation of a new fighter is a normal NFT or a special dendroid NFT. Type 0 == normal, 1 == dendroid
.
However, in the context of the reroll function the arbitrary input passed by the player as fighterType
is used to influence the new rerolled fighter stats as well as the new rerolled physical attributes.
(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 );
Since the return values newDna
and element
of the rerolled fighter's base are influenced by the user's input, this creats the following issue:
There is no check in the reroll function if the passed fighter type actually matches with the user owned fighter's type, due to this, the element
that is returned and applied to the fighter can access a pool of elements that are different and unavailable to the original fighter's type. The aim of the function is not to be able to access new stats from a different generation/fighter type but to reroll the ones of your current fighter. As time goes and with new added types and elements, this allows Alice that has an NFT of type 0 to reroll with access to elements of type 2 which should not be possible and is unintended.
uint256 element = dna % numElements[generation[fighterType]];
Furthermore, if the supplied fighterType
is !=0
the newDna
generated is uint256(fighterType)
. This allows Alice to also reroll her fighter's dna as if another type.
uint256 newDna = fighterType == 0 ? dna : uint256(fighterType);
Later in the reroll, newDna
and generation[fighterType]
are passed to reroll the new physical attributes of the fighter which are simply cosmetic/visual aspects of a fighter but are ranked by rarity from Bronze to Diamond.
As you can see here the passed variables serve as an input to create visuals with varying rarity which should not be able to be influenced arbitrarily by the user.
uint256 rarityRank = (dna / attributeToDnaDivisor[attributes[i]]) % 100;
uint256 attributeIndex = dnaToIndex(generation, rarityRank, attributes[i]);
The intent of the function is for a player to reroll their fighter with new "randomized" stats/physical attributes, by influencing fighterType
and newDna
the user can pre-compute the uint256(fighterType)
that rerolls the fighter, as well as access another pool/generation of cosmetics. Randomness is gamed.
Above shown examples of stats that can be influenced by players which are not allowed to be influenced arbitrarily.
Manual Review/Foundry
Do not allow the player to influence the newDna
when rerolling a fighter by passing an arbitrary fighterType
param.
Invalid Validation
#0 - c4-pre-sort
2024-02-22T01:31:03Z
raymondfam marked the issue as sufficient quality report
#1 - c4-pre-sort
2024-02-22T01:31:10Z
raymondfam marked the issue as duplicate of #306
#2 - c4-judge
2024-03-05T04:32:32Z
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: 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/cd1a0e6d1b40168657d1aaee8223dc050e15f8cc/src/FighterFarm.sol#L370-L391 https://github.com/code-423n4/2024-02-ai-arena/blob/cd1a0e6d1b40168657d1aaee8223dc050e15f8cc/src/FighterFarm.sol#L36 https://github.com/code-423n4/2024-02-ai-arena/blob/cd1a0e6d1b40168657d1aaee8223dc050e15f8cc/src/FighterFarm.sol#L132 https://github.com/code-423n4/2024-02-ai-arena/blob/cd1a0e6d1b40168657d1aaee8223dc050e15f8cc/src/FighterFarm.sol#L370
Players can reroll their fighters to randomize their stats with new ones. Each fighter has their own respective fighter type, currently there are only 2 in the protocol - normal champions and special dendroids, and each fighter type has a limited number of rerolls. At the start, both types have only 3 rerolls available per fighter.
Whenever a new generation of that fighter type starts, they are added +1 more reroll.
The issue is that when a player rerolls their fighter, they are allowed to arbitrarily pass in the fighterType
and the function has no input validation if what the user passes matches up with the type of the fighter they're rerolling.
The only thing that is checked is that the current number of rerolls of the token are less than the max rerolls allowed for that 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] = ""; } }
This means that Alice can reroll her fighter more times than her fighter type allows by passing in another fighter type when the generation of that type is increased, since it gets an additional reroll. Note, even if her NFT's type is not of the one she passes as a param, she can still reroll as many times as the passed type can.
Players can use up more rerolls than their fighter's type is limited to by just passing other fighter type's since there's no input validation to see if the token matches the fighter type passed.
Manual Review
Input validation if the passed fighter type matches with the player's.
Invalid Validation
#0 - c4-pre-sort
2024-02-22T01:32:49Z
raymondfam marked the issue as sufficient quality report
#1 - c4-pre-sort
2024-02-22T01:32:58Z
raymondfam marked the issue as duplicate of #306
#2 - c4-judge
2024-03-05T04:32:55Z
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: 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/cd1a0e6d1b40168657d1aaee8223dc050e15f8cc/src/RankedBattle.sol#L448-L451 https://github.com/code-423n4/2024-02-ai-arena/blob/cd1a0e6d1b40168657d1aaee8223dc050e15f8cc/src/MergingPool.sol#L111-L132 https://github.com/code-423n4/2024-02-ai-arena/blob/cd1a0e6d1b40168657d1aaee8223dc050e15f8cc/src/MergingPool.sol#L139-L142 https://github.com/code-423n4/2024-02-ai-arena/blob/cd1a0e6d1b40168657d1aaee8223dc050e15f8cc/src/FighterFarm.sol#L313-L331 https://github.com/code-423n4/2024-02-ai-arena/blob/cd1a0e6d1b40168657d1aaee8223dc050e15f8cc/src/FighterFarm.sol#L219 https://github.com/code-423n4/2024-02-ai-arena/blob/cd1a0e6d1b40168657d1aaee8223dc050e15f8cc/src/FighterFarm.sol#L259 https://github.com/code-423n4/2024-02-ai-arena/blob/cd1a0e6d1b40168657d1aaee8223dc050e15f8cc/src/FighterFarm.sol#L499-L500 https://github.com/code-423n4/2024-02-ai-arena/blob/cd1a0e6d1b40168657d1aaee8223dc050e15f8cc/src/FighterFarm.sol#L503-L504
Players can distribute a portion of the points they win in battle to the Merging Pool contract. By accumulating points in that contract, they have a higher chance of being picked to win a reward NFT Fighter.
There are various different stats that fighters posses, one of them being weight - weight is the main stat that determines other battle attributes as per the documentation.
"The relative composition of the metal alloy determines a fighterβs weight in the game. A fighterβs weight is the primary determinant of its other relative strength and weaknesses (i.e. all other battle attributes are a function of weight). Additionally, it is used to calculate how far the fighter moves when being knocked back."
When a player claims a reward NFT from the Merging Pool contract, they are allowed to pass 3 params for the creation of their fighter, that being the AI Model URI, AI Model Type and Custom Attributes - the custom attributes array holds element and weight:
function claimRewards( string[] calldata modelURIs, string[] calldata modelTypes, uint256[2][] calldata customAttributes )
After which the Merging Pool calls upon the Fighter Farm contract to create a new fighter for the player, passing in those 3 params.
The issue is that when the player inputs the weight/element, there is no input validation on the number that they give. If we look at all other functions in the Fighter Farm contract that mint fighters, we can see that both element and weight are always capped at 100:
When 100 is passed as element, the _createNewFighter()
function just generates new stats based on the dna and fighter type. But if another number is given, the fighter is generated with the exact numbers the user inputs for element and weight.
Although the idea of claiming a reward is to be able to influence the element and weight to some extent, this allows the player to choose values that are out of normal expected behavior range. Additional clarification by the sponsor is needed about the off-chain game server in the judging process, but the point is that this issue allows a player to mint a fighter with values outside of the expected behavior range.
Player is able to mint a fighter with element and weight outside of the normal protocol expected range. Due to game server being off-chain the extent of impact cannot be determined by me.
PoC shows I can just mint a fighter with 39158 element and 77777 weight.
Add the code below to MergingPool.t.sol
and run with forge test --mt testNoMaxLimit -vvvv
function testNoMaxLimit() public { // Adjusting Merging Pool to only pick 1 winner for reward NFT vm.prank(_ownerAddress); _mergingPoolContract.updateWinnersPerPeriod(1); // Creating instance of Alice and minting her a fighter so it hypothetically gets picked to win an NFT address alice = makeAddr("alice"); _mintFromMergingPool(alice); assertEq(_fighterFarmContract.ownerOf(0), alice); // Creating winners array uint256[] memory _winners = new uint256[](1); _winners[0] = 0; // Winner of roundId 0 is picked _mergingPoolContract.pickWinner(_winners); assertEq(_mergingPoolContract.isSelectionComplete(0), true); // Winner matches ownerOf tokenId assertEq(_mergingPoolContract.winnerAddresses(0, 0) == alice, true); // Alice instantiates the data to pass in as params for the reward claim 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); // As long as element != 100 it will go into the else statement of _createNewFighter // Setting randomly inflated numbers that are out of normal expected behavior for element and weight _customAttributes[0][0] = uint256(39158); _customAttributes[0][1] = uint256(77777); // Alice claims reward vm.prank(alice); _mergingPoolContract.claimRewards(_modelURIs, _modelTypes, _customAttributes); // Caching the new fighter's stats and asserting they are indeed what Alice passed (, , uint256 rewardWeight, uint256 rewardElement, , , ) = _fighterFarmContract.getAllFighterInfo(1); assertEq(rewardElement, 39158); assertEq(rewardWeight, 77777); }
Manual Review/Foundry
Add input validation to the mintFromMergingPool()
function to cap element/weight at 100.
function mintFromMergingPool( address to, string calldata modelHash, string calldata modelType, uint256[2] calldata customAttributes ) public { require(msg.sender == _mergingPoolAddress); + require(customAttributes[0] <= 100 && customAttributes[1] <= 100); _createNewFighter( to, uint256(keccak256(abi.encode(msg.sender, fighters.length))), modelHash, modelType, 0, 0, customAttributes ); }
Invalid Validation
#0 - c4-pre-sort
2024-02-24T08:55:52Z
raymondfam marked the issue as insufficient quality report
#1 - c4-pre-sort
2024-02-24T08:56:09Z
raymondfam marked the issue as duplicate of #226
#2 - c4-judge
2024-03-11T10:24:35Z
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/cd1a0e6d1b40168657d1aaee8223dc050e15f8cc/src/FighterFarm.sol#L370-L391 https://github.com/code-423n4/2024-02-ai-arena/blob/cd1a0e6d1b40168657d1aaee8223dc050e15f8cc/src/FighterFarm.sol#L324
Users are allowed to reroll their fighters a number of times to generate new random stats like physical attributes, weight and element.
The dna
variable that is used to create the new "random" stats for a fighter reroll is derived from the keccak256 hash of on-chain public variables. Users can run the stats generation locally and see what the new stats would be before deciding if the reroll is worth it or not. This games and bypasses the randomization element in the rerolling system. Furthermore, certain elements and body types counter other ones, and by knowing what the new re-rolled stats would be ahead of time, a player can decide if the reroll will give him the upper hand in an upcoming fight.
Physical Attributes also have rarity levels from Bronze to Diamond, knowing this ahead of time a user can determine if the re-rolled NFT will be more valuable in terms of rarity than his current version and decide if the re-roll is worth it.
Additionally, when a user mints a new fighter when receiving a reward from the Merging Pool, the dna
there is also predictable:
_createNewFighter( to, @> uint256(keccak256(abi.encode(msg.sender, fighters.length))), modelHash, modelType, 0, 0, customAttributes );
A user could hold on from claiming a merging pool reward until they know beforehand that at some exact number of fighters.length
, a good NFT will be minted.
Users can predict ahead of time new rerolled stats and physical attributes, as well as mint reward stats, when that should be random and impossible to predict.
For the PoC I have created new "simulation contracts" instances which would be the ones that a user can run locally on their computer to pre-compute the random generation. After that we compare that the simulated NFT's reroll is exactly the same as the real instance's reroll.
FighterFarm.t.sol
add the following to // CONTRACT INSTANCES // sectionFighterFarm internal _fighterFarmContractSimulation; AiArenaHelper internal _helperContractSimulation; MergingPool internal _mergingPoolContractSimulation;
FighterFarm.t.sol
add the following to setUp()
_fighterFarmContractSimulation = new FighterFarm(_ownerAddress, _DELEGATED_ADDRESS, _treasuryAddress); _helperContractSimulation = new AiArenaHelper(_probabilities); _mergingPoolContractSimulation = new MergingPool(_ownerAddress, address(_rankedBattleContract), address(_fighterFarmContractSimulation)); _fighterFarmContractSimulation.instantiateAIArenaHelperContract(address(_helperContractSimulation)); _fighterFarmContractSimulation.setMergingPoolAddress(address(_mergingPoolContractSimulation)); _fighterFarmContractSimulation.instantiateNeuronContract(address(_neuronContract));
FighterFarm.t.sol
and run with forge test --mt testPredictReroll -vvvv
function testPredictReroll() public { // Creating instance of Alice and giving her $NRN for reroll address alice = makeAddr("alice"); _fundUserWith4kNeuronByTreasury(alice); // Minting 2 NFTs - one on the "real" fighterFarm contract instance (which would be protocol's on-chain real implementation) // and one on a "simulated" local instance on Alice's computer vm.prank(address(_mergingPoolContract)); _fighterFarmContract.mintFromMergingPool(alice, "_neuralNetHash", "original", [uint256(1), uint256(80)]); vm.prank(address(_mergingPoolContractSimulation)); _fighterFarmContractSimulation.mintFromMergingPool(alice, "_neuralNetHash", "original", [uint256(1), uint256(80)]); // Adding both as spender so she can reroll _neuronContract.addSpender(address(_fighterFarmContract)); _neuronContract.addSpender(address(_fighterFarmContractSimulation)); // Alice rerolls both NFTs vm.startPrank(alice); _fighterFarmContract.reRoll(0, 0); _fighterFarmContractSimulation.reRoll(0, 0); // Caching the new rerolled attributes, elements and weights both on protocol and simulation instances to compare (, uint256[6] memory simulationAttributes, uint256 simulationWeight, uint256 simulationElement, , , ) = _fighterFarmContractSimulation.getAllFighterInfo(0); (, uint256[6] memory realAttributes, uint256 realWeight, uint256 realElement, , , ) = _fighterFarmContract.getAllFighterInfo(0); uint256 simulationHead = simulationAttributes[0]; uint256 simulationEyes = simulationAttributes[1]; uint256 simulationMouth = simulationAttributes[2]; uint256 realHead = realAttributes[0]; uint256 realEyes = realAttributes[1]; uint256 realMouth = realAttributes[2]; // All assertions are successful, Alice has predicted what the reroll will change on her fighter and can decide if it's worth it before spending her $NRN // Conclusion: Fighter stats generation is not truly random assertEq(simulationHead, realHead); assertEq(simulationEyes, realEyes); assertEq(simulationMouth, realMouth); assertEq(simulationWeight, realWeight); assertEq(simulationElement, realElement); }
Manual Review/Foundry
Add real randomness through service such as Chainlink VRF.
Other
#0 - c4-pre-sort
2024-02-24T01:41:24Z
raymondfam marked the issue as duplicate of #53
#1 - c4-judge
2024-03-06T03:49:26Z
HickupHH3 changed the severity to 3 (High Risk)
#2 - c4-judge
2024-03-06T03:50:06Z
HickupHH3 marked the issue as satisfactory
#3 - c4-judge
2024-03-15T02:10:55Z
HickupHH3 changed the severity to 2 (Med Risk)
#4 - c4-judge
2024-03-22T04:21:07Z
HickupHH3 marked the issue as duplicate of #376
π Selected for report: t0x1c
Also found by: 0xCiphky, 0xDetermination, Draiakoo, Greed, Kalogerone, MatricksDeCoder, MidgarAudits, MrPotatoMagic, PedroZurdo, Shubham, SpicyMeatball, VAD37, Velislav4o, ZanyBonzy, btk, cats, djxploit, forkforkdog, givn, ladboy233, lanrebayode77, lil_eth, visualbits, zaevlad
59.2337 USDC - $59.23
https://github.com/code-423n4/2024-02-ai-arena/blob/f2952187a8afc44ee6adc28769657717b498b7d4/src/GameItems.sol#L158-L161 https://github.com/code-423n4/2024-02-ai-arena/blob/f2952187a8afc44ee6adc28769657717b498b7d4/src/GameItems.sol#L212
Some items have a daily item allowance limit of how many a user can buy. Once a user buys up enough to hit their limit, they are required to wait for the dailyAllowanceReplenishTime
for that item id.
require( dailyAllowanceReplenishTime[msg.sender][tokenId] <= block.timestamp || quantity <= allowanceRemaining[msg.sender][tokenId] );
The limit can be completely bypassed if a user uses multiple addresses to purchase the items and due to this a few issues exist:
If the item is transferrable, they can buy the item from n
amount of addresses and transfer all items to the main user address. Although this alone is an issue since users have a set daily limit and this bypasses it, it is an even bigger issue if the item has a finite quantity. Then, it can be monopolized by a single player, buying out all available stock from multiple addresses and then looking to negotiate and sell the items to other players for a profit.
Add the code below to GameItems.t.sol
and run with forge test --mt testDailyItemAllowance -vvvv
function testDailyItemAllowance() public { // Creating instance of Alice and AliceTwo address alice = makeAddr("alice"); address aliceTwo = makeAddr("aliceTwo"); // Funding Alice & AliceTwo with $NRN vm.startPrank(_treasuryAddress); _neuronContract.transfer(alice, 10_000 * 10 ** 18); _neuronContract.transfer(aliceTwo, 10_000 * 10 ** 18); // Buying the maximum daily limit of batteries as Alice vm.startPrank(alice); _gameItemsContract.mint(0, 10); assertEq(_gameItemsContract.balanceOf(alice, 0), 10); // Trying to buy 1 more (expecting it to fail) and asserting Alice still has only 10 vm.expectRevert(); _gameItemsContract.mint(0, 1); assertEq(_gameItemsContract.balanceOf(alice, 0), 10); // Buying 10 batteries as AliceTwo and transferring them to Alice vm.startPrank(aliceTwo); _gameItemsContract.mint(0, 10); assertEq(_gameItemsContract.balanceOf(aliceTwo, 0), 10); _gameItemsContract.safeTransferFrom(aliceTwo, alice, 0, 10, ""); // Checking Alice's balance now assertEq(_gameItemsContract.balanceOf(alice, 0), 20); }
Manual Review/Foundry
The monopolizing issue only occurs for transferable/finite quantity items. There can be various solutions:
These are 2 I could think of that seem logical.
Other
#0 - c4-pre-sort
2024-02-25T07:13:53Z
raymondfam marked the issue as insufficient quality report
#1 - c4-pre-sort
2024-02-25T07:14:25Z
raymondfam marked the issue as duplicate of #43
#2 - c4-judge
2024-03-07T06:33:28Z
HickupHH3 marked the issue as satisfactory
π Selected for report: t0x1c
Also found by: 0xCiphky, 0xDetermination, Draiakoo, Greed, Kalogerone, MatricksDeCoder, MidgarAudits, MrPotatoMagic, PedroZurdo, Shubham, SpicyMeatball, VAD37, Velislav4o, ZanyBonzy, btk, cats, djxploit, forkforkdog, givn, ladboy233, lanrebayode77, lil_eth, visualbits, zaevlad
59.2337 USDC - $59.23
https://github.com/code-423n4/2024-02-ai-arena/blob/f2952187a8afc44ee6adc28769657717b498b7d4/src/RankedBattle.sol#L345-L346 https://github.com/code-423n4/2024-02-ai-arena/blob/f2952187a8afc44ee6adc28769657717b498b7d4/src/VoltageManager.sol#L107-L108 https://github.com/code-423n4/2024-02-ai-arena/blob/f2952187a8afc44ee6adc28769657717b498b7d4/src/GameItems.sol#L147-L176 https://github.com/code-423n4/2024-02-ai-arena/blob/f2952187a8afc44ee6adc28769657717b498b7d4/src/VoltageManager.sol#L93-L99 https://github.com/code-423n4/2024-02-ai-arena/blob/f2952187a8afc44ee6adc28769657717b498b7d4/src/FighterFarm.sol#L333-L348
Voltage is the currency used when players initiate a battle with their fighter in the protocol - users pay a flat VOLTAGE_COST
.
if (initiatorBool) { _voltageManagerInstance.spendVoltage(fighterOwner, VOLTAGE_COST); }
Users have 2 options when it comes to replenishing their Voltage:
battery
item from the protocol's GameItems
contract and then use it to fill up to 100But since fighters are NFTs and can be transferred, a player can use up the daily voltage on their address to participate in battles, then transfer the fighter to another address and use up voltage from there and so on indefinitely. The protocol game item contract is designed for users to buy batteries in order to replenish their Voltage and fight. If a user shuffles the fighter between accounts they completely bypass the daily limit without the need to buy batteries which is value lost for the protocol for every battery purchase that is avoided.
Bypassing daily address Voltage limit imposed onto users by the protocol.
Add the code below to FighterFarm.t.sol
and run with forge test --mt testVoltageLimitBypass -vvvv
function testVoltageLimitBypass() public { // Creating instance of Alice and Alice 2 address alice = makeAddr("alice"); address aliceTwo = makeAddr("aliceTwo"); // Minting Alice a fighter NFT _mintFromMergingPool(alice); // Using Voltage with Alice from her primary address using spendVoltage as the Ranked Battle contract would when a fight is initiated vm.startPrank(alice); _voltageManagerContract.spendVoltage(alice, 10); // Caching Alice's left Voltage uint256 aliceVoltageLeft = _voltageManagerContract.ownerVoltage(alice); // Transferring fighter NFT to Alice's secondary address _fighterFarmContract.transferFrom(alice, aliceTwo, 0); // Using Voltage with Alice's secondary address now vm.startPrank(aliceTwo); _voltageManagerContract.spendVoltage(aliceTwo, 20); // Caching AliceTwo's left Voltage uint256 aliceTwoVoltageLeft = _voltageManagerContract.ownerVoltage(aliceTwo); // Console logging both accounts' left Voltage, Primary Alice used up 10, so should have 90 remaining // Secondary Alice used up 20, so should have 80 remaining console.log("Primary Alice left Voltage:", aliceVoltageLeft); console.log("Secondary Alice left Voltage:", aliceTwoVoltageLeft); }
Manual Review/Foundry
I think refactoring Voltage to be tracked per fighterId
instead of per address is a more logical approach.
Other
#0 - c4-pre-sort
2024-02-23T01:55:43Z
raymondfam marked the issue as duplicate of #43
#1 - c4-pre-sort
2024-02-23T01:55:47Z
raymondfam marked the issue as insufficient quality report
#2 - raymondfam
2024-02-23T02:39:06Z
Should be infeasible if staking is entailed.
#3 - c4-judge
2024-03-07T04:22:08Z
HickupHH3 marked the issue as satisfactory