Protector Blog
Discovering Vulnerabiltities, Preventing Harm: AI Arena
We provide a case study of our comprehensive audit of AI Arena below.
by our Senior Blockchain Security Researchers: 10ambear and bLnk
Introduction
In our view, meticulous examination of smart contracts is a process of scrutiny and discovery that averts potential harm to all protocols and projects with smart contracts. Recently, our team participated in a comprehensive audit of the AI Arena protocol, a PvP platform fighting game. We focused on diving deep into the codebase, pinpointing weaknesses, and recommending robust remediations to the vulnerabilities we discovered in the protocol. Here, we present our findings, but first, a quick summary of what the AI Arena protocol is.
A Quick Summary of AI Arena
TLDR: It’s a PVP game with regular and shiny robots where (mostly) weight determines how hard they punch. You win or lose Elo points and $NRN by staking tokens when battling.
For context, here’s a snippet from AI arena’s official documentation:
“AI Arena is a PvP platform fighting game where the fighters are AIs that were trained by humans. In the web3 version of our game, these fighters are tokenized via the FighterFarm.sol
smart contract. Players can enter their NFT fighters into ranked battle to earn rewards in our native token $NRN.”
In order to find vulnerabilities, we need to understand two important concepts about AI Arena: (1) attributes and (2) fighter types. Fighters have attributes that affect battle outcomes, i.e. better attributes lead to better battle outcomes. In other words, “a fighter’s weight is the primary determinant of its other relative strength and weaknesses (i.e. all other battle attributes are a function of weight)” (AI Arena Official Documentation - link?). In addition to attributes and fighter types, there are also elemental powers that determine strengths and weaknesses (akin to Pokémon). Fighters come in two flavors: Champion and Dendroid. Champions are the standard class whereas Dendroid is the shiny class! Dendroids are not pay to win and don’t contain additional attributes, but they are rare which makes them more valuable.
The fight rankings work on an Elo system (similar to League of Legends) and a points system where players stake AI Arena’s native token ($NRN) to earn points. Players gain Elo (points) when they win and lose Elo (points) when they lose. You may also play for fun without ranking, but in order to earn $NRN you have to stake $NRN.
Vulnerabilities Discovered
We discovered 2 High and 2 Medium vulnerabilities in this codebase.
01
High - A User Can Change Their Fighter Type By Re-Rolling Their Fighter
The reRoll
function in the FighterFarm.sol
contractallows a user to roll a new fighter with random traits.
/// @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] = "";
}
}
Referring to the code snippet above, the function takes two parameters namely tokenId
and fighterType
. The fighterType
parameter is used in the _createFighterBase
function to create base parameters for a fighter.
This means that any user can choose any fighterType
when they re-roll. The problem with this is that the shiny rare Dendroid types wouldn’t be so rare if anyone could re-roll their standard Champion fighters into a Dendroid.
We recommended that the protocol does not include the fighterType
in the re-roll.
02
High - Truncation In Divide Could Net a Player Points By Staking 1 wei of NRN Tokens
In the AI Arena, you claim a fighter, stake some funds, and then participate in ranked battles with your money at stake. The more you win, the bigger your stake becomes. AI Arena employs an Elo system similar to chess, where winning or losing results in gaining or losing points according to the difference in score between you and your opponent. Winning against opponents with higher Elo ratings means acquiring more points. These points are subsequently translated into money using formulas that take your stake amount into account. Now, the protocol doesn't allow you to play for points if you don't have any stake, as you have nothing to lose and can simply play for points. For this situation, the protocol has implemented special checks, such as:
if (amountStaked[tokenId] + stakeAtRisk > 0) { _addResultPoints(battleResult, tokenId, eloFactor, mergingPortion, fighterOwner); }
If your stake is zero, then you simply won't earn any points if you win. You can still participate in fights, but they would only be for fun. Now, what exactly happens if you win or lose? Do you lose your entire stake if you lose the first time? The answer to the second question is no. You're meant to lose only a percentage of your stake. If you win, your points increase based on your stake amount and Elo. You don't double your stake every time; you gain only a percentage. How is all of this calculated?
curStakeAtRisk = (bpsLostPerLoss * (amountStaked[tokenId] + stakeAtRisk)) / 10**4;
This is how it works: If you have experience with truncation issues, the alarm in your head is probably going off right now. bpsLostPerLoss
is meant to represent the amount you lose (or put at risk of losing) each time you experience a defeat. This value is set in the contract:
uint256 public bpsLostPerLoss = 10;
This could lead to potential issues as users can exploit the truncation here. For instance, a user could stake only 1 wei worth of NRN Tokens (AI Arena's ERC20), and the above calculation would truncate to zero, effectively nullifying any risk for the user.
curStakeAtRisk = (10 * (1 + 0)) / 10**4;
curStakeAtRisk = 0
By doing this, it results in the player losing absolutely nothing whenever they lose. However, the calculation changes when they win.
if (stakeAtRisk == 0) { points = stakingFactor[tokenId] * eloFactor; }
Under this exploit, the user consistently has zero stake at risk, ensuring that they always receive points when they win. With the Elo factor set at 1500
and the stakingFactor having a minimum value of 1
, the user effectively plays only for points without any funds at risk. Consequently, the user can capitalize on their win streaks by cashing out the accumulated points.
The protocol employs a function named _addResultPoints
to allocate points to the user's address. To address this vulnerability, we proposed implementing a check or utilizing the safeTransfer
function to revert transactions with zero transfers. This measure would help mitigate the exploit.
03
Medium - A user can game the protocol through weight and element manipulation
The success rate of a fighter in the arena is influenced by the fighter’s attributes. This is especially important in the weight
and element
categories, but how are these determined?
Ultimately we derive the fighter’s stats in two steps:
We calculate a
dna
valueuint256 dna = uint256(keccak256(abi.encode(msg.sender, tokenId, numRerolls[tokenId])));
. This calculation was taken from thereRoll
function above (https://github.com/code-423n4/2024-02-ai-arena/blob/b05b54a9eb5b0964bde9825f75305caa2d943155/src/FighterFarm.sol#L370-L391).We send the
dna
value to the_createFighterBase
function to calculate the attributes.
We added the function below that shows how the base attributes of fighters are created.
/// @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]];
uint256 weight = dna % 31 + 65;
uint256 newDna = fighterType == 0 ? dna : uint256(fighterType);
return (element, weight, newDna);
}
If you trace the parameters on both steps of the calculation, you’ll notice that every parameter is deterministic, meaning we need to know:
The
msg.sender
. This is us.The
tokenId
. This is our fighter Id.The
numRerolls[tokenId]
mapping. We haven’t mentioned this yet, but it’s a mapping that keeps track of how many times a fighter has been re-rolled.The
fighterType
. This is our fighter type.The
numElements[generation[fighterType]]
mapping. We also haven’t looked at this. It’s a mapping of the number of elements available for this generation (think Pokémon).
We can get every piece of this puzzle before it’s built. Does this mean that we can calculate our stats before we re-roll a fighter? Yes! Better yet, does this mean that we can determine the amount of re-rolls required to get the best fighter possible? Yes, it does!
What would this mean for users with this knowledge? Well, I wouldn’t want to go up against their fighters in the arena, they’ll be OP! We’ve added a coded proof of concept here. If you want to play around and see if you can re-roll to get the most OP fighters, use the proof of concept.
Our recommendation is to make the dna
calculation as random as possible by making use of off-chain random functionality such as Chainlink’s VRF.
04
Medium - Winners can set attributes of fighters when claiming rewards, netting gamified earnings
AI Arena has a mechanism for users to potentially earn fighters through a Merging pool contract. The Merging pool allows users to call a claimRewards
function to mint fighters as a reward.
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);
}
}
As we mentioned earlier fighters have attributes that affect their performance such as element
and weight
. So the question is - if a new fighter is minted as a reward, what should the attributes be? Should the attributes be randomized or should we give users the opportunity to choose attributes to mint the perfect fighter?
In the latter scenario the user can choose the function parameter values, including the customAttributes
parameters. Allowing them to choose perfect attributes for their newly minted overpowered fighters!
Our suggestion for this fix is to randomize the attributes using chainlink’s VRF.
Conclusion