Sublime contest - hickuphh3's results

Democratizing credit via Web3.

General Information

Platform: Code4rena

Start Date: 29/03/2022

Pot Size: $30,000 USDC

Total HM: 6

Participants: 24

Period: 3 days

Judge: HardlyDifficult

Total Solo HM: 4

Id: 101

League: ETH

Sublime

Findings Distribution

Researcher Performance

Rank: 1/24

Findings: 5

Award: $14,361.92

๐ŸŒŸ Selected for report: 4

๐Ÿš€ Solo Findings: 2

Findings Information

๐ŸŒŸ Selected for report: hickuphh3

Labels

bug
3 (High Risk)
sponsor confirmed

Awards

8635.2861 USDC - $8,635.29

External Links

Lines of code

https://github.com/sublime-finance/sublime-v1/blob/46536a6d25df4264c1b217bd3232af30355dcb95/contracts/PooledCreditLine/LenderPool.sol#L594-L599 https://github.com/sublime-finance/sublime-v1/blob/46536a6d25df4264c1b217bd3232af30355dcb95/contracts/PooledCreditLine/LenderPool.sol#L399-L404

Vulnerability details

Details & Impact

The _principalWithdrawable calculated will be more than expected if _start() is invoked with a non-zero start fee, because the borrow limit is reduced by the fee, resulting in totalSupply[id] not being 1:1 with the borrow limit.

function _calculatePrincipalWithdrawable(uint256 _id, address _lender) internal view returns (uint256) {
  uint256 _borrowedTokens = pooledCLConstants[_id].borrowLimit;
  uint256 _totalLiquidityWithdrawable = _borrowedTokens.sub(POOLED_CREDIT_LINE.getPrincipal(_id));
  uint256 _principalWithdrawable = _totalLiquidityWithdrawable.mul(balanceOf(_lender, _id)).div(_borrowedTokens);
  return _principalWithdrawable;
}

Proof of Concept

Assume the following conditions:

  • Alice, the sole lender, provided 100_000 tokens: totalSupply[_id] = 100_000
  • borrowLimit = 99_000 because of a 1% startFee
  • Borrower borrowed zero amount

When Alice attempts to withdraw her tokens, the _principalWithdrawable amount is calculated as

_borrowedTokens = 99_000
_totalLiquidityWithdrawable = 99_000 - 0 = 99_000
_principalWithdrawable = 99_000 * 100_000 / 99_000 = 100_000

This is more than the available principal amount of 99_000, so the withdrawal will fail.

One hack-ish way is to save the initial supply in minBorrowAmount (perhaps rename the variable to minInitialSupply) when the credit line is accepted, and replace totalSupply[_id] with it.

The other places where minBorrowAmount are used will not be affected by the change because:

  • startTime has been zeroed, so start() cannot be invoked (revert with error S1)

  • credit line status would have been changed to ACTIVE and cannot be changed back to REQUESTED, meaning the check below will be false regardless of the value of minBorrowAmount.

    _status == PooledCreditLineStatus.REQUESTED &&
    block.timestamp > pooledCLConstants[_id].startTime &&
    totalSupply[_id] < pooledCLConstants[_id].minBorrowAmount

Code amendment example:


function _accept(uint256 _id, uint256 _amount) internal {
  ...
  // replace delete pooledCLConstants[_id].minBorrowAmount; with the following:
  pooledCLConstants[_id].minInitialSupply = totalSupply[_id];
}

// update comment in _withdrawLiquidity
// Case 1: Pooled credit line never started because desired amount wasn't reached
// state will never revert back to REQUESTED if credit line is accepted so this case is never run

function _calculatePrincipalWithdrawable(uint256 _id, address _lender) internal view returns (uint256) {
  uint256 _borrowedTokens = pooledCLConstants[_id].borrowLimit;
  uint256 _totalLiquidityWithdrawable = _borrowedTokens.sub(POOLED_CREDIT_LINE.getPrincipal(_id));
  // totalSupply[id] replaced with minInitialSupply
  uint256 _principalWithdrawable = _totalLiquidityWithdrawable.mul(balanceOf(_lender, _id)).div(minInitialSupply);
  return _principalWithdrawable;
}

