Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

hyh - Minimum borrow amount can be surpassed and borrower can be treated as being overdue earlier than their actual overdue time #114

Open
sherlock-admin4 opened this issue Jul 13, 2024 · 3 comments
Labels
Has Duplicates A valid issue with 1+ other issues describing the same vulnerability Medium A valid Medium severity issue Reward A payout will be made for this issue Sponsor Confirmed The sponsor acknowledged this issue is valid Will Fix The sponsor confirmed this issue will be fixed

Comments

@sherlock-admin4
Copy link
Contributor

sherlock-admin4 commented Jul 13, 2024

hyh

Medium

Minimum borrow amount can be surpassed and borrower can be treated as being overdue earlier than their actual overdue time

Summary

It is possible to borrow less than _minBorrow and preliminary be marked as overdue when assetManager have temporary fund access limitations.

Vulnerability Detail

UToken's borrow() can be effectively run with lesser amount than _minBorrow when it is a liquidity shortage in the asset manager's underlying markets and they can return only some dust amount or nothing at all. In these cases borrow() call will still be concluded. Particularly, it is possible to run it with zero amount when assetManager cannot access liquidity.

In that case the borrower, if they borrow for the first time after full repay, will not have their lastRepay field reset on a subsequent material borrow operations as it will already be set on zero amount borrow before. As a result such borrowers can be effectively overdue for the system way before the actual overdue time passes for them.

Impact

