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: 4/111
Findings: 3
Award: $3,606.36
🌟 Selected for report: 1
🚀 Solo Findings: 0
2465.9718 USDC - $2,465.97
https://github.com/GenerationSoftware/pt-v5-vault/blob/b1deb5d494c25f885c34c83f014c8a855c5e2749/src/Vault.sol#L1138-L1139 https://github.com/GenerationSoftware/pt-v5-vault/blob/b1deb5d494c25f885c34c83f014c8a855c5e2749/src/Vault.sol#L509-L521 https://github.com/GenerationSoftware/pt-v5-vault/blob/b1deb5d494c25f885c34c83f014c8a855c5e2749/src/Vault.sol#L407-L415
When the Vault.withdraw()
function is called, a maximum of type(uint96).max shares are being burnt subsequently: Vault.withdraw()
-> Vault._withdraw()
-> Vault._burn
burns uint96(_shares), see Vault.sol line 1139.
A malicious user can exploit this in the following way:
A malicious user deposits for example two times a value of type(uint96).max underlying assets into the Vault, calling two times the function Vault.deposit()
. They can't deposit more in a single transaction because type(uint96).max is the maximum value to deposit.
Then the malicious user calls Vault.withdraw()
with a higher value of assets to withdraw than type(uint96).max, for example they withdraw (2 * type(uint96).max) which is the total amount of assets they depositted before.
Now what happens is that the Vault.sol contract only burns type(uint96).max shares for the user, but transfers 2 * type(uint96).max underlying assets to the malicious user, which is the total amount they depositted before.
This happens because Vault._burn()
only burns uint96(shares)
shares of the malicious users - see Vault.sol line 1155.
Now the malicious user has still vault shares left but they withdrew the total amount of their depositted assets.
Now the vault transferred the total amount of the malicious user's assets back to them, and the malicious user has still shares left to withdraw even more assets that are now being stolen from assets depositted by other users.
Or if the malicious user was the first depositor, they wait until another user deposits and the malicious user can now withdraw the other users depositted assets since the malicious user still has Vault shares left.
Or if the malicious user is not the first depositor, they use a flashLoan or flashMint to deposit multiple times type(uint96).max assets into the vault, then withdraw their deposit, pay back the flashLoan or flashMint and they will still have enough vault shares left to steal all other users assets by withdrawing them.
In this way, other user's depositted assets can be stolen, as explained above.
Here is a POC, where the problem is illustrated:
https://gist.github.com/zzzitron/397790302ca95aa3fbf05694ae1497ab
Manual review
Consider adjusting the Vault._burn
function to not convert from uint256 to uint96 when burning shares.
Math
#0 - c4-sponsor
2023-07-20T22:58:14Z
asselstine marked the issue as sponsor confirmed
#1 - PierrickGT
2023-08-03T22:04:59Z
Fixed in this PR by using SafeCast: https://github.com/GenerationSoftware/pt-v5-vault/pull/9
#2 - c4-judge
2023-08-05T21:45:12Z
Picodes marked the issue as satisfactory
#3 - c4-judge
2023-08-07T16:48:04Z
Picodes marked the issue as primary issue
#4 - c4-judge
2023-08-08T14:38:32Z
Picodes marked the issue as selected for report
🌟 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
https://github.com/GenerationSoftware/pt-v5-vault/blob/b1deb5d494c25f885c34c83f014c8a855c5e2749/src/Vault.sol#L573 https://github.com/GenerationSoftware/pt-v5-vault/blob/b1deb5d494c25f885c34c83f014c8a855c5e2749/src/Vault.sol#L394-L399 https://github.com/GenerationSoftware/pt-v5-vault/blob/b1deb5d494c25f885c34c83f014c8a855c5e2749/src/Vault.sol#L704-L706 https://github.com/GenerationSoftware/pt-v5-vault/blob/b1deb5d494c25f885c34c83f014c8a855c5e2749/src/Vault.sol#L1229-L1231
The function Vault.mintYieldFee()
is missing access control and can be called by anybody. It also allows to specify any _recipient
to receive the shares for the yield fee. It ignores the state variable Vault._yieldFeeRecipient
, which is the address of the yield fee recipient, that can be set via the constructor of the Vault.sol contract or via Vault.setYieldFeeRecipient()
by the owner of the vault.
All these issues potentially lead to undesired behavior:
When Vault.liquidate()
is called, the function Vault._increaseYieldFeeBalance
gets called which increases the _yieldFeeTotalSupply
. After Vault.liquidate()
was called, a malicious actor can call Vault.mintYieldFee()
before the liquidator is calling the function, in order to frontrun and steal the shares of the yield fee (_yieldFeeTotalSupply
), by specifying the malicious actor's address for the _recipient
function parameter and minting the shares for themselves. Vault._yieldFeeRecipient
gets ignored.
Also the liquidator could send the shares for the yield fee anywhere when calling Vault.mintYieldFee()
, by specifying any address for the function parameter _recipient
. Vault._yieldFeeRecipient
gets ignored.
The owner of the Vault.sol contract calls Vault.setYieldFeeRecipient()
in order to specify who should receive the shares for the yield fee. But when Vault.mintYieldFee()
is called afterwards, the value Vault._yieldFeeRecipient
that the owner set is ignored, and the shares for the yield fee are being sent to some other recipient potentially.
Because of these issues, yield fee shares could be lost: either because they could be stolen by a malicious actor or they could be send to an inadvertent recipient by a liquidator. The Vault owner can't influence who will receive the shares for the yield fees.
Here is a POC:
https://gist.github.com/zzzitron/7ec08fc8428ebe86e193dc35717a997f
Manual review
Consider removing the function parameter _recipient
from Vault.mintYieldFee()
and send the shares to the specified Vault._yieldFeeRecipient
of the Vault.sol contract.
Or consider introducing access control to Vault.mintYieldFee()
:
// Vault.sol 394 function mintYieldFee(uint256 _shares, address _recipient) external { 395 if (msg.sender != address(_liquidationPair)) { 396 // revert
Access Control
#0 - c4-judge
2023-07-16T22:23:34Z
Picodes marked the issue as duplicate of #396
#1 - c4-judge
2023-08-05T22:03:51Z
Picodes changed the severity to 3 (High Risk)
#2 - c4-judge
2023-08-05T22:04:41Z
Picodes marked the issue as satisfactory
🌟 Selected for report: RedTiger
Also found by: wangxx2026, zzzitron
1138.1408 USDC - $1,138.14
https://github.com/GenerationSoftware/pt-v5-vault/blob/b1deb5d494c25f885c34c83f014c8a855c5e2749/src/Vault.sol#L307-L315 https://github.com/GenerationSoftware/pt-v5-vault/blob/b1deb5d494c25f885c34c83f014c8a855c5e2749/src/Vault.sol#L818-L819 https://github.com/GenerationSoftware/pt-v5-vault/blob/b1deb5d494c25f885c34c83f014c8a855c5e2749/src/Vault.sol#L394-L399 https://github.com/GenerationSoftware/pt-v5-vault/blob/b1deb5d494c25f885c34c83f014c8a855c5e2749/src/Vault.sol#L868 https://github.com/GenerationSoftware/pt-v5-vault/blob/b1deb5d494c25f885c34c83f014c8a855c5e2749/src/Vault.sol#L886
Vault.availableYieldBalance()
correctly checks, whether the Vault is undercollateralized, and returns 0 if that is the case, since there must not be more shares minted than underlying assets available. It does so by calculating the needed amount of assets for the Vault to be collateralized, based on _totalShares()
. _totalShares()
is returning the sum of _totalSupply()
plus Vault._yieldFeeTotalSupply
. This is important since Vault._yieldFeeTotalSupply
can also be converted to shares via Vault.mintYieldFee()
.
The problem is that the Vault contract also uses another function to determine the vault collateralization: Vault._requireVaultCollateralized()
, where subsequently Vault._currentExchangeRate()
is called to do the calculations for the collateralization, but there is a flaw:
Vault._currentExchangeRate()
is using Vault._totalSupply()
to determine the assets needed to be collateralized (Vault.sol line 1169)Vault._totalSupply()
doesn't take into account Vault._yieldFeeTotalSupply
Vault._totalSupply()
, which leads to the problem that fewer assets are calculated (_totalSupplyToAssets
) than needed for the Vault to be collateralized, because Vault._yieldFeeTotalSupply
wasn't taken into account (Vault.sol line 1170)_totalSupplyToAssets
on line 1183, returning a wrong exchange rate which is 1e18
, but should indeed be smaller than 1e18
to indicate that the vault is undercollateralized.Vault._currentExchangeRate()
which was wrongly returned as 1e18
is used to determine whether the vault is collateralized.Vault._requireVaultCollateralized()
is checked and doesn't revert, more shares can be minted despite the fact that potentially any additional share minted is not covered by the available assets, so minting means to render the Vault undercollateralized as a result.As a consequence, more shares can be minted than there are underlying assets available, which should not be allowed and which should be reverted - see comment in Vault.sol line 308.
Also as another issue, Vault._currentExchangeRate()
can potentially and wrongly return 1e18
(or 10**underlyingAssetDecimals if the underlying asset doesn't use 18 decimals precision) as exchange rate despite that the exchange rate should be smaller than 1e18
because Vault._yieldFeeTotalSupply
was not taken into account as shown above. That means that there can be potential issues wherever Vault._currentExchangeRate()
is used in the context of the described issue above. Just as an example when Vault._convertToAssets()
gets called it uses Vault._currentExchangeRate()
(Vault.sol line 886) for its calculations. The same applies to Vault._convertToShares()
in Vault.sol line 868.
Since there are multiple issues, I consider this report's severity as high.
Here is a POC, where at the end of the POC it can be observed that vault.isVaultCollateralized()
still returns true
, despite that vault.availableYieldBalance()
is returning 0 indicating that minting should not be possible any more since there are not enough underlying assets available. Then another single share is minted successfully which leads to the Vault being undercollateralized.
https://gist.github.com/zzzitron/2a5f748a376f121d521211b97c4f781a
Manual review
Consider adjusting Vault._currentExchangeRate()
to use _totalShares()
to determine the total shares for which underlying assets should be available:
// Vault.sol 1168 function _currentExchangeRate() internal view returns (uint256) { 1169 uint256 _totalSupplyAmount = _totalShares(); // @audit: consider using _totalShares()
Math
#0 - c4-judge
2023-07-18T19:50:44Z
Picodes marked the issue as primary issue
#1 - c4-sponsor
2023-07-20T20:37:35Z
asselstine marked the issue as sponsor confirmed
#2 - c4-judge
2023-08-06T10:51:04Z
Picodes marked the issue as selected for report
#3 - Picodes
2023-08-06T10:56:08Z
Keeping High severity as this report shows how share could be minted although the vault is in fact undercollateralized, leading to a loss of funds for users
#4 - c4-judge
2023-08-06T11:06:37Z
Picodes marked the issue as satisfactory
#5 - PierrickGT
2023-08-07T18:42:09Z
The warden missed the following comment:
* @dev We exclude the amount of yield generated by the YieldVault, so user can only withdraw their share of deposits. * Except when the vault is undercollateralized, in this case, any unclaim yield fee is included in the calculation.
If the Vault ends up being undercollateralized, any yield that has not been claimed will be shared proportionally between depositors.
If we use _totalShares
instead of _totalSupply
, we would account for Vault shares that have not been minted yet since _yieldFeeTotalSupply
keeps track of the accrued yield fee but needs to be minted as Vault shares by calling mintYieldFee
: https://github.com/GenerationSoftware/pt-v5-vault/blob/44a6c6b081db5cc5e2acc4757a3c9dbaa6f60943/src/Vault.sol#L395
I've added the following test to test this scenario: https://github.com/GenerationSoftware/pt-v5-vault/blob/44a6c6b081db5cc5e2acc4757a3c9dbaa6f60943/test/unit/Vault/Withdraw.t.sol#L128
#6 - PierrickGT
2023-08-07T18:44:34Z
@Picodes see my comment above. I don't have the permission to add labels but this issue should not be part of the audit report.
#7 - Picodes
2023-08-12T15:56:38Z
@PierrickGT I do agree with you that the mitigation of this report is incorrect. However, my understanding is that there is still an important issue here, because mintYieldFee
could be called even when it would lead to an undercollateralized state. So we could imagine a scenario where _yieldFeeTotalSupply
is 4, assets in the vault are 10, and user shares are 10. Then a call to mintYieldFee
would mint 4
and would lead to a loss of funds for users as it'd bring the vault in an undercollateralized state.
Please let me know your thoughts!
#8 - PierrickGT
2023-08-14T15:10:36Z
@PierrickGT I do agree with you that the mitigation of this report is incorrect. However, my understanding is that there is still an important issue here, because
mintYieldFee
could be called even when it would lead to an undercollateralized state. So we could imagine a scenario where_yieldFeeTotalSupply
is 4, assets in the vault are 10, and user shares are 10. Then a call tomintYieldFee
would mint4
and would lead to a loss of funds for users as it'd bring the vault in an undercollateralized state.Please let me know your thoughts!
Yes exactly, this issue you are referring to was better explained in this issue: https://github.com/code-423n4/2023-07-pooltogether-findings/issues/435 I've fixed it in the following PR: https://github.com/GenerationSoftware/pt-v5-vault/pull/13
#9 - c4-judge
2023-08-14T17:41:37Z
Picodes marked issue #190 as primary and marked this issue as a duplicate of 190