Unlock Protocol contest - GiveMeTestEther's results

Protocol for memberships built on a blockchain, where fans can support their favourite creators by purchasing their NFTs!

General Information

Platform: Code4rena

Start Date: 18/11/2021

Pot Size: $50,000 USDC

Total HM: 18

Participants: 26

Period: 7 days

Judge: leastwood

Total Solo HM: 12

Id: 54

League: ETH

Unlock Protocol

Findings Distribution

Researcher Performance

Rank: 6/26

Findings: 5

Award: $2,486.65

🌟 Selected for report: 9

🚀 Solo Findings: 0

Findings Information

🌟 Selected for report: pauliax

Also found by: 0x0x0x, GiveMeTestEther, Reigada, Ruhum, WatchPug, cmichel, kenzo

Labels

bug
duplicate
1 (Low Risk)

Awards

71.4681 USDC - $71.47

External Links

Handle

GiveMeTestEther

Vulnerability details

Impact / POC

If the lock creator is not aware that a ERC20 token (1) can charge a fee on transfer, but chooses it as token to be paid in. The amount of tokens that will be transferred to the lock can be less than "pricePaid" & "inMemoryKeyPrice" (2) in the purchase() function.

Note: this can also impact the refund capability, but this is not the core issue. More about the "global" refund / keyPrice issue in a another submission.

If the lock manager or beneficiary calls withdraw() (3) to get the funds from the "lock" there also the amount that the beneficiary will receive is less than the amount of ERC20 tokens that are currently owned by the "lock".

The "keyPrice" of goods/NFTs etc. is normally set by some economical reasoning (e.g. set price to Y s.t. we can get profit margin of X%) but if the transfer fees wasn't a part of this reasoning/calculation (because of no awareness that this can happen) this can even result in a loss for the creators.

(1) Lock creator can set an arbitrary ERC20 token with supply > 0 https://github.com/code-423n4/2021-11-unlock/blob/ec41eada1dd116bcccc5603ce342257584bec783/smart-contracts/contracts/Unlock.sol#L211 in the createLock() function.

Note: in updateKeyPricing the lock manager can also set an arbitary ERC20 token https://github.com/code-423n4/2021-11-unlock/blob/ec41eada1dd116bcccc5603ce342257584bec783/smart-contracts/contracts/mixins/MixinLockCore.sol#L169

(2) https://github.com/code-423n4/2021-11-unlock/blob/ec41eada1dd116bcccc5603ce342257584bec783/smart-contracts/contracts/mixins/MixinPurchase.sol#L103

(3) https://github.com/code-423n4/2021-11-unlock/blob/ec41eada1dd116bcccc5603ce342257584bec783/smart-contracts/contracts/mixins/MixinLockCore.sol#L133

Tools Used

Manual Analysis

possible solutions

  • communicate clearly that this can happen

  • to make sure that we receive the correct amount in purchase(): save the balance_before = token.balance(address(this)) before calling transferFrom(...., amount) and make sure the new balance is at least increased by the amount: require(balance_before + amount ≥ token.balance(address(this)));

    for the withdraw(): we can't really do something

#0 - julien51

2022-01-03T15:13:24Z

When a user deploys a lock that uses a custom ERC20, we assume that they understand how this ERC20 works and hence understand/expect that they'll be paid less than what they expect...

I think there is a misunderstanding on the security model/threat here.

#1 - 0xleastwood

2022-01-16T08:53:58Z

Duplicate of #31

#2 - 0xleastwood

2022-01-16T09:11:04Z

Duplicate of #162

#3 - 0xleastwood

2022-01-18T13:09:44Z

Bumped to medium as #221 mentions this as a potential risk.

Findings Information

🌟 Selected for report: kenzo

Also found by: GiveMeTestEther, cmichel

Labels

bug
duplicate
2 (Med Risk)
sponsor confirmed

Awards

322.7515 USDC - $322.75

External Links

Handle

GiveMeTestEther

Vulnerability details

Impact / POC

