-
Notifications
You must be signed in to change notification settings - Fork 4
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
scammed - User can benefit from emergencyWithdrawal
in case other tokens are added
#217
Comments
emergencyWithdrawal
in case other tokens are added
emergencyWithdrawal
in case other tokens are addedemergencyWithdrawal
in case other tokens are added
Low/Info; |
emergencyWithdrawal
in case other tokens are addedemergencyWithdrawal
in case other tokens are added
Escalate
This should be a valid issue. if the admin can always process all withdraws, there will be no emergency withdraw function in the first place. the emergency withdraw's design is the let user process withdraw themselves, but the way that user can withdraw more than their entitled token makes the code works not intended and leads to loss of fund. this is still a bug that must be fixed. in reality, add new tokens has nothing to do with withdraw, " resolve all withdraws before add new tokens" is not a documented assumption at all. again, the statement below is not possible..
how about sandwich the add token / create a emergency withdraw right before the add token via frontrunning, |
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. |
Works as expected.
Additionally, new tokens deposited after such a deposit partially belong to the old user at the moment of the new deposit, as LP minting is calculated based on the TVL of the vault per deposit value, not the amount or type of token. The entire contract flow allows for one token to be a deposit token and another to be a withdrawal token if the ratiosX96 for deposit and withdrawal differ. Therefore, there is no issue when emergency tokens differ from what the user initially deposited, as long as they receive them in proportion to their LP / Total supply (which is correct in an emergency withdrawal). |
@0xklapouchy as I understand you mean that this report in fact described the intended design of the protocol (as evident by the code comments), correct? |
Yes, to my understanding, it is intended by protocol that as a last resort, if the system collapses and users are able to perform an emergencyWithdraw(), they can grab what is left in any tokens (from baseTvl() list) proportionally to the LP amount they are withdrawing. Plus there is no benefit from new token as LP amount is calculated from deposit value / total TVL of the Vault, so if new token is added to a list of (X, Y) tokens, and new list is (X, Y, Z) my LP is now valued in (X, Y, Z). Tokens hash comparison and minAmounts in a normal withdrawal are needed for slippage protection. In such cases, the minAmounts need to match the token list. However, this is not needed in an emergency withdrawal, as here we got new minAmount for slippage. |
That is not the intended design at all and clearly a bug. Adding a new token by admin does not increase the lp total balance and only result in one more additional loop running (before adding token user withdraw 2 tokens, after adding token user withdraw 3 tokens, the request.lpAmount from user does not really change.) for (uint256 i = 0; i < tokens.length; i++) {
if (amounts[i] == 0) {
if (minAmounts[i] != 0) revert InsufficientAmount();
continue;
}
uint256 amount = FullMath.mulDiv(
IERC20(tokens[i]).balanceOf(address(this)),
request.lpAmount,
totalSupply
);
if (amount < minAmounts[i]) revert InsufficientAmount();
IERC20(tokens[i]).safeTransfer(request.to, amount);
actualAmounts[i] = amount;
} the action sequence below is very practical to make user lose fund if not addressed.
|
Let me update this flow:
|
there is no document saying that the admin looks compromised / system is not operational when emergency withdraw is triggered... as long as the withdraw is not processed within time window (could be admin has too many withdraw to process), the user can trigger emergency withdraw themselves. |
Where is the impact?
|
Here is what happens:
This is what makes the opportunity since a bond is the symbiotic representation of the LST token that is deposited - a completely different token.
And since there are 4 different tokens |
The question I've got is this indeed true?
As I see Therefore, the following is also incorrect:
The |
underlyingTVLs (function called from the normal withdrawal) calls _calculateTVL with emergencyWithdrawUnlike the normal withdrawal, here baseTvl we can see that function baseTvl()
public
view
returns (address[] memory tokens, uint256[] memory amounts)
{
...
amounts = _calculateTvl(tokens, false); //HERE
} Then in DefaultBondTvlModule contract DefaultCollateral is ERC20Upgradeable, ReentrancyGuardUpgradeable, IDefaultCollateral {
using SafeERC20 for IERC20;
using Permit2Lib for IERC20;
uint8 private DECIMALS;
/**
* @inheritdoc ICollateral
*/
address public asset;
function deposit(address recipient, uint256 amount) public nonReentrant returns (uint256) {
uint256 balanceBefore = IERC20(asset).balanceOf(address(this));
IERC20(asset).transferFrom2(msg.sender, address(this), amount);
amount = IERC20(asset).balanceOf(address(this)) - balanceBefore;
if (amount == 0) {
revert InsufficientDeposit();
}
if (totalSupply() + amount > limit) {
revert ExceedsLimit();
}
_mint(recipient, amount);
emit Deposit(msg.sender, recipient, amount);
return amount;
} The entire contract is the That's why I said 4 loops will be performed, not 2. I really hope that clears the confusion, thanks! |
You agree that there will be more tokens than the initial ones. Exactly what the report says.
|
Yep, I didn't say here that only 1 token will be used.
Thank you for that response, but I think I still need additional clarification. What I don't understand is that the problem is in new underlying tokens being added, but |
Emm I think a coded POC is more persuasive then the numerial analysis. I think the severity is high then because it leads to straight loss of fund, basically user can deposit one token and withdraw two or three token, and indeed when a new token is added the user can withdraw more. there is a straightford test case it just deposit one token and emergency withdraw above we can add the above test: function testEmergencyWithdraw_POC() external {
Vault vault = new Vault("Mellow LRT Vault", "mLRT", admin);
vm.startPrank(admin);
vault.grantRole(vault.ADMIN_DELEGATE_ROLE(), admin);
vault.grantRole(vault.OPERATOR(), operator);
_setUp(vault);
vm.stopPrank();
_initialDeposit(vault);
address depositor = address(bytes20(keccak256("depositor")));
vm.startPrank(depositor);
deal(Constants.WSTETH, depositor, 10 ether);
IERC20(Constants.WSTETH).safeIncreaseAllowance(
address(vault),
10 ether
);
uint256[] memory amounts = new uint256[](3);
amounts[0] = 10 ether;
uint256[] memory minAmounts = new uint256[](3);
vault.deposit(depositor, amounts, 10 ether, type(uint256).max);
vault.registerWithdrawal(
depositor,
10 ether,
minAmounts,
type(uint256).max,
type(uint256).max,
false
);
// vm.stopPrank();
uint256 WSETH_Balance = IERC20(Constants.WSTETH).balanceOf(address(depositor));
uint256 RETH_Balance = IERC20(Constants.RETH).balanceOf(address(depositor));
uint256 USDT_Balance = IERC20(Constants.USDT).balanceOf(address(depositor));
console.log("WSETH_Balance : ", RETH_Balance);
console.log("RETH_Balance : ", RETH_Balance);
console.log("USDT Balance: ", USDT_Balance);
deal(Constants.RETH, address(vault), 10 ether);
(address[] memory tokens, uint256[] memory vals) = vault.baseTvl();
console.log(tokens.length);
for(uint i = 0; i < tokens.length; ++i) {
console.log("val", vals[i]);
}
vm.stopPrank();
vm.prank(admin);
vault.addToken(Constants.USDT);
deal(Constants.USDT, address(vault), 10 ** 6 * 100000);
vm.startPrank(depositor);
vault.emergencyWithdraw(new uint256[](4), type(uint256).max);
(tokens, vals) = vault.baseTvl();
console.log(tokens.length);
for(uint i = 0; i < tokens.length; ++i) {
console.log("val", vals[i]);
}
WSETH_Balance = IERC20(Constants.WSTETH).balanceOf(address(depositor));
RETH_Balance = IERC20(Constants.RETH).balanceOf(address(depositor));
USDT_Balance = IERC20(Constants.USDT).balanceOf(address(depositor));
console.log("WSTETH_Balance : ", WSETH_Balance);
console.log("RETH_Balance : ", RETH_Balance);
console.log("USDT Balance: ", USDT_Balance);
// IVault.WithdrawalRequest memory request = vault.withdrawalRequest(
// depositor
// );
// assertEq(request.lpAmount, 0);
// assertEq(IERC20(Constants.WSTETH).balanceOf(address(vault)), 10 gwei);
// assertEq(
// IERC20(Constants.WSTETH).balanceOf(address(depositor)),
// 10 ether
// );
// assertEq(vault.balanceOf(address(vault)), 10 gwei);
// assertEq(vault.balanceOf(address(depositor)), 0);
// vm.stopPrank();
} basically user only deposit WSETH, but because admin is adding a new token and also the RETH token presents in the contract, the user can withdraw WSETH, RETH and USDT even when the user never deposit USDT. running the test command is: forge test -vv --match-test "testEmergencyWithdraw_POC" --fork-url "https://rpc.ankr.com/eth" output: Ran 1 test for tests/mainnet/unit/VaultTest.t.sol:VaultTestUnit
[PASS] testEmergencyWithdraw_POC() (gas: 13019938)
Logs:
WSETH_Balance : 0
RETH_Balance : 0
USDT Balance: 0
3
val 10000000010000000000
val 10000000000000000000
val 0
4
val 10000000000
val 9999999991
val 0
val 100
WSTETH_Balance : 10000000000000000000
RETH_Balance : 9999999990000000009
USDT Balance: 99999999900 |
deal(Constants.USDT, address(vault), 10 ** 6 * 100000); mimic other user's deposit after the token is added. |
I do appreciate lead judge's efforts to try to apply technical rigor to resolve escalation because there are a lot of them. can @z3s judge please also help lead judge interpret the POC? thank you! |
Thank you for the POC, but I found it's a bit incorrect. The main problem is that instead of making a second user actually deposit into the vault, you deal the tokens directly into the vault. That leads to the amount of underlying tokens increasing, but the share amount remains the same. That is the reason, why the depositor who had all the shares received all the tokens in the vault, even though new tokens were sent in the vault before their withdrawal. And here's no issue since the number of tokens withdrawn by the user is proportional to their shares, i.e. 100% of shares == 100% of tokens. Here's the updated POC where I added the second user who deposits into the vault. Small scenario:
Here's the POC function testEmergencyWithdraw_POC() external {
Vault vault = new Vault("Mellow LRT Vault", "mLRT", admin);
vm.startPrank(admin);
vault.grantRole(vault.ADMIN_DELEGATE_ROLE(), admin);
vault.grantRole(vault.OPERATOR(), operator);
_setUp(vault);
vm.stopPrank();
_initialDeposit(vault);
address depositor = address(bytes20(keccak256("depositor")));
vm.startPrank(depositor);
deal(Constants.WSTETH, depositor, 10 ether);
IERC20(Constants.WSTETH).safeIncreaseAllowance(
address(vault),
10 ether
);
// @Wang just additional console logs
uint256 WSETH_Initial = IERC20(Constants.WSTETH).balanceOf(address(depositor));
console.log("WSETH_Initial D1: ", WSETH_Initial);
uint256[] memory amounts = new uint256[](3);
amounts[0] = 10 ether;
uint256[] memory minAmounts = new uint256[](3);
vault.deposit(depositor, amounts, 10 ether, type(uint256).max);
vault.registerWithdrawal(
depositor,
10 ether,
minAmounts,
type(uint256).max,
type(uint256).max,
false
);
vm.stopPrank();
//@Wang adding new token
vm.startPrank(admin);
vault.addToken(Constants.STETH); // adding a fourth token, but decided to add the one with 18 decimals
IVaultConfigurator configurator = vault.configurator(); // get the config to change the ratios, otherwise, the second user cannot deposit
uint128[] memory ratiosX96 = new uint128[](4);
ratiosX96[0] = 2 ** 96; // from VaultTestCommon, the only change is adding a fourth token, the ratio remains unchanged
ManagedRatiosOracle(configurator.ratiosOracle())
.updateRatios(address(vault), true, ratiosX96);
// adding a Chainlink Oracle for stETH for the deposit to go through. Using the same as wstETH
ChainlinkOracle chainlinkOracle = new ChainlinkOracle();
chainlinkOracle.setBaseToken(address(vault), Constants.WSTETH);
address[] memory tokensNew = new address[](4);
tokensNew[0] = Constants.WSTETH;
tokensNew[1] = Constants.RETH;
tokensNew[2] = Constants.WETH;
tokensNew[3] = Constants.STETH;
IChainlinkOracle.AggregatorData[]
memory data = new IChainlinkOracle.AggregatorData[](4);
data[0] = IChainlinkOracle.AggregatorData({
aggregatorV3: address(
new WStethRatiosAggregatorV3(Constants.WSTETH)
),
maxAge: 30 days
});
data[1] = IChainlinkOracle.AggregatorData({
aggregatorV3: Constants.RETH_CHAINLINK_ORACLE,
maxAge: 30 days
});
data[2] = IChainlinkOracle.AggregatorData({
aggregatorV3: address(new ConstantAggregatorV3(1 ether)),
maxAge: 30 days
});
data[3] = IChainlinkOracle.AggregatorData({
aggregatorV3: address(
new WStethRatiosAggregatorV3(Constants.WSTETH)
),
maxAge: 30 days
});
chainlinkOracle.setChainlinkOracles(address(vault), tokensNew, data);
configurator.stagePriceOracle(address(chainlinkOracle));
configurator.commitPriceOracle();
// @Wang not dealing tokens directly
//deal(Constants.USDT, address(vault), 10 ** 6 * 100000);
// @Wang adding a second actual depositor, since just dealing to the vault doesn't increase the shares amount
address depositor2 = address(0x69);
uint256[] memory amounts2 = new uint256[](4);
amounts2[0] = 10 ether;
uint256[] memory minAmounts2 = new uint256[](4);
vm.startPrank(depositor2);
deal(Constants.WSTETH, depositor2, 10 ether);
IERC20(Constants.WSTETH).safeIncreaseAllowance(
address(vault),
10 ether
);
uint256 WSETH_D2_Balance_Initial = IERC20(Constants.WSTETH).balanceOf(address(depositor2));
console.log("WSTETH_Balance D2 before deposit: ", WSETH_D2_Balance_Initial);
vault.deposit(depositor2, amounts2, 10 ether, type(uint256).max);
vault.registerWithdrawal(
depositor2,
10 ether,
minAmounts2,
type(uint256).max,
type(uint256).max,
false
);
vm.stopPrank();
vm.startPrank(depositor);
vault.emergencyWithdraw(new uint256[](4), type(uint256).max);
uint256 WSETH_D1_Balance_Final = IERC20(Constants.WSTETH).balanceOf(address(depositor));
console.log("WSTETH_Balance D1 after withdrawal: ", WSETH_D1_Balance_Final);
vm.stopPrank();
// @Wang second depositor withdrawing
vm.startPrank(depositor2);
vault.emergencyWithdraw(new uint256[](4), type(uint256).max);
uint256 WSETH_D2_Balance_Final = IERC20(Constants.WSTETH).balanceOf(address(depositor2));
console.log("WSTETH_Balance D2 after withdrawal: ", WSETH_D2_Balance_Final);
vm.stopPrank();
} You can search for OutputRan 1 test for tests/mainnet/unit/VaultTest.t.sol:VaultTestUnit
[PASS] testEmergencyWithdraw_POC() (gas: 14413378)
Logs:
WSETH_Initial D1: 10000000000000000000
WSTETH_Balance D2 before deposit: 10000000000000000000
WSTETH_Balance D1 after withdrawal: 10000000000000000000
WSTETH_Balance D2 after withdrawal: 10000000000000000000
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 9.18s (7.91s CPU time) As proven with the POC, even though another token was added after the user deposited, they didn't receive any additional tokens and got the very same amount they deposited. Hence, I believe there is no issue and the report is incorrect. Of, course if my POC is wrong, please correct it. However, the current decision is to reject the escalation and leave the issue as it is. |
LP minted are based on the TVL of provided assets, in the oracle system you configured the prices of tokens are messed up, but let recheck:
I don't need to even count to notice that (10 WSTETH/10 RETH/10 WETH) deposit in terms of LP will give me more at the withdrawal in terms of new token in case he is worth less then my initial 10 WSTETH in proportion I added. And you are expecting exact 10/10/10/10 withdrawals. |
@blckhv also could send instead of coded POC, a numerical POC which shows how an emergency withdrawal results in a user getting more tokens, so the discussion doesn't circle test configuration issues. |
@blckhv and please recheck how much of LP tokens are you withdrawing for depositor2 as for 40e18 it is more then 10e18. |
I did calculations for you, based on the 10e18 LP you are withdrawing for both users, the end result is the same: you should change in PoC the
Total withdraw for depositor2 is below deposit as he got more than 10e18 LP and is not withdrawing all LP. |
@WangSecurity Based on the TVL value from the deposit, the withdrawal amounts are perfectly fine |
@WangSecurity please do not allow this type of conversation, since if anyone has any arguments can provide them in one comment and with facts, not to post countless comments and only numbers without contraarguments of our state. We will provide the PoC again with examples later, and will ask you to specify the way it can be argued by someone, because just dropping comments lacking facts and explanation will goes to nothing. Thanks |
@Slavchew I think @0xklapouchy is correct at some points. Firstly, indeed the second depositor deposits only 4e18 tokens and not 31 ether, since the target ratio is 25% for each token, but they deposit only 1 ether of stETH. Hence, for each token, the vault claims only 1 ether as well. Secondly, even though the token amounts, in the end, may be different to what the users got initially, if we calculate the final value by prices, i.e. rethAmount * rethPrice + wstethAmount * wstETHPrice + ..., wouldn't the value be the same? |
This is not true because the second depositor who deposited 4 tokens totaled at X price and then he got less from these 4 tokens, so less than X amount. Regarding the test:
Really, @0xklapouchy if you don't have concrete arguments to prove what you state, we should stop unnecessary extending the escalation process. @WangSecurity although it is good to have discussions about issue validity I think this is getting out of control, if he wants to prove something, the same effort should be put into, it instead of throwing random numbers. |
@blckhv I think to finally settle the discussion, could show the balances of the users if we call |
@WangSecurity, It won't be possible since the |
So |
To let you know what those random numbers ware:
With WETH as base token we currently have:
Once again deposited token value is same as value of tokens after withdrawal. |
@0xklapouchy but as we know Mellow assumes 1 STETH == 1 ETH, could you adjust the calculations? |
It uses real Chainlink values and also 1 STETH = 1 ETH for WSTETH. The PoC is correct, values are correct. The conclusion the @Slavchew and @blckhv are making from it is incorrect. They are expecting the same amounts to be withdraw as deposited, but LP amount is minted based on Value of the tokens deposited not the amount of them. LP = Deposit Value / Total Value, not Deposit Amount / Total Amount. The total value of tokens deposited and withdrawn is correct in terms of the its value in WETH. |
This is why I also recommended using |
Thank you for the calculations. Indeed, the users receive different amounts of tokens, while they can receive the amount even fewer than they had. But, in ETH value (or WETH to be precise), the token amounts match (with slight discrepancies). That is why depositor 1 received less RETH and WETH, but more STETH than they deposited. Also, depositor 2 received more RETH and WETH, but less STETH, than they deposited, but the difference in the price of these assets mitigates it. Hence, the initial and the final values of the tokens are matching. Planning to reject the escalation tomorrow morning CEST timezone (approx. noon), cause I think all the arguments have been said and there's nothing more to add. |
They are matching and issue won't occur unless there are both bond and wstETH tokens in the Vault, this is completely viable situation as currently Symbiotic has no more limit and the situation where both tokens have non-zero balance in the system is possible - there are some wstETH deposited in Symbiotic that received bond asset and wstETH idling in the Vault. This was mentioned in the original submission and can be seen. So if we modify the test to contain both tokens + other underlying tokens and since the POCfunction test_BLCKHVEmergencyWithdraw() external {
DefaultBondTvlModule bondModule = new DefaultBondTvlModule();
Vault vault = new Vault("Mellow LRT Vault", "mLRT", admin);
vm.startPrank(admin);
vault.grantRole(vault.ADMIN_DELEGATE_ROLE(), admin);
vault.grantRole(vault.OPERATOR(), operator);
_setUp(vault);
//TODO add bond module:
vault.addTvlModule(address(bondModule));
//TODO deploy bond contract and configure
BondCtr bondCtr = new BondCtr();
address[] memory bonds = new address[](1);
bonds[0] = address(bondCtr);
bondModule.setParams(address(vault), bonds);
vm.stopPrank();
_initialDeposit(vault);
address depositor = address(bytes20(keccak256("depositor")));
vm.startPrank(depositor);
deal(Constants.WSTETH, depositor, 10 ether);
deal(Constants.RETH, depositor, 10 ether);
deal(Constants.WETH, depositor, 10 ether);
// deal(Constants.USDC, depositor, 1 ether);
IERC20(Constants.WSTETH).safeIncreaseAllowance(
address(vault),
10 ether
); IERC20(Constants.RETH).safeIncreaseAllowance(
address(vault),
10 ether
); IERC20(Constants.WETH).safeIncreaseAllowance(
address(vault),
10 ether
);
uint256[] memory amounts = new uint256[](3);
amounts[0] = 10 ether;
amounts[1] = 10 ether;
amounts[2] = 10 ether;
uint256[] memory minAmounts = new uint256[](3);
vault.deposit(depositor, amounts, 10 ether, type(uint256).max);
uint vault_Balance = vault.balanceOf(address(depositor));
// console.log("depositor 1 lp:", vault_Balance);
vault.registerWithdrawal(
depositor,
10 ether,
minAmounts,
type(uint256).max,
type(uint256).max,
false
);
vm.stopPrank();
//TODO add another token: USDC
addStEthAsUnderlying(vault);
//TODO create another user and deposit from 4 tokens:
address depositor2 = makeAddr("depositor2");
deal(Constants.WSTETH, depositor2, 10 ether);
deal(Constants.RETH, depositor2, 10 ether);
deal(Constants.WETH, depositor2, 10 ether);
//TODO deal doesn't work, mock stETH whale:
//deal(Constants.STETH, depositor2, 10 ether);
vm.startPrank(0x7f39C581F595B53c5cb19bD0b3f8dA6c935E2Ca0);
IERC20(Constants.STETH).transfer(depositor2, 10 ether);
vm.stopPrank();
vm.startPrank(depositor2);
IERC20(Constants.WSTETH).safeIncreaseAllowance(
address(vault),
10 ether
); IERC20(Constants.RETH).safeIncreaseAllowance(
address(vault),
10 ether
); IERC20(Constants.WETH).safeIncreaseAllowance(
address(vault),
10 ether
);
IERC20(Constants.STETH).safeIncreaseAllowance(
address(vault),
10 ether
);
uint256[] memory amounts2 = new uint256[](4);
amounts2[0] = 10 ether;
amounts2[1] = 10 ether;
amounts2[2] = 10 ether;
amounts2[3] = 10 ether;
vault.deposit(depositor2, amounts2, 0, type(uint256).max);
vault_Balance = vault.balanceOf(address(depositor2));
//console.log("depositor 2 lp:", vault_Balance);
vm.stopPrank();
//TODO instead of depositing WSTETH directly, simulate by automatically minting Bond assets to the Vault
vm.startPrank(address(vault));
bondCtr.mint(address(vault), IERC20(Constants.WSTETH).balanceOf(address(vault)));
//TODO if you want to see how output is being affected modify the amount transferred:
//IERC20(Constants.WSTETH).transfer(address(1), IERC20(Constants.WSTETH).balanceOf(address(vault)) / 4);
vm.stopPrank();
vm.prank(depositor);
vault.emergencyWithdraw(new uint256[](5), type(uint256).max);
(address[] memory tokens, uint256[] memory vals) = vault.baseTvl();
uint RETH_Balance = IERC20(Constants.RETH).balanceOf(address(depositor));
uint WETH_Balance = IERC20(Constants.WETH).balanceOf(address(depositor));
uint bond_Balance = IERC20(bondCtr).balanceOf(address(depositor));
uint STETH_Balance = IERC20(Constants.STETH).balanceOf(address(depositor));
uint WSTETH_Balance = IERC20(Constants.WSTETH).balanceOf(address(depositor));
console.log("RETH_Balance : ", RETH_Balance);
console.log("WETH Balance: ", WETH_Balance);
console.log("bond Balance: ", bond_Balance);
console.log("STETH Balance: ", STETH_Balance);
console.log("WSTETH Balance: ", WSTETH_Balance);
console.log("D1 balance:", RETH_Balance + WETH_Balance + bond_Balance + STETH_Balance + WSTETH_Balance);
vm.startPrank(depositor2);
// uint256[] memory minAmounts2 = new uint256[](4);
minAmounts = new uint256[](4);
vault.registerWithdrawal(
depositor2,
100 ether,
minAmounts,
type(uint256).max,
type(uint256).max,
false
);
vault.emergencyWithdraw(new uint256[](5), type(uint256).max);
vm.stopPrank();
RETH_Balance = IERC20(Constants.RETH).balanceOf(address(depositor2));
WETH_Balance = IERC20(Constants.WETH).balanceOf(address(depositor2));
bond_Balance = IERC20(bondCtr).balanceOf(address(depositor2));
STETH_Balance = IERC20(Constants.STETH).balanceOf(address(depositor2));
WSTETH_Balance = IERC20(Constants.WSTETH).balanceOf(address(depositor2));
console.log("RETH_Balance : ", RETH_Balance);
console.log("WETH Balance: ", WETH_Balance);
console.log("bond Balance: ", bond_Balance);
console.log("STETH Balance: ", STETH_Balance);
console.log("WSTETH Balance: ", WSTETH_Balance);
console.log("D2 balance:", RETH_Balance + WETH_Balance + bond_Balance + STETH_Balance);
}
Then this bond asset can be exchanged for 1:1 ratio with wstETH. This situation is profitable even for the relatively small amounts of wstETH and bond tokens, the only requirements is that both tokens to be in the Vault.
|
The value of the D1 with prices from here = 43128341604690656809 = 43.1e18. For example, if we take the bond value, i.e.
Now the same for the D2. Total value is 56224198074418346192 = 56.2e18. Now, let's deduct the bond value, i.e.
Hence, the only additional tokens the users withdraw are the bond assets. The second depositor got more of them since they LP share is larger than the 1st depositor's one. Additionally, the depositors just received them because they were directly minted to the vault without additional shares being minted to anyone. Hence, there's no loss, everyone is getting as much as they should. The decision remains the same, if you want me to share the full calculations, please ask. |
Yes, this is the idea of using
This is how the strategy deposits work, users deposit wstETH and receive lp tokens based on this token, then it is deposited by the depositCallback - https://github.com/sherlock-audit/2024-06-mellow/blob/26aa0445ec405a4ad637bddeeedec4efe1eba8d2/mellow-lrt/src/strategies/DefaultBondStrategy.sol#L51-L81. And then bond token is minted to the Vault. No need to mint additional lp tokens since this is how the code works now. I've researched a bit and even the first Vault that I've checked contains both wstETH and SymbioticWstETH. Reference: https://docs.mellow.finance/mellow-lrt-primitive/contract-deployments |
You are aware that symbioticWstETH is part of the TVL calculation? https://etherscan.io/address/0x1E1d1eD64e4F5119F60BF38B322Da7ea5A395429#readContract Therefore depositing and LP minting is correct, based on Value of the deposit. And when withdrawing via /// @notice Handles emergency withdrawals, proportionally withdrawing all tokens in the system (not just the underlying).
/// @dev Transfers tokens based on the user's share of lpAmount / totalSupply. |
Can you point me where do the TVL calculation is being used in function emergencyWithdraw(
uint256[] memory minAmounts,
uint256 deadline
)
external
nonReentrant
checkDeadline(deadline)
returns (uint256[] memory actualAmounts)
{
uint256 timestamp = block.timestamp;
address sender = msg.sender;
if (!_pendingWithdrawers.contains(sender)) revert InvalidState();
WithdrawalRequest memory request = _withdrawalRequest[sender];
if (timestamp > request.deadline) {
_cancelWithdrawalRequest(sender);
return actualAmounts;
}
if (
request.timestamp + configurator.emergencyWithdrawalDelay() >
timestamp
) revert InvalidState();
uint256 totalSupply = totalSupply();
(address[] memory tokens, uint256[] memory amounts) = baseTvl();
if (minAmounts.length != tokens.length) revert InvalidLength();
actualAmounts = new uint256[](tokens.length);
for (uint256 i = 0; i < tokens.length; i++) {
if (amounts[i] == 0) {
if (minAmounts[i] != 0) revert InsufficientAmount();
continue;
}
uint256 amount = FullMath.mulDiv(
IERC20(tokens[i]).balanceOf(address(this)),
request.lpAmount,
totalSupply
);
if (amount < minAmounts[i]) revert InsufficientAmount();
IERC20(tokens[i]).safeTransfer(request.to, amount);
actualAmounts[i] = amount;
}
delete _withdrawalRequest[sender];
_pendingWithdrawers.remove(sender);
_burn(address(this), request.lpAmount);
emit EmergencyWithdrawal(sender, request, actualAmounts);
} |
@blckhv @WangSecurity |
I see that |
https://github.com/sherlock-audit/2024-06-mellow/blob/main/mellow-lrt/src/Vault.sol#L96-L98 Please stop waisting my time. You have no idea how this works. |
Comments above still don’t show if there’s a loss of funds for any user. Emergency withdrawal should send all tokens in the system as said in its description. Hence, the users get the bond assets as they should. The decision remains the same, planning to reject the escalation and leave the issue as it is. |
Will proceed with my decision in approx. 2 hours |
Result: |
Escalations have been resolved successfully! Escalation status:
|
scammed
Medium
User can benefit from
emergencyWithdrawal
in case other tokens are addedSummary
emergencyWithdrawal
doesn’t check for the request’s token hash and will transfer from all the currently active underlying tokens in case new ones are added, despite he deposited in a different set.Vulnerability Detail
Emergency withdrawal can be used when requests cannot be satisfied in a normal way, but due to missing checks whether the underlying tokens at the time of
emergencyWithdrawal
execution are the same as when the withdrawal request was created. As a result, all the current underlying tokens + bond tokens received from deposits will be transferred to him, eventually leading to more profit than what he would have received fromprocessWithdrawal
.As we can see
baseTvl
is called that will take both all the underlying token balances in the vault itself + all the bonds lpTokens balances per underlying tokens.For example user has entered only in
wstETH
and later on decides to create withdrawal request:Knowing that there is a possibility more underlying tokens to be added he specifies
requestDeadline
above theemergencyWithdrawalDelay
in order to make it possible to executeemergencyWithdraw
in a latter stage. No normal withdrawals are being executed fromoperators
because of either the low withdrawal requests or simply to maximise the yield from the underlying strategies by keeping the deposited funds inside bonds and even adding another underlying tokens (rETH, weth, etc). ThenemergencyWithdraw
will be the better option as it will give to the user from all of the circulating tokens in the system, then he can simply withdraw bond tokens in the Symbiotic and take the profit.Impact
EmergencyWithdrawal will transfer more tokens than normally processed withdraw in case there are underlying tokens added after request creation.
Code Snippet
https://github.com/mellow-finance/mellow-lrt/blob/8191087682cc6a7f36c1c6390e37eb308953b11a/src/Vault.sol#L434-L473
https://github.com/mellow-finance/mellow-lrt/blob/8191087682cc6a7f36c1c6390e37eb308953b11a/src/Vault.sol#L371-L431
Tool used
Manual Review
Recommendation
Consider checking the underlying tokens hash in
vault::emergencyWithdraw
also.The text was updated successfully, but these errors were encountered: