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: 2/26
Findings: 8
Award: $8,857.88
π Selected for report: 10
π Solo Findings: 3
π Selected for report: cmichel
3984.587 USDC - $3,984.59
cmichel
The locks implement three different approval types, see onlyKeyManagerOrApproved
for an overview:
keyManagerOf
)approved
). Cleared by _clearApproval
or _setKeyManagerOf
managerToOperatorApproved
)The MixinTransfer.transferFrom
requires any of the three approval types in the onlyKeyManagerOrApproved
modifier on the tokenId to authenticate transfers from from
.
Notice that if the to
address previously had a key but it expired only the _setKeyManagerOf
call is performed, which does not clear approved
if the key manager was already set to 0:
function transferFrom( address _from, address _recipient, uint _tokenId ) public onlyIfAlive hasValidKey(_from) onlyKeyManagerOrApproved(_tokenId) { // @audit this is skipped if user had a key that expired if (toKey.tokenId == 0) { toKey.tokenId = _tokenId; _recordOwner(_recipient, _tokenId); // Clear any previous approvals _clearApproval(_tokenId); } if (previousExpiration <= block.timestamp) { // The recipient did not have a key, or had a key but it expired. The new expiration is the sender's key expiration // An expired key is no longer a valid key, so the new tokenID is the sender's tokenID toKey.expirationTimestamp = fromKey.expirationTimestamp; toKey.tokenId = _tokenId; // Reset the key Manager to the key owner // @audit doesn't clear approval if key manager already was 0 _setKeyManagerOf(_tokenId, address(0)); _recordOwner(_recipient, _tokenId); } // ... } // function _setKeyManagerOf( uint _tokenId, address _keyManager ) internal { // @audit-ok only clears approved if key manager updated if(keyManagerOf[_tokenId] != _keyManager) { keyManagerOf[_tokenId] = _keyManager; _clearApproval(_tokenId); emit KeyManagerChanged(_tokenId, address(0)); } }
It's possible to sell someone a key and then claim it back as the approvals are not always cleared.
tokenId = 42
) with an expiry date far in the future.MixinKeys.setApprovalForAll(A', true)
, which sets managerToOperatorApproved[A][A'] = true
._setKeyManagerOf(42, address(0));
in transferFrom
MixinKeys.approve(A', 42)
, setting approved[42] = A'
.transferFrom(A, V, 42)
call sets the owner of token 42 to V
, but does not clear the approved[42] == A'
field as described above. (_setKeyManagerOf(_tokenId, address(0));
is called but the key manager was already zero, which then does not clear approvals.)transferFrom(V, A', 42)
and the onlyKeyManagerOrApproved(42)
modifier will pass as approved[42] == A'
is still set.The _setKeyManagerOf
function should not handle clearing approvals of single-token approvals (approved
) as these are two separate approval types.
The transferFrom
function should always call _clearApproval
in the (previousExpiration <= block.timestamp)
case.
#0 - julien51
2021-12-11T15:40:25Z
Thanks for reporting this.
#1 - julien51
2022-03-16T06:39:40Z
This is valid and we will fix it.
71.4681 USDC - $71.47
cmichel
Some tokens (like USDT) don't correctly implement the EIP20 standard and their transferFrom
function returns void
instead of a success boolean. Calling these functions with the correct EIP20 function signatures will always revert.
For the tokens that return a success value, the contract does not check it.
Non-safe transfers are used in:
MixinPurchase.purchase
: token.transferFrom(msg.sender, address(this), pricePaid);
Tokens that return false
on a failed transferFrom
or that don't correctly implement the latest EIP20 spec, like USDT, will be unusable in the protocol as they revert the transaction because of the missing return value.
We recommend using OpenZeppelinβs SafeERC20
versions with the safeTransferFrom
function that handle the return value check as well as non-standard-compliant tokens.
#0 - 0xleastwood
2022-01-16T05:25:22Z
Agree with warden. Marking this is primary issue.
π Selected for report: kenzo
Also found by: GiveMeTestEther, cmichel
322.7515 USDC - $322.75
cmichel
The MixinTransfer.shareKey
function does not disallow transfering the key to oneself, i.e. from == recipient
.
When doing a self-transfer, the remaining time for oneself should be reduced by the fees, but the keys immediately expire due to explicitly setting the expiry on fromKey
:
// @audit self-transfer from == recipient expires this key and sets tokenid to 0 fromKey.expirationTimestamp = block.timestamp;
An easy fix is to disallow self-transfers as they have no use.
Add a require(_from != _recipient, "!self")
statement.
#0 - 0xleastwood
2022-01-16T09:51:19Z
Duplicate of #87
π Selected for report: cmichel
cmichel
The Unlock.recordKeyPurchase
function is called on each key purchase (MixinPurchase.purchase
) and mints UDT tokens to the referrer.
The amount to mint is based on the transaction's gas price which is controlled by the caller (purchaser):
uint tokensToDistribute = (estimatedGasForPurchase * tx.gasprice) * (125 * 10 ** 18) / 100 / udtPrice;
Tokens can be minted by purchasing a key with themself as the referrer at a high transaction gas price. Depending on the UDT price on external markets, it could be profitable to buy a key at a high gas price, receive UDT and then sell them on a market for a profit.
The amount minted should be more predictable and not depend on the user's gas price input. Consider declaring an average gas price storage variable that is set by a trusted party and use this one instead.
#0 - julien51
2021-12-11T15:51:59Z
Depending on the UDT price on external markets, it could be profitable to buy a key at a high gas price, receive UDT and then sell them on a market for a profit.
Since we get the token price from the Uniswap oracle, the amount of tokens received is always at most equal to what they would have spent to acquire them on Uniswap.
#1 - 0xleastwood
2022-01-16T09:45:38Z
As the uniswap oracle provides averaged price data, if there is any discrepancy between the spot price and the TWAP price, this can definitely be abused to extract value from the protocol. Keeping this as medium
.
537.9192 USDC - $537.92
cmichel
The MixinTransfer.shareKey
function wants to compute a fee such that time + fee * time == timeRemaining (timePlusFee)
:
uint fee = getTransferFee(keyOwner, _timeShared); uint timePlusFee = _timeShared + fee;
However, if the time remaining is less than the computed fee time, the computation changes and a different formula is applied. The fee is now simply taken on the remaining time.
if(timePlusFee < timeRemaining) { // now we can safely set the time time = _timeShared; // deduct time from parent key, including transfer fee _timeMachine(_tokenId, timePlusFee, false); } else { // we have to recalculate the fee here fee = getTransferFee(keyOwner, timeRemaining); // @audit want it such that time + fee * time == timeRemaining, but fee is taken on timeRemaining instead of time time = timeRemaining - fee; }
It should compute the time
without fee as time = timeRemaining / (1.0 + fee_as_decimal)
instead, i.e., time = BASIS_POINTS_DEN * timeRemaining / (transferFeeBasisPoints + BASIS_POINTS_DEN)
.
To demonstrate the difference with a 10% fee and a _timeShared = 10,000s
which should be credited to the to
account.
The correct time plus fee which is reduced from from
(as in the timePlusFee < timeRemaining
branch) would be 10,000 + 10% * 10,000 = 11,000
.
However, if from
has not enough time remaining and timePlusFee >= timeRemaining
, the entire time remaining is reduced from from
but the credited time
is computed wrongly as:
(Let's assume timeRemaining == timePlusFee
): time = 11,000 - 10% * 11,000 = 11,000 - 1,100 = 9900
.
They would receive 100 seconds less than what they are owed.
When transferring more time than the from
account has, the credited time is scaled down wrongly and the receiver receives less time (a larger fee is applied).
It should change the first if
branch condition to timePlusFee <= timeRemaining
(less than or equal).
In the else
branch, it should compute the time without fee as time = BASIS_POINTS_DEN * timeRemaining / (transferFeeBasisPoints + BASIS_POINTS_DEN)
.
#0 - julien51
2021-12-11T15:29:00Z
Great find!
π Selected for report: cmichel
cmichel
The Unlock.recordKeyPurchase
function computes the maxTokens
as:
maxTokens = IMintableERC20(udt).balanceOf(address(this)) * valueInETH / (2 + 2 * valueInETH / grossNetworkProduct) / grossNetworkProduct;
Note that grossNetworkProduct
was already increased by valueInETH
in the code before.
Meaning, the (2 + 2 * valueInETH / grossNetworkProduct)
part of the computation will almost always be 2
as usually grossNetworkProduct > 2 * valueInETH
, and thus the 2 * valueInETH / grossNetworkProduct
is zero by integer division.
The maxTokens
curve might not be computed as intended and lead to being able to receive more token rewards than intended.
The comment "we distribute tokens using asymptotic curve between 0 and 0.5" should be more clear to indicate how exactly the curve looks like.
It could be that a floating-point number was desired instead of the integer division in 2 * valueInETH / grossNetworkProduct
. In that case, consider adding a scaling factor to this term and divide by it at the end of the computation again.
#0 - julien51
2021-12-11T15:49:56Z
I am not fully sure I understand what the problem is here?
#1 - 0xleastwood
2022-01-16T09:57:11Z
I think the warden is raising an issue where 2 * valueInEth / grossNetworkProduct
will more than likely truncate and return 0
. I think this is a valid finding.
#2 - julien51
2022-03-16T06:48:43Z
Hum, we did some tests and could not reproduce here.
#3 - 0xleastwood
2022-03-22T22:19:35Z
I'm not sure how 2 * valueInETH / grossNetworkProduct
does not always lead to some truncation. grossNetworkProduct
is equal to valueInETH
in the first call but always greater than valueInETH
in any subsequent calls.
107.5838 USDC - $107.58
cmichel
Some tokens (like USDT) don't correctly implement the EIP20 standard and their approve
function returns void
instead of a success boolean. Calling these functions with the correct EIP20 function signatures will always revert.
For the tokens that return a success value, the contract does not check it.
Non-safe transfers are used in:
MixinLockCore.approveBeneficiary
: IERC20Upgradeable(tokenAddress).approve(_spender, _amount)
Tokens that return false
on a failed approve
or that don't correctly implement the latest EIP20 spec, like USDT, will be unusable in the protocol as they revert the transaction because of the missing return value.
We recommend using OpenZeppelinβs SafeERC20
versions with the safeApprove
function that handle the return value check as well as non-standard-compliant tokens.
179.3064 USDC - $179.31
cmichel
The initialize
function that initializes important contract state can be called by anyone.
See:
Unlock.initialize
UnlockOwnable.__initializeOwnable
is a public
functionThe attacker can initialize the contract before the legitimate deployer, hoping that the victim continues to use the same contract. In the best case for the victim, they notice it and have to redeploy their contract costing gas.
Use the constructor to initialize non-proxied contracts.
For initializing proxy contracts deploy contracts using a factory contract that immediately calls initialize
after deployment or make sure to call it immediately after deployment and verify the transaction succeeded.
π Selected for report: cmichel
398.4587 USDC - $398.46
cmichel
The Unlock.setLockTemplate
function sets the default lock tempalte for new lock creations.
However, it does not verify that this lock template is a valid template that was added to _publicLockVersions
via addLockTemplate
.
A default template with a wrong version number can be set which is incompatible with updating locks through upgradeLock
(requires version == currentVersion + 1
).
Add new lock templates using addLockTemplate
first and restrict setLockTemplate
to only use these templates, not arbitrary code.
#0 - julien51
2021-12-11T15:44:21Z
Indeed, we could improve things by making sure that setLockTemplate
is called with a version number rather than the address of a lock template.
π Selected for report: cmichel
398.4587 USDC - $398.46
cmichel
The Unlock.addLockTemplate
function allows modifying an already existing version.
addLockTemplate
action and update to a version which they don't want. This frontrunning may happen accidentallyDisallow updating existing template versions. Ensure that the versions that are deployed work and create a new version for each iteration.
#0 - julien51
2021-12-11T15:57:25Z
We actually decided against this to keep some flexibility for the "owner" of the Unlock contact (DAO) to push a new version with no storage change at no risk for users.
π Selected for report: cmichel
cmichel
The MixinPurchase.purchase
function calls into an unknown onKeyPurchaseHook
contract which can be set by the owner.
If the hook reverts, the entire transaction reverts and no keys can be purchased anymore.
Ensure the onKeyPurchaseHook
contracts do not revert, or use a low-level call to onKeyPurchaseHook.onKeyPurchase
and do not revert if success
is false
.
#0 - julien51
2021-12-11T15:33:26Z
We indeed considered that when implementing hooks and wondered if we should use try/catch
blocks for them. Given that hooks can be set up by local managers -de facto admins- (and reset if they did something wrong), we assumed it would be better to not incur gas costs and let them only set hooks that are verified (and could include their own try/catch blocks if needed).
#1 - 0xleastwood
2022-01-16T12:12:42Z
I think this is worth documenting for developers intending to implement these functions. As such, I'll keep this open.
π Selected for report: HardlyDifficult
Also found by: TomFrenchBlockchain, cmichel
14.4834 USDC - $14.48
cmichel
The Unlock
contract stores the proxy admin address in two storage variables, proxyAdminAddress
and proxyAdmin
, see _deployProxyAdmin
.
This is unnecessary and bad practice. If you want to type cast from address
to ProxyAdmin
, do it on the fly, there's no need to store a second storage variable.
#0 - 0xleastwood
2022-01-17T09:02:14Z
Duplicate of #129
π Selected for report: cmichel
cmichel
There are many calls to _recordOwner
which does not check if the new owner is already in this list.
As a push is always done the gas costs can be high.
Use an [EnumerableSet])(https://github.com/OpenZeppelin/openzeppelin-contracts/blob/master/contracts/utils/structs/EnumerableSet.sol) that can push and check for existence in constant time.