Unlock Protocol contest - WatchPug'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: 1/26

Findings: 8

Award: $12,005.46

🌟 Selected for report: 16

πŸš€ Solo Findings: 4

Findings Information

🌟 Selected for report: WatchPug

Labels

bug
3 (High Risk)
sponsor confirmed

Awards

3984.587 USDC - $3,984.59

External Links

Handle

WatchPug

Vulnerability details

The current design/implementation of freeTrial allows users to get full refund before the freeTrial ends. Plus, a user can transfer partial of thier time to another user using shareKey.

This makes it possible for the attacker to steal from the protocol by transferring freeTrial time from multiple addresses to one address and adding up to expirationDuration and call refund to steal from the protocol.

PoC

Given:

  • keyPrice is 1 ETH;
  • expirationDuration is 360 days;
  • freeTrialLength is 31 days.

The attacker can create two wallet addresses: Alice and Bob.

  1. Alice calls purchase(), transfer 30 days via shareKey() to Bob, then calls cancelAndRefund() to get full refund; Repeat 12 times;
  2. Bob calls cancelAndRefund() and get 1 ETH.

Recommendation

Consider disabling cancelAndRefund() for users who transferred time to another user.

#0 - julien51

2021-11-26T03:13:18Z

I think this is valid! The free trial approach is indeed a risk on that front and we need to "warn" lock managers about this more.

#1 - julien51

2021-11-26T03:14:13Z

For lock manager who still want to offer free trials, the best approach would probably be to set a high transfer fee to make sure that free trials cannot be transfered.

#2 - julien51

2021-11-26T03:14:48Z

As a consequence of this I am not sure this is as critical as indicated by the submitter.

#3 - 0xleastwood

2022-01-16T08:16:26Z

Nice find!

From what I can tell at least, this does seem like a viable attack vector. Can I ask why this should not be treated as high risk? @julien51

#4 - julien51

2022-03-16T06:36:04Z

Sorry for the long delay here. In short: this is valid, but only an issue for locks which are enabling free trials (no one has done it) and we would make sure our UI shows this as a potential issue. In other words: a lock manager would need to explicitly enable free trials, despite our warning to put their own funds at risk. For that reason I don't think this is "High".

#5 - 0xleastwood

2022-03-22T21:36:19Z

While this is a valid issue pertaining only to lock managers who explicitly enable free trials, this may still lead to a loss of funds if cancelAndRefund is called by a user who has transferred their time to another account. I still believe this deserves a high severity rating.

#6 - 0xleastwood

2022-03-22T21:37:03Z

In my honest opinion, a warning isn't sufficient to prevent such abuse. I think on-chain enforcement ideal in this situation.

Findings Information

🌟 Selected for report: WatchPug

Labels

bug
3 (High Risk)
sponsor confirmed

Awards

3984.587 USDC - $3,984.59

External Links

Handle

WatchPug

Vulnerability details

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

    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
      _setKeyManagerOf(_tokenId, address(0));

      _recordOwner(_recipient, _tokenId);
    } else {
      // The recipient has a non expired key. We just add them the corresponding remaining time
      // SafeSub is not required since the if confirms `previousExpiration - block.timestamp` cannot underflow
      toKey.expirationTimestamp = fromKey.expirationTimestamp + previousExpiration - block.timestamp;
    }

Based on the context, L131-136 seems to be the logic of handling the case of the recipient with no key, and L138-148 is handing the case of the recipient's key expired.

However, in L131-136, the key manager is not being reset.

This allows attackers to keep the role of key manager after the transfer, and transfer the key back or to another recipient.

PoC

Given:

  • Alice owns a key that is valid until 1 year later.
  1. Alice calls setKeyManagerOf(), making herself the keyManager;
  2. Alice calls transferFrom(), transferring the key to Bob; Bob might have paid a certain amount of money to Alice upon receive of the key;
  3. Alice calls transferFrom() again, transferring the key back from Bob.

Recommendation

Consider resetting the key manager regardless of the status of the recipient's key.

#0 - julien51

2021-12-11T15:16:18Z

I think you are onto something here. We will need to investigate further and reproduce to fix!

#1 - 0xleastwood

2022-01-16T08:14:20Z

@julien51 Just following up if you were able to double-check this?

#2 - julien51

2022-03-16T06:36:53Z

This is indeed valid and I think we will need to "patch" this. We're still unsure how but we're exploring multiple ways.

Findings Information

🌟 Selected for report: WatchPug

Labels

bug
1 (Low Risk)

Awards

1195.3761 USDC - $1,195.38

External Links

Handle

WatchPug

Vulnerability details

In the current implementation, Unlock.sol#recordKeyPurchase() will send estimatedGasForPurchase * tx.gasprice worth of UDT to the referrer.

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

uint tokensToDistribute = (estimatedGasForPurchase * tx.gasprice) * (125 * 10 ** 18) / 100 / udtPrice;

We believe there are multiple potential economic attack vectors to exploit this.

If estimatedGasForPurchase is misconfigured to a higher amount than the actual avg gas cost for a purchase call, or future network upgrades make the actual gas cost become lower than the configured estimatedGasForPurchase, it can be exploited simply by creating a lock and call purchase() many times to mint UDT.

Even if estimatedGasForPurchase is configured to an amount similar to the actual gas cost, a more sophisticated attack is still possible:

PoC

Given:

  • estimatedGasForPurchase is configured as 200,000;
  • The gas cost of a regular purchase call is about 200,000.

The attacker can create a lock contract and set the token address to a special gas saving token, which will SELFDESTRUCT to get a gas refund on transfer.

The attacker can:

  1. Mint gas saving token with gas price: 1 gwei;
  2. Call purchase() and use 48 contract slots with gas price: 1000 gwei;

Total gas saved will be ~0.8 ETH (or other native tokens, eg. BNB, MATIC). Therefore, the attacker will profit ~0.8 ETH worth of UDT.

See: https://gastoken.io/

Recommendation

Consider setting a global daily upper limit of total UDT grants to referrers, plus, an upper limit for UDT minted per purchase.

#0 - julien51

2021-12-11T15:01:46Z

If estimatedGasForPurchase is misconfigured to a higher amount than the actual avg gas cost for a purchase call, or future network upgrades make the actual gas cost become lower than the configured estimatedGasForPurchase, it can be exploited simply by creating a lock and call purchase() many times to mint UDT.

Absolutely but considering the security model, the admin indeed have full control over the protocol. We are thinking about finding a mechanism to not hardcode gas spent but use the actual number eventually. When we do that we should consider the impact of things like gas-token (even though EIP1559 has probably made them mostly impractical?).

At this point given that the gas spent is hardcoded, there is a de-facto cap on how much UDT they could earn (based on the token price).

#1 - 0xleastwood

2022-01-16T04:56:49Z

While I agree with the warden, there is potential for value extraction, however, it does require the admin to be unaware about upcoming network upgrades.

As the sponsor has noted, they will be moving towards a dynamic estimatedGasForPurchase value, however, from the perspective of the c4 contest, this doesn't change the outcome of my decision.

#2 - 0xleastwood

2022-01-16T04:57:44Z

As the protocol may leak value based on certain network assumptions, I'll mark this as medium severity.

#3 - 0xleastwood

2022-01-16T04:57:59Z

2 β€” Med (M): vulns have a risk of 2 and are considered β€œMedium” severity when assets are not at direct risk, but the function of the protocol or its availability could be impacted, or leak value with a hypothetical attack path with stated assumptions, but external requirements.

#4 - julien51

2022-03-16T06:42:57Z

I don't think the protocl can "leak" value based on that. the tokens that are used to compute GDP and distribute tokens have to be approved by the DAO (right now only USDC, DAI and BAT have been approved on mainnet, and only USDC on Polygon). I don't think the DAO would approve gas tokens givem that indeed they could result in leakage of UDT, so I think it is minor.

#5 - 0xleastwood

2022-03-22T21:44:30Z

Considering the sponsor's comments, I actually agree that this is less likely than initially stated. Similar to the SafeERC20 issue, it isn't expected that gas saving tokens will be approved to compute and distribute UDT tokens. I'll downgrade this to low.

Findings Information

🌟 Selected for report: pauliax

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

Labels

bug
duplicate
1 (Low Risk)
sponsor confirmed

Awards

71.4681 USDC - $71.47

External Links

Handle

WatchPug

Vulnerability details

It is usually good to add a require-statement that checks the return value or to use something like safeTransferFrom; unless one is sure the given token reverts in case of a failure.

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

// We explicitly allow for greater amounts of ETH or tokens to allow 'donations'
uint pricePaid;
if(tokenAddress != address(0))
{
    pricePaid = _value;
    IERC20Upgradeable token = IERC20Upgradeable(tokenAddress);
    token.transferFrom(msg.sender, address(this), pricePaid);
}
// ...

Recommendation

Consider adding a require-statement or using safeTransferFrom.

#0 - julien51

2021-12-11T15:19:07Z

