-
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
infect3d - Users can arbitrage assets into a vault despite the presence of a withdrawal queue #119
Comments
!isProcessingPossible
case inside processWithdrawals
canceling user withdrawal request can cause issues
Escalate This should be a valid issue. This report shows a path allowing a user to circumvent the withdrawal queue mechanism, and thus depositing/withdrawing in a same block, front-running the price update with a deposit, and back-running it with a withdraw The implementation of withdrawal queue is a well-known protection against this type of exploit, but as demonstrated, this mechanism can be avoided by the user. Applying the recommandation will fix this and withdrawal queue will effectively prevent this arbitrage. |
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. |
This issue is a duplicate of dany.armstrong90 - User can continue to earn points during withdrawal lockup period. #68 |
Hey, @InfectedIsm I asked the protocol team about these front-running issues, and their answer was:
|
Hey @z3s , thank you for your swift reply, much appreciated I agree with protocol team that operating through Flashbots would prevent this from happening. Thus, this issue should be considered valid and not excluded, as the mitigation measures are shared post-audit. |
I agree with the escalation, flashbots were not mentioned anywhere and without them I agree the issue possible. The only consideration I've now got is would the deposit queue fix this vulnerability. It will make the scenario in the report impossible, but is it possible to make a small withdrawal request, then front-run the |
Yes, when user call
This would require the deposit (register) queue to be correctly implemented, i.e ensuring that a malicious user cannot deposit and withdraw in a same block. |
I recommend you read #68. |
Thank you for the answers. I agree it's a valid issue cause it allows to significantly increase the withdrawal for the user if they see a significant price decrease of LST/LRT token in the next block (assuming the withdrawal is also in the next block). This big withdrawal (that was initially small), allows the attack to decrease the price even lower and then re-deposit in the next block at lower price, resulting in extracting tokens from users who kept their funds in the protocol. But I believe, medium severity is more appropriate, since for this to be profitable, there should be a big price decrease of LST/LRT and withdrawal in the same block, since there is a withdrawal fee that may be even bigger than the profit from such arbitrage. Planning to accept the escalation. |
Result: |
Escalations have been resolved successfully! Escalation status:
|
How is this valid if you deposit at time = 0, amount = 100e18 at a price of $1 for 1e18 = $100, and right after that request a withdrawal. Then at time = 100, the Also the upper example - If you front-run and deposit more tokens before the price drops at the end, you will get the same amount of tokens that you deposited. The price has nothing to do with Mellow's contract. This can be seen in the tests also - https://github.com/sherlock-audit/2024-06-mellow/blob/26aa0445ec405a4ad637bddeeedec4efe1eba8d2/mellow-lrt/tests/mainnet/unit/VaultTest.t.sol#L2996-L3052 The whole idea of the deposit and withdrawal request has nothing to do with the price change. @WangSecurity Isn't it appropriate to ask the Watson for a test on this to show what bonus he will get from doing this, without it there is no main context to the problem, only that you can front-run the operator, which is not a wrong thing, so as you still get all your tokens not more. |
The issue didn't explicitly prove how arbitrage become profitable. |
Allowing user to withdraw right after its deposit in a same block ensure he is not exposed to any infortunate event (as slahsing), thus securing its profit, avoiding losses. While vault are subject to price increase of shares, they can also incur price decrease through slashing for example, or other decreasing events depending on the used strategy. For recall, the Regarding the profit, front-running the price increase of the LP allow the user to deposit the assets right before, getting LP for “cheap”, then instantly redeem the LP for a greater amount of assets. It must not be forgotten that vaults can have as underlying tokens USDC and USDT, as it was updated afterward by the sponsors. Those vaults will likely use other kind of strategies to generate profit for depositors, where share price increase can be captured too. Finally, must also be noted that deposit and withdraw ratios can be configured differently as there is a I am not an expert about flashbots, but I think that a user using flashbots do not have to take care of gas price ordering inside the bundle when proposing a tx bundle to be executed. |
As I know, the flashbots are for avoiding front-run attack, so the attacker can't use flashbots for front-run attack. |
@Slavchew how I see the attacker profiting from the price increase here: The attacker sees a price sharp price increase in the underlying LRT/LST and the operator processing withdrawals in the same block. They deposit more funds into the protocol at price X, then a price increase takes place and they withdraw all the deposited amounts at a price of 1.1X (I know 10% is a very large increase, but it's an example to show the profit here, also they still withdraw the underlying token LST/LRT, but they can swap it on the secondary market). Essentially, the attacker made a free trade here. Yes, they can be subject to withdrawal fees. Additionally, a similar scenario was mentioned here. But, in the linked issue the attacker is also subject to a withdrawal queue, where here the attacker almost bypasses the withdrawal queue. As for avoiding the slashing event. The attacker can bypass the queue, but still, they would withdraw the underlying token, i.e. the LRT/LST (e.g. wstETH) and would still suffer a loss due to the slashing event. On the other hand, in that case, the attacker can swap wstETH they got out of the Vault before the slashing event. @debugging3 yep, there are quite a lot of constraints for this issue and the probability may be low, but it's still possible and we don't judge the severity based on the likelihood since it's quite a subjective parameter. With that said, I agree it's a very borderline issue, but I lean towards it being medium since the attack is still possible, even though the constraints are high. |
Just a small point, we really need the operator to process withdrawals in the same time of slashing event but with higher gas, so that the attack is successful, right? The corner stone of the attack is that the operator process withdrawals(front run his own protocol of slashing or price changes). Doesn't this seems involving some acts from trusted roles? |
I don't think it involves "some acts from trusted roles" as it may happen unintentionally. Again, the scenario with the slashing event is not the only one and I think the scenario with the price increase is more viable, but of course, it's subjective. Still, there's not only one scenario and considering both of them, I believe it qualifies for the Medium sevrity. |
@WangSecurity |
I don’t have enough context on #68 because I didn’t invalidate it and it wasn’t escalated:
|
I have stated that #68 is a duplicate of this issue during escalation period. |
Oh, it was misunderstanding from my side, I'll check if it's a duplicate |
Here is a PoC showing that a user cannot get more tokens after a price increase, because the amount they receive is the amount they deposited, and the price increase/decrease does nothing. In case he deposits 1000 wsteth tokens and the price increases, he will withdraw 1000 tokens again and of course, he will have more money, but he will also have the same amount if he waits for the price to increase and does not interact with Mellow. function testPriceIncreaseDoesNothing() 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);
//
VaultConfigurator configurator = VaultConfigurator(
address(vault.configurator())
);
ChainlinkOracle pricesOracle = ChainlinkOracle(address(configurator.priceOracle()));
ChainlinkOracle.AggregatorData memory agg = pricesOracle.aggregatorsData(address(vault), Constants.WSTETH);
WStethRatiosAggregatorV3 aggAdd = WStethRatiosAggregatorV3(agg.aggregatorV3);
uint256 priceBefore = uint256(aggAdd.getAnswer());
console.log("WSTETH price", priceBefore);
address depositor = address(bytes20(keccak256("depositor")));
address depositor2 = address(bytes20(keccak256("depositor2")));
address depositor3 = address(bytes20(keccak256("depositor3")));
vm.startPrank(depositor);
deal(Constants.WSTETH, depositor, 100 ether);
console.log("Balance Before price drop", IERC20(Constants.WSTETH).balanceOf(depositor));
IERC20(Constants.WSTETH).safeIncreaseAllowance(
address(vault),
100 ether
);
uint256[] memory amounts = new uint256[](1);
amounts[0] = 100 ether;
uint256[] memory minAmounts = amounts;
vault.deposit(depositor, amounts, 0, type(uint256).max);
vm.startPrank(depositor2);
deal(Constants.WSTETH, depositor2, 180 ether);
IERC20(Constants.WSTETH).safeIncreaseAllowance(
address(vault),
180 ether
);
uint256[] memory amounts2 = new uint256[](1);
amounts2[0] = 180 ether;
vault.deposit(depositor2, amounts2, 0, type(uint256).max);
vm.stopPrank();
vm.startPrank(depositor3);
deal(Constants.WSTETH, depositor3, 86 ether);
IERC20(Constants.WSTETH).safeIncreaseAllowance(
address(vault),
86 ether
);
uint256[] memory amounts3 = new uint256[](1);
amounts3[0] = 86 ether;
vault.deposit(depositor3, amounts3, 0, type(uint256).max);
vm.stopPrank();
aggAdd.setPrice(priceBefore + (priceBefore * 1000 / 10000));
// CASE with Decrease - aggAdd.setPrice(priceBefore - (priceBefore * 1000 / 10000));
(, int256 priceAfter , , ,) = aggAdd.latestRoundData();
console.log("WSTETH price after", uint(priceAfter));
vm.startPrank(depositor);
vault.registerWithdrawal(
depositor,
100 ether,
minAmounts,
type(uint256).max,
type(uint256).max,
false
);
vm.stopPrank();
vm.startPrank(operator);
address[] memory users = new address[](1);
users[0] = depositor;
vault.processWithdrawals(users);
{
address[] memory withdrawers = vault.pendingWithdrawers();
assertEq(withdrawers.length, 0);
}
console.log("Balance After", IERC20(Constants.WSTETH).balanceOf(depositor));
} OUTPUT:
The following files must also be changed because the test setUp is wrong. function _setUp(Vault vault) internal {
ERC20TvlModule erc20TvlModule = new ERC20TvlModule();
vault.addTvlModule(address(erc20TvlModule));
vault.addToken(Constants.WSTETH);
VaultConfigurator configurator = VaultConfigurator(
address(vault.configurator())
);
// oracles setup
{
ManagedRatiosOracle ratiosOracle = new ManagedRatiosOracle();
uint128[] memory ratiosX96 = new uint128[](1);
ratiosX96[0] = 2 ** 96;
ratiosOracle.updateRatios(address(vault), true, ratiosX96);
ratiosOracle.updateRatios(address(vault), false, ratiosX96);
configurator.stageRatiosOracle(address(ratiosOracle));
configurator.commitRatiosOracle();
ChainlinkOracle chainlinkOracle = new ChainlinkOracle();
chainlinkOracle.setBaseToken(address(vault), Constants.WETH);
address[] memory tokens = new address[](2);
tokens[0] = Constants.WSTETH;
tokens[1] = Constants.WETH;
IChainlinkOracle.AggregatorData[]
memory data = new IChainlinkOracle.AggregatorData[](2);
data[0] = IChainlinkOracle.AggregatorData({
aggregatorV3: address(
new WStethRatiosAggregatorV3(Constants.WSTETH)
),
maxAge: 30 days
});
data[1] = IChainlinkOracle.AggregatorData({
aggregatorV3: address(new ConstantAggregatorV3(1 ether)),
maxAge: 30 days
});
chainlinkOracle.setChainlinkOracles(address(vault), tokens, data);
configurator.stagePriceOracle(address(chainlinkOracle));
configurator.commitPriceOracle();
}
configurator.stageMaximalTotalSupply(1000 ether);
configurator.commitMaximalTotalSupply();
}
function _initialDeposit(Vault vault) internal {
vm.startPrank(admin);
_setupDepositPermissions(vault);
vm.stopPrank();
vm.startPrank(operator);
deal(Constants.WSTETH, operator, 10 gwei);
IERC20(Constants.WSTETH).safeIncreaseAllowance(address(vault), 10 gwei);
uint256[] memory amounts = new uint256[](1);
amounts[0] = 10 gwei;
vault.deposit(address(vault), amounts, 10 gwei, type(uint256).max);
assertEq(IERC20(Constants.WSTETH).balanceOf(address(vault)), 10 gwei);
assertEq(IERC20(Constants.RETH).balanceOf(address(vault)), 0);
assertEq(IERC20(Constants.WETH).balanceOf(address(vault)), 0);
assertEq(vault.balanceOf(address(vault)), 10 gwei);
assertEq(vault.balanceOf(operator), 0);
vm.stopPrank();
} As well as adding a way to change the price in the test // SPDX-License-Identifier: BSL-1.1
pragma solidity 0.8.23;
import "../interfaces/external/chainlink/IAggregatorV3.sol";
import "../interfaces/external/lido/IWSteth.sol";
contract WStethRatiosAggregatorV3 is IAggregatorV3 {
uint8 public constant decimals = 18;
address public immutable wsteth;
+ uint256 public price;
+ bool public isCustomPrice;
constructor(address wsteth_) {
wsteth = wsteth_;
}
+ function setPrice(uint256 _price) external {
+ price = _price;
+ isCustomPrice = true;
+ }
function getAnswer() public view returns (int256) {
return int256(IWSteth(wsteth).getStETHByWstETH(10 ** decimals));
}
function latestRoundData()
public
view
override
returns (uint80, int256, uint256, uint256, uint80)
{
- return (0, getAnswer(), block.timestamp, block.timestamp, 0);
+ return (0, isCustomPrice ? int256(price) : getAnswer(), block.timestamp, block.timestamp, 0);
}
}
|
With that test, I would agree the issue is low severity. Yes, it allows for the attacker to get essentially a free trade, but it doesn't lead to a loss of funds for other users or a protocol. It also allows to bypass the withdrawal queue, but I don't see it as medium severity:
Hence, will invalidate it tomorrow morning to give time to @InfectedIsm to provide counterarguments. |
Hi @WangSecurity, I think I basically shared how this can lead to a profit for the user (and thus a loss for the other depositors) in this comment: #119 (comment) To summarize the comment, there can be profit in the following situations:
To develop further example 2, here a scenario:
Using easy numbers to make the scenario easily understandable, but numbers are available here |
Totally wrong First read about wstETH, the token is not rebasing. There are no rebasing tokens in the allowed tokens. Then increasing the price of an underlying do not change the value the user receives. If you have any contra-arguments provide coded PoC showing how price increase change the user token balance, not the price. |
I'm not sure why this remark about wstETH, as stETH rebase => wstETH value increase. |
@InfectedIsm as I understand from Lido docs, wstETH is indeed not a rebase token:
And other underlying tokens from the README are not rebase tokens, are they? |
Below is the changed test with RETH as underlying as well. @WangSecurity Just to mention something, @InfectedIsm stated function testC111() 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);
//
VaultConfigurator configurator = VaultConfigurator(
address(vault.configurator())
);
ChainlinkOracle pricesOracle = ChainlinkOracle(address(configurator.priceOracle()));
ChainlinkOracle.AggregatorData memory agg = pricesOracle.aggregatorsData(address(vault), Constants.WSTETH);
WStethRatiosAggregatorV3 aggAdd = WStethRatiosAggregatorV3(agg.aggregatorV3);
uint256 priceBefore = uint256(aggAdd.getAnswer());
console.log("WSTETH price", priceBefore);
address depositor = address(bytes20(keccak256("depositor")));
address depositor2 = address(bytes20(keccak256("depositor2")));
address depositor3 = address(bytes20(keccak256("depositor3")));
vm.startPrank(depositor);
deal(Constants.WSTETH, depositor, 100 ether);
deal(Constants.RETH, depositor, 100 ether);
console.log("Depositor WSTETH balance before deposit", IERC20(Constants.WSTETH).balanceOf(depositor));
console.log("Depositor RETH balance before deposit", IERC20(Constants.RETH).balanceOf(depositor));
IERC20(Constants.WSTETH).safeIncreaseAllowance(
address(vault),
100 ether
);
IERC20(Constants.RETH).safeIncreaseAllowance(
address(vault),
100 ether
);
uint256[] memory amounts = new uint256[](2);
amounts[0] = 100 ether;
amounts[1] = 100 ether;
vault.deposit(depositor, amounts, 0, type(uint256).max);
vm.startPrank(depositor2);
deal(Constants.WSTETH, depositor2, 20 ether);
deal(Constants.RETH, depositor2, 20 ether);
IERC20(Constants.WSTETH).safeIncreaseAllowance(
address(vault),
20 ether
);
IERC20(Constants.RETH).safeIncreaseAllowance(
address(vault),
20 ether
);
uint256[] memory amounts2 = new uint256[](2);
amounts2[0] = 20 ether;
amounts2[1] = 20 ether;
vault.deposit(depositor2, amounts2, 0, type(uint256).max);
vm.stopPrank();
vm.startPrank(depositor3);
deal(Constants.WSTETH, depositor3, 10 ether);
deal(Constants.RETH, depositor3, 10 ether);
IERC20(Constants.WSTETH).safeIncreaseAllowance(
address(vault),
10 ether
);
IERC20(Constants.RETH).safeIncreaseAllowance(
address(vault),
10 ether
);
uint256[] memory amounts3 = new uint256[](2);
amounts3[0] = 10 ether;
amounts3[1] = 10 ether;
vault.deposit(depositor3, amounts3, 0, type(uint256).max);
vm.stopPrank();
aggAdd.setPrice(priceBefore + (priceBefore * 1000 / 10000));
// CASE with Decrease - aggAdd.setPrice(priceBefore - (priceBefore * 1000 / 10000));
(, int256 priceAfter , , ,) = aggAdd.latestRoundData();
console.log("WSTETH price after", uint(priceAfter));
uint256[] memory minAmounts = new uint256[](2);
minAmounts[0] = 0;
minAmounts[1] = 0;
vm.startPrank(depositor);
vault.registerWithdrawal(
depositor,
1000 ether,
minAmounts,
type(uint256).max,
type(uint256).max,
false
);
vm.stopPrank();
vm.startPrank(operator);
address[] memory users = new address[](1);
users[0] = depositor;
vault.processWithdrawals(users);
{
address[] memory withdrawers = vault.pendingWithdrawers();
assertEq(withdrawers.length, 0);
}
console.log("Balance After", IERC20(Constants.WSTETH).balanceOf(depositor));
console.log("Balance After", IERC20(Constants.RETH).balanceOf(depositor));
} function _setUp(Vault vault) internal {
ERC20TvlModule erc20TvlModule = new ERC20TvlModule();
vault.addTvlModule(address(erc20TvlModule));
vault.addToken(Constants.WSTETH);
vault.addToken(Constants.RETH);
VaultConfigurator configurator = VaultConfigurator(
address(vault.configurator())
);
// oracles setup
{
ManagedRatiosOracle ratiosOracle = new ManagedRatiosOracle();
uint128[] memory ratiosX96 = new uint128[](2);
ratiosX96[0] = 2 ** 95;
ratiosX96[1] = 2 ** 95;
uint128[] memory ratiosX96Withdraw = new uint128[](2);
ratiosX96Withdraw[0] = 47536897508558602556126370201;
ratiosX96Withdraw[1] = 31691265005705735037417580135;
ratiosOracle.updateRatios(address(vault), true, ratiosX96);
ratiosOracle.updateRatios(address(vault), false, ratiosX96Withdraw);
configurator.stageRatiosOracle(address(ratiosOracle));
configurator.commitRatiosOracle();
ChainlinkOracle chainlinkOracle = new ChainlinkOracle();
chainlinkOracle.setBaseToken(address(vault), Constants.WETH);
address[] memory tokens = new address[](3);
tokens[0] = Constants.WSTETH;
tokens[1] = Constants.RETH;
tokens[2] = Constants.WETH;
IChainlinkOracle.AggregatorData[]
memory data = new IChainlinkOracle.AggregatorData[](3);
data[0] = IChainlinkOracle.AggregatorData({
aggregatorV3: address(
new WStethRatiosAggregatorV3(Constants.WSTETH)
),
maxAge: 30 days
});
data[1] = IChainlinkOracle.AggregatorData({
aggregatorV3: address(new ConstantAggregatorV3(1 ether)), // RETH price
maxAge: 30 days
});
data[2] = IChainlinkOracle.AggregatorData({
aggregatorV3: address(new ConstantAggregatorV3(1 ether)),
maxAge: 30 days
});
chainlinkOracle.setChainlinkOracles(address(vault), tokens, data);
configurator.stagePriceOracle(address(chainlinkOracle));
configurator.commitPriceOracle();
}
configurator.stageMaximalTotalSupply(1000 ether);
configurator.commitMaximalTotalSupply();
}
function _initialDeposit(Vault vault) internal {
vm.startPrank(admin);
_setupDepositPermissions(vault);
vm.stopPrank();
vm.startPrank(operator);
deal(Constants.WSTETH, operator, 10 gwei);
deal(Constants.RETH, operator, 10 gwei);
IERC20(Constants.WSTETH).safeIncreaseAllowance(address(vault), 10 gwei);
IERC20(Constants.RETH).safeIncreaseAllowance(address(vault), 10 gwei);
uint256[] memory amounts = new uint256[](2);
amounts[0] = 10 gwei;
amounts[1] = 10 gwei;
vault.deposit(address(vault), amounts, 10 gwei, type(uint256).max);
assertEq(IERC20(Constants.WSTETH).balanceOf(address(vault)), 10 gwei);
assertEq(IERC20(Constants.RETH).balanceOf(address(vault)), 10 gwei);
assertEq(IERC20(Constants.WETH).balanceOf(address(vault)), 0);
assertEq(vault.balanceOf(address(vault)), 10 gwei);
assertEq(vault.balanceOf(operator), 0);
vm.stopPrank();
} Here is the output when 3 users deposit 50% wstETH and 50% rETH, and one of them withdraw immediately after wstETH price increase.
This is the result when using the same 50%/50% ratio for withdrawal as well.
@WangSecurity There are no examples of how withdrawal ratios will be configured for multiple tokens, and even with that these examples show that a user cannot get a free token via a sandwich price increase, especially since the report does not mention a different withdrawal ratio anywhere. This should be treated as configured and with the current configuration after price increase/decrease, users receive 0 more tokens. |
Yep, I agree that ratios are not mentioned anywhere and after reading the report I understand the problem is not in them and the attack should be possible with the same ratios for deposit and withdrawal. 2 underlying (or more) are not mentioned in the report and it talks solely about the price increase of an asset. As was proven the price increase doesn't affect the tokens at all and won't lead to a loss of funds. Additionally, as I understand the attack is possible with rebasing tokens, but they're not used. Hence, the decision is to invalidate this report tomorrow morning, since no other users lose funds. |
This is a test modified with different ratio: Click to expand function testPriceIncreaseDoesNothing() 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);
VaultConfigurator configurator = VaultConfigurator(
address(vault.configurator())
);
ChainlinkOracle pricesOracle = ChainlinkOracle(address(configurator.priceOracle()));
ChainlinkOracle.AggregatorData memory agg = pricesOracle.aggregatorsData(address(vault), Constants.WSTETH);
ChainlinkOracle.AggregatorData memory aggRETH = pricesOracle.aggregatorsData(address(vault), Constants.RETH);
WStethRatiosAggregatorV3[] memory aggAdd = new WStethRatiosAggregatorV3[](2);
aggAdd[0] = WStethRatiosAggregatorV3(agg.aggregatorV3);
uint256 priceBefore = uint256(aggAdd[0].getAnswer());
console.log("WSTETH price", priceBefore);
address depositor = address(bytes20(keccak256("depositor")));
address depositor2 = address(bytes20(keccak256("depositor2")));
address depositor3 = address(bytes20(keccak256("depositor3")));
vm.startPrank(depositor);
deal(Constants.WSTETH, depositor, 100 ether);
console.log("Balance Before price drop", IERC20(Constants.WSTETH).balanceOf(depositor));
IERC20(Constants.WSTETH).safeIncreaseAllowance(address(vault), 100 ether);
deal(Constants.RETH, depositor, 100 ether);
console.log("Balance Before price drop", IERC20(Constants.RETH).balanceOf(depositor));
IERC20(Constants.RETH).safeIncreaseAllowance(address(vault), 100 ether);
uint256[] memory amounts = new uint256[](3);
amounts[0] = 100 ether;
amounts[1] = 100 ether;
vault.deposit(depositor, amounts, 0, type(uint256).max);
vm.stopPrank();
vm.startPrank(depositor2);
deal(Constants.WSTETH, depositor2, 100 ether);
IERC20(Constants.WSTETH).safeIncreaseAllowance(address(vault), 100 ether);
deal(Constants.RETH, depositor2, 100 ether);
IERC20(Constants.RETH).safeIncreaseAllowance(address(vault), 100 ether);
amounts[0] = 100 ether;
amounts[1] = 100 ether;
vault.deposit(depositor2, amounts, 0, type(uint256).max);
vm.stopPrank();
vm.startPrank(depositor3);
deal(Constants.WSTETH, depositor3, 100 ether);
IERC20(Constants.WSTETH).safeIncreaseAllowance(address(vault), 100 ether);
deal(Constants.RETH, depositor3, 100 ether);
IERC20(Constants.RETH).safeIncreaseAllowance(address(vault), 100 ether);
amounts[0] = 100 ether;
amounts[1] = 100 ether;
vault.deposit(depositor3, amounts, 0, type(uint256).max);
vm.stopPrank();
aggAdd[0].setPrice(priceBefore + (priceBefore * 1000 / 10000));
// CASE with Decrease - aggAdd.setPrice(priceBefore - (priceBefore * 1000 / 10000));
(, int256 priceAfter , , ,) = aggAdd[0].latestRoundData();
console.log("WSTETH price after", uint(priceAfter));
uint256[] memory minAmounts = new uint256[](3);
vm.startPrank(depositor);
vault.registerWithdrawal(
depositor,
IERC20(address(vault)).balanceOf(depositor),
minAmounts,
type(uint256).max,
type(uint256).max,
false
);
vm.stopPrank();
vm.startPrank(operator);
address[] memory users = new address[](1);
users[0] = depositor;
vault.processWithdrawals(users);
{
address[] memory withdrawers = vault.pendingWithdrawers();
assertEq(withdrawers.length, 0);
}
console.log("Balance After", IERC20(Constants.WSTETH).balanceOf(depositor));
console.log("Balance After", IERC20(Constants.RETH).balanceOf(depositor));
}
And output: WSTETH price 1166209160223626863
Balance Before price drop 100000000000000000000
Balance Before price drop 100000000000000000000
WSTETH price after 1282830076245989549
Balance After 144638007597241338868
Balance After 48212669199080446289 If rETH suffer slashing, the user will have made a profitable operation. Nevertheless, scenario (1) based on USDT/USDC vaults will work as a standard vault as those are not LST. Yield is generated through strategies and increasing LP value in a way that can be extracted by depositing right before yield is sent to vault, and LP value increase. @WangSecurity Please note that the initial submission isn't focused solely on LST vaults even though the escalation has revolved around that specific case, and that both type of vaults are in scope. |
@InfectedIsm but what would be the estimated loss that other users would suffer? |
On scenario (1), user will capture a share of the yield through the increase of the vault LP token value, based on the additional USDT/USDC accrued into the vault by the strategy, without the risk of incurring a loss from the strategy. |
But the attacker would suffer the withdrawal fee themselves. Additionally, the yield is sent to the vault as a one-time transaction, correct? So for example, there's a one-time injection of the yield of 2%, correct? |
A withdrawal fee would lower the profit surely, and if set higher than expected yield from the strategy, then user would be able to profit from the vault only in cases where the generates yield is greater than the withdrawal fee. |
Thank you, but I don't think you answered my questions, the question wasn't about the withdrawal fee, but how the yield is accrued:
|
Sorry I've missed to answer that. |
@InfectedIsm so you mean the admins will inject yield via deposit and withdraw callbacks? Wouldn't the yield come from Mellow investing in other protocols, such as Symbiotic now, so the yield comes from the symbiotic bonds and it will steadily accrue? |
@WangSecurity can you share a GMT time until when I can defend? I'm right now not in a position to correctly research as I only have access to my phone due to constraints. But I'm interested about you point, I'm not sure if USDT/USDC tokens are eligible for Symbiotic vaults. If it is the case, then we would expect admins to use them, yes. |
Honestly, further thinking about the issue, I don't think it's defendable. Of course, I'm giving you time until 11 am GMT (hope that's enough). But, this issue is about the LP share value increasing. It's quite fair to assume the larger the vault TVL the larger will be the share value increase (since more tokens to accrue yield). But the larger the pool, the larger capital the attack has to have. The larger the capital, the larger the withdrawal fee that has to be paid (but it can be 0, of course). Moreover, it depends on a one-time yield injection to increase the LP share value, if the yield accrues steadily with time, this won't be possible. With that being said, I don't see how the attack could cause a considerable loss of funds with the attacker ending up with a profit or at least breakeven. Again, you have a bit of time to defend it, but I can't give you much time, unfortunately, and will invalidate the issue if no new concrete arguments are present. |
Thanks @WangSecurity for having letting me this time, really appreciate. |
infect3d
High
Users can arbitrage assets into a vault despite the presence of a withdrawal queue
Summary
The withdrawal queue do not prevent malicious users to deposit and withdraw in the same block, opening-up possibilities for arbitrage.
Vulnerability Detail
In order to limit arbitrage of the different assets of the vault, Mellow has implemented a withdrawal queue as it is common practice.
Each user can only have one withdrawal request at a time, which is stored inside a mapping at
_withdrawalRequest[sender]
If a user has created a withdrawal request, its address gets addd to the
_pendingWithdrawers
set.Then, operators can process users request by calling
processWithdrawals(address[] memory users)
with the array of users they want to process the requests.This system should logically prevent a user to withdraw in the same block as a deposit, or at least make it really unlikely as the operator (which is trusted) must send the tx to process the request of the user in the same block, after the withdrawal request tx.
But there a way for a malicious user (Bob) to easily get around that protection following these steps:
processWithdrawals(address[] memory users)
Bob was able to deposit and withdraw in a same block and profit from a price change, also avoiding risk of slashing for LRT vaults like wstETH
Please note that this is even easier for vaults using the defaultBondStrategy as there is a
processAll()
function, in this case there is no need to keep a request alive in the queueImpact
Users can circumvent the withdrawal queue and deposit>withdraw in the same block, opening possibilities for arbitrage
Code Snippet
The function analyzeRequest perform sanity checks against deadline and amounts to be withdrawn:
https://github.com/sherlock-audit/2024-06-mellow/blob/main/mellow-lrt/src/Vault.sol#L536
But no check of request timestamp creation against
block.timestamp
, which would prevent such operations:https://github.com/sherlock-audit/2024-06-mellow/blob/main/mellow-lrt/src/Vault.sol#L482
Tool used
Manual review
Recommendation
Check request creation timestamp against
block.timestamp
and act accordingly if equalThe text was updated successfully, but these errors were encountered: