Forgeries contest - indijanc'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: 47/77

Findings: 2

Award: $45.17

Gas:
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/main/src/VRFNFTRandomDraw.sol#L304

Vulnerability details

Impact

The admin/owner of VRFNFTRandomDraw can wait for recoverTimelock to expire before making the draw. This way he can use lastResortTimelockOwnerClaimNFT() to take back the reward NFT from the contract without any time to allow for the winner to claim. He could also avoid the redraw timelock this way by taking back the NFT and creating another Draw. The protocol does mention they're not considering Draw owners/admins to be malicious but would still consider this at least a medium because the rugpull vector can easily be prevented within the contract.

Proof of Concept

This can be quickly verified with modifying one of the protocol tests - test_FullDrawing():

diff --git a/test/VRFNFTRandomDraw.t.sol b/test/VRFNFTRandomDraw.t.sol
index a023626..2697a10 100644
--- a/test/VRFNFTRandomDraw.t.sol
+++ b/test/VRFNFTRandomDraw.t.sol
@@ -338,10 +338,19 @@ address sender = address(0x994);

         targetNFT.setApprovalForAll(consumerAddress, true);

+        // Owner waits for the recover timelock before starting the draw
+        vm.warp(block.timestamp + 2 weeks);
+
         uint256 drawingId = drawing.startDraw();

         mockCoordinator.fulfillRandomWords(drawingId, consumerAddress);

+        // After getting random word and determining winner admin changes his mind and claims the NFT
+        drawing.lastResortTimelockOwnerClaimNFT();
+        // Assert that we're still within the draw timelock
+        (,,, uint256 drawTimelock) = drawing.request();
+        assert(drawTimelock > block.timestamp);
+
         vm.stopPrank();

         assertEq(targetNFT.balanceOf(winner), 0);

Notice the test fails because winner cannot claim the NFT even though we're still within the draw timelock.

Tools Used

Manual review