We have already explained the if a lock manager creates a lock with a malicious ERC20 they should expect to indeed see unexpected behaviors.

#1 - 0xleastwood

2022-01-16T12:05:35Z

Duplicate of #162

#2 - 0xleastwood

2022-01-16T12:06:17Z

Duplicate of #221

Findings Information

🌟 Selected for report: kenzo

Also found by: 0x0x0x, WatchPug

Labels

bug
duplicate
2 (Med Risk)

Awards

322.7515 USDC - $322.75

External Links

Handle

WatchPug

Vulnerability details

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

function _getCancelAndRefundValue(
    address _keyOwner
  )
    private view
    hasValidKey(_keyOwner)
    returns (uint refund)
  {
    Key storage key = keyByOwner[_keyOwner];
    // Math: safeSub is not required since `hasValidKey` confirms timeRemaining is positive
    uint timeRemaining = key.expirationTimestamp - block.timestamp;
    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;
      }
    }
  }

The current implementation of _getCancelAndRefundValue() is using the latest value of freeTrialLength, refundPenaltyBasisPoints and expirationDuration for calculation.

Therefore, after the user purchased a key, if the lock owner changed the settings of freeTrialLength, refundPenaltyBasisPoints and expirationDuration for worse, eg, a shorter freeTrialLength, a larger refundPenaltyBasisPoints or a shorter expirationDuration, user will get worse terms with _getCancelAndRefundValue(), which is unexpected.

Recommendation

Consider taking a snapshot of the settings on purchase() and use the snapshot values for _getCancelAndRefundValue().

#0 - julien51

2021-12-11T14:57:56Z

When we shipped this feature, we decided against keeping track of "past" purchases to decide on the cancellation terms because we wanted to avoid wasting gas for "marginal" issues. In practice, the lock manager has full control over the lock contract and could easily just prevent cancellation altogether if they were malicious anyway by withdrawing all of the funds on it.

#1 - 0xleastwood

2022-01-16T09:48:09Z

Duplicate of #53

Findings Information

🌟 Selected for report: WatchPug

Labels

bug
2 (Med Risk)

Awards

1195.3761 USDC - $1,195.38

External Links

Handle

WatchPug

Vulnerability details

The current design/implementation allows users who are refunded before to get another freeTrial. This can be exploited by malicious users to get an infinite free trial.

PoC

Given:

  • keyPrice is 1 ETH;
  • freeTrialLength is 31 days.

A malicious user can:

  1. Call purchase(), pay 1 ETH and get 31 days of freeTrial on day 1;
  2. Call cancelAndRefund() on day 30 and get 1 ETH of refund; then call purchase() again, pay 1 ETH and get 31 days of freeTrial again.

Repeat the steps above and the user can get infinite freeTrial.

Impact

A malicious third party may provide a service named freeUnlock, which will call cancelAndRefund() and purchase() automatically right before the end of the freeTrial. This can cause fund loss to all the owners that provide a freeTrial.

Recommendation

Consider adding a mapping(address => uint256) freeTrialEnds and make sure each address can only get 1 freeTrial.

#0 - julien51

2021-11-26T03:10:12Z

Isn't that the case with every free trial system? If they use the same address the lock manager could easily use the hook system to keep track of who already had received a full refund and not grant it on the 2nd cancellation. The user could still use new addresses all the time, and in that case that would be valid, but that is actually the case with a lot of systems like that :) One of my roommates in colleges was just subscribing to newspaper and getting the full risk-free refund by using a different name every time (but used the same address)

#1 - 0xleastwood

2022-01-16T08:19:22Z

While I agree with the warden, there is potential for unlimited free trials. Limiting a free trial to a single address does not resolve the issue as an attacker can generate any number of addresses from a single seed. However, I do understand this is a tricky issue to workaround.

#2 - 0xleastwood

2022-01-16T08:20:39Z

So I'm not sure how this should be treated as it does affect how the protocol is intended to operate. Is there any reason for users to not abuse this @julien51 ? Typically with newspapers, you have to provide credit card details, so an individual is really limited by the number of cards they hold.

#3 - julien51

2022-01-17T02:10:59Z

As you noted, there is no way to prevent free trials from being abused which is why by default, locks do not have a free trial: they have to be manually explicitly configured. From there, since it's trivial to just create an infinite number of accounts, anyone could just claim free trials over and over from new accounts.

#4 - 0xleastwood

2022-01-17T03:02:56Z

As per sponsor, trials are not enabled by default. But seeing as this impacts protocol availability through abuse if enabled. I'll mark this as medium.

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