Lines of code
Vulnerability details
Lines of code
Mitigated lines of code
https://github.com/ArenaX-Labs/2024-02-ai-arena-mitigation/blob/setUpAirdrop-mitigation/src/RankedBattle.sol#L145-L146
https://github.com/ArenaX-Labs/2024-02-ai-arena-mitigation/blob/setUpAirdrop-mitigation/src/RankedBattle.sol#L375-L380
Vulnerability details
The issue was reported in #137 and #1795.
In each round, a player can decide to assign an amount of NRN to each of his/her fighters: it is called stakeAtRisk
. When a battle finishes, the RankedBattle._addResultPoints()
is called. There are 5 scenarios:
/// @dev 1) Win + no stake-at-risk = Increase points balance
/// @dev 2) Win + stake-at-risk = Reclaim some of the stake that is at risk
/// @dev 3) Lose + positive point balance = Deduct from the point balance
/// @dev 4) Lose + no points = Move some of their NRN staked to the Stake At Risk contract
/// @dev 5) Tie = no consequence
The vulnerability relies on RankedBattle.sol#L486:
472 } else if (battleResult == 2) {
473 /// If the user lost the match
474
475 /// Do not allow users to lose more NRNs than they have in their staking pool
476 if (curStakeAtRisk > amountStaked[tokenId]) {
477 curStakeAtRisk = amountStaked[tokenId];
478 }
479 if (accumulatedPointsPerFighter[tokenId][roundId] > 0) {
480 /// If the fighter has a positive point balance for this round, deduct points
481 points = stakingFactor[tokenId] * eloFactor;
482 if (points > accumulatedPointsPerFighter[tokenId][roundId]) {
483 points = accumulatedPointsPerFighter[tokenId][roundId];
484 }
485 accumulatedPointsPerFighter[tokenId][roundId] -= points;
486 accumulatedPointsPerAddress[fighterOwner][roundId] -= points;
487 totalAccumulatedPoints[roundId] -= points;
488 if (points > 0) {
489 emit PointsChanged(tokenId, points, false);
490 }
491 } else {
If it was possible to reach a state where accumulatedPointsPerAddress[fighterOwner][roundId]
is less than points
, line #486 would always revert. In this way, when the player loses during roundId
, he/she will not lose points. In other words, the player could start earning risk-free rewards.
To reach the above situation, the malicious player Bob has to execute some steps.
- Let's assume Bob has two accounts, i.e., two addresses, Bob1 and Bob2.
- Bob1 owns the
fighter0
, i.e., the fighter with tokenId = 0
. He decides to stake an amount of 1 NRN on fighter0
calling RankedBattle.stakeNRN()
amountStaked[0] = 1;
fighterStaked[0] = true;
- Then, Bob1 loses a battle using
fighter0
. Then the _gameServerAddress
calls RankedBattle.updateBattleRecord()
which calls RankedBattle._addResultPoints()
:
RankedBattle.sol#L491-L497
491 } else {
492 /// If the fighter does not have any points for this round, NRNs become at risk of being lost
493 bool success = _neuronInstance.transfer(_stakeAtRiskAddress, curStakeAtRisk);
494 if (success) {
495 _stakeAtRiskInstance.updateAtRiskRecords(curStakeAtRisk, tokenId, fighterOwner);
496 amountStaked[tokenId] -= curStakeAtRisk;
497 }
amountStaked[0] = 0;
fighterStaked[0] = true;
stakeAtRisk[roundId][0] = 1;
- Now, Bob1 calls RankedBattle.unstakeNRN()
amountStaked[0] = 0;
fighterStaked[0] = false;
stakeAtRisk[roundId][0] = 1;
- Once Bob1 has reached this state, he uses
fighter0
to win a battle. He aims to have something to claim. If he win, the amountStaked[0]
increases:
RankedBattle.sol#L439-L471
439 curStakeAtRisk = (bpsLostPerLoss * (amountStaked[tokenId] + stakeAtRisk)) / 10**4;
440 if (battleResult == 0) {
441 /// If the user won the match
442
443 /// If the user has no NRNs at risk, then they can earn points
444 if (stakeAtRisk == 0) {
445 points = stakingFactor[tokenId] * eloFactor;
446 }
447
448 /// Divert a portion of the points to the merging pool
449 uint256 mergingPoints = (points * mergingPortion) / 100;
450 points -= mergingPoints;
451 _mergingPoolInstance.addPoints(tokenId, mergingPoints);
452
453 /// Do not allow users to reclaim more NRNs than they have at risk
454 if (curStakeAtRisk > stakeAtRisk) {
455 curStakeAtRisk = stakeAtRisk;
456 }
457
458 /// If the user has stake-at-risk for their fighter, reclaim a portion
459 /// Reclaiming stake-at-risk puts the NRN back into their staking pool
460 if (curStakeAtRisk > 0) {
461 _stakeAtRiskInstance.reclaimNRN(curStakeAtRisk, tokenId, fighterOwner);
462 amountStaked[tokenId] += curStakeAtRisk;
463 }
464
465 /// Add points to the fighter for this round
466 accumulatedPointsPerFighter[tokenId][roundId] += points;
467 accumulatedPointsPerAddress[fighterOwner][roundId] += points;
468 totalAccumulatedPoints[roundId] += points;
469 if (points > 0) {
470 emit PointsChanged(tokenId, points, true);
471 }
amountStaked[0] = (bpsLostPerLoss * (amountStaked[tokenId] + stakeAtRisk)) / 10**4 = bpsLostPerLoss/ 10**4 = X > 0;
fighterStaked[0] = false;
stakeAtRisk[roundId][0] = 1;
- Now, let's start a new round: RankedBattle.setNewRound():
RankedBattle.sol#L233-L239
233 function setNewRound() external {
234 require(isAdmin[msg.sender]);
235 require(totalAccumulatedPoints[roundId] > 0);
236 roundId += 1;
237 _stakeAtRiskInstance.setNewRound(roundId);
238 rankedNrnDistribution[roundId] = rankedNrnDistribution[roundId - 1];
239 }
amountStaked[0] = X > 0;
fighterStaked[0] = false;
stakeAtRisk[roundId][0] = 0;
Now Bob has obtained the critical situation. When a fighter
token has an amount staked on it, it should be not possible to transfer it. However,
the transfer method just checks the value of fighterStaked[tokenId]
:
FighterFarm.sol#L539-L545
539 function _ableToTransfer(uint256 tokenId, address to) private view returns(bool) {
540 return (
541 _isApprovedOrOwner(msg.sender, tokenId) &&
542 balanceOf(to) < MAX_FIGHTERS_ALLOWED &&
543 !fighterStaked[tokenId]
544 );
545 }
- Bob uses
fighter0
to win a battle:
amountStaked[0] = Y > 0;
fighterStaked[0] = false;
stakeAtRisk[roundId][0] = 0;
accumulatedPointsPerFighter(tokenId, roundId) = Z;
accumulatedPointsPerAddress(Bob1, roundId) = Z;
- Then Bob transfer
fighter0
from Bob1 to Bob2. As we said above, he is allowed to do it.
amountStaked[0] = Y > 0;
fighterStaked[0] = false;
stakeAtRisk[roundId][0] = 0;
accumulatedPointsPerFighter(tokenId, roundId) = Z;
accumulatedPointsPerAddress(Bob2, roundId) = 0;
In this situation, when fighter0
loses, it is not possible to call RankedBattle.updateBattleRecord()
, because line #486 would always revert:
RankedBattle.sol#L486
486 accumulatedPointsPerAddress[fighterOwner][roundId] -= points;
In other words, during the current round, Bob earns risk-free rewards.
Recommended Mitigation proposed by wardens
The mitigation proposed by #137 is to update RankedBattle.unstakeNRN()
:
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);
}
}
In this way, it should not be possible to reach a state like this:
amountStaked[0] = 0;
fighterStaked[0] = false;
stakeAtRisk[roundId][0] = 1;
which breakes an invariant: fighterStaked[tokenId] = false
iff stakeAtRisk[roundId][tokenId] == 0
.
This solution was implemented by the Ai Arena team.
Mitigation applied by developers
Developers have applied the mitigation proposed by #137.
Furthermore, they decided to add a specific mapping, called addressStartedRound
, to check whether the owner of a fighter
changes during a round:
@@ -133,6 +133,9 @@ contract RankedBattle {
/// @notice Indicates whether we have calculated the staking factor for a given round and token.
mapping(uint256 => mapping(uint256 => bool)) _calculatedStakingFactor;
+ /// @notice Maps token ID to round ID to starting address.
+ mapping(uint256 => mapping(uint256 => address)) public addressStartedRound;
+
/*//////////////////////////////////////////////////////////////
CONSTRUCTOR
//////////////////////////////////////////////////////////////*/
@@ -337,6 +340,13 @@ contract RankedBattle {
_voltageManagerInstance.ownerVoltage(fighterOwner) >= VOLTAGE_COST
);
+ if (addressStartedRound[tokenId][roundId] == address(0)) {
+ addressStartedRound[tokenId][roundId] = fighterOwner;
+ }
+ else {
+ require(addressStartedRound[tokenId][roundId] == fighterOwner);
+ }
+
_updateRecord(tokenId, battleResult);
uint256 stakeAtRisk = _stakeAtRiskInstance.getStakeAtRisk(tokenId);
if (amountStaked[tokenId] + stakeAtRisk > 0) {
This solution was implemented by the Ai Arena team.
Comment about the Mitigation Proposal
As we said above, thanks to mitigation proposed by wardens, it should not be possible to break the invariant
fighterStaked[tokenId] = false iff stakeAtRisk[roundId][tokenId] == 0
amountStaked[tokenId]
can be modified only by 4 events:
- A call to stakeNRN (which increases it)
- A call to unstakeNRN (which decreases it)
- A win in a battle (which increases it only if the previous
amountStaked[tokenId]>0
or stakeAtRisk>0
)
- A loss in a battle (which decreases it)
Among them, only unstakeNRN()
sets fighterStaked[tokenId] = false
Before mitigation, the state that should never be reached is the following:
amountStaked[0] = 0;
fighterStaked[0] = true;
stakeAtRisk[roundId][0] = Y>0;
stakeAtRisk
can increase only when the player loses a battle having amountStaked[tokenId]>0
or stakeAtRisk>0
. In other words, the player can have stakeAtRisk>0
during this round iff the player has called stakeNRN
with amount > 0
during this round, i.e., iff fighterStaked[0] = true
.
In the state above, it was possible to call unstakeNRN()
successfully.
After mitigation, the state above is not critical anymore: it is not possible to call unstakeNRN()
successfully. Now, to obtain fighterStaked[0] = false
, unstakeNRN(amount, tokenId)
can be called only from the following state:
amountStaked[tokenId] = amount;
fighterStaked[tokenId] = true/false;
stakeAtRisk[roundId][tokenId] = 0;
After this call, the only state reachable is
amountStaked[tokenId] = 0;
fighterStaked[tokenId] = false;
stakeAtRisk[roundId][tokenId] = 0;
which is the right one.
In conclusion, thanks to the mitigation proposed by wardens, the vector attack above is no longer feasible. The part of mitigation proposed by developers is not used yet. addressStartedRound
is not used in any check. However, we think that it would be unnecessary in any case, and it can be deleted.