Platform: Code4rena
Start Date: 28/01/2022
Pot Size: $30,000 USDC
Total HM: 4
Participants: 22
Period: 3 days
Judge: GalloDaSballo
Total Solo HM: 2
Id: 80
League: ETH
Rank: 1/22
Findings: 3
Award: $12,844.92
🌟 Selected for report: 2
🚀 Solo Findings: 1
3964.904 USDC - $3,964.90
leastwood
ConvexYieldWrapper.sol
is a wrapper contract for staking convex tokens on the user's behalf, allowing them to earn rewards on their deposit. Users will interact with the Ladle.sol
contract's batch()
function which:
ConvexYieldWrapper.sol
.Ladle.sol
.During wrap()
and unwrap()
actions, _checkpoint()
is used to update the rewards for the from_
and to_
accounts. However, the reference contract implements a _beforeTokenTransfer()
function which has been removed from Yield Protocol's custom implementation.
As a result, it is possible to transfer wCVX
tokens to another account after an initial checkpoint has been made. By manually calling user_checkpoint()
on the new account, this user is able to update its deposited balance of the new account while the sender's balance is not updated. This can be repeated to effectively replicate a user's deposited balance over any number of accounts. To claim yield generated by the protocol, the user must only make sure that the account calling getReward()
holds the tokens for the duration of the call.
The exploit can be outlined through the following steps:
wCVX
tokens from the protocol after wrapping their convex tokens._getDepositedBalance()
returns 100 as its result. A checkpoint has also been made on this balance, giving Alice claim to her fair share of the rewards.user_checkpoint()
to update his balance.wCVX
tokens as calculated by the _getDepositedBalance()
function.wCVX
tokens are in their account upon calling getReward()
. Afterwards, the tokens can be transferred out.Manual code review. Discussion/confirmation with the Yield Protocol team.
Consider implementing the _beforeTokenTransfer()
function as shown in the reference contract. However, it is important to ensure the wrapper contract and collateral vaults are excluded from the checkpointing so they are not considered in the rewards calculations.
#0 - alcueca
2022-02-02T12:11:25Z
Confirmed. The fact that rewards can be drained also means that users lose on their expected rewards, so I think that Sev 3 is right.
#1 - GalloDaSballo
2022-02-14T23:06:29Z
In systems that track growing rewards, anytime a user balances changes, it's important to recalculate their balances as to properly distribute pending rewards and to influence the future-rate at which rewards will be distributed (process generally called accruing
)
In the case of the ConvexYieldWrapper, the warden has shown that because the wCVX
token doesn't perform a _checkpoint
on each transfer, a malicious attacker could repeatedly transfer their tokens in order to reuse the same balance in multiple accounts, effectively sybil attacking the protocol.
The fix seems to be straightforward, however the impact of the finding breaks the accounting of the protocol, as such I believe High Severity to be appropraite
#2 - GalloDaSballo
2022-02-14T23:07:47Z
The sponsor has mitigated in a subsequent PR by overriding the _transfer
function
🌟 Selected for report: leastwood
8810.8979 USDC - $8,810.90
leastwood
ConvexYieldWrapper.sol
is a wrapper contract for staking convex tokens on the user's behalf, allowing them to earn rewards on their deposit. Users will interact with the Ladle.sol
contract's batch()
function which:
ConvexYieldWrapper.sol
.Ladle.sol
._getDepositedBalance()
takes into consideration the user's total collateral stored in all of their owned vaults. However, as a vault owner, you are allowed to give the vault to another user, move collateral between vaults and add/remove collateral. Therefore, it is possible to manipulate the result of this function by checkpointing one user's balance at a given time, transferring ownership to another user and then create a new checkpoint with this user.
As a result, a user is able to generate protocol yield multiple times over on a single collateral amount. This can be abused to effectively extract all protocol yield.
Consider the following exploit scenario:
_getDepositedBalance()
returns 100 as its result. A checkpoint has also been made on this balance, giving Alice claim to her fair share of the rewards.Ladle.give()
, transferring the ownership of the vault to Bob and calls ConvexYieldWrapper.addVault()
.user_checkpoint()
and effectively update their checkpointed balance.https://github.com/code-423n4/2022-01-yield/blob/main/contracts/ConvexYieldWrapper.sol#L100-L120
function _getDepositedBalance(address account_) internal view override returns (uint256) { if (account_ == address(0) || account_ == collateralVault) { return 0; } bytes12[] memory userVault = vaults[account_]; //add up all balances of all vaults registered in the wrapper and owned by the account uint256 collateral; DataTypes.Balances memory balance; uint256 userVaultLength = userVault.length; for (uint256 i = 0; i < userVaultLength; i++) { if (cauldron.vaults(userVault[i]).owner == account_) { balance = cauldron.balances(userVault[i]); collateral = collateral + balance.ink; } } //add to balance of this token return _balanceOf[account_] + collateral; }
Manual code review. Discussion/confirmation with the Yield Protocol team.
Ensure that any change to a vault will correctly checkpoint the previous and new vault owner. The affected actions include but are not limited to; transferring ownership of a vault to a new account, transferring collateral to another vault and adding/removing collateral to/from a vault.
#0 - GalloDaSballo
2022-02-18T14:57:45Z
The warden identified a way to sidestep the accounting in the ConvexYieldWrapper
.
Because ConvexYieldWrapper
takes lazy accounting, transferring vaults at the Ladle
level allows to effectively register the same vault under multiple accounts, which ultimately allow to steal more yield than expected.
While the loss of yield can be classified as a medium severity, the fact that the warden was able to break the accounting invariants of the ConvexYieldWrapper
leads me to raise the severity to high
#1 - GalloDaSballo
2022-02-18T14:59:44Z
Ultimately mitigation will require to _checkpoint
also when vault operations happen (especially transfer), this may require a rethinking at the Ladle level as the reason why the warden was able to sidestep the checkpoint is because the Ladle
doesn't notify the Wrapper
of any vault transfers
#2 - alcueca
2022-02-21T09:19:20Z
Yes, that's right. To fix this issue we will deploy a separate Ladle to deal specifically with convex tokens. The fix will probably involve removing stir
and give
instead of notifying the wrapper, but we'll see.
leastwood
latestRoundData
is missing additional validation to ensure that the round is complete and has returned a valid/expected price. This is documented here.
https://github.com/code-423n4/2022-01-yield/blob/main/contracts/Cvx3CrvOracle.sol#L120-L127
(, int256 daiPrice, , , ) = DAI.latestRoundData(); (, int256 usdcPrice, , , ) = USDC.latestRoundData(); (, int256 usdtPrice, , , ) = USDT.latestRoundData(); require( daiPrice > 0 && usdcPrice > 0 && usdtPrice > 0, "Chainlink pricefeed reporting 0" );
Manual code review. Chainlink best practices.
Consider validating the output of latestRoundData
to match the following code snippet:
( uint80 roundID, int256 daiPrice, , uint256 updateTime, uint80 answeredInRound ) = DAI.latestRoundData(); require( answeredInRound >= roundID, "Yield: Chainlink Price Stale" ); require(daiPrice > 0, "Yield: Chainlink Malfunction"); require(updateTime != 0, "Yield: Incomplete round");
The same recommendation can be applied to the USDC.latestRoundData()
and USDT.latestRoundData()
calls.
#0 - iamsahu
2022-02-01T19:29:09Z
Duplicate of #136