In terminate(), the shares withdrawable can simply be _sharesHeld.

function terminate(uint256 _id, address _to) external override onlyPooledCreditLine nonReentrant {
  address _strategy = pooledCLConstants[_id].borrowAssetStrategy;
  address _borrowAsset = pooledCLConstants[_id].borrowAsset;
  uint256 _sharesHeld = pooledCLVariables[_id].sharesHeld;

  SAVINGS_ACCOUNT.withdrawShares(_borrowAsset, _strategy, _to, _sharesHeld, false);
  delete pooledCLConstants[_id];
  delete pooledCLVariables[_id];
}

Findings Information

๐ŸŒŸ Selected for report: hickuphh3

Also found by: WatchPug, rayn

Labels

bug
3 (High Risk)
sponsor confirmed

Awards

2331.5273 USDC - $2,331.53

External Links

Lines of code

https://github.com/sublime-finance/sublime-v1/blob/46536a6d25df4264c1b217bd3232af30355dcb95/contracts/PooledCreditLine/LenderPool.sol#L404-L406

Vulnerability details

Details & Impact

_principalWithdrawable is denominated in the borrowAsset, but subsequently treats it as the share amount to be withdrawn.

// _notBorrowed = borrowAsset amount that isn't borrowed
// totalSupply[_id] = ERC1155 total supply of _id
// _borrowedTokens = borrower's specified borrowLimit
uint256 _principalWithdrawable = _notBorrowed.mul(totalSupply[_id]).div(_borrowedTokens);

SAVINGS_ACCOUNT.withdrawShares(_borrowAsset, _strategy, _to, _principalWithdrawable.add(_totalInterestInShares), false);

The amount of shares to withdraw can simply be _sharesHeld.

Note that this comes with the assumption that terminate() is only called when the credit line is ACTIVE or EXPIRED (consider ensuring this condition on-chain), because _sharesHeld excludes principal withdrawals, so the function will fail once a lender withdraws his principal.

function terminate(uint256 _id, address _to) external override onlyPooledCreditLine nonReentrant {
  address _strategy = pooledCLConstants[_id].borrowAssetStrategy;
  address _borrowAsset = pooledCLConstants[_id].borrowAsset;
  uint256 _sharesHeld = pooledCLVariables[_id].sharesHeld;

  SAVINGS_ACCOUNT.withdrawShares(_borrowAsset, _strategy, _to, _sharesHeld, false);
  delete pooledCLConstants[_id];
  delete pooledCLVariables[_id];
}

#0 - liveactionllama

2022-03-31T15:57:24Z

I've updated the title of this submission, per request from warden hickuphh3: I accidentally put the code line reference https://github.com/sublime-finance/sublime-v1/blob/46536a6d25df4264c1b217bd3232af30355dcb95/contracts/PooledCreditLine/LenderPool.sol#L404-L406 as the title instead. Kindly help change it to "PooledCreditLine: termination likely fails because _principleWithdrawable is treated as shares"

Findings Information

๐ŸŒŸ Selected for report: hickuphh3

Labels

bug
2 (Med Risk)
disagree with severity

Awards

2590.5858 USDC - $2,590.59

External Links

Lines of code

https://github.com/sublime-finance/sublime-v1/blob/46536a6d25df4264c1b217bd3232af30355dcb95/contracts/PooledCreditLine/PooledCreditLine.sol#L1215-L1221

Vulnerability details

Details & Impact

Interest is calculated as

(_principal.mul(_borrowRate).mul(_timeElapsed).div(YEAR_IN_SECONDS).div(SCALING_FACTOR));

It is possible for the calculated interest to be zero for principal tokens with small decimals, such as EURS (2 decimals). Accumulated interest can therefore be zero by borrowing / repaying tiny amounts frequently.

Proof of Concept

Assuming a borrow interest rate of 5% (5e17) and principal borrow amount of 10_000 EURS (10_000 * 1e2 = 1_000_000), the interest rate calculated would be 0 if principal updates are made every minute (around 63s).

// in this example, maximum duration for interest to be 0 is 63s
1_000_000 * 5e17 * 63 / (86400 * 365) / 1e18 = 0.99885 // = 0

While plausible, this method of interest evasion isnโ€™t as economical for tokens of larger decimals like USDC and USDT (6 decimals).

Take caution when allowing an asset to be borrowed. Alternatively, scale the principal amount to precision (1e18) amounts.

#0 - ritik99

2022-04-06T11:30:30Z

Tokens are whitelisted to ensure precision issues would not occur. Hence the issue is improbable and doesn't occur for widely used tokens as the decimals are generally higher.

Since there is no direct attack path (the steps required for this to occur would be: the token would first have to be whitelisted -> a loan request created using it -> lenders supply sufficient liquidity for this request to go active) and is, in essence, a value leak, we would suggest reducing the severity of the issue to (1) Low / (2) Medium

Findings Information

Awards

700.7235 USDC - $700.72

Labels

bug
QA (Quality Assurance)

External Links

Codebase Impressions & Summary

Overall, code quality for the PooledCreditLine contracts is great. Majority of the logic lies in the 2 contracts PooledCreditLine and LenderPool, with a small part on the twitterVerifier that handles verification via Twitter.

Complexity

The project is a little high in complexity because of there are quite a number of possible states that a pooled credit line can have over its lifecycle, which means state handling has to be thoroughly scrutinised between transitions. The handling of interest rate calculations, borrower and lender shares accounting, and shares <> amounts conversions for integration with the saving account and strategies are other factors that raise the complexity. A lot of logic and functionality is thus packed into the 2 contracts that makes this 3 day contest feel underscoped.

Documentation

The documentation provided was adequate in understanding the pool credit line functionality. Documentation about the termination functionality and start and protocol fees were unfortunately omitted. It would be great to add them in.

It would also have been great if inline comments were added to the _calculateInterestToWithdraw() and _rebalanceInterestWithdrawn() functions as I spent quite a bit of time deciphering what these functions were doing. Nevertheless, there were sufficient inline comments for the other parts of the contracts.

Tests

Tests were unfortunately omitted because last minute changes were made to the contract and โ€œthe tests couldn't be modified to meet those changes in time for the contest. In order to not confuse people we decided it was best to remove the tests from the final releaseโ€. It would have been a nice to have so that coverage can be run, and for us to quickly spin up POCs. Iโ€™m not sure how feasible it would have been to postpone the contest by a few days so that tests could be modified, but it wouldโ€™ve been beneficial.

Responsiveness

I would like to commend Ritik for his quick responses to my DMs and question on the Discord channel! =)

Low Severity Findings

L01: Discrepancy between recorded borrow amount in event and state update

Line References

https://github.com/sublime-finance/sublime-v1/blob/46536a6d25df4264c1b217bd3232af30355dcb95/contracts/PooledCreditLine/PooledCreditLine.sol#L910

https://github.com/sublime-finance/sublime-v1/blob/46536a6d25df4264c1b217bd3232af30355dcb95/contracts/PooledCreditLine/PooledCreditLine.sol#L913

https://github.com/sublime-finance/sublime-v1/blob/46536a6d25df4264c1b217bd3232af30355dcb95/contracts/PooledCreditLine/PooledCreditLine.sol#L917

Description

A protocol fee is taken whenever the borrower decides to borrow more tokens. The state update includes this protocol fee, but the amount emitted in the BorrowedFromPooledCreditLine event does not.

In my opinion, since the protocol fee should be included in the emitted event.

emit BorrowedFromPooledCreditLine(_id, _borrowedAmount.add(protocolFee));

L02: Use upgradeable version of OZ contracts

Line References

https://github.com/sublime-finance/sublime-v1/blob/46536a6d25df4264c1b217bd3232af30355dcb95/contracts/PooledCreditLine/LenderPool.sol#L7-L9

https://github.com/sublime-finance/sublime-v1/blob/46536a6d25df4264c1b217bd3232af30355dcb95/contracts/PooledCreditLine/PooledCreditLine.sol#L5-L7

Description

It is recommended to use the upgradeable version of OpenZeppelin contracts, as some contracts like ReentrancyGuard has a constructor method to set the initial status as _NOT_ENTERED = 1. The status would currently defaults to 0, which fortunately doesnโ€™t break the nonReentrant() functionality.

Nevertheless, it would be recommended to use the upgradeable counterparts instead.

L03: calculatePrincipalWithdrawable() should return user balance for CANCELLED status

Line References

https://github.com/sublime-finance/sublime-v1/blob/46536a6d25df4264c1b217bd3232af30355dcb95/contracts/PooledCreditLine/LenderPool.sol#L579-L592

Description

In the event the borrower cancels his borrow request, the principal withdrawable by the lender should be the liquidity he provided, but the function returns 0 instead.

Add the CANCELLED case in the second if branch.

else if (
  (
    _status == PooledCreditLineStatus.REQUESTED &&
    block.timestamp > pooledCLConstants[_id].startTime &&
    totalSupply[_id] < pooledCLConstants[_id].minBorrowAmount
  ) || (_status == PooledCreditLineStatus.CANCELLED)
) {
  return balanceOf(_lender, _id);
}

L04: Use continue instead of return in _beforeTokenTransfer()

Line References

https://github.com/sublime-finance/sublime-v1/blob/46536a6d25df4264c1b217bd3232af30355dcb95/contracts/PooledCreditLine/LenderPool.sol#L686-L688

Description

Should the contract be upgraded to use _mintBatch() in the future, the function will terminate prematurely after minting the first id.

Replace return with continue.

if (from == address(0)) {
  continue;
}

L05: Token approval issues

Description

  • safeApprove() has been deprecated in favour of safeIncreaseAllowance() and safeDecreaseAllowance()
  • using approve() might fail because some tokens (eg. USDT) donโ€™t work when changing the allowance from an existing non-zero allowance value

Update instances of approve() and safeApprove() to safeIncreaseAllowance().

Non-Critical Findings

NC01: Typos

Description

Do a CTRL / CMD + F for the following errors:

terminatd โ†’ terminated

pooleed โ†’ pooled

reqeuested โ†’ requested

NC02: Definition mix-up in documentation

Reference

https://docs.sublime.finance/sublime-docs/the-protocol/pooled-credit-lines#creating-a-pooled-credit-line

Description

The definitions for the Collateral Savings Strategy and Borrowed Asset Savings Strategy have been mixed up.

9. Collateral Savings Strategy: Savings strategy where any collateral locked in by the borrower will be deployed, e.g., all WBTC deposited by the borrower as collateral could be locked in Aave to earn interest
10. Borrowed Asset Savings Strategy: Savings strategy where any idle liquidity in the credit line will be deployed, e.g., all the idle USDC in the pooled credit line can be deployed to Compound

NC03: Inconsistent Naming

Description

It would be great to have variable naming kept consistent for better readibility.

  • _lendingShare, _liquidityProvided to represent balanceOf(msg.sender, _id);
  • withdrawnShares vs sharesWithdrawn

#0 - ritik99

2022-04-13T08:13:29Z

Thanks for the comments! We'll definitely be updating our documentation to make it more detailed, both the external docs as well as inline comments.

All the issues mentioned by the warden are relevant. Usually, where approve() is used the allowance is used entirely in the subsequent transfer step, so it shouldn't be an issue, although we'll recheck all such instances. The report is of high quality.

#1 - HardlyDifficult

2022-04-19T00:34:56Z

#2 - HardlyDifficult

2022-05-13T13:51:53Z

This report clear / easy to read. The intro is a great addition to provide some high level feedback.

  • L01: Discrepancy between recorded borrow amount in event and state update Non-critical: This is somewhat arbitrary, but useful feedback to consider. Depending on the use case for consumers of this event, it may be useful to emit both _borrowedAmount and protocolFee as separate params as well.
  • L02: Use upgradeable version of OZ contracts Non-critical: This is a best practice but as the warden points out it will not break anything in the current state. Switching to ReentrancyGuardUpgradeable would save gas on first usage.
  • L03: calculatePrincipalWithdrawable() should return user balance for CANCELLED status Low-risk: This impacts an external getter that in the original form may return misleading results after a request is canceled.
  • L04: Use continue instead of return in _beforeTokenTransfer() Low-risk: If the return in this loop is executed than other tokenIds being transferred would skip the require checks and possibly some expected state updates. However given that the code currently does not batch mint this effectively has no impact but could crop up unexpectedly after an upgrade as the warden pointed out.
  • L05: Token approval issues Non-issue: Several wardens pointed to this concern. The way the contract is implemented, approval always resets back to 0 after the transfer so the failure scenario would not arise. It's a good consideration though and something to be careful about to ensure that assumption holds true.
  • NC01: Typos Non-critical: Always nice to fix up the spelling errors.
  • NC02: Definition mix-up in documentation Non-critical: This is a nice catch to improve the documentation. Ramping up on a new protocol takes time and changes like this can help the reader create the right mental models.
  • NC03: Inconsistent Naming Non-critical: Naming is always hard to do well. Improving internal consistency does help the reader.

Findings Information

Awards

103.7854 USDC - $103.79

Labels

bug
G (Gas Optimization)

External Links

G01: Shift if case up to return earlier

Line References

https://github.com/sublime-finance/sublime-v1/blob/46536a6d25df4264c1b217bd3232af30355dcb95/contracts/PooledCreditLine/PooledCreditLine.sol#L950

Description

The case if (_maxPossible <= _currentDebt) return 0; can be shifted up a couple of lines to avoid the additional storage reads of the borrow limit and principal.

uint256 _maxPossible = type(uint256).max;
if (_collateralRatio != 0) {
  _maxPossible = _totalCollateralToken.mul(_ratioOfPrices).div(_collateralRatio).mul(SCALING_FACTOR).div(10**_decimals);
}
// shifted up
if (_maxPossible <= _currentDebt) return 0;

uint256 _borrowLimit = pooledCreditLineConstants[_id].borrowLimit;
uint256 _principal = pooledCreditLineVariables[_id].principal;

G02: Duplicate time check if cancelling request on low collection

Line References

https://github.com/sublime-finance/sublime-v1/blob/46536a6d25df4264c1b217bd3232af30355dcb95/contracts/PooledCreditLine/PooledCreditLine.sol#L1174

https://github.com/sublime-finance/sublime-v1/blob/46536a6d25df4264c1b217bd3232af30355dcb95/contracts/PooledCreditLine/LenderPool.sol#L315

Description

CRLC2 and S2 are essentially the same checks because pooledCreditLineConstants[_id].startsAt and pooledCLConstants[_id].startTime have the same value.

Either one of the checks can be removed.

G03: from != address(0) check is redundant in _rebalanceInterestWithdrawn()

Line References

https://github.com/sublime-finance/sublime-v1/blob/46536a6d25df4264c1b217bd3232af30355dcb95/contracts/PooledCreditLine/LenderPool.sol#L700

Description

_rebalanceInterestWithdrawn() is called just once in a couple of lines above in _beforeTokenTransfer(). Note that the function wonโ€™t be invoked if from == address(0).

if (from == address(0)) {
  // note: replaced with continue;
  continue;
}

Hence, from will always be a non-zero address in _rebalanceInterestWithdrawn(), making the check redundant.

if (to != address(0)) {
  _withdrawInterest(id, from);
  _withdrawInterest(id, to);
}

#0 - ritik99

2022-04-12T18:57:51Z

All suggestions are valid.

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