Platform: Code4rena
Start Date: 13/12/2022
Pot Size: $36,500 USDC
Total HM: 5
Participants: 77
Period: 3 days
Judge: gzeon
Total Solo HM: 1
Id: 191
League: ETH
Rank: 1/77
Findings: 3
Award: $18,435.06
π Selected for report: 2
π Solo Findings: 1
π Selected for report: Soosh
Also found by: 9svR6w, Apocalypto, Ch_301, HE1M, Koolex, SmartSek, Titi, Trust, Zarf, bin2chen, btk, carrotsmuggler, csanuragjain, dic0de, dipp, gz627, hansfriese, hihen, imare, immeas, indijanc, jadezti, kuldeep, ladboy233, maks, neumo, obront, rvierdiiev, sces60107, sk8erboy
19.2206 USDC - $19.22
https://github.com/code-423n4/2022-12-forgeries/blob/fc271cf20c05ce857d967728edfb368c58881d85/src/VRFNFTRandomDraw.sol#L173 https://github.com/code-423n4/2022-12-forgeries/blob/fc271cf20c05ce857d967728edfb368c58881d85/src/VRFNFTRandomDraw.sol#L203
In RandomDraw, host can call startDraw() or redraw() to request a Chainlink random number, which will be used to select the winning user. They may then collect the prize NFT using winnerClaimNFT()
.
The issue is that in the two draw() functions, it is never checked that the current time or the time when oracle returns the random number, is less than the recoverTimelock time. When recoverTimelock is passed, owner can always claim back the NFT using lastResortTimelockOwnerClaimNFT
. Therefore, host can delay calling draw or redraw until moments before recoverTimelock. If they like the selected winner, they let them pick up the item. If they don't, they can immediately send a TX to pick up the NFT using lastResortTimelockOwnerClaimNFT
. This creates an unfair illusion of fair draw which users cannot be aware of. It harms the integrity of the protocol.
Draw organizer can time draws so that user's have the illusion of fair random, but draw can be cancelled.
Manual audit
In both draw() functions, make sure the remaining time until recoveryTimelock is enough for Chainlink answer + sensible user NFT pickup time.
#0 - c4-judge
2022-12-17T15:33:40Z
gzeon-c4 marked the issue as duplicate of #146
#1 - c4-judge
2023-01-23T16:53:04Z
gzeon-c4 marked the issue as satisfactory
#2 - c4-judge
2023-01-23T17:09:25Z
gzeon-c4 changed the severity to 3 (High Risk)
π Selected for report: Trust
18272.4859 USDC - $18,272.49
In RandomDraw, the host initiates a draw using startDraw() or redraw() if the redraw draw expiry has passed. Actual use of Chainlink oracle is done in _requestRoll:
request.currentChainlinkRequestId = coordinator.requestRandomWords({ keyHash: settings.keyHash, subId: settings.subscriptionId, minimumRequestConfirmations: minimumRequestConfirmations, callbackGasLimit: callbackGasLimit, numWords: wordsRequested });
Use of subscription API is explained well here. Chainlink VRFCoordinatorV2 is called with requestRandomWords() and emits a random request. After minimumRequestConfirmations
blocks, an oracle VRF node replies to the coordinator with a provable random, which supplies the random to the requesting contract via fulfillRandomWords()
call. It is important to note the role of subscription ID. This ID maps to the subscription charged for the request, in LINK tokens. In our contract, the raffle host supplies their subscription ID as a parameter. Sufficient balance check of the request ID is not checked at request-time, but rather checked in Chainlink node code as well as on-chain by VRFCoordinator when the request is satisfied. In the scenario where the subscriptionID lacks funds, there will be a period of 24 hours when user can top up the account and random response will be sent:
"Each subscription must maintain a minimum balance to fund requests from consuming contracts. If your balance is below that minimum, your requests remain pending for up to 24 hours before they expire. After you add sufficient LINK to a subscription, pending requests automatically process as long as they have not expired."
The reason this is extremely interesting is because as soon as redraws are possible, the random response can no longer be treated as fair. Indeed, Draw host can wait until redraw cooldown passed (e.g. 1 hour), and only then fund the subscriptionID. At this point, Chainlink node will send a TX with the random response. If host likes the response (i.e. the draw winner), they will not interfere. If they don't like the response, they can simply frontrun the Chainlink TX with a redraw() call. A redraw will create a new random request and discard the old requestId so the previous request will never be accepted.
function fulfillRandomWords( uint256 _requestId, uint256[] memory _randomWords ) internal override { // Validate request ID // <---------------- swap currentChainlinkRequestId ---> if (_requestId != request.currentChainlinkRequestId) { revert REQUEST_DOES_NOT_MATCH_CURRENT_ID(); } ... }
//<------ redraw swaps currentChainlinkRequestId ---> request.currentChainlinkRequestId = coordinator.requestRandomWords({ keyHash: settings.keyHash, subId: settings.subscriptionId, minimumRequestConfirmations: minimumRequestConfirmations, callbackGasLimit: callbackGasLimit, numWords: wordsRequested });
Chainlink docs warn against this usage pattern of the VRF -"Donβt accept bids/bets/inputs after you have made a randomness request". In this instance, a low subscription balance allows the host to invalidate the assumption that 1 hour redraw cooldown is enough to guarantee Chainlink answer has been received.
Draw organizer can rig the draw to favor certain participants such as their own account.
Owner offers a BAYC NFT for holders of their NFT collection X. Out of 10,000 tokenIDs, owner has 5,000 Xs. Rest belong to retail users.
Note that Forgeries draws are presumably intended as incentives for speculators to buy NFTs from specific collections. Without having a fair shot at receiving rewards from raffles, these NFTs user buys could be worthless. Another way to look at it is that the impact is theft of yield, as host can freely decrease the probability that a token will be chosen for rewards with this method.
Also, I did not categorize it as centralization risk as the counterparty is not Forgeries but rather some unknown third-party host which offers an NFT incentive program. It is a similar situation to the distinction made between 1st party and 3rd party projects here
Manual audit Chainlink docs Chainlink co-ordinator code
The root cause is that Chainlink response can arrive up to 24 hours from the most request is dispatched, while redraw cooldown can be 1 hour+. The best fix would be to enforce minimum cooldown of 24 hours.
#0 - c4-judge
2022-12-17T14:16:21Z
gzeon-c4 marked the issue as satisfactory
#1 - c4-judge
2022-12-17T14:16:25Z
gzeon-c4 marked the issue as primary issue
#2 - c4-sponsor
2023-01-01T18:29:31Z
iainnash marked the issue as sponsor confirmed
#3 - gzeoneth
2023-01-07T17:52:48Z
This issue weaponized https://github.com/code-423n4/2022-12-forgeries-findings/issues/133 and https://github.com/code-423n4/2022-12-forgeries-findings/issues/194 to violate the fairness requirement of the protocol. Downgrading this to Med because the
Difficulty of attack is high; you need to a) front-run the fulfillRandomWords call and b) own a meaningful % of the collection
Require to use an underfunded subscription This will flag the raffle is fishy, since the owner might as well never fund the subscription.
3rd party can mitigate this by funding the subscription
There is another case where the chainlink node wait almost 24 hours before fulfilling the request, but I don't think that is the normal behavior and is out of the attacker's control.
#4 - c4-judge
2023-01-07T17:52:58Z
gzeon-c4 changed the severity to 2 (Med Risk)
#5 - trust1995
2023-01-09T19:55:18Z
Would like to respectfully state my case and why this finding is clearly HIGH impact. Manipulation of RNG is an extremely serious impact as it undermines assumption of fairness which is the main selling point of raffles, lotteries etc. As proof one can view Chainlink's BBP which lists "Predictable or manipulable RNG that results in abuse of downstream services" as a critical impact, payable up to $3M.
I would like to relate to the conditions stated by the judge:
- Difficulty of attack is high; you need to a) front-run the fulfillRandomWords call and b) own a meaningful % of the collection
frontrunning is done in practically every block by MEV bots proving it's practical and easy to do on mainnet, where the protocol is deployed. Owning a meaningful % of the collection is not necessary, as:
- Require to use an underfunded subscription This will flag the raffle is fishy, since the owner might as well never fund the subscription.
- 3rd party can mitigate this by funding the subscription
It is unrealistic to expect users of the protocol to be savvy on-chain detectives and also anticipate this specific attack vector. Even so, the topping-up of the subscription is done directly subscriber -> ChainlinkVRFCoordinator, so it's not visible by looking at the raffle contract.
To summarize, the characteristics of this finding are much more aligned to those of High-severity, than those of Med-severity.
#6 - gzeoneth
2023-01-09T20:20:53Z
The difficulty arise when only the raffle creator can perform the front running, not any interested MEV searcher. For sure, this is only 1 of the reason I think the risk of this issue is not High.
As the project seems to be fine with a raffle being created, but never actually started; I think when the attack require a chainlink subscription to be underfunded to begin with also kinda fall in to the "creator decided not to start raffle" category.
The argument of judging this apart from that is the raffle would looks like completed but might not be fair, which I think is a very valid issue. However, I don't see this as High risk given the relative difficulty as said and we seems to agree that it is fine if the raffle creator decided not to start the raffle. The end state would basically be the same.
#7 - trust1995
2023-01-09T20:34:44Z
The end states are in my opinion very different. In order to understand the full impact of the vulnerability we need to understand the context in which those raffles take place. The drawing tokens are shilled to give users a chance to win a high valued item. Their worth is correlated to the fair chance users think they have in winning the raffle. The "fake raffle" on display allows the attacker to keep profiting from ticket sales while not giving away high value. I think this is why @iainnash agreed this to be a high risk find.
I've also listed several other justifications including theft of user's chances of winning which is high impact. I'd be happy to provide additional proof of why frontrunning is easily high enough % if that is the source of difficulty observed.
#8 - gzeoneth
2023-01-09T21:01:13Z
The drawing tokens are shilled to give users a chance to win a high valued item. Their worth is correlated to the fair chance users think they have in winning the raffle.
That's my original thought, but you and the sponsor tried to convince me the raffle is permissioned by design considering startDraw.
If we think we need to guarantee the raffle token can get something fairly, we will also need to guarantee the raffle will, well, start. So I would say these are very similar since the ticket would be already sold anyway.
I think I might either keep everything as-is, or I am going to reinstate those other issue that I invalidated due to assuming the permissioned design, and upgrading this to High. Would love to hear more from the sponsor before making the final call.
#9 - trust1995
2023-01-12T06:08:42Z
Mind sharing your thoughts, @iainnash ?
Regarding your smart observation @gzeoneth , I think the idea is clearly to make the draw methods decentralized in the future, but owner controlled as a first step. However they were not aware of this exploit, which from day 1 allows to put on a show and drive draw token prices up.
#10 - c4-judge
2023-01-16T05:09:52Z
gzeon-c4 changed the severity to 3 (High Risk)
#11 - gzeon-c4
2023-01-16T05:10:28Z
#12 - c4-judge
2023-01-16T05:38:49Z
gzeon-c4 marked the issue as selected for report
π Selected for report: Trust
Also found by: Apocalypto, Madalad, Matin, aga7hokakological, evan, kaliberpoziomka8552, mookimgo, poirots, subtle77, wagmi, yixxas
143.3525 USDC - $143.35
In VRFNFtRandomDraw.sol initialize(), the MONTH_IN_SECONDS variable is used to validate two values:
if (_settings.drawBufferTime > MONTH_IN_SECONDS) { revert REDRAW_TIMELOCK_NEEDS_TO_BE_LESS_THAN_A_MONTH(); } ... if ( _settings.recoverTimelock > block.timestamp + (MONTH_IN_SECONDS * 12) ) { revert RECOVER_TIMELOCK_NEEDS_TO_BE_LESS_THAN_A_YEAR(); }
The issue is that MONTH_IN_SECONDS is calculated incorrectly:
/// @dev 60 seconds in a min, 60 mins in an hour uint256 immutable HOUR_IN_SECONDS = 60 * 60; /// @dev 24 hours in a day 7 days in a week uint256 immutable WEEK_IN_SECONDS = (3600 * 24 * 7); // @dev about 30 days in a month uint256 immutable MONTH_IN_SECONDS = (3600 * 24 * 7) * 30;
MONTH_IN_SECONDS multiplies by 7 incorrectly, as it was copied from WEEK_IN_SECONDS. Therefore, actual seconds calculated is equivalent of 7 months. Therefore, recoverTimelock can be up to a non-sensible value of 7 years, and re-draws every up to 7 months.
Protocol safeguards for time durations are skewed by a factor of 7. Protocol may potentially lock NFT for period of 7 years.
Manual audit
Fix MONTH_IN_SECONDS calculation:
uint256 immutable MONTH_IN_SECONDS = (3600 * 24) * 30;
#0 - c4-judge
2022-12-17T12:51:38Z
gzeon-c4 marked the issue as primary issue
#1 - c4-judge
2022-12-17T12:51:43Z
gzeon-c4 marked the issue as satisfactory
#2 - c4-sponsor
2023-01-01T18:31:02Z
iainnash marked the issue as sponsor confirmed
#3 - c4-judge
2023-01-23T17:14:15Z
gzeon-c4 marked the issue as selected for report