_minBorrow threshold can be violated when market conditions restrict assetManager withdrawals. A user can have lastRepay set earlier than time of obtaining the funds, which will mark them overdue before the actual overdue time comes by. This will have a material adverse impact both on such a borrower (for them checkIsOverdue will be true, so they won't be able to borrow or create vouches) and their lenders (for them stakerFrozen and frozenCoinAge will be increased and staking rewards diminished).

Code Snippet

If current market conditions don't allow any material withdrawal then borrow() still can happen and lastRepay be set on any dust or even zero amount being lent out:

UToken.sol#L611-L634

    function borrow(address to, uint256 amount) external override onlyMember(msg.sender) whenNotPaused nonReentrant {
        IAssetManager assetManagerContract = IAssetManager(assetManager);
        uint256 actualAmount = decimalScaling(amount, underlyingDecimal);
>>      if (actualAmount < _minBorrow) revert AmountLessMinBorrow();

        // Calculate the origination fee
        uint256 fee = calculatingFee(actualAmount);

        if (_borrowBalanceView(msg.sender) + actualAmount + fee > _maxBorrow) revert AmountExceedMaxBorrow();
        if (checkIsOverdue(msg.sender)) revert MemberIsOverdue();
        if (amount > assetManagerContract.getLoanableAmount(underlying)) revert InsufficientFundsLeft();
        if (!accrueInterest()) revert AccrueInterestFailed();

        uint256 borrowedAmount = borrowBalanceStoredInternal(msg.sender);

        // Initialize the last repayment date to the current block timestamp
>>      if (getLastRepay(msg.sender) == 0) {
            accountBorrows[msg.sender].lastRepay = getTimestamp();
        }

        // Withdraw the borrowed amount of tokens from the assetManager and send them to the borrower
>>      uint256 remaining = assetManagerContract.withdraw(underlying, to, amount);
>>      if (remaining > amount) revert WithdrawFailed();
>>      actualAmount -= decimalScaling(remaining, underlyingDecimal);

If market is such that assetManagerContract.withdraw can only withdraw dust or can't withdraw anything, a user can request to borrow an amount bigger than minimal, but borrow() will be executed with some dust or even zero amount effectively borrowed. This isn't fully covered by the getLoanableAmount() check since it measures total funds invested via getSupplyView() calls to the underlying markets.

As _minBorrow is for amount effectively borrowed, and not just for amount requested, it will be in a violation:

UToken.sol#L141-L144

    /**
>>   *  @dev Min amount that can be borrowed by a single member
     */
    uint256 private _minBorrow;

Also, it will have a side effect of resetting lastRepay even with zero amount borrowed when the borrower had no debt as of time of the call. This will effectively mark a borrower as an overdue when time since they obtained any material debt is in fact much less than overdueTime:

UToken.sol#L459-L465

    function checkIsOverdue(address account) public view override returns (bool isOverdue) {
        if (_getBorrowed(account) != 0) {
>>          uint256 lastRepay = getLastRepay(account);
>>          uint256 diff = getTimestamp() - lastRepay;
>>          isOverdue = overdueTime < diff;
        }
    }

UToken.sol#L450-L452

    function getLastRepay(address account) public view override returns (uint256) {
        return accountBorrows[account].lastRepay;
    }

This can happen as subsequent borrow() calls will not set lastRepay as the logic is based on having empty lastRepay:

UToken.sol#L627-L629

        if (getLastRepay(msg.sender) == 0) {
            accountBorrows[msg.sender].lastRepay = getTimestamp();
        }

Tool used

Manual Review

Recommendation

Consider controlling the effective amount being borrowed, e.g.:

UToken.sol#L611-L634

    function borrow(address to, uint256 amount) external override onlyMember(msg.sender) whenNotPaused nonReentrant {
        IAssetManager assetManagerContract = IAssetManager(assetManager);
        uint256 actualAmount = decimalScaling(amount, underlyingDecimal);
-       if (actualAmount < _minBorrow) revert AmountLessMinBorrow();

        // Calculate the origination fee
        uint256 fee = calculatingFee(actualAmount);

        if (_borrowBalanceView(msg.sender) + actualAmount + fee > _maxBorrow) revert AmountExceedMaxBorrow();
        if (checkIsOverdue(msg.sender)) revert MemberIsOverdue();
        if (amount > assetManagerContract.getLoanableAmount(underlying)) revert InsufficientFundsLeft();
        if (!accrueInterest()) revert AccrueInterestFailed();

        uint256 borrowedAmount = borrowBalanceStoredInternal(msg.sender);

        // Initialize the last repayment date to the current block timestamp
        if (getLastRepay(msg.sender) == 0) {
            accountBorrows[msg.sender].lastRepay = getTimestamp();
        }

        // Withdraw the borrowed amount of tokens from the assetManager and send them to the borrower
        uint256 remaining = assetManagerContract.withdraw(underlying, to, amount);
        if (remaining > amount) revert WithdrawFailed();
        actualAmount -= decimalScaling(remaining, underlyingDecimal);
+       if (actualAmount < _minBorrow) revert AmountLessMinBorrow();
@github-actions github-actions bot added Medium A valid Medium severity issue Has Duplicates A valid issue with 1+ other issues describing the same vulnerability labels Jul 15, 2024
@sherlock-admin2 sherlock-admin2 changed the title Quaint Golden Gecko - Minimum borrow amount can be surpassed and borrower can be treated as being overdue earlier than their actual overdue time hyh - Minimum borrow amount can be surpassed and borrower can be treated as being overdue earlier than their actual overdue time Jul 19, 2024
@sherlock-admin2 sherlock-admin2 added the Reward A payout will be made for this issue label Jul 19, 2024
@sherlock-admin3 sherlock-admin3 added Sponsor Confirmed The sponsor acknowledged this issue is valid Will Fix The sponsor confirmed this issue will be fixed labels Jul 21, 2024
@maxweng
Copy link

maxweng commented Jul 24, 2024

I think this issue only happens when the underlying market protocol we integrate doesn't implement the withdraw() function correctly, which it succeeded and returned true but the withdrawal amount was less than what the caller requested.
But to tight things up on our end, I think we can add another check on the actual withdrawal amount.

@sherlock-admin2
Copy link
Contributor

The protocol team fixed this issue in the following PRs/commits:
unioncredit/union-v2-contracts#175

@dmitriia
Copy link
Collaborator

dmitriia commented Aug 31, 2024

The protocol team fixed this issue in the following PRs/commits: unioncredit/union-v2-contracts#175

Fix looks ok

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Has Duplicates A valid issue with 1+ other issues describing the same vulnerability Medium A valid Medium severity issue Reward A payout will be made for this issue Sponsor Confirmed The sponsor acknowledged this issue is valid Will Fix The sponsor confirmed this issue will be fixed
Projects
None yet
Development

No branches or pull requests

5 participants