Open sherlock-admin3 opened 4 months ago
1 comment(s) were left on this issue during the judging contest.
0xmystery commented:
Devoid of coded POC to substantiate exploit
Escalate.
This issue should be a valid High.
The lead judge mentioned that the issue was "Devoid of coded POC to substantiate exploit" and marked it as invalid. However, the POC in the report is already sufficient to demonstrate that the vulnerability mentioned in the report could lead to a loss of assets. Thus, it should be valid.
Escalate.
This issue should be a valid High.
The lead judge mentioned that the issue was "Devoid of coded POC to substantiate exploit" and marked it as invalid. However, the POC in the report is already sufficient to demonstrate that the vulnerability mentioned in the report could lead to a loss of assets. Thus, it should be valid.
You've created a valid escalation!
To remove the escalation from consideration: Delete your comment.
You may delete or edit your escalation comment anytime before the 48-hour escalation window closes. After that, the escalation becomes final.
Escalate.
This issue should be a valid High.
The lead judge mentioned that the issue was "Devoid of coded POC to substantiate exploit" and marked it as invalid. However, the POC in the report is already sufficient to demonstrate that the vulnerability mentioned in the report could lead to a loss of assets. Thus, it should be valid.
Will have the sponsors look into this finding to decide whether or not their reward tokens will have hook entailed. It seems to me the sponsors would have specified any of these weird tokens requiring non-reentrant visibility in the 2nd question of the contest readme Details
if they had been adopted.
The protocol didn't specify which tokens will be used as rewards, so we have to assume only the standard tokens without any weird traits will be used. Hence, we should assume tokens with hooks or callbacks allowing for reentrancy won't be used. Planning to reject the escalation and leave the issue as it is.
The protocol didn't specify which tokens will be used as rewards, so we have to assume only the standard tokens without any weird traits will be used. Hence, we should assume tokens with hooks or callbacks allowing for reentrancy won't be used. Planning to reject the escalation and leave the issue as it is.
@WangSecurity The contracts are meant to handle tokens that Notional protocol will receive from other protocols or projects (e.g., grants). Thus, it is not possible for Notional to predict what kind of tokens they will receive in the future. To be on the safe side, we should assume the worst-case scenario where it might be possible that some of the reward tokens might contain hook or callback, and necessary measures should be implemented to guard against potential re-entrancy attack.
@jeffywu You might want to have a look at this. Thanks.
I agree with @xiaoming9090's assessment, this is a valid issue. Some reward tokens may hold callback hooks that we are unaware of.
In my opinion, even if there is reentrancy, there won’t be any loss. If my understanding is incorrect, please correct me. @xiaoming9090 @jeffywu Thank you!
The loss due to reentrancy is based on the description by @xiaoming9090 , ‘When Line 197 is executed, the totalVaultSharesBefore will still remain 100 vault shares because the execution of the _redeemFromNotional function has not been completed yet. Thus, the number of vault shares has not been updated on the Notional side yet.’
However, after research, I found that totalVaultSharesBefore is updated first and then _redeemFromNotional() is called.
function exitVault(
address account,
address vault,
address receiver,
uint256 vaultSharesToRedeem,
uint256 lendAmount,
uint32 minLendRate,
bytes calldata exitVaultData
) external payable override nonReentrant returns (uint256 underlyingToReceiver) {
//---------skip---------
//update here
@>> vaultState.exitMaturity(vaultAccount, vaultConfig, vaultSharesToRedeem);
if (vaultAccount.tempCashBalance > 0) {
Emitter.emitTransferPrimeCash(
vault, receiver, vaultConfig.borrowCurrencyId, vaultAccount.tempCashBalance
);
underlyingToReceiver = VaultConfiguration.transferFromNotional(
receiver, vaultConfig.borrowCurrencyId, vaultAccount.tempCashBalance, vaultConfig.primeRate, false
);
vaultAccount.tempCashBalance = 0;
}
// If insufficient strategy tokens are redeemed (or if it is set to zero), then
// redeem with debt repayment will recover the repayment from the account's wallet
// directly.
// call _redeemFromNotional() in redeemWithDebtRepayment()
@>> underlyingToReceiver = underlyingToReceiver.add(vaultConfig.redeemWithDebtRepayment(
vaultAccount, receiver, vaultSharesToRedeem, exitVaultData
));
// Set the vault state after redemption completes
vaultState.setVaultState(vaultConfig);
//---skip----------
}
update storage
function exitMaturity(
VaultState memory vaultState,
VaultAccount memory vaultAccount,
VaultConfig memory vaultConfig,
uint256 vaultSharesToRedeem
) internal {
require(vaultAccount.maturity == vaultState.maturity);
mapping(address => mapping(uint256 => VaultStateStorage)) storage store = LibStorage.getVaultState();
VaultStateStorage storage s = store[vaultConfig.vault][vaultState.maturity];
// Update the values in memory
vaultState.totalVaultShares = vaultState.totalVaultShares.sub(vaultSharesToRedeem);
vaultAccount.vaultShares = vaultAccount.vaultShares.sub(vaultSharesToRedeem);
// Update the global value in storage
@>> s.totalVaultShares = vaultState.totalVaultShares.toUint80();
}
redeemWithDebtRepayment calls _redeem, then calls redeemFromNotional, then calls _redeemFromNotional
/// @notice Redeems without any debt repayment and sends profits back to the receiver
function redeemWithDebtRepayment(
VaultConfig memory vaultConfig,
VaultAccount memory vaultAccount,
address receiver,
uint256 vaultShares,
bytes calldata data
) internal returns (uint256 underlyingToReceiver) {
uint256 amountTransferred;
uint256 underlyingExternalToRepay;
{
//-----------skip-------------
(amountTransferred, underlyingToReceiver, /* primeCashRaised */) = _redeem(
vaultConfig,
underlyingToken,
vaultAccount.account,
receiver,
vaultShares,
vaultAccount.maturity,
underlyingExternalToRepay,
data
);
}
// ------skip--------
}
function _redeem(
VaultConfig memory vaultConfig,
Token memory underlyingToken,
address account,
address receiver,
uint256 vaultShares,
uint256 maturity,
uint256 underlyingExternalToRepay,
bytes calldata data
) private returns (
uint256 amountTransferred,
uint256 underlyingToReceiver,
int256 primeCashRaised
) {
//-----skip-----
{
uint256 balanceBefore = underlyingToken.balanceOf(address(this));
@>> underlyingToReceiver = IStrategyVault(vaultConfig.vault).redeemFromNotional(
account, receiver, vaultShares, maturity, underlyingExternalToRepay, data
);
}
//--------skip------
Because all data is updated before token transfer, There is nothing can be done in re-entrancy.
During the re-entrancy, Bob's vault shares should have decreased from 100 vault shares to 10 vault shares since he withdraws 90 vault shares. However, it remains at 100 vault shares. This is because vault account shares are not updated in storage until the vault complete its exit here. Thus, the reward calculation in the re-entrancy will be incorrect.
There is a minor typo in this paragraph. Refer to the update below. Apart from that, the rest, including the math calculation, is correct.
- Line 197 is executed, the totalVaultSharesBefore will still remain 100 vault shares because the execution _redeemFromNotional function has not been completed yet.
+ Line 198 is executed, the vaultSharesBefore will still remain 100 vault shares because the execution _redeemFromNotional function has not been completed yet.
During the re-entrancy, Bob's vault shares should have decreased from 100 vault shares to 10 vault shares since he withdraws 90 vault shares. However, it remains at 100 vault shares. This is because vault account shares are not updated in storage until the vault complete its exit here. Thus, the reward calculation in the re-entrancy will be incorrect.
There is a minor typo in this paragraph. Refer to the update below. Apart from that, the rest, including the math calculation, is correct.
- Line 197 is executed, the totalVaultSharesBefore will still remain 100 vault shares because the execution _redeemFromNotional function has not been completed yet. + Line 198 is executed, the vaultSharesBefore will still remain 100 vault shares because the execution _redeemFromNotional function has not been completed yet.
Then I think you are right. User's vault account shares indeed updates after _redeemFromNotional(). If the reward token is ERC777 , it could be a re-entrancy attack.
How are these in scope since the answer of the question in readMe is: ` If you are integrating tokens, are you allowing only whitelisted tokens to work with the codebase or any complying with the standard? Are they assumed to have certain properties, e.g. be non-reentrant? Are there any types of weird tokens you want to integrate?
EtherFi: weETH, eETH Ethena: USDe, sUSDe Pendle: PT tokens Kelp: rsETH `
How are these in scope since the answer of the question in readMe is: ` If you are integrating tokens, are you allowing only whitelisted tokens to work with the codebase or any complying with the standard? Are they assumed to have certain properties, e.g. be non-reentrant? Are there any types of weird tokens you want to integrate?
EtherFi: weETH, eETH Ethena: USDe, sUSDe Pendle: PT tokens Kelp: rsETH `
Note that there are two areas where ERC20 tokens will be used:
1) Vault's assets 2) Reward tokens
The above-listed tokens (weETH, USDe, sUSDe, rsETH) are the vault's asset tokens. Anyone who reviews the vault code will clearly understand it. Thus, it is understandable from the sponsor's point of view that this information refers only to the vault's asset tokens, and not the reward tokens.
As mentioned in my earlier comments, the reward-related contracts meant to handle tokens that Notional protocol will receive from other protocols or projects (e.g., grants). Thus, it is not possible for Notional to predict what kind of tokens they will receive in the future. Thus, it makes sense for the protocol not to narrow down the reward tokens accepted at this point.
In addition, in the sponsor's earlier comment, they already shared the view that some reward tokens might contain callback hooks that they are unaware of.
The question is regarding any strange ERC20 tokens. Their answer is a list of tokens that does include some rebasing tokens. I agree that there are two areas where ERC20 tokens are used, but I do not agree that by any means this means that all the other tokens are supported(from this answer). Given Sherlock's hierarchy of truth, both this and #61 should be invalid, as sponsors comments in judging cannot change the scope.
About the reward tokens. The reason why they weren't specified in the README is because they're not set by the admins of Notional. The reward tokens are the tokens that other protocols, which Notional integrates with, use to pay out the rewards/grants. Hence, I believe it's enough contextual evidence that the protocol indeed needs to work with any type of reward tokens.
Therefore, I believe this issue is valid. Even though it's only possible with tokens with hooks, the report shows how the attacker gets almost 200% more shares than they should've got. Hence, I believe high severity is appropriate, planning to accept the escalation.
Result: High Unique
@xiaoming9090 @mystery0x @brakeless-wtp are there any duplicates?
The protocol team fixed this issue in the following PRs/commits: https://github.com/notional-finance/leveraged-vaults/pull/96
The Lead Senior Watson signed off on the fix.
xiaoming90
High
Malicious users can steal reward tokens via re-entrancy attack
Summary
Malicious users can steal reward tokens via re-entrancy attack.
Vulnerability Detail
During the redemption of vault shares, the
_updateAccountRewards
function will be triggered at the end of the function to update the account rewards.https://github.com/sherlock-audit/2024-06-leveraged-vaults/blob/main/leveraged-vaults-private/contracts/vaults/common/SingleSidedLPVaultBase.sol#L282
Assume that at T1
rewardsPerVaultShare
is 1.0VaultStorage.getAccountRewardDebt()[rewardToken][Bob]
) is 100 (100 shares * 1.0
)Assume that at T2:
rewardsPerVaultShare
is 2.0When Line 211 below is executed, the
vaultSharesBefore
will be set to 100 vault shares. The_claimAccountRewards
function will be executed in Line 212, and it will execute the_claimRewardToken
function internally.https://github.com/sherlock-audit/2024-06-leveraged-vaults/blob/main/leveraged-vaults-private/contracts/vaults/common/VaultRewarderLib.sol#L211
Within the
_claimRewardToken
function, the_getRewardsToClaim
function will be executed to compute the number of reward tokens that Bob is entitled to. Based on the formula within the_getRewardsToClaim
function, Bob is entitled 100 reward tokens.In Line 306 below, Bob's debt (
VaultStorage.getAccountRewardDebt()[rewardToken][Bob]
) will be updated to 20 (vaultSharesAfter * rewardsPerVaultShare = 10 shares * 2.0 = 20
). Note thatvaultSharesAfter
is 10 shares because Bob withdraws 90 shares from his initial 100 shares.In Line 316 below, 100 reward tokens will be transferred to Bob.
https://github.com/sherlock-audit/2024-06-leveraged-vaults/blob/main/leveraged-vaults-private/contracts/vaults/common/VaultRewarderLib.sol#L316
Assume that the reward token contains a hook or callback. As a result, the control will be passed back to Bob. Note that there are no restrictions on the type of reward tokens in the context of this audit.
Bob can re-enter the vault and execute the
claimAccountRewards
function, which is not guarded against re-entrancy. When Line 197 is executed, thetotalVaultSharesBefore
will still remain 100 vault shares because the execution_redeemFromNotional
function has not been completed yet. Thus, the number of vault shares has not been updated on Notional side yet. The_claimRewardToken
function, followed by_getRewardsToClaim
will be executed again internally.https://github.com/sherlock-audit/2024-06-leveraged-vaults/blob/main/leveraged-vaults-private/contracts/vaults/common/VaultRewarderLib.sol#L197
Based on the formula within the
_getRewardsToClaim
function, Bob is entitled 180 reward tokens.The vault will transfer an additional 180 reward tokens to Bob again, which is incorrect. In this case, Bob has stolen 180 reward tokens from the vault and other shareholders.
Instance 2
https://github.com/sherlock-audit/2024-06-leveraged-vaults/blob/main/leveraged-vaults-private/contracts/vaults/common/WithdrawRequestBase.sol#L110
Attackers can also call this function. Because Line 110 will still read the outdated vault share info, it will be the higher than expected number.
Impact
Reward tokens can be stolen by malicious users.
Code Snippet
https://github.com/sherlock-audit/2024-06-leveraged-vaults/blob/main/leveraged-vaults-private/contracts/vaults/common/SingleSidedLPVaultBase.sol#L282
https://github.com/sherlock-audit/2024-06-leveraged-vaults/blob/main/leveraged-vaults-private/contracts/vaults/common/VaultRewarderLib.sol#L211
https://github.com/sherlock-audit/2024-06-leveraged-vaults/blob/main/leveraged-vaults-private/contracts/vaults/common/VaultRewarderLib.sol#L316
https://github.com/sherlock-audit/2024-06-leveraged-vaults/blob/main/leveraged-vaults-private/contracts/vaults/common/VaultRewarderLib.sol#L197
https://github.com/sherlock-audit/2024-06-leveraged-vaults/blob/main/leveraged-vaults-private/contracts/vaults/common/WithdrawRequestBase.sol#L110
Tool used
Manual Review
Recommendation
Add re-entrancy guard on the
claimAccountRewards
function to prevent anyone from re-entering the vault under any circumstance.