A couple of options to mitigate this.

  1. Include the check that we're not within draw timelock inside lastResortTimelockOwnerClaimNFT() to allow for the winner to claim his NFT:
    function lastResortTimelockOwnerClaimNFT() external onlyOwner {
        // If recoverTimelock is not setup, or if not yet occurred
        if (settings.recoverTimelock > block.timestamp || request.drawTimelock > block.timestamp) {
            // Stop the withdraw
            revert RECOVERY_IS_NOT_YET_POSSIBLE();
        }
  1. Extend the settings.recoverTimelock every time the startDraw() or redraw() are called in a similar manner to the request.drawTimelock recalculation.

#0 - c4-judge

2022-12-17T12:36:24Z

gzeon-c4 marked the issue as duplicate of #146

#1 - c4-judge

2022-12-17T12:36:30Z

gzeon-c4 marked the issue as satisfactory

#2 - c4-judge

2023-01-23T17:09:17Z

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

Awards

25.9485 USDC - $25.95

Labels

bug
G (Gas Optimization)
grade-b
sponsor confirmed
edited-by-warden
G-24

External Links

Wrap calculations in unchecked block if guaranteed not to over- or underflow

VRFNFTRandomDraw.sol L112-L117 If statement with subtraction can be wrapped in unchecked because the statement already checks the first parameter shouldn't be smaller than the second. This slightly reduces contract size and function gas cost. Tests show the following gas cost reduction:

src/VRFNFTRandomDraw.sol:VRFNFTRandomDraw contract
Deployment CostDeployment Size
12375266717
Function Nameminavgmedianmax# calls
initialize4379014654617552319292315
After update
Deployment CostDeployment Size
12357266708
Function Nameminavgmedianmax# calls
initialize4379014650317545119285115
DIFF
Deployment CostDeployment Size
-1800-9
Function Nameminavgmedianmax# calls
initialize0-43-72-72
src/VRFNFTRandomDrawFactory.sol:VRFNFTRandomDrawFactory contract
Function Nameminavgmedianmax# calls
makeNewDraw4687218363921323223979516
After update
makeNewDraw4687218359821316023972316
DIFF
makeNewDraw0-41-72-72

All event emitting can be moved to end of functions

The event emitting can be moved to the end of the functions. This save some gas in error cases and is in line with the checks-effects-interactions paradigm. There's some places where event emitting is done before checks.

Redundant check in startDraw()

VRFNFTRandomDraw.sol L175-L177 The check for currentChainlinkRequestId in startDraw() is redundant because the check is already present in _requestRoll(). Remove the check from startDraw() function and also move emit SetupDraw(msg.sender, settings); after _requestRoll(); to save gas. Here's the benefits with these changes as observed from project tests:

src/VRFNFTRandomDraw.sol:VRFNFTRandomDraw contract
Deployment CostDeployment Size
12375266717
Function Nameminavgmedianmax# calls
startDraw4828657396467997759
After update
Deployment CostDeployment Size
12309196684
Function Nameminavgmedianmax# calls
startDraw4978647096350996589
DIFF
Deployment CostDeployment Size
-6607-33
Function Nameminavgmedianmax# calls
startDraw+15-103-117-1170

Storage variable can be cached in local variable

VRFNFTRandomDraw.sol L250 VRFNFTRandomDraw.sol L256 The storage variable settings.drawingTokenStartId is accessed twice inside fulfillRandomWords(). It can instead be cached in a local variable to save deployment and function gas cost. These are gas savings as observed with projects tests:

src/VRFNFTRandomDraw.sol:VRFNFTRandomDraw contract
Deployment CostDeployment Size
12375266717
Function Nameminavgmedianmax# calls
rawFulfillRandomWords11313849646390463906
After update
Deployment CostDeployment Size
12371266715
Function Nameminavgmedianmax# calls
rawFulfillRandomWords11313841546292462926
DIFF
Deployment CostDeployment Size
-400-2
Function Nameminavgmedianmax# calls
rawFulfillRandomWords0-81-98-98

Redundant variable can be removed

VRFNFTRandomDraw.sol L279 The user local variable can be removed and msg.sender from calldata used instead. This slightly reduces contract size and function gas cost. Here's the project test results after removing the variable:

src/VRFNFTRandomDraw.sol:VRFNFTRandomDraw contract
Deployment CostDeployment Size
12375266717
Function Nameminavgmedianmax# calls
lastResortTimelockOwnerClaimNFT3811106111061217412
winnerClaimNFT422116382422276245
After update
Deployment CostDeployment Size
12273136666
Function Nameminavgmedianmax# calls
lastResortTimelockOwnerClaimNFT3811106111061217422
winnerClaimNFT419116352419276215
DIFF
Deployment CostDeployment Size
-10213-51
Function Nameminavgmedianmax# calls
lastResortTimelockOwnerClaimNFT000+1
winnerClaimNFT-3-3-3-3

Another redundant variable can be removed in makeNewDraw()

VRFNFTRandomDrawFactory.sol L42 The admin local variable can be removed and msg.sender used instead to save some gas. Here's the savings:

src/VRFNFTRandomDrawFactory.sol:VRFNFTRandomDrawFactory contract
Deployment CostDeployment Size
10422035678
Function Nameminavgmedianmax# calls
makeNewDraw4687218363921323223979516
After update
Deployment CostDeployment Size
10414035674
Function Nameminavgmedianmax# calls
makeNewDraw4686018363121322423978316
DIFF
Deployment CostDeployment Size
-800-4
Function Nameminavgmedianmax# calls
makeNewDraw-12-8-8-12

Use msg.sender instead of owner() function calls

VRFNFTRandomDraw.solL312 VRFNFTRandomDraw.sol L317 lastResortTimelockOwnerClaimNFT() already ensures that msg.sender is the owner with the onlyOwner modifier. Using the owner() function calls instead of using msg.sender is redundant in this case and increases gas cost. Here's the benefits after using msg.sender instead, as seen with the project tests:

src/VRFNFTRandomDraw.sol:VRFNFTRandomDraw contract
Deployment CostDeployment Size
12375266717
Function Nameminavgmedianmax# calls
lastResortTimelockOwnerClaimNFT3811106111061217412
After update
Deployment CostDeployment Size
12265136662
lastResortTimelockOwnerClaimNFT3811093410934214882
DIFF
Deployment CostDeployment Size
-11013-55
lastResortTimelockOwnerClaimNFT0-127-127-253

emitting events in OwnableUpgradeable can use msg.sender

OwnableUpgradeable.sol L91 OwnableUpgradeable.sol L109 OwnableUpgradeable.sol L129 Several events inside OwnableUpgradeable can use msg.sender as all calls are protected with onlyOwner modifier. This saves gas in several function calls:

src/VRFNFTRandomDraw.sol:VRFNFTRandomDraw contract
Deployment CostDeployment Size
12375266717
After update
Deployment CostDeployment Size
12313196686
DIFF
Deployment CostDeployment Size
-6207-31
src/VRFNFTRandomDrawFactory.sol:VRFNFTRandomDrawFactory contract
Deployment CostDeployment Size
10422035678
Function Nameminavgmedianmax# calls
safeTransferOwnership5551244312443243312
After update
Deployment CostDeployment Size
10359965647
Function Nameminavgmedianmax# calls
safeTransferOwnership5551237912379242042
DIFF
Deployment CostDeployment Size
-6207-31
Function Nameminavgmedianmax# calls
safeTransferOwnership0-64-64-127
src/VRFNFTRandomDrawFactoryProxy.sol:VRFNFTRandomDrawFactoryProxy contract
Function Nameminavgmedianmax# calls
safeTransferOwnership9541284012840247262
After update
Function Nameminavgmedianmax# calls
safeTransferOwnership9541277612776245992
DIFF
Function Nameminavgmedianmax# calls
safeTransferOwnership0-64-64-127
test/OwnableTest.t.sol:OwnedContract contract
Deployment CostDeployment Size
2726641811
Function Nameminavgmedianmax# calls
cancelOwnershipTransfer18401840184018401
resignOwnership92399239923992391
safeTransferOwnership5421937425318263184
transferOwnership4123454452554263
After update
Deployment CostDeployment Size
2664581780
cancelOwnershipTransfer17441744174417441
resignOwnership91219121912191211
safeTransferOwnership5421927825191261914
transferOwnership4123383443153083
DIFF
Deployment CostDeployment Size
-6206-31
cancelOwnershipTransfer-96-96-96-961
resignOwnership91219121912191211
safeTransferOwnership5421927825191261914
transferOwnership4123383443153083

#0 - indijanc

2022-12-16T21:55:12Z

Last table showcasing gas savings is not fully updated, sorry about that, was out of time ...

#1 - c4-judge

2022-12-17T17:35:19Z

gzeon-c4 marked the issue as grade-b

#2 - c4-sponsor

2023-01-01T18:27:05Z

iainnash marked the issue as sponsor confirmed

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