There is no check _from != _recipient in transferFrom [https://github.com/code-423n4/2021-11-unlock/blob/ec41eada1dd116bcccc5603ce342257584bec783/smart-contracts/contracts/mixins/MixinTransfer.sol#L109]

the key manager/approver can expire the key with transferFrom _from = _recipient by mistake

shareKey() also doesn't check for this case, but is not as bad the transferFrom case as there is only a "time fee"

Tools Used

Manual Analysis

  • transferFrom: require( _from != _recipient )
  • shareKey: require(_ownerOf[_tokenId] ≠ _to)

#0 - clemsos

2022-01-04T15:38:38Z

Note that transferFrom is already addressed in #87.

#1 - clemsos

2022-01-05T12:26:03Z

fixed in unlock-protocol/unlock#7992

#2 - 0xleastwood

2022-01-16T08:41:07Z

Duplicate of #87

Findings Information

🌟 Selected for report: GiveMeTestEther

Also found by: kenzo

Labels

bug
2 (Med Risk)
sponsor acknowledged

Awards

537.9192 USDC - $537.92

External Links

Handle

GiveMeTestEther

Vulnerability details

Impact

the shareKey function allows a user to share some time with another user that doesn't already has/had a key and this generates a new key. This even allows to generate more keys than _maxNumberOfKeys.

attacker generates a lot of EOA adresses, buys a key, share the minimum necessary time with each address and in each "sharing" a new key gets generated. This allows cheaply to allocate alot of "keys" with out really purchasing them and a lot of user can't get a "key" because purchase has a modifier notSoldOut, that limits the max purchasable to "keys" to maxNumberOfKeys

Proof of Concept

Provide direct links to all referenced code in GitHub. Add screenshots, logs, or any other relevant proof that illustrates the concept.

Tools Used

Manual Analysis

  • rethink the whole shareKey thingy,

#0 - julien51

2021-11-26T02:49:43Z

I am not sure this is a bug or even a risk. Someone could actually achieve the same thing by purchasing keys at the full price and cancelling them immediately getting an almost full refund (or even full refund when there is a free trial) and could quickly get the lock to "sell out". It is actually the case with any NFT project where there is a cap/limit to number of tokens and someone can easily "Capture" them all.

One way to limit the impact for the lock manager would to set a cancellation penalty AND a transfer fee.

#1 - 0xleastwood

2022-01-16T08:44:50Z

Nice find! While I understand what the sponsor is saying, this does seem like a valid way to deny a lock from selling membership to honest users.

#2 - julien51

2022-03-16T06:47:33Z

Note that a lock manager can easily increase supply to mitigate that (and even could delete existing keys/NFT to reduce the outstanding supply)

#3 - 0xleastwood

2022-03-22T21:22:01Z

While the lock manager can restore the contract to some valid state, this will still impact protocol availability, even in the short-term.

Findings Information

🌟 Selected for report: GiveMeTestEther

Labels

bug
1 (Low Risk)
sponsor acknowledged

Awards

398.4587 USDC - $398.46

External Links

Handle

GiveMeTestEther

Vulnerability details

Impact / POC

A user can share the minimum possible time of his key (and buy additional time by extending the key to have enough time to make the attack work) to a recipient of the "grantKeys()" such that for this recipient the "require(expirationTimestamp > toKey.expirationTimestamp, 'ALREADY_OWNS_KEY');" reverts. This will revert the whole transaction and no recipient will receive an airdropped key.

An attacker can optimize for which recipient & key expiration timestamp to maximize gas griefing.

This attack is also feasible, by buying a key for a recipient or transferring his "key" to the recipient (buys for himself and transfers to the recipient) if this allows the "require(expirationTimestamp > toKey.expirationTimestamp, 'ALREADY_OWNS_KEY');" to revert.

grantKeys(): https://github.com/code-423n4/2021-11-unlock/blob/ec41eada1dd116bcccc5603ce342257584bec783/smart-contracts/contracts/mixins/MixinGrantKeys.sol#L22

Tools Used

Manual Analysis

  • skip the users that satisfy the require condition (change to an if statement) and don't emit an event for skipped users
  • or create a new event for key granting that differentiates by user that received an airdrop an by users that didn't

#0 - 0xleastwood

2022-01-16T08:47:22Z

This sounds like something that should be handled. As the protocol can be impacted by this issue, I'll keep this as medium for now.

#1 - 0xleastwood

2022-01-16T08:52:15Z

Actually, after thinking about this more, the onlyKeyGranterOrManager could simply not include the malicious user in the transaction. To maintain such an attack could also be costly as it requires the attacker to continuously purchase membership. Marking this as low.

Findings Information

🌟 Selected for report: GiveMeTestEther

Labels

bug
1 (Low Risk)
sponsor acknowledged

Awards

398.4587 USDC - $398.46

External Links

Handle

GiveMeTestEther

Vulnerability details

Impact

  • use safeApprove from SafeERC20 instead of approve, because token can be arbitrary
  • call safeApprove(_spender, 0) before calling safeApprove(_spender, _amount)

Proof of Concept

https://github.com/code-423n4/2021-11-unlock/blob/ec41eada1dd116bcccc5603ce342257584bec783/smart-contracts/contracts/mixins/MixinLockCore.sol#L233

Tools Used

Manual Analysis

token.safeApprove(_spender, 0); token.safeApprove(_spender, _amount);

Findings Information

🌟 Selected for report: GiveMeTestEther

Labels

bug
1 (Low Risk)
sponsor confirmed

Awards

398.4587 USDC - $398.46

External Links

Handle

GiveMeTestEther

Vulnerability details

Impact / POC

A single user can become the owner of multiple token ids and break the assumption of the comment https://github.com/code-423n4/2021-11-unlock/blob/ec41eada1dd116bcccc5603ce342257584bec783/smart-contracts/contracts/mixins/MixinKeys.sol#L181 of the function numberOfOwners() that it returns "total number of unique owners"

If a key manager/approved transfers a key with transferFrom() https://github.com/code-423n4/2021-11-unlock/blob/ec41eada1dd116bcccc5603ce342257584bec783/smart-contracts/contracts/mixins/MixinTransfer.sol#L109 to a recipient that also owns a valid key then we don't go into the "if block" L131 and also not into the "if block" L138 (this is important s.t. no key owner change happens) and go into the "else block" L148 (not really important).

We end with: fromKey.expirationTimestamp = block.timestamp; and fromKey.tokenId = 0;

If the key owner or someone else buys for this key owner again a "key" https://github.com/code-423n4/2021-11-unlock/blob/ec41eada1dd116bcccc5603ce342257584bec783/smart-contracts/contracts/mixins/MixinPurchase.sol#L51 satisfies the condition idTo ==0 (bcs of tokenId = 0) and in _assignNewTokenId(toKey); the key gets a new token id and the owner gets also registered as the new owner of the new token id in _recordOwner(_recipient, idTo);

The "old" key got overwritten but we are now the owner of two token ids.

This breaks the comment of numberOfOwners() that it returns "total number of unique owners" but for this the key owner that owns now two token ids, we executed "_recordOwner" twice and therefore added the same address twice to the owner array https://github.com/code-423n4/2021-11-unlock/blob/ec41eada1dd116bcccc5603ce342257584bec783/smart-contracts/contracts/mixins/MixinKeys.sol#L327

Tools Used

Manual Analysis

  • need to also implement the removal of ownership of a tokenId when it is set 0 zero to be congruent with the state of the key, and also adapt the other logic depending on it

Findings Information

🌟 Selected for report: WatchPug

Also found by: GiveMeTestEther

Labels

bug
duplicate
1 (Low Risk)

Awards

179.3064 USDC - $179.31

External Links

Handle

GiveMeTestEther

Vulnerability details

Impact

If a user has timeRemaining = key.expirationTimestamp - block.timestamp; (L142) bigger than "expirationDuration" (lock config storage variable) by (e.g. purchasing/extending key time) the assumption from the user would be to be able to refund more than "keyPrice". But the maximal refund amount in "_getCancelAndRefundValue()" is bounded by "keyPrice" (L143)

https://github.com/code-423n4/2021-11-unlock/blob/ec41eada1dd116bcccc5603ce342257584bec783/smart-contracts/contracts/mixins/MixinRefunds.sol#L143

A user can get to this state by extending/purchasing key or receiving time from sharing/transferring key time from another user.

Proof of Concept

extreme case:

  • the user purchases twice a key in the same block (the second purchase extends the key additionally by expirationDuration)
  • this results in key.expirationTimestamp = block.timestamp + 2 * expirationDuration
  • if the user calls "cancelAndRefund()" (also same block) the if statement is true even if "freeTrialLength" is 0:
    • block.timestamp + 2 * expirationDuration - block.timestamp + freeTrialLength >= expirationDuration
    • ⇒ 2 * expirationDuration >= expirationDuration
  • on line L144 we set "refund = keyPrice" but from the users view, he should get back 2 * "keyPrice"

Tools Used

Manual Analysis

  • Clearly state that an early key extension may result in a disadvantage for refunding and that always maximal "keyPrice" can be refunded.
  • change calculation and accounting, but this can get complicated

#0 - julien51

2022-01-03T14:43:31Z

Arguably if a user purchases and extension to their existing membership it is fair to assume that they did not want to cancel the first one... and if they did it by accident they could still transfer half of the time of the membership to another account and then cancel them both.

#1 - 0xleastwood

2022-01-16T09:24:33Z

Duplicate of #187

Findings Information

🌟 Selected for report: GiveMeTestEther

Labels

G (Gas Optimization)

Awards

53.6422 USDC - $53.64

External Links

Handle

GiveMeTestEther

Vulnerability details

Impact

Save gas by writing unchecked {timeToTransfer = key.expirationTimestamp -**** block.timestamp;} bcs the getHasValidKey(_keyOwner) checks if keyByOwner[_keyOwner].expirationTimestamp > block.timestamp; and therefore we can save gas by not doing the under/overflowflow check.

https://github.com/code-423n4/2021-11-unlock/blob/ec41eada1dd116bcccc5603ce342257584bec783/smart-contracts/contracts/mixins/MixinTransfer.sol#L275

Tools Used

Manual Analysis

  • {timeToTransfer = key.expirationTimestamp -**** block.timestamp;}

Findings Information

🌟 Selected for report: WatchPug

Also found by: GiveMeTestEther, mics, pauliax

Labels

bug
duplicate
G (Gas Optimization)

Awards

9.7763 USDC - $9.78

External Links

Handle

GiveMeTestEther

Vulnerability details

Impact

In the function _getCancelAndRefundValue we can save gas by using the unchecked {} block in L142 and L154.

Proof of Concept

  • L142: the modifier hasValidKey checks key.expirationTimestamp > block.timestamp and therefore no underflow can happen in this subtraction: we can write unchecked {uint timeRemaining = key.expirationTimestamp - block.timestamp;}
  • L154: the if statement check "refund > penalty" and therefore no underflow can happen in this subtraction: we can write unchecked {refund -= penalty;}

Tools Used

Manual Analysis

  • L142: unchecked {uint timeRemaining = key.expirationTimestamp - block.timestamp;}
  • L154: unchecked {refund -= penalty;}

#0 - 0xleastwood

2022-01-17T08:03:32Z

I think this makes sense for both of these lines.

#1 - 0xleastwood

2022-01-17T08:55:26Z

Duplicate of #203

Findings Information

🌟 Selected for report: GiveMeTestEther

Also found by: Reigada, defsec

Labels

bug
G (Gas Optimization)

Awards

14.4834 USDC - $14.48

External Links

Handle

GiveMeTestEther

Vulnerability details

Impact

  • (1.1) save gas by incrementing the loop index (uint256) in an unchecked {}, because we would run out of gas before if the loop index overflows uint256
  • (1.2) write ++i (more gas efficient) instead of i++
  • store the array length in a local variable to save gas by not reading the array length from calldata in each loop iteration

Proof of Concept

Tools Used

  • Manual Analysis
  • (1.1 & 1.2): write the loop index increment i++ as unchecked { ++ i }
  • (1.3) store length in a local var: uint recicipientsLength = _recipients.length; and then write the index bound check as i < recicipientsLength

Findings Information

🌟 Selected for report: GiveMeTestEther

Also found by: WatchPug

Labels

bug
G (Gas Optimization)

Awards

24.139 USDC - $24.14

External Links

Handle

GiveMeTestEther

Vulnerability details

Impact

don't need to set the local variable newLock as payable on L220 of the function createLock, we save gas by not casting the proxy address

Proof of Concept

https://github.com/code-423n4/2021-11-unlock/blob/ec41eada1dd116bcccc5603ce342257584bec783/smart-contracts/contracts/Unlock.sol#L220

Tools Used

Manual Analysis

  • replace L220 with: address newLock = address(proxy);

#0 - 0xleastwood

2022-01-17T08:08:46Z

Confirmed this finding in remix. There are potential gas savings and it doesn't seem necessary to mark newLock as payable.

Findings Information

🌟 Selected for report: GiveMeTestEther

Labels

bug
G (Gas Optimization)

Awards

53.6422 USDC - $53.64

External Links

Handle

GiveMeTestEther

Vulnerability details

Impact

the return value of the private _deployProxyAdmin() function is not used in initialize() and initializeProxyAdmin(), therefore the _deployProxyAdmin() function doesn't need to return a value and we can save gas

Proof of Concept

https://github.com/code-423n4/2021-11-unlock/blob/ec41eada1dd116bcccc5603ce342257584bec783/smart-contracts/contracts/Unlock.sol#L162

Tools Used

Manual Analysis

  • change _deployProxyAdmin() to no return a value

Findings Information

🌟 Selected for report: GiveMeTestEther

Also found by: loop

Labels

bug
G (Gas Optimization)

Awards

24.139 USDC - $24.14

External Links

Handle

GiveMeTestEther

Vulnerability details

Move the require statement to the beginning of the function so save gas in the case of a revert (no storage write) https://github.com/code-423n4/2021-11-unlock/blob/ec41eada1dd116bcccc5603ce342257584bec783/smart-contracts/contracts/mixins/MixinFunds.sol#L24

Tools Used

Manual Analysis

#0 - 0xleastwood

2022-01-17T08:10:41Z

This seems like there could be some small gas savings.

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