Closed code423n4 closed 1 year ago
thereksfour marked the issue as primary issue
I would say this is an issue, but would say it is a medium risk.
rand0c0des marked the issue as disagree with severity
After a careful reading, I will consider this report as two medium issues and will link with the corresponding duplicates
thereksfour changed the severity to QA (Quality Assurance)
thereksfour marked the issue as grade-c
rand0c0des marked the issue as sponsor confirmed
Lines of code
https://github.com/code-423n4/2023-03-wenwin/blob/91b89482aaedf8b8feb73c771d11c257eed997e8/src/RNSourceController.sol#L106-L120 https://github.com/code-423n4/2023-03-wenwin/blob/91b89482aaedf8b8feb73c771d11c257eed997e8/src/RNSourceController.sol#L60-L75 https://github.com/code-423n4/2023-03-wenwin/blob/91b89482aaedf8b8feb73c771d11c257eed997e8/src/RNSourceController.sol#L89-L104
Vulnerability details
Introduction
This report bundles together several issues with regard to the current implementation of Executing a draw, Requesting RNG from Chainlink and Fulfilling RNG requests. The reason to summarize several attack paths in one vulnerability report is that the discovered logic flaws and assumptions are closely interconnected and the clarity of explaining the disclosed findings will be greater.
The current implementation of Random Number Generation uses Chainlink’s V2 Direct Funding Method (https://docs.chain.link/vrf/v2/direct-funding).
VRFv2RNSource.sol
(inherits Chainlink’sVRFV2WrapperConsumerBase.sol
) is responsible for handling requests and responses for the Lottery system. The communicator betweenVRFv2RNSource.sol
contract andLottery.sol
isRNSourceController.sol
. The ideal flow of control is the following:executeDraw()
inLottery.sol
assuming that the current draw is past the scheduled time for registering tickets.executeDraw()
puts the system in the state ofdrawExecutionInProgress = true
and callsrequestRandomNumber()
.RNSourceController.sol
-requestRandomNumber()
checks if the previous draw was completed and callsrequestRandomNumberFromSource()
.requestRandomNumberFromSource()
records the timestamp of the request inlastRequestTimestamp = block.timestamp
and setslastRequestFulfilled = false
i.eexecuteDraw()
cannot be called until the draw is finished. Lastlysource.requestRandomNumber()
is invoked.source.requestRandomNumber()
callsrequestRandomnessFromUnderlyingSource()
and that subsequently callsrequestRandomness()
to generate a RN from Chainlink VRF.fulfillRandomWords()
that callsfulfill()
, which callsonRandomNumberFulfilled()
in theRNSourceController.sol
that setslastRequestFulfilled = true
and lastlyreceiveRandomNumber(randomNumber)
is invoked inLottery.sol
that setsdrawExecutionInProgress = false
and starts a new draw (incrementscurrentDraw
state variable).Issue #1 - An attacker can leave the protocol in a "drawing" state for extended period of time
The culprit for this issue is the implementation of
requestRandomNumberFromSource()
inRNSourceController.sol
. AfterlastRequestFulfilled = false
the invocation toVRFv2RNSource.sol
is done in atry{} catch{}
block -This is very problematic due to how
try{} catch{}
works - OpenZeppelin article. If the request to Chainlink VRF fails at any point then execution of the above block will not revert but will continue in the catch{} statements only emitting an event and leaving RNSourceController in the statelastRequestFulfilled = false
and triggering themaxRequestDelay
(currently 5 hours) untilretry()
becomes available to call to retry sending a RN request. This turns out to be dangerous since there is a trivial way of making Chainlink VRF revert - simply not supplying enough gas for the transaction either initially in callingexecuteDraw()
or subsequently inretry()
invocations with the attacker front-running the malicious transaction.Proof of Concept
Moreover, the attacker doesn’t have any incentive to deposit LINK himself since VRF will also revert on insufficient LINK tokens. This Proof of Concept was also implemented and confirmed in a Remix environment, tested on the Ethereum Sepolia test network. A video walk-through can be provided on my behalf if requested by judge or sponsors.
Impact
System is left for extended period of time in “Drawing” state without the possibility to execute further draws, user experience is damaged significantly.
Comment
Building upon Issue #1, an obvious observation arises - there is the
swapSource()
method that becomes available ( only to the owner ) after a predefined number of failedretry()
invocations -maxFailedAttempts
. Therefore, potentially, admins of the protocol could replace the VRF solution with a better one that is resistant to the try catch exploit? It turns out that the current implementation ofswapSource()
introduces a new exploit that breaks the fairness of the protocol and an edge case could even be constructed that leads to an attacker stealing a jackpot.Issue #2 - Undermining the fairness of the protocol in
swapSource()
and possibilities for stealing a jackpotThis issue will demonstrate how the current implementation of
swapSource()
andretry()
goes directly against Chainlink's Security Consideration of Not re-requesting randomness (https://docs.chain.link/vrf/v2/security#do-not-re-request-randomness).Note
For clarity it is assumed that after
swapSource()
the new source would be Chainlink Subscription Method implementation and I would refer to it again asChainlink VRF
since this was the initial design decision, however it is of no consequence what the new VRF is.The Exploit
The
swapSource()
method can be successfully called if 2 important boolean checks aretrue
.notEnoughRetryInvocations
- makes sure that there weremaxFailedAttempts
failed requests for a RN.notEnoughTimeReachingMaxFailedAttempts
- makes sure thatmaxRequestDelay
amount of time has passed since the timestamp for reachingmaxFailedAttempts
was recorded inmaxFailedAttemptsReachedAt
i.e sufficient time has passed since the lastretry()
invocation. The most important detail to note here is that theswapSource()
function does not rely onlastRequestTimestamp
to check whethermaxRequestDelay
has passed since the last RN request.The critical bug resides in the
retry()
method.maxFailedAttemptsReachedAt
is ONLY updated whenfailedAttempts == maxFailedAttempts
- notice again the strict equality - meaning that maxFailedAttemptsReachedAt won't be updated if there are moreretry()
invocations afterfailedAttempts == maxFailedAttempts
. This means that after the point of time when the last failedretry()
setsmaxFailedAttemptsReachedAt
and themaxRequestDelay
time passes -retry()
andswapSource()
(in that exact order) can be called simultaneously.The attacker would monitor the transaction mempool for the
swapSource()
invocation and front-run aretry()
invocation before theswapSource
transaction. Now we have two separate - seemingly at the same time - calls torequestRandomNumberFromSource()
and again to note that theretry()
call will updatelastRequestTimestamp = block.timestamp
but it will not updatemaxFailedAttemptsReachedAt
since nowfailedAttempts > maxFailedAttempts
and as presentedswapSource()
does not rely onlastRequestTimestamp
which makes all of this possible. Now we have two requests at the same time toVRFv2RNSource.sol
and in turn Chainlink VRF. Chainlink VRF will in turn send 2 callback calls each containing a random number and the callbacks can be inspected by the attacker and in turn he will front-run the RN response that favours him greater thus undermining the fairness of the protocol.Proof of Concept
Impact
Re-requesting randomness is achieved when swapping sources of randomness. Fairness of protocol is undermined.
Note
Recently published finding by warden Trust discusses a very similar attack path that has to do with more than 1 VRF callbacks residing in the mempool (https://code4rena.com/reports/2022-12-forgeries#h-02-draw-organizer-can-rig-the-draw-to-favor-certain-participants-such-as-their-own-account).
Edge Case - stealing a jackpot when swapping randomness Source
The Wenwin protocol smart contracts are build such that various configurations of the system can be deployed. The provided documentation gives example with a weekly draw, however
drawPeriod
inLotterySetup.sol
could be any value. A lottery that is deployed withdrawPeriod
of for example 1 hour rather than days can be much more susceptible to re-requesting randomness. Similarly to Issue #2 an attacker would anticipate aswapSource()
call to front-run it withretry()
call and generate 2 RN requests. Now the attacker would use another front-running technique - Supression, also called block-stuffing, an attack that delays a transaction from being executed (reference in link section). The attacker would now let one of the generated RN callback requests return to the contract and reach thereceiveRandomNumber()
inLottery.sol
and let the protocol complete the current draw and return the system back in a state that can continue with the next draw - all of that while suppressing the second RN callback request. The attacker would register a ticket with the combination generated from the Random Number in the suppressed callback request and whenexecuteDraw()
is triggered he would then front-run the "floating" callback request to be picked first by miners therefore calling 'fulfillRandomWords()' with the known RN and winning the jackpot.Relevant links
Consensys front-running attacks - (https://consensys.github.io/smart-contract-best-practices/attacks/frontrunning/#suppression) Medium article on Block Stuffing and Fomo3D - (https://medium.com/hackernoon/the-anatomy-of-a-block-stuffing-attack-a488698732ae)
Proof of Concept
Impact
Fairness of the protocol is undermined when swapping Sources in Lottery configurations with short
drawPeriod
. Unfair win of a jackpot.Tools Used
Manual Inspection Issue #1 PoC implementation in Remix tested against Sepolia test network Chainlink documentation VRF, VRF security practices, Direct funding OpenZeppelin try{} catch{} article Consenys front-running attacks link ImmuneBytes front-running attacks article Medium article on Suppression link Previous codearena finding by warden Trust link
Recommended Mitigation Steps
Refactor the
try{} catch{}
inrequestRandomNumberFromSource()
inRNSourceController.sol
. ReplacefailedAttempts == maxFailedAttempts
withfailedAttempts >= maxFailedAttempts
inretry()
inRNSourceController.sol
. Evaluate centralization risks that spawn from the fact that only owners can decide on what a new source of randomness can be i.eswapSource()
. Ensure that when swapping sources the new Source doesn't introduce new potential attack vectors, I would suggest reading warden's Trust report from Forgeries competition that displays potential attack vectors withretry
-like functionality when using Chainlink VRF Subscription Method. Ensure all Security Considerations in Chainlink VRF documentation are met.Final note
I would be glad to be invited to participate in any discussion that arises from the disclosed findings.