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
Rank: 6/26
Findings: 5
Award: $2,486.65
🌟 Selected for report: 9
🚀 Solo Findings: 0
GiveMeTestEther
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
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.
🌟 Selected for report: kenzo
Also found by: GiveMeTestEther, cmichel
322.7515 USDC - $322.75
GiveMeTestEther
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"
Manual Analysis
#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
🌟 Selected for report: GiveMeTestEther
Also found by: kenzo
537.9192 USDC - $537.92
GiveMeTestEther
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
Provide direct links to all referenced code in GitHub. Add screenshots, logs, or any other relevant proof that illustrates the concept.
Manual Analysis
#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.
🌟 Selected for report: GiveMeTestEther
398.4587 USDC - $398.46
GiveMeTestEther
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.
Manual Analysis
#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
.
🌟 Selected for report: GiveMeTestEther
398.4587 USDC - $398.46
GiveMeTestEther
Manual Analysis
token.safeApprove(_spender, 0); token.safeApprove(_spender, _amount);
🌟 Selected for report: GiveMeTestEther
398.4587 USDC - $398.46
GiveMeTestEther
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
Manual Analysis
#0 - clemsos
2022-01-05T14:25:19Z
note that this was indicated as "TODO" https://github.com/code-423n4/2021-11-unlock/blob/ec41eada1dd116bcccc5603ce342257584bec783/smart-contracts/contracts/mixins/MixinKeys.sol#L333
🌟 Selected for report: WatchPug
Also found by: GiveMeTestEther
GiveMeTestEther
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)
A user can get to this state by extending/purchasing key or receiving time from sharing/transferring key time from another user.
extreme case:
Manual Analysis
#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
🌟 Selected for report: GiveMeTestEther
GiveMeTestEther
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.
Manual Analysis
🌟 Selected for report: WatchPug
Also found by: GiveMeTestEther, mics, pauliax
GiveMeTestEther
In the function _getCancelAndRefundValue we can save gas by using the unchecked {} block in L142 and L154.
Manual Analysis
#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
🌟 Selected for report: GiveMeTestEther
GiveMeTestEther
🌟 Selected for report: GiveMeTestEther
Also found by: WatchPug
GiveMeTestEther
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
Manual Analysis
#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
.
🌟 Selected for report: GiveMeTestEther
GiveMeTestEther
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
Manual Analysis
🌟 Selected for report: GiveMeTestEther
Also found by: loop
GiveMeTestEther
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
Manual Analysis
#0 - 0xleastwood
2022-01-17T08:10:41Z
This seems like there could be some small gas savings.