Summary
This protocol implements time constraints for a number of functions. Dussehra::killRavana() is one such function, and it is of great important to RamNFT::organiser and ChoosingRam::selectedRam as it is the only way these two actors can receive their funds.
function killRavana() public RamIsSelected {
if (block.timestamp < 1728691069) {
revert Dussehra__MahuratIsNotStart();
}
if (block.timestamp > 1728777669) {
revert Dussehra__MahuratIsFinished();
}
...
}
Anyone can call this function, but it must be done in the span of one day
Of all the participants, only RamNFT::organiser and ChoosingRam::selectedRam are incentivized to call this function
It would be in the best interest of RamNFT::organiser and ChoosingRam::selectedRam to ensure that Dussehra::killRavana() is called within the given time constraints. If for any reason they are unable to call the function, all funds will be stuck in the contract.
Vulnerability Details
We can see this vulnerability in action using Foundry.
Foundry test setup
pragma solidity ^0.8.13;
import {Test, console} from "forge-std/Test.sol";
import {Dussehra} from "../../src/Dussehra.sol";
import {ChoosingRam} from "../../src/ChoosingRam.sol";
import {RamNFT} from "../../src/RamNFT.sol";
contract CounterTest is Test {
error Dussehra__MahuratIsFinished();
Dussehra public dussehra;
RamNFT public ramNFT;
ChoosingRam public choosingRam;
address public organiser = makeAddr("organiser");
uint256 public constant PICK_A_NUMBER = 5;
address[] players = new address[](PICK_A_NUMBER);
function setUp() public {
vm.startPrank(organiser);
ramNFT = new RamNFT();
choosingRam = new ChoosingRam(address(ramNFT));
dussehra = new Dussehra(1 ether, address(choosingRam), address(ramNFT));
ramNFT.setChoosingRamContract(address(choosingRam));
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();
}
_;
}
}
Foundry Test
function test_stuckFunds() public participants {
uint256 contractBalance = address(dussehra).balance;
console.log("balance start: ", contractBalance);
vm.warp(1728691200 + 1);
vm.startPrank(organiser);
choosingRam.selectRamIfNotSelected();
address selected = choosingRam.selectedRam();
vm.stopPrank();
vm.warp(1728777669+1);
vm.expectRevert(abi.encodeWithSelector(Dussehra__MahuratIsFinished.selector));
vm.startPrank(selected);
dussehra.killRavana();
vm.stopPrank();
vm.warp(1728777669+1);
vm.expectRevert(abi.encodeWithSelector(Dussehra__MahuratIsFinished.selector));
vm.startPrank(organiser);
dussehra.killRavana();
vm.stopPrank();
vm.expectRevert();
vm.startPrank(selected);
dussehra.withdraw();
vm.stopPrank();
contractBalance = address(dussehra).balance;
console.log("balance end: ", contractBalance);
assertEq(dussehra.IsRavanKilled(), false);
}
This test shows what happens if ChoosingRam::selectedRam or RamNFT::organiser miss the window to call Dussehra::killRavana().
We can see that the contract balance remains the same from the start of the test to the finish, and there is no way to extract these funds.
It is up to the protocol how to address this issue, but it should be addressed. This is an example of a simple fix added to Dussehra that would allow entrants to reclaim their entrance fees (this example neglects the RamNFT::organiser)
New Withdraw Option
function withdrawStillAlive() public {
if (IsRavanKilled) {
revert();
}
if (block.timestamp <= 1728777669) {
revert();
}
if (!peopleLikeRam[msg.sender]) {
revert();
}
peopleLikeRam[msg.sender] = false;
(bool success, ) = msg.sender.call{value: entranceFee}("");
if (!success) {
revert();
}
}
We need to make sure IsRavanKilled is false
The block.timestap has to be after the event is finished
msg.sender must return true when used in Dussehra::peopleLikeRam
Update msg.sender in Dussehra::peopleLikeRam to false
Allow msg.sender to recollect entrance fee
Foundry Test
function test_newWithdraw() public participants {
vm.warp(1728691200 + 1);
vm.startPrank(organiser);
choosingRam.selectRamIfNotSelected();
address selected = choosingRam.selectedRam();
vm.stopPrank();
vm.warp(1728777669+1);
vm.expectRevert(abi.encodeWithSelector(Dussehra__MahuratIsFinished.selector));
vm.startPrank(selected);
dussehra.killRavana();
vm.stopPrank();
vm.warp(1728777669+1);
vm.expectRevert(abi.encodeWithSelector(Dussehra__MahuratIsFinished.selector));
vm.startPrank(organiser);
dussehra.killRavana();
vm.stopPrank();
uint256 contractBalance = address(dussehra).balance;
console.log("balance start: ", contractBalance);
vm.startPrank(players[0]);
dussehra.withdrawStillAlive();
vm.stopPrank();
vm.expectRevert();
vm.startPrank(players[0]);
dussehra.withdrawStillAlive();
vm.stopPrank();
contractBalance = address(dussehra).balance;
console.log("balance middle: ", contractBalance);
for (uint256 i=1; i<PICK_A_NUMBER;i++) {
vm.startPrank(players[i]);
dussehra.withdrawStillAlive();
vm.stopPrank();
}
contractBalance = address(dussehra).balance;
console.log("balance end: ", contractBalance);
assertEq(dussehra.IsRavanKilled(), false);
}
This test shows the implementation of Dussehra::withdrawStillAlive
After the event is over, if RamNFT::organiser and ChoosingRam::selectedRam fail to call Dussehra::killRavana(), the new withdraw function will allow participants to reclaim their entrance fees
The test shows that a participant can only call the function once
The contract balance at the end of the test is 0
Impact
This is a medium risk vulnerability that can lead to a loss of all funds if actors don't participate accordingly.
There are two participants that are incentivized to be mindful of the time constraints in Dussehra::killRavana (RamNFT::organiser and ChoosingRam::selectedRam). While anyone can call Dussehra::killRavana, it is up to two actors to make sure it happens within the time constrains. Dussehra::killRavana() is the only way RamNFT::organiser and ChoosingRam::selectedRam can receive their funds. So the likelihood of this vulnerability occurring intentionally is low. But if the vulnerability occurs unintentionally, the impact will lead to a complete loss of funds for both actors leaving the funds stuck in the contract.
Tools Used
Recommendations
I provided an example of a withdraw function that can be used to allow participants to reclaim their fees. That is just one way to fix the problem. But there are many ways to fix the issue, and the problem must be addressed.
The protocol may feel that RamNFT::organiser should still be rewarded as this was an nft mint. In that case the suggested function can be adjusted.
Alternative Fix
Add a bool to track whether the RamNFT::organiser has withdrawn:
contract Dussehra {
...
+ bool public organiserFee;
...
}
Add a calculation to Dussehra::withdrawStillAlive to return half the entrance fees:
function withdrawStillAlive() public {
if (IsRavanKilled) {
revert();
}
if (block.timestamp <= 1728777669) {
revert();
}
if (!peopleLikeRam[msg.sender]) {
revert();
}
peopleLikeRam[msg.sender] = false;
+ uint256 giveThemHalf = (entranceFee * 50) / 100;
+ (bool success, ) = msg.sender.call{value: giveThemHalf}("");
if (!success) {
revert();
}
}
Create Dussehra::organizerWithdraw()
function organizerWithdraw() public {
if (msg.sender != ramNFT.organiser()) {
revert();
}
if (address(this).balance == 0) {
revert();
}
if (organiserFee) {
revert();
}
organiserFee = true;
uint256 totalAmountByThePeople = WantToBeLikeRam.length * entranceFee;
totalAmountGivenToRam = (totalAmountByThePeople * 50) / 100;
(bool success, ) = organiser.call{value: totalAmountGivenToRam}("");
require(success, "Failed to send money to organiser");
}
Foundry Test
function test_giveThemHalf() public participants {
vm.warp(1728691200 + 1);
vm.startPrank(organiser);
choosingRam.selectRamIfNotSelected();
address selected = choosingRam.selectedRam();
vm.stopPrank();
uint256 contractBalance = address(dussehra).balance;
console.log("balance start: ", contractBalance);
vm.startPrank(organiser);
dussehra.organizerWithdraw();
vm.stopPrank();
vm.expectRevert();
vm.startPrank(organiser);
dussehra.organizerWithdraw();
vm.stopPrank();
contractBalance = address(dussehra).balance;
console.log("balance middle: ", contractBalance);
for (uint256 i=0; i<PICK_A_NUMBER;i++) {
vm.startPrank(players[i]);
dussehra.withdrawStillAlive();
vm.stopPrank();
}
contractBalance = address(dussehra).balance;
console.log("balance end: ", contractBalance);
assertEq(dussehra.IsRavanKilled(), false);
}
Another simple fix would be to remove the second time constraint in Dussehra::killRavana():
function killRavana() public RamIsSelected {
if (block.timestamp < 1728691069) {
revert Dussehra__MahuratIsNotStart();
}
- if (block.timestamp > 1728777669) {
- revert Dussehra__MahuratIsFinished();
- }
...
}
It is up to the protocol on how to proceed, but this is an issue that should not be left unaddressed.