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: 5/26
Findings: 3
Award: $3,322.38
🌟 Selected for report: 16
🚀 Solo Findings: 0
pauliax
The current version of the codebase does not handle special cases of tokens, e.g. deflationary, rebasing, or those that return true/false on success (see: https://github.com/d-xo/weird-erc20). Function purchase transfers tokens from msg.sender but it does not check the return value, nor how many tokens were actually transferred:
token.transferFrom(msg.sender, address(this), pricePaid);
I have 2 suggestions here:
#0 - julien51
2021-12-11T14:27:40Z
The only party that would be penalized in the examples you describe is the lock manager (and beneficiary) who has explicitly deployed the lock using the (noncompliant) ERC20. If we consider the threat model here then I think this is not really an issue, as additional checks would incur a gas cost for everyone.
#1 - 0xleastwood
2022-01-16T09:04:03Z
I think this is technically a duplicate of #162
#2 - 0xleastwood
2022-01-16T09:06:16Z
Marking this as primary issue.
#3 - julien51
2022-03-16T06:44:02Z
The fact that this requires an explicit action by the lock manager (ie using a buggy/malicious ERC20 token) and that it puts only their tokens at risk, I think this is minor
.
#4 - 0xleastwood
2022-03-22T21:32:40Z
Giving this a bit more thought, I think its always safe to enforce these checks rather than leave it up to the lock manage to potentially make the mistake and then be liable for this mistake later on. However, considering the threat model, I do think this is better suited as a low
severity issue.
🌟 Selected for report: elprofesor
Also found by: 0x0x0x, harleythedog, loop, pauliax
pauliax
function addLockTemplate has a comment that it adds a new template:
Registers a new PublicLock template immplementation
However, it does not validate impl and version, so an owner can override an existing version/implementation. Another problem is that it doesn't check that versions are in incremental order, thus gaps may occur if an owner (accidentally) skips a version. Lock upgrades can only happen in an incremental order though.
My suggestion is to get rid of the version parameter, and use/increment publicLockLatestVersion instead. Also, consider validating that impl is not empty.
#0 - julien51
2021-12-11T14:08:55Z
However, it does not validate impl and version, so an owner can override an existing version/implementation.
This is actually intentional. We wanted to let owners re-upload a version if needed. The rationale here is that the "owner" is someone who has full control of the protocol anyway and could very well just upgrade the contract in a malicious way if they wanted. In order to save gas, we decided that it was not worth "blocking" overrides. See thread https://github.com/unlock-protocol/unlock/pull/7765#pullrequestreview-805192925
#1 - 0xleastwood
2022-01-16T11:37:50Z
As the sponsor has suggested, the owner is in full control, so they are able to make any changes they deem necessary. Gaps can also be filled by calling this function. Will mark as a duplicate of #39
🌟 Selected for report: pauliax
pauliax
function _cancelAndRefund is not protected from re-entrancy. _cancelAndRefund uses an unsafe _transfer that performs a low-level call which is susceptible to re-entrancy attack. These calls should be the last step or you may receive unexpected guests. Even the comment says that but it is actually not the last step:
if (refund > 0) { // Security: doing this last to avoid re-entrancy concerns _transfer(tokenAddress, _keyOwner, refund); } // inform the hook if there is one registered if(address(onKeyCancelHook) != address(0)) { onKeyCancelHook.onKeyCancel(msg.sender, _keyOwner, refund); }
Consider either making this _transfer the last step or adding re-entrancy protection: https://github.com/OpenZeppelin/openzeppelin-contracts/blob/master/contracts/security/ReentrancyGuard.sol
#0 - julien51
2021-12-11T14:24:01Z
Interesting. Considering here that the hook itself can only be set by a lock manager, I would assume that it would not trigger a re-entrency call to canceling a key again. Additionally the function _cancelAndRefund
can only be called if the the key owner has a valid key (hasValidKey
) and the key is expired before the refund.
so all in all, i think we should clarify the comment but I would think we are actually safe here.
#1 - 0xleastwood
2022-01-16T09:37:47Z
Agree with sponsor. Doesn't seem like its exploitable but will keep as low
.
🌟 Selected for report: pauliax
pauliax
function recordKeyPurchas does not distribute the tokens on chainId > 1 when the balance is not sufficient:
uint balance = IMintableERC20(udt).balanceOf(address(this)); if (balance > tokensToDistribute) { // Only distribute if there are enough tokens IMintableERC20(udt).transfer(_referrer, tokensToDistribute - devReward); IMintableERC20(udt).transfer(owner(), devReward); }
I see at least 2 problems here. First, the check should be inclusive >=, because now when balance = tokensToDistribute, it will still skip the distribution. Another problem, I think it is not fair when only a small fraction is missing, you should consider distributing the whole balance in this case.
Here is my prosposed solution:
uint balance = IMintableERC20(udt).balanceOf(address(this)); if (balance >= tokensToDistribute) { IMintableERC20(udt).transfer(_referrer, tokensToDistribute - devReward); IMintableERC20(udt).transfer(owner(), devReward); } else { devReward = balance * 20 / 100; IMintableERC20(udt).transfer(_referrer, balance - devReward); IMintableERC20(udt).transfer(owner(), devReward); }
#0 - julien51
2021-12-11T13:56:05Z
I see at least 2 problems here. First, the check should be inclusive >=, because now when balance = tokensToDistribute, it will still skip the distribution.
It is a good point, but also almost impossible to achieve =
because :
#1 - julien51
2021-12-11T13:58:38Z
Another problem, I think it is not fair when only a small fraction is missing, you should consider distributing the whole balance in this case.
I am not sure I understand this comment. What is "missing"?
#2 - 0xleastwood
2022-01-16T11:41:27Z
I think the warden has provided useful information, however, it is more in line with a low
severity issue.
1 — Low (L): vulns that have a risk of 1 are considered “Low” severity when assets are not at risk. Includes state handling, function incorrect as to spec, and issues with comments.
🌟 Selected for report: pauliax
398.4587 USDC - $398.46
pauliax
function purchase is payable, thus it should validate that msg.value is 0 when tokenAddress != address(0) to prevent accidental sent Ether.
Check no ether was sent when the token is not a native currency.
#0 - julien51
2021-12-11T14:28:52Z
That's indeed a risk.
🌟 Selected for report: pauliax
398.4587 USDC - $398.46
pauliax
function _assignNewTokenId first increments _totalSupply and then assigns token id, so ids start from 1, not 0. However, function tokenByIndex in MixinERC721Enumerable expects the index to be less than totalSupply:
/// @notice Enumerate valid NFTs /// @dev Throws if `_index` >= `totalSupply()`. /// @param _index A counter less than `totalSupply()` /// @return The token identifier for the `_index`th NFT, /// (sort order not specified) function tokenByIndex( uint256 _index ) public view returns (uint256) { require(_index < _totalSupply, 'OUT_OF_RANGE'); return _index; }
This mismatch between indexes and token ids may trick other platforms or integrations.
I think the solution is simply returning index + 1:
require(_index < _totalSupply, 'OUT_OF_RANGE'); return _index + 1; // index 0 = token id 1
#0 - julien51
2021-12-11T14:25:09Z
Good find! The "last" tokenId cannot be retrieved!
🌟 Selected for report: pauliax
398.4587 USDC - $398.46
pauliax
Discrepancies between the interface and implementation, e.g. IUnlock declares a function createLock with 6 parameters but Unlock has only 1 data parameter.
Consider explicitly inheriting an interface to avoid such discrepancies:
contract Unlock is IUnlock // similar with other contracts
#0 - julien51
2021-12-11T14:18:46Z
That's a good find!
🌟 Selected for report: pauliax
398.4587 USDC - $398.46
pauliax
After purchasing the key, it calls the hook:
onKeyPurchaseHook.onKeyPurchase(msg.sender, _recipient, _referrer, _data, inMemoryKeyPrice, pricePaid);
It passes inMemoryKeyPrice as the amount paid, however, the documentation above this function says:
* @param minKeyPrice the price including any discount granted from calling this * hook's `keyPurchasePrice` function
so it expects an amount with a discount included, but actually receives inMemoryKeyPrice which has already discount deducted from it.
Consider either passing inMemoryKeyPrice + discount, or updating the comment.
#0 - julien51
2021-12-11T14:10:21Z
That's a good find! We will get that fixed.
🌟 Selected for report: pauliax
pauliax
There are some validations missing that could make the system more robust. For instance:
function updateRefundPenalty could validate:
_freeTrialLength <= expirationDuration _refundPenaltyBasisPoints <= BASIS_POINTS_DEN
function shareKey could validate that _to != keyOwner and _timeShared > 0.
function grantKeys could validate that the length of _recipients, _expirationTimestamps, and _keyManagers are the same.
function grantKeys may also have onlyIfAlive and notSoldOut modifiers applied. refunds could also consider onlyIfAlive unless that's intended behavior.
Also, I think the same address shouldn't be able to purchase a key again if he refunded a key from the same lock before.
function upgradeLock should validate that version <= publicLockLatestVersion to prevent an upgrade to a non yet existent version.
Consider applying the aforementioned validations.
#0 - julien51
2021-12-11T14:15:19Z
I am not sure I agree with all these. In many cases, adding these verifications will increase gas costs for no actual benefits.
For example on updateRefundPenalty
, since only a lock manager can call these things, and the lock manager is a de-facto "admin" they can be "trusted", because if they are malicious they will be able to break the behavior of their lock contract.
Also, I think the same address shouldn't be able to purchase a key again if he refunded a key from the same lock before.
Why? If that's business logic, I don't agree. You can maybe cancel a subscription and and eventually decide to purchase it again. Even if we disabled this, it would be trivial for that user to "bypass" this behavior by just having a 2nd address...
#1 - 0xleastwood
2022-01-16T22:55:14Z
I think the warden has outlined some useful validations, so I'll keep this open.
#2 - 0xleastwood
2022-01-16T22:56:58Z
i.e. it can be argued that validation of shareKey
is important to avoid the issue outlined in #87
🌟 Selected for report: WatchPug
Also found by: GiveMeTestEther, mics, pauliax
pauliax
The unchecked keyword can be applied in the following lines of code since there are statements before that ensure the arithmetic operations would not cause an integer underflow or overflow:
if (refund > penalty) { refund -= penalty; if(discount < minKeyPrice) { minKeyPrice -= discount; require(unlockDiscount <= minKeyPrice, 'INVALID_DISCOUNT_FROM_UNLOCK'); minKeyPrice -= unlockDiscount; for (uint i = 0; i < 20; i++) // Math: safeSub is not required since `hasValidKey` confirms timeRemaining is positive uint timeRemaining = key.expirationTimestamp - block.timestamp;
There might be more places where this can be applied.
#0 - 0xleastwood
2022-01-17T09:08:35Z
Duplicate of #203
🌟 Selected for report: pauliax
pauliax
Some expressions do not change, so can be precalculated to reduce gas usage. For example, this:
uint devReward = tokensToDistribute * 20 / 100;
can be simplified to:
uint devReward = tokensToDistribute / 5;
and this:
(125 * 10 ** 18) / 100
doesn't need to be reevaluated every time, so can be extracted as a constant.
🌟 Selected for report: pauliax
pauliax
function _assignNewTokenId does not necessarily need to check if (_key.tokenId == 0) as this check was already performed in all places where this function is called. Similarly, function getTransferFee does not need to check getHasValidKey as I don't see a case where this was not checked before.
🌟 Selected for report: pauliax
pauliax
This:
if(timeRemaining + freeTrialLength >= expirationDuration) { refund = keyPrice; } else { refund = keyPrice * timeRemaining / expirationDuration; } // Apply the penalty if this is not a free trial if(freeTrialLength == 0 || timeRemaining + freeTrialLength < expirationDuration) { uint penalty = keyPrice * refundPenaltyBasisPoints / BASIS_POINTS_DEN; if (refund > penalty) { refund -= penalty; } else { refund = 0; } }
can be refactored to avoid duplicate checks:
if (freeTrialLength != 0 && timeRemaining + freeTrialLength >= expirationDuration) { refund = keyPrice; } else { refund = keyPrice * timeRemaining / expirationDuration; // Apply the penalty if this is not a free trial uint penalty = keyPrice * refundPenaltyBasisPoints / BASIS_POINTS_DEN; if (refund > penalty) { refund -= penalty; } else { refund = 0; } }
🌟 Selected for report: pauliax
pauliax
The check could be inclusive <= to handle the case when timePlusFee = timeRemaining:
if(timePlusFee < timeRemaining)
pauliax
Assigned operations to constant variables are re-evaluated every time. See https://github.com/ethereum/solidity/issues/9232 Change to 'constant' to 'immutable':
bytes32 private constant _TYPE_HASH = keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"); bytes32 private constant _DELEGATION_TYPEHASH = keccak256("Delegation(address delegatee,uint256 nonce,uint256 expiry)"); bytes32 public constant LOCK_MANAGER_ROLE = keccak256("LOCK_MANAGER"); bytes32 public constant KEY_GRANTER_ROLE = keccak256("KEY_GRANTER");
🌟 Selected for report: pauliax
pauliax
valueInETH can be 0 when the token is not native but oracle is not set for it. Consider skipping all the expensive calculations in recordKeyPurchase to save gas when valueInETH is 0. Also, in special cases, this can invoke a division by zero runtime error when calculating maxTokens and dividing by grossNetworkProduct.
🌟 Selected for report: pauliax
pauliax
Incrementing / decrementing values is cheapest by doing variable++/--; Examples where this can be applied:
counter._value += 1; counter._value = value - 1; k = k-1;
🌟 Selected for report: pauliax
pauliax
address(this).address2Str() can be extracted as a constant to avoid reevaluation again each time tokenURI function is called.
#0 - julien51
2021-12-11T13:49:29Z
Thanks for the tip!