Forgeries contest - obront's results

A protocol for on-chain games with NFT prizes on Ethereum.

General Information

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

Forgeries

Findings Distribution

Researcher Performance

Rank: 35/77

Findings: 2

Award: $64.93

QA:
grade-b

🌟 Selected for report: 0

🚀 Solo Findings: 0

Awards

19.2206 USDC - $19.22

Labels

bug
3 (High Risk)
satisfactory
upgraded by judge
duplicate-146

External Links

Lines of code

https://github.com/code-423n4/2022-12-forgeries/blob/fc271cf20c05ce857d967728edfb368c58881d85/src/VRFNFTRandomDraw.sol#L302-L320

Vulnerability details

Impact

The owner of a draw contract is able to call the lastResortTimelockOwnerClaimNFT() to reclaim their NFT, as long as the recoverTimelock has passed.

However, because the owner has complete control over when the startDraw() function is called, they are able to wait for the recovertTimelock without actually following through with the contest.

The result is that the timelock is essentially useless, as owners are able to withdraw the NFT without ever honoring the contest offer.

It's not hard to imagine a situation where a contest was promoted and users may have invested in the drawingToken in anticipation of the contest, and ended up harmed by these actions.

In an extreme case, the team behind the drawingToken could maliciously create raffles to get users to invest in their NFT and not follow through with them.

Proof of Concept

  • recoverTimelock is set as a variable in settings
  • startDraw() needs to be called by the owner, and if it isn't called, nothing happens
  • lastResortTimelockOwnerClaimNFT() only looks at whether recoverTimelock has passed before allowing the owner to unlock the NFT

Tools Used

Manual Review

There are two ways to approach mitigating this issue:

  1. The simpler is that recoverTimelock is changed to a variable that counts the amount of time after startDraw() is called that the unlock can happen. The startDraw() call time would need to be recorded in state, and the check in lastResortTimelockOwnerClaimNFT() adjusted to account for this.

  2. The better representation would be to ensure that owners follow through with the contests that they laid out. This would probably mean pre-committing to a number of redraws they intend to do, and keeping a counter. Then the lastResortTimelockOwnerClaimNFT() could check this counter, as well as the recoverTimelock to ensure that the owner has both waited sufficiently and followed through on their commitment.

#0 - c4-judge

2022-12-17T15:58:38Z

gzeon-c4 marked the issue as duplicate of #146

#1 - c4-judge

2023-01-23T16:53:09Z

gzeon-c4 marked the issue as satisfactory

#2 - c4-judge

2023-01-23T17:09:30Z

gzeon-c4 changed the severity to 3 (High Risk)

Awards

45.7078 USDC - $45.71

Labels

bug
grade-b
QA (Quality Assurance)
Q-15

External Links

[L-1] InitializedDraw event emits wrong parameters

In VRFNFTRandomDraw.sol#initialize(), we emit the following event:

emit InitializedDraw(msg.sender, settings);

However, this function is called by the Factory, so msg.sender will always be the Factory address.

The original msg.sender is passed to this function as admin, so the event should be replaced with:

emit InitializedDraw(admin, settings);

[L-2] redraw() can be called for first draw

There is no check in redraw() that it is actually a redraw. The only different check is that request.drawTimelock >= block.timestamp, but when we are starting the first draw, request.drawTimelock == 0, which will pass this check.

Later in the function, we check that the contract already holds the NFT, but it would be easy for any user to transfer it in manually before calling redraw().

This can't be used to cause any harm to the protocol, but we should be enforcing that users use the startDraw() function for their first draw.

Add the following check at the top of redraw():

if (request.drawTimelock == 0) {
    revert CANNOT_REDRAW_BEFORE_DRAW();
}

[QA-1] Unnecessary check on drawTimelock in _requestRoll()

In _requestRoll() we perform the following check:

if (
    request.hasChosenRandomNumber &&
    request.drawTimelock != 0 &&
    request.drawTimelock > block.timestamp
) 

The request.drawTimelock != 0 check is intended to check whether it's the first draw. However, in the situation where request.drawTimelock == 0, it will also be less than block.timestamp, so the additional check is unnecessary.

Replace with:

if (
    request.hasChosenRandomNumber &&
    request.drawTimelock > block.timestamp
) 

[QA-2] Unnecessary check of request ID in startDraw()

When startDraw() is called, the first thing we do is the following check:

if (request.currentChainlinkRequestId != 0) {
    revert REQUEST_IN_FLIGHT();
}

However, the next action we take is to call _requestRoll(), where this exact check is immediately done. There is no need to do it twice.

Remove the check from the top of startDraw().

#0 - c4-judge

2022-12-17T17:15:30Z

gzeon-c4 marked the issue as grade-b

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