Strateg Pro League
Findings & Analysis Report
2024-07-29
Table of contents
- Summary
- Scope
- Severity Criteria
-
- H-1 Duplicate strategies in Vault cause assets to be counted repeatedly
- H-2 Donation attacks can inflate the ratio of assets/shares
- H-3 Precision loss in
withdrawalRebalance()
will make users lose funds - H-4 All rewards in
StrategERC3525
can be stolen with a single token - Medium Risk Findings (12)
- M-01 A malicious user can frontrun permit transaction to make it revert due to invalid signature
- M-02
putInBuffer()
doesnt work with rebase tokens. - M-03
signedHash
should contain_vault
invaultWithdrawalRebalance()
- M-04 Incorrect check in
_applyTvlLimits()
- M-05
_fetchNativeTVL(false)
causes stale nativeTvl to be used. - M-06 Users can frontrun
_harvestFees()
to withdraw assets to avoid losses. - M-07 User can bypass timelock by minting shares to alternate receiver
- M-08 Holder, whitelist and TVL requirements can be bypassed by minting or sending shares to alternate address
- M-09
_decreaseValueDeposited
is applied to incorrect user in multiple locations which can lead to deposit DOS - M-10
StrategVault._applyTvlLimits()
incorrectly assumesmsg.sender
is the depositor - M-11
StrategVault.withdrawalRebalance()
can be DOSed by_harvestFees()
when remaining assets in the buffer is small - M-12 Missing
_bufferDerivation <= _bufferSize
inStrategVaultFactory.deployNewVault()
could DOS rebalancing
-
- L-1
removeTokenPercent()
andremoveAllTokenPercent()
do not work as described - L-2 The pending settings cannot be canceled.
- L-3 _rebalance does not consider assets in the vault
- L-4 StrategVault#withdraw and redeem applies timelock to sender which can lead to incorrectly reverting transactions
- L-5
StrategVault#_rebalance
fails to clear highly sensitive dynamic parameters when exiting to buffer - L-6
minUserDeposit
limit inStrategVault
can be bypassed - L-7 Exit hook is not called when performing a withdrawal in
StrategVault.withdrawalRebalance()
- L-8
StrategVault.redeem()
can be forced to revert if the shares redeemed is close tobufferShares
- L-9 Missing
_vault
address validation inStrategVaultFactory
functions - L-10 Missing array length checks in
StrategVaultFactory.editVaultParams()
- L-1
- Disclosures
Overview
About C4
Code4rena (C4) is an open organization consisting of security researchers, auditors, developers, and individuals with domain expertise in smart contracts.
A C4 Pro League Audit is an event where elite tier Code4rena contributors, commonly referred to as wardens, reviews, audits and analyzes smart contract logic in exchange for a bounty provided by sponsoring projects.
During the Pro League audit outlined in this document, C4 conducted an analysis of the Strateg smart contract system written in Solidity. The audit took place between June 17 - July 03, 2024.
Wardens
3 Wardens contributed to Strateg:
Final report assembled by bytes032, thebrittfactor & Sentinel
Summary
The C4 Pro League analysis yielded 4 HIGH, 12 MEDIUM and 10 LOW severity vulnerabilities.
Additionally, C4 Pro League analysis included 3 findings rated as Informational.
Scope
The source code was delivered to Code4rena in a private Git repository.
Severity Criteria
C4 assesses the severity of disclosed vulnerabilities based on three primary risk categories: high, medium, and low/non-critical.
High-level considerations for vulnerabilities span the following key areas when conducting assessments:
- Malicious Input Handling
- Escalation of privileges
- Arithmetic
- Gas use
For more information regarding the severity criteria referenced throughout the submission review process, please refer to the documentation provided on the C4 website, specifically our section on Severity Categorization.
High Risk Findings (4)
[H-1] Duplicate strategies in Vault cause assets to be counted repeatedly
Context:
Description:
Protocol allows vault to set duplicate strategies, but this can lead to repeated counting of assets in the strategy, malicious deployers can exploit it to inflate total assets.
For example, the malicious deployer sets up 5 strategies with the same address and deposits 1000 tokens A to get 1000 shares, and then enters the strategies.
The first strategy converts Token A to Token B. The next four strategies do not perform any action because the balance of Token A is 0.
Then in _getNativeTVL()
, oracleExit()
of all five strategies will use Token B’s balance to calculate, which means Token B’s balance will be counted 5 times repeatedly.
function _getNativeTVL() internal view returns (uint256) {
address _asset = asset();
DataTypes.OracleState memory oracleState;
oracleState.vault = address(this);
uint256 _strategyBlocksLength = strategyBlocksLength;
if (_strategyBlocksLength == 0 || !isLive) {
return IERC20(_asset).balanceOf(address(this)) + IERC20(_asset).allowance(buffer, address(this));
} else if (_strategyBlocksLength == 1) {
oracleState =
IStrategStrategyBlock(strategyBlocks[0]).oracleExit(
oracleState,
LibBlock.getStrategyStorageByIndex(0),
10000
);
} else {
uint256 revertedIndex = _strategyBlocksLength - 1;
for (uint256 i = 0; i < _strategyBlocksLength; i++) {
uint256 index = revertedIndex - i;
oracleState = IStrategStrategyBlock(strategyBlocks[index]).oracleExit(
oracleState, LibBlock.getStrategyStorageByIndex(index), 10000
);
}
}
At this point totalAsset() is 5000, and when other users deposit 1000 tokens of A, they will only get 200 share. Exiting the strategy, the attacker will have (1000+1000)*1000/(1000+200) = 1667 token A.
Recommendation:
Since the protocol supports Vault setting duplicate strategies, it is recommended to remove duplicate token counts in oracleExit()
of the strategies.
Strateg:
The oracleExit function keep a list of all tokens address manipulated by the vault strategy. On this block, we’ll always use the tokenExists function that has been added in 0.1.17 version of the strateg-protocol-libraries: LibOracleState.sol#L35
It will avoid the encounter of this duplication issue.
C4 Pro League:
Acknowledged
[H-2] Donation attacks can inflate the ratio of assets/shares
Context:
Description:
The first depositor can make a donation attack, which will inflate the ratio of assets/shares.
In _harvestFees()
, taxableValue
is based on the portion of funds that have appreciated.
lastHarvestIndex
is initially 10000, which means that it is assumed that assets/shares = 1:1 at the beginning.
If the first depositor inflate the ratio of assets/shares by donation attack, for example, inflate the ratio to 10000:1, i.e. currentVaultIndex = 100000000
, then _harvestFees()
will charge fees based on total assets.
A malicious deployer have incentive to do so in order to overcharge fees.
function _harvestFees(bool _applyHarvestFee) private {
_fetchNativeTVL(false);
DataTypes.VaultConfigurationMap memory config = _getConfiguration();
uint256 tSupply = totalSupply();
uint256 _lastFeeHarvestIndex = lastHarvestIndex;
uint256 tAssets = totalAssets();
uint256 currentVaultIndex = (tAssets * 10000) / tSupply;
uint256 _protocolFee = config.getProtocolFee();
uint256 _creatorFee = config.getCreatorFee();
uint256 _harvestFee = config.getHarvestFee();
if (_lastFeeHarvestIndex == currentVaultIndex || currentVaultIndex < _lastFeeHarvestIndex) {
lastHarvestIndex = currentVaultIndex;
return;
}
uint256 taxableValue = (tSupply * currentVaultIndex / 10000) - (tSupply * _lastFeeHarvestIndex / 10000);
Also, donation attack can be used to bypass the maxUserDeposit
limit.
Consider maxUserDeposit
is 1e18 assets, the user deposits 1e17 assets, and gets 1e17 shares.
The user sends 1e20 assets to the Vault, and totalAssets() is 1e20+1e17. The user’s 1e17 shares will be worth 1e17 * (1e20 + 1e17 + 1) / (1e17 + 1) asseets, which is basically no loss.
Recommendation: Consider getting rid of the tracking of _asset.balanceOf(address(this)
in _getNativeTVL()
, since the protocol by design doesn’t keep the asset in the vault, but sends it to the buffer.
Strateg:
We reworked the fees system and the tracking of the tvl. the lastVaultIndex has been replaced by vaultIndexHighWaterMark that represent the highest vaultIndex on which fees has been taken. The vaultIndex is now calculated on each deposit and fees are taken instantly before processing shares to mint. StrategVault.sol#L928
additionally, we added a protection on deposit against rounding loss to avoid loss on donation frontrun attack, it wwas the only way we found to pass the donation attack fuzzing test StrategVault.sol#L254
C4 Pro League:
Fix Confirmed
[H-3] Precision loss in withdrawalRebalance()
will make users lose funds
Context:
Description:
Users may suffer losses due to precision loss in withdrawalRebalance()
.
Consider totalSupply = 100000e18, amount = 19e18.
withdrawPercent = 19e18 * 10000/100000e18 = 1.9, round down to 1.
In the end, users will only withdraw 0.01% of totalAssets, but 0.019% of totalShares will be burned.
uint256 withdrawPercent = (_amount * 10000) / totalSupply();
if(withdrawPercent == 10000) {
_harvestFees(false);
}
_exitStrategy(withdrawPercent);
uint256 totalBufferWithdraw = withdrawPercent * _asset.allowance(_buffer, address(this)) / 10000;
_asset.safeTransferFrom(
_buffer,
address(this),
totalBufferWithdraw
);
uint256 tAssets = _fetchNativeTVL(false);
totalWithdraw = tAssets * withdrawPercent / 10000;
uint256 currentBalance = _asset.balanceOf(address(this));
if(currentBalance < totalWithdraw) {
uint256 diffPercent = (totalWithdraw - currentBalance) * 10000 / totalWithdraw;
if(diffPercent < 100) { //less than 1% spread
totalWithdraw = currentBalance;
} else {
revert WithdrawalRebalanceIssue();
}
}
/**
* @dev Execute user withdrawal with ERC4626 low level function
*/
_burn(msg.sender, _amount);
Less than 0.01 % is lost in withdrawalRebalance()
. And even if the user is aware of this, it may be lost due to earlier transactions by other users.
Consider Alice deposits 0.01%, Bob withdraws 0.03%, and Alice’s transaction is executed first, so Bob’s 0.03% becomes 0.03%/(1+0.01%) = 0.0299%, and Bob withdraws only 0.02% assets.
Recommendation:
Instead of burning _amount
, it is recommended to burn totalSupply * withdrawPercent / 10000
.
- _burn(msg.sender, _amount);
+ _burn(msg.sender, mulDiv(totalSupply(), withdrawPercent, 10000, true)); // round up
Strateg:
The recommandation has been implemented: StrategVault.sol#L648
C4 Pro League:
Fix reviewed and confirmed
[H-4] All rewards in StrategERC3525
can be stolen with a single token
Context:
Description:
In _claimRewards()
, info.lastClaimTotalRewards
is only updated for a user when feeClaimable
, which is the amount of rewards currently accrued to the user, is more than 0
:
if (feeClaimable > 0) {
info.timeClaimed = block.timestamp;
info.lastClaimTotalRewards = totalRewards;
However, not updating info.lastClaimTotalRewards
when feeClaimable = 0
allows for a tokenId
’s balance to change while lastClaimTotalRewards
remains outdated.
_claimRewards()
is called in _beforeValueTransfer()
whenever value is transferred to an existing tokenid
:
if (_exists(_toTokenId)) {
_claimRewards(_toTokenId);
} else {
As such, if value is transferred to an existing tokenId
that has a zero balance, that tokenId
instantly gains rewards. This effectively creates rewards out of thin air, for example:
-
Assume a user has two token IDs:
tokenId = 1
has a balance of 500 (half ofRATIO
).tokenId = 2
has a balance of 0.info.lastClaimTotalRewards
is currently0
for both tokens.
- Vault calls
addRewards()
to add 100 tokens of rewards. - User claims 50 tokens from
tokenId = 1
. - User calls
transferFrom()
withvalue_ = 500
to transfer value fromtokenId = 1
totokenId = 2
. -
In the
_beforeValueTransfer()
hook:tokenId = 2
exists, so_claimedRewards()
is called.feeClaimable = 0
astokenId = 2
has no balance.info.lastClaimTotalRewards
is not updated.
-
Now, when
_claimRewards()
is called again fortokenId = 2
(eg. throughredeem()
):concernedRewards = 100 - 0 = 100
feeClaimable = 100 * 500 / 1000 = 50
- The user gets to claim 50 tokens again, even though they were already claimed in
tokenId = 1
.
This can be repeatedly performed to steal all rewards in the contract.
Note that a tokenId
with zero balance can be created by calling transferFrom()
with value_ = 0
to a new address.
Recommendation:
Consider updating info.lastClaimTotalRewards
regardless of feeClaimable
:
+ info.timeClaimed = block.timestamp;
+ info.lastClaimTotalRewards = totalRewards;
if (feeClaimable > 0) {
- info.timeClaimed = block.timestamp;
- info.lastClaimTotalRewards = totalRewards;
Strateg:
The recommendation has been implemented: StrategERC3525.sol#L258
C4 Pro League:
Fix reviewed and confirmed.
Medium Risk Findings (12)
[M-01] A malicious user can frontrun permit transaction to make it revert due to invalid signature
Context:
- LibPermit.sol#L56-L59
- LibPermit.sol#L27-L41
- StrategOperatorProxy.sol#L151-L152
- StrategUserInteractions.sol#L312-L321
Description:
A malicious user can frontrun a permit transaction and directly use the signature exposed in the permit transaction.
For executePermit()
, when the malicious user uses the signature (call asset.permit()
directly), the signature in executePermit()
will be invalid and the transaction will revert.
function executePermit(address _asset, address _from, address _to, uint256 _amount, bytes memory _permitParams) internal {
DataTypes.PermitParams memory p = abi.decode(_permitParams, (DataTypes.PermitParams));
ERC20Permit(_asset).permit(_from, _to, _amount, p.deadline, p.v, p.r, p.s);
}
Recommendation:
It is recommended to wrap asset.permit()
with try catch block in executePermit()
.
Strateg:
The recommendation has been implemented: LibPermit.sol#L58
C4 Pro League:
Fix confirmed and reviewed
[M-02] putInBuffer()
doesnt work with rebase tokens.
Context:
Description:
The user can deploy the Vault with any tokens, and when the user makes a deposit into the Vault, StrategAssetBuffer.putInBuffer()
is called to transfer the tokens into the buffer and approve the Vault for spending.
function putInBuffer(address _asset, uint256 _amount) external {
IERC20(_asset).safeTransferFrom(msg.sender, address(this), _amount);
IERC20(_asset).safeIncreaseAllowance(msg.sender, _amount);
}
However, when the Vault’s assets are rebase tokens, the amount approved will be different from the actual balance, resulting in the Vault being able to spend more or less tokens in the buffer.
Recommendation:
It is recommended to apply whitelist restrictions to the assets available for Vault deployment, or just document this issue.
Strateg:
Already whitelisted on the dApp and will be included in documentation
C4 Pro League:
Acknowledged
[M-03] signedHash
should contain _vault
in vaultWithdrawalRebalance()
Context:
Description:
In vaultWithdrawalRebalance()
, if the user uses dynParamsExit
, the signature of the authority member needs to be provided.
function vaultWithdrawalRebalance(
address _user,
address _vault,
uint256 _deadline,
uint256 _amount,
bytes memory _signature,
bytes memory _portalPayload,
bytes memory _permitParams,
uint256[] memory _dynParamsIndexExit,
bytes[] memory _dynParamsExit
) external payable returns (uint256 returnedAssets) {
if(msg.sender != addressProvider.userInteractions()) revert OnlyUserInteraction();
if(_deadline < block.timestamp) revert DeadlineExceeded();
bool isProtected = _dynParamsIndexExit.length > 0;
if(isProtected) {
bytes32 signedHash = keccak256(
abi.encode(
_user,
userWithdrawalRebalancedNonce[_user],
_deadline,
_dynParamsIndexExit,
_dynParamsExit
)
);
The problem here is that the signature does not include the _vault
parameter, which means that the user can use that dynParamsExit
in any vault.
Considering the different strategies of vault, the same dynParamsExit
may have side effects in different vaults.
Recommendation:
It is recommended to include _vault
in signedHash
.
Strateg:
The recommandations has been implemented: StrategOperatorProxy.sol#L125
C4 Pro League
Reviewed and confirmed.
[M-04] Incorrect check in _applyTvlLimits()
Context:
Description:
_applyTvlLimits()
performs deposit limit checking.
function _applyTvlLimits(uint256 _amount, uint256 _vaultTvl, DataTypes.VaultConfigurationMap memory config) internal view {
uint256 mode = config.getLimitMode();
//No limit set
if (mode == 0) return;
(
uint256 minUserDeposit,
uint256 maxUserDeposit,
,
uint256 maxVaultDeposit
) = IStrategVaultFactory(factory).getVaultDepositLimits(address(this));
uint256 userDeposited = userDeposit[msg.sender];
//Minimum limit set
if (mode % 2 == 1) {
uint256 minDeposit = minUserDeposit;
if (userDeposited == 0) {
if (_amount < minDeposit) revert MinDepositNotReached();
} else {
if (userDeposited + _amount < minDeposit) {
revert MinDepositNotReached();
}
}
}
//Vault max limit set
if (mode >= 4 && _vaultTvl + _amount > maxVaultDeposit) {
revert MaxVaultDepositReached();
}
//User max limit set
if (mode % 4 > 1 && userDeposited + _amount > maxUserDeposit) {
revert MaxUserDepositReached();
}
}
The problem here is that userDeposit
represents the amount of shares the user has, while _applyTvlLimits()
checks for the amount of assets deposited.
Since assets and shares are not 1:1, this check is incorrect.
Recommendation:
It is recommended to make the checking consistent so that _applyTvlLimits()
checks for only assets or shares.
Strateg:
All checks are based on shares now StrategVault.sol#L957
C4 Pro League:
Reviewed and confirmed.
[M-05] _fetchNativeTVL(false)
causes stale nativeTvl to be used.
Context:
Description:
_fetchNativeTVL(false)
will only update nativeTvl when block.timestamp > lastNativeTVLUpdate
, if mint/deposit
is executed multiple times in one block, the vaultTVL gotten by _fetchNativeTVL(false)
will be stale.
function _fetchNativeTVL(bool _force) internal returns (uint256) {
if (lastNativeTVLUpdate == block.timestamp && !_force) return nativeTVL;
uint256 tvl = _getNativeTVL();
nativeTVL = tvl;
lastNativeTVLUpdate = block.timestamp;
return tvl;
}
Consider that the current VaultTVL is 10000.
In the same block, Alice calls deposit()
to deposit 1000 and gets the VaultTVL of 10000, lastNativeTVLUpdate = block.timestamp
.
Alice calls deposit()
the second time and deposits 1000, and since _force
is false and lastNativeTVLUpdate == block.timestamp
, _fetchNativeTVL(false)
returns the cached 10000 instead of the latest 11000, which allows the user to bypass the maxVaultDepositcheck
.
Also in withdrawalRebalance()
, if stratBlocksLength == 0
, _fetchNativeTVL(true)
in _exitStrategy()
will not be called, then _fetchNativeTVL(false)
after that will return the stale nativeTvl.
_exitStrategy(withdrawPercent);
uint256 totalBufferWithdraw = withdrawPercent * _asset.allowance(_buffer, address(this)) / 10000;
_asset.safeTransferFrom(
_buffer,
address(this),
totalBufferWithdraw
);
uint256 tAssets = _fetchNativeTVL(false);
totalWithdraw = tAssets * withdrawPercent / 10000;
If the attacker frontruns withdrawalRebalance()
to deposit a large amount of assets, the withdrawPercent will decrease due to the increase in totalSupply()
, but since the stale VaultTVL returned by _fetchNativeTVL(false)
is used here, this will reduce the asset withdrawn by the user.
Recommendation:
It is recommended to update nativeTvl in deposit/mint/redeem/withdraw/withdrawalRebalance/harvest
to make it latest.
Strateg:
The recommandation has been implemented.
Now on each function modifiying the vault total supply, the _updateIndex function is called. the _updateIndex function update the nativeTVL variable StrategVault.sol#L928
C4 Pro League:
Reviewed and confirmed.
[M-06] Users can frontrun _harvestFees()
to withdraw assets to avoid losses.
Context:
Description:
_harvestFees()
will reduce totalAsset()
, and users can frontrun _harvestFees() to withdraw assets to avoid losses.
function _harvestFees(bool _applyHarvestFee) private { // 会减小 totalAsset,
_fetchNativeTVL(false);
DataTypes.VaultConfigurationMap memory config = _getConfiguration();
uint256 tSupply = totalSupply();
uint256 _lastFeeHarvestIndex = lastHarvestIndex;
uint256 tAssets = totalAssets();
uint256 currentVaultIndex = (tAssets * 10000) / tSupply;
uint256 _protocolFee = config.getProtocolFee();
uint256 _creatorFee = config.getCreatorFee();
uint256 _harvestFee = config.getHarvestFee();
if (_lastFeeHarvestIndex == currentVaultIndex || currentVaultIndex < _lastFeeHarvestIndex) {
lastHarvestIndex = currentVaultIndex;
return;
}
uint256 taxableValue = (tSupply * currentVaultIndex / 10000) - (tSupply * _lastFeeHarvestIndex / 10000);
uint256 totalFeeDistributed = (taxableValue * _creatorFee / 10000) + (_applyHarvestFee ? taxableValue * _harvestFee / 10000 : 0) + (taxableValue * _protocolFee / 10000);
lastHarvestIndex = (tAssets - totalFeeDistributed) * 10000 / tSupply;
IERC20 _asset = IERC20(asset());
//creatorFee
uint256 creatorFeeAmount = (taxableValue * _creatorFee) / 10000;
_asset.safeTransferFrom(buffer, address(this), creatorFeeAmount);
_asset.safeIncreaseAllowance(erc3525, creatorFeeAmount);
IStrategERC3525(erc3525).addRewards(creatorFeeAmount);
//harvestFee
if(_applyHarvestFee)
_asset.safeTransferFrom(buffer, msg.sender, (taxableValue * _harvestFee) / 10000);
//protocolFee
_asset.safeTransferFrom(buffer, addressProvider.feeCollector(), (taxableValue * _protocolFee) / 10000);
}
Recommendation:
It is recommended that _harvestFees()
should be called before each deposit and withdrawal. In order to save gas, we can save the fee to be collected in a state variable and subtract it from totalAsset, then the operator can collect it.
Strateg:
The recommandation has been implemented. Now on each function modifiying the vault total supply, the _updateIndex function is called. StrategVault.sol#L928
C4 Pro League:
Reviewed and confirmed.
[M-07] User can bypass timelock by minting shares to alternate receiver
Context:
Description:
Users can bypass timelock on shares by minting to a separate receiver
.
_applyMiddleware(assets, vaultTVL, config);
_resetTimelock(sender, config);
_deposit(sender, receiver, assets, shares);
_incrementValueDeposited(sender, shares);
When depositing, _resetTimelock is always called with sender address rather than receiver address. This fails to apply the timelock to receiver
, allowing them to immediately transfer or redeem the tokens.
Recommendation:
Timelock should be set on receiver
rather than sender
Strateg:
The checks are done on _update now and based on the receiver: https://github.com/strateg-protocol/strateg-protocol-core/blob/0.4.6/contracts/StrategVault.sol#L957
Additionally, following the recommandation on PR comments, a weighted calculation is done on transfer and mint to avoid withdraw DOS: StrategVault.sol#L1111
C4 Pro League:
Reviewed and confirmed. Middleware is now applied on _update, meaning that it is always applied to the receiver of the shares. Additionally while DOS attacks are still technically possible it would require 1/timelock * funds locked compounded, per withdrawal attempt blocked. This makes DOS attempts impractical and costly to the attacker.
[M-08] Holder, whitelist and TVL requirements can be bypassed by minting or sending shares to alternate address
Context:
Description:
function _applyWhitelisted() internal view {
if (
!IStrategVaultFactory(factory).addressIsWhitelistedOnVault(address(this), _msgSender())
) revert NotWhitelisted();
}
_applyWhitelist
is only checks that sender
is on the whitelist allowing them to set the receiver to a non-whitelisted address, bypassing whitelisting. The same applies to _applyHolder
and _applyTvlLimits
.
function _update(address from, address to, uint256 value) internal override {
_applyTimelock(from);
super._update(from, to, value);
emit StrategVaultUpdate(
StrategVaultUpdateType.Transfer,
abi.encode(from, to, value)
);
}
Additionally, middleware is not applied at all during update allowing easy bypass of these limits by simply sending the shares to another address.
Recommendation:
applyMiddleware should be called on _update and `applyWhitelistapplyHolder
and
applyTvlLimits` should apply to the user receiving the shares, rather than the sender.
Strateg:
The checks are done on _update now: StrategVault.sol#L957
C4 Pro League:
Reviewed and confirmed. Middleware is now applied on _update which applies whitelist, holder and tvl limits requirements to the correct user.
[M-09] _decreaseValueDeposited
is applied to incorrect user in multiple locations which can lead to deposit DOS
Context:
Description:
uint256 assets = previewRedeem(_shares);
_withdraw(sender, _receiver, __owner, assets, _shares);
_decreaseValueDeposited(sender, _shares);
return assets;
}
When redeeming or withdrawing, userDeposit
is decreased for the sender rather than the owner
providing the shares. userDeposit
is used to determine user specific deposit limits. This will inflate userDeposit
for owner
(since their withdraw is not credited back to them) and incorrectly prevents them from depositing more due to deposit limits.
_burn(msg.sender, _amount);
_asset.safeTransfer(_user, totalWithdraw);
emit Withdraw(msg.sender, msg.sender, msg.sender, totalWithdraw, _amount);
_decreaseValueDeposited(_user, _amount);
Additionally withdrawalRebalance
applies _decreaseValueDeposited
to _user
which in this case is always the operator proxy. This leads to the same issue as outlined above.
Recommendation:
As discussed, user deposit limits will be removed
Strateg:
User deposit limits have been removed.
C4 Pro League:
Reviewed and confirmed. User min and max deposits are now based on token balances, resolving this issue.
[M-10] StrategVault._applyTvlLimits()
incorrectly assumes msg.sender
is the depositor
Context:
Description:
In _applyTvlLimits()
, the total deposit amount the depositor has is fetched from the userDeposit
mapping using msg.sender
:
uint256 userDeposited = userDeposit[msg.sender];
However, this is incorrect as deposit()
/mint()
is called through the factory, msg.sender
would be the factory address instead of the user. Using deposit()
as an example, the user is determined through _msgSender()
instead of msg.sender
:
address sender = _msgSender();
address assetProvider =
msg.sender != factory ?
msg.sender : addressProvider.userInteractions();
As such, the minimum and maximum user deposit checks will be performed with the wrong userDeposited
amount, allowing these checks to be bypassed.
Recommendation:
This has been fixed by the team by removing the userDeposit
mapping amongst other changes.
Strateg:
This has been fixed by the team by removing the userDeposit mapping amongst other changes.
C4 Pro League:
Reviewed and confirmed
[M-11] StrategVault.withdrawalRebalance()
can be DOSed by _harvestFees()
when remaining assets in the buffer is small
Context:
Description:
In _harvestFees()
, fees are taken from the buffer and transferred to their respective recipients:
//creatorFee
uint256 creatorFeeAmount = (taxableValue * _creatorFee) / 10000;
_asset.safeTransferFrom(buffer, address(this), creatorFeeAmount);
_asset.safeIncreaseAllowance(erc3525, creatorFeeAmount);
IStrategERC3525(erc3525).addRewards(creatorFeeAmount);
//harvestFee
if(_applyHarvestFee)
_asset.safeTransferFrom(buffer, msg.sender, (taxableValue * _harvestFee) / 10000);
//protocolFee
_asset.safeTransferFrom(buffer, addressProvider.feeCollector(), (taxableValue * _protocolFee) / 10000);
However, if the buffer has too little assets remaining, it becomes possible for _harvestFees()
to revert when attempting to collect fees.
This becomes an issue in withdrawalRebalance()
, where _harvestFees()
is called when attempting to withdraw all shares:
if(withdrawPercent == 10000) {
_harvestFees(false);
}
If the remaining amount of assets in the buffer is smaller than the total amount of fees, _harvestFees()
will always revert, causing the function to be DOSed until rebalance()
is called by operator to refill the buffer.
Recommendation:
Instead of taking fees from the buffer whenever _harvestFees()
is called, consider accumulating the amount of fees in a state variable. The fees should only be taken from the buffer and paid out when harvestFees()
is called by the operator.
This ensures that no functions will be DOSed when the buffer has a small amount of assets remaining. Additionally, to resolve the situation the operator can simply call rebalance()
to refill the buffer before calling harvestFees()
.
Strateg:
The recommandation has been implemented. Now on each function modifiying the vault total supply, the _updateIndex function is called. StrategVault.sol#L928
This function calculate fees on the flight and store it on the state. Now the harvest function execute the harvest block execution, execute the _updateIndex and distribute the fees StrategVault.sol#L551
C4 Pro League:
Reviewed and confirmed With the new change, fees are accumulated in taxValueCumulated and only distributed when harvest() is called.
[M-12] Missing _bufferDerivation <= _bufferSize
in StrategVaultFactory.deployNewVault()
could DOS rebalancing
Context:
Description:
An invariant of all vaults is that _bufferDerivation
has to be not greater than _bufferSize
, which is enforced in _setVaultBufferParams()
through the following check:
if (
_bufferSize > 9000 ||
_bufferSize < 300 ||
_bufferDerivation < 200 ||
_bufferDerivation > 2000 ||
_bufferSize < _bufferDerivation
) revert BadBufferParams();
This ensures that if the buffer is undersized, calculating bufferSize - derivation
in StrategVault.rebalance()
does not revert:
/**
* Buffer undersized
*/
if (currentBufferSize < bufferSize - derivation) {
However, in StrategVaultFactory.deployNewVault()
, there is no check that ensures _bufferSize
is greater than or equal to the default buffer derivation of 500
:
c.setBufferSize(_bufferSize);
c.setBufferDerivation(500);
As such, if _bufferSize
is smaller than 500
on vault deployment, rebalance()
will always revert when called while the vault’s buffer is undersized, making rebalancing impossible.
Recommendation:
In StrategVaultFactory.deployNewVault()
, check that _bufferSize
is larger than or equal to 500
.
Strateg:
Here we have removed the buffer configuration on the deploy function to force it to bufferSize = 1000 and bufferDerivation = 500 to avoid duplicated code and ensure a valid buffer configuration on vault deployment
C4 Pro League:
Reviewed and confirmed _bufferSize is now 1000 by default
Low Risk Findings (10)
[L-1] removeTokenPercent()
and removeAllTokenPercent()
do not work as described
Context:
Description:
The comments for removeTokenPercent()
and removeAllTokenPercent()
indicate that _percent
is the percentage to be reduced, but actually it’s the percentage that’s to be left.
/**
* @notice Applies a percentage reduction to a specific token's amount in the oracle state.
* @param self The oracle state to modify.
* @param _token The token address to apply the reduction to.
* @param _percent The percentage (in basis points) to reduce the token's amount by.
*/
function removeTokenPercent(DataTypes.OracleState memory self, address _token, uint256 _percent) internal pure {
for (uint256 i = 0; i < self.tokens.length; i++) {
if (self.tokens[i] == _token) {
self.tokensAmount[i] = (self.tokensAmount[i] * _percent) / 10000;
}
}
}
Recommendation: It is recommended to change as follows
function removeTokenPercent(DataTypes.OracleState memory self, address _token, uint256 _percent) internal pure {
for (uint256 i = 0; i < self.tokens.length; i++) {
if (self.tokens[i] == _token) {
- self.tokensAmount[i] = (self.tokensAmount[i] * _percent) / 10000;
+ self.tokensAmount[i] -= (self.tokensAmount[i] * _percent) / 10000;
}
}
}
...
function removeAllTokenPercent(DataTypes.OracleState memory self, uint256 _percent) internal pure {
for (uint256 i = 0; i < self.tokens.length; i++) {
- self.tokensAmount[i] = (self.tokensAmount[i] * _percent) / 10000;
+ self.tokensAmount[i] = (self.tokensAmount[i] * _percent) / 10000;
}
}
Strateg:
This changes has been applied: LibOracleState.sol#L126
C4 Pro League:
Reviewed and confirmed
[L-2] The pending settings cannot be canceled.
Context:
Description:
When the owner edits the vault’s settings, the settings are pushed to the vaultParamsChangeQueue
, and the settings are applied three days later.
} else {
uint256 index = vaultParamsChangeQueue[_vault].length;
VaultParametersEditQueueItem memory changes = VaultParametersEditQueueItem({
initializedAt: block.timestamp,
settings: _settings,
settingsData: _data
});
vaultParamsChangeQueue[_vault].push(changes);
emit NewVaultParametersEditQueueItem(_vault, index);
}
The problem here is that once the settings are pushed, the owner cannot cancel them, the setting will always be applied.
Additionally, when there are multiple settings pushed to the queue, there’s nothing in executeVaultParamsEdit()
that ensures changes are executed in the order they were added.
This could create scenarios where settings end up different to what the owner expects, for example:
- Owner calls
editVaultParams()
to settimelockDuration
to 50 days. We call this change A. - Owner changes his mind, he calls
editVaultParams()
to changetimelockDuration
to 100 days instead. We call this change B. - Change A passes the timelock of
EDIT_SETTINGS_QUEUE_DURATION
, but isn’t executed. - After some time, change B passes the timelock as well.
- Attacker calls
executeVaultParamsEdit()
twice to execute change B, then change A afterwards. timelockDuration
is set to 50 days, instead of 100 days as the owner intended.
Essentially, when there is more than one queued change, the owner has to monitor and execute the changes as they pass the timelock, otherwise an attacker could execute them in a different sequence.
Recommendation:
It is recommended to implement a function to allow the owner to cancel the settings.
Allowing the owner to cancel changes would mean the owner doesn’t have to pay attention to when the queued changes become executable - in the scenario described above, the owner can just cancel change A.
Strateg:
A new queue system has been implemented for vault settings change. This implementation ensure:
- The execution of changes in the right order
- Allow to cancel a queued change StrategVaultFactory.sol#L318
C4 Pro League:
Reviewed and confirmed
[L-3] _rebalance does not consider assets in the vault
Context:
Description:
When rebalance is called, it does not first transfer the assets in the vault to the buffer, which results in the actual bufferSize being larger than asset.allowance(), which could cause rebalance to remove an incorrect amount of assets from the strategy.
function rebalance(
uint256[] memory _dynParamsIndexEnter,
bytes[] memory _dynParamsEnter,
uint256[] memory _dynParamsIndexExit,
bytes[] memory _dynParamsExit
) external onlyOperator {
_rebalance(_dynParamsIndexEnter, _dynParamsEnter, _dynParamsIndexExit, _dynParamsExit);
}
Vault may retain assets for the following reasons.
- when
strategyBlocksLength
is 0,_enterInStrategy()
does not deposit assets from the vault into the strategy. withdrawalRebalance()
withdraws assets greater thantotalWithdrawal
.
Recommendation:
It is recommended to call _sendVaultAssetsInBuffer()
before calling _rebalance()
.
Strateg:
In the new implementation, a check has been added to avoid strategyBlocksLength = 0 StrategVaultFactory.sol#L225
and the rebalance is calculated from the _getNativeTvl and the buffer allowance. the _getNativeTvl taking care of the assets owned by the vault. StrategVault.sol#L795
C4 Pro League:
Reviewed and confirmed
[L-4] StrategVault#withdraw and redeem applies timelock to sender which can lead to incorrectly reverting transactions
Context:
Description
function withdraw(uint256 _assets, address _receiver, address __owner)
public
virtual
override
nonReentrant
returns (uint256)
{
address sender = _msgSender();
_executeHooks(DataTypes.BlockExecutionType.EXIT);
_applyTimelock(sender);
if (_assets > maxWithdraw(__owner)) revert WithdrawMoreThanMax();
uint256 shares = previewWithdraw(_assets);
_decreaseValueDeposited(sender, shares);
_withdraw(sender, _receiver, __owner, _assets, shares);
return shares;
}
When withdrawing and redeeming, _applyTimelock
is called for sender
rather than owner
. Since owner is the one providing the shares, it would be incorrect to revert if sender
was timelocked.
Recommendation
As discussed, this should be removed and all validation should happen in _update
Strateg:
As discussed, all validation has been moved to _update function StrategVault.sol#L957
C4 Pro League:
Reviewed and confirmed. Timelock has been moved to _update and is now correctly applied to the owner of the shares.
[L-5] StrategVault#_rebalance
fails to clear highly sensitive dynamic parameters when exiting to buffer
Context:
Description
Dynamic parameters are used to communicate important parameters when interacting with the block contracts.
if (currentBufferSize < bufferSize - derivation) {
_storeDynamicParams(_dynParamsIndexExit, _dynParamsExit);
_exitStrategy(bufferSize - currentBufferSize);
_sendVaultAssetsInBuffer();
}
isLive = true;
_purgeDynamicParams(_dynParamsIndexEnter);
}
As seen above, dynamic parameters are stored when exiting the due to undersized buffer but they are never purged from storage afterwards. This leaves highly sensitive parameters in storage that can be accessed outside of the intended scope of the transaction.
Recommendation
Purge dynamic exit parameters as well.
Strateg:
This function has been changed, now there is only 2 parameters as inputs of the function.
C4 Pro League:
Reviewed and confirmed. Contract now utilizes a single set of inputs parameters and correctly clears them after usage.
[L-6] minUserDeposit
limit in StrategVault
can be bypassed
Context:
Description:
Whenever users call deposit()
or mint()
to deposit assets into a StrategVault
, if the minUserDeposit
configuration parameter is set, a minimum deposit limit is enforced on the total amount of deposits they have:
//Minimum limit set
if (mode % 2 == 1) {
uint256 minDeposit = minUserDeposit;
if (userDeposited == 0) {
if (_amount < minDeposit) revert MinDepositNotReached();
} else {
if (userDeposited + _amount < minDeposit) {
revert MinDepositNotReached();
}
}
}
However, since _applyTvlLimits()
is not called in withdraw()
, redeem()
or withdrawalRebalance()
, this limit is not enforced when a withdrawal is performed, Therefore, it’s possible for a depositor to bypass the minimum deposit limit by depositing followed by withdrawing immediately afterwards.
Recommendation:
In the _update()
hook, consider checking that the user’s new share balance is not below minUserDeposit
when shares are burnt.
Strateg:
A check on the sender balance has been added on the _applyTvlLimits function: StrategVault.sol#L1057
If the remaining balance of a user is greater than 0 after a withdraw or a transfer, it will verify if the balance of the sender stays greater than the minimum limit.
C4 Pro League
Reviewed and confirmed.
[L-7] Exit hook is not called when performing a withdrawal in StrategVault.withdrawalRebalance()
Context:
Description:
When a withdrawal is performed in withdraw()
and redeem()
, exit hooks are executed for all strategies before the withdrawal by calling _executeHooks(DataTypes.BlockExecutionType.EXIT)
:
_executeHooks(DataTypes.BlockExecutionType.EXIT);
// ...
_withdraw(sender, _receiver, __owner, _assets, shares);
However, even though withdrawalRebalance()
also performs a withdrawal, exit hooks are not executed in the function. This is inconsistent with the other withdrawal functions and might break composability with strategies.
Recommendation:
Call _executeHooks(DataTypes.BlockExecutionType.EXIT)
in withdrawalRebalance()
before performing asset calculations and performing the withdrawal.
Strateg:
The hook execution has been added: StrategVault.sol#L622
C4 Pro League:
Fix reviewed and confirmed
[L-8] StrategVault.redeem()
can be forced to revert if the shares redeemed is close to bufferShares
Context:
Description:
The maximum amount of shares a user can withdraw through redeem()
is determined in maxRedeem()
as such:
uint256 assetsInBuffer = IERC20(asset()).allowance(buffer, address(this));
uint256 bufferShares = convertToShares(assetsInBuffer);
uint256 ownerBal = balanceOf(__owner);
return bufferShares < balanceOf(__owner) ? bufferShares : ownerBal;
As seen from above, the maximum amount of shares redeemable is limited by bufferShares
, which is the share equivalent of the total assets held in the buffer.
However, if a user tries to redeem an amount of shares close to bufferShares
, it’s possible to make the call to redeem()
revert by donating assets and inflating the share price.
For example:
-
Assume the vault has the following state:
totalSupply() = 2e18
,totalAssets() = 2e18
- The buffer holds
1e18
assets.
- User calls
redeem()
with_shares = 1e18
to withdraw1e18
assets. - Attacker front-runs the user and transfers 1 wei of assets to the vault contract.
-
When the call to
redeem()
is executed,bufferShares
is calculated as:assetsInBuffer * totalShares / totalAssets = 1e18 * 2e18 / (2e18 + 1) = 1e18 - 1
- Since
bufferShares
is smaller than1e18
,redeem()
reverts.
Recommendation:
Given that the user can call redeem()
to attempt his withdrawal again, this issue doesn’t need to be fixed. Nevertheless, consider documenting this limitation as it could cause issues with external integrations.
Strateg:
Acknowledged
[L-9] Missing _vault
address validation in StrategVaultFactory
functions
Context:
Description:
In StrategVaultFactory
, all functions going through StrategUserInteractions
don’t actually validate that _vault
is a vault address deployed by this contract. This allows the user to specify _vault
as any arbitrary address as long as the call passes.
There doesn’t seem to be a way to exploit this in the current iteration of the code, but nevertheless, it is best to validate that the _vault
address was deployed by the factory.
Recommendation:
Consider adding the following check to setVaultStrat()
, editVaultParams()
, executeVaultParamsEdit()
and vaultDeposit()
:
if (vaultConfiguration[_vault].erc3525 == address(0)) revert InvalidVault();
vaultConfiguration.erc3525
is only set for vaults that have been deployed through deployNewVault()
, it is used to check if a vault is valid.
Strateg:
This checks are now done by the following modifier: StrategVaultFactory.sol#L94C14-L94C26
This modifier has been added on all functions that have a vault address in their inputs
C4 Pro League:
Reviewed and confirmed
[L-10] Missing array length checks in StrategVaultFactory.editVaultParams()
Context:
Description:
The StrategVaultFactory.editVaultParams()
function does not ensure that _settings.length == _data.length
, so it is possible for both arrays to have different lengths.
The impact isn’t really severe as:
- If
_settings.length > data.length
,_editVaultParams()
reverts due to an out-of-bounds access on_data
. - If
_settings.length < data.length
, the remaining elements in_data
are ignored.
In the worst-case scenario, the vault owner accidentally adds _data
with less elements and his settings change can’t be executed after going through the 3-day timelock as executeVaultParamsEdit()
reverts.
Recommendation:
Consider ensuring that the length of both arrays are the same:
if (_settings.length != _data.length) revert ArrayLengthsMismatch();
Strateg:
The checks have been added: StrategVaultFactory.sol#L263
C4 Pro League:
Reviewed and confirmed
Informational Findings (3)
[I-1] Calls to maxMint
and maxDeposit
as validation during deposits and mints are unnecessary
Context:
Description:
StrategERC4626Upgradeable.sol#L167-L169
function maxDeposit(address) public view virtual returns (uint256) {
return type(uint256).max;
}
StrategERC4626Upgradeable.sol#L174-L176
function maxMint(address) public view virtual returns (uint256) {
return type(uint256).max;
}
During deposits and mints, maxDeposit
and maxMint
are called to validate that the deposit or mint is valid. However these functions always return uint256.max
which is a meaningless check.
Recommendation:
Remove these checks for mints and deposits.
Strateg:
This checks has been removed: StrategVault.sol#L240 StrategVault.sol#L276
C4 Pro League:
Reviewed and confirmed
[I-2] Missing length checks on _dynParamsIndex
and _dynParams
in StrategVault._storeDynamicParams()
Context:
Description:
In the StrategVault
contract, when the operator calls functions that require entering or exiting strategies, they specify _dynParamsIndex
and _dynParams
, which are the dynamic parameters for each individual strategy.
However, these functions do not ensure that _dynParamsIndex
and _dynParams
have the same lengths. In the event that the operator misses out on one parameter, it might cause unexpected behavior when entering/exiting strategies.
Recommendation:
In _storeDynamicParams()
, consider ensuring that the lengths of _dynParamsIndex
and _dynParams
are equal:
uint256 arrLength = _dynParamsIndex.length;
+ if (arrLength != _dynParams.length) revert UnequalLengths();
for (uint256 i = 0; i < arrLength; i++) {
LibBlock.setupDynamicBlockData(_dynParamsIndex[i], _dynParams[i]);
}
Strateg:
The length checks has been added: StrategVault.sol#L843
C4 Pro League:
Reviewed and confirmed
[I-3] Specifying gasleft()
as the gas limit in StrategVaultFactory.vaultDeposit()
is redundant
Context:
Description:
In StrategVaultFactory.vaultDeposit()
, deposit()
is called while specifying the gas limit as gasleft()
:
uint256 gas = gasleft();
(bool success, bytes memory returndata) = _vault.call{gas: gas}(
However, specifying gasleft()
as the gas limit for the deposit()
here is redundant, the low-level call will never send more gas than the remaining amount.
Recommendation:
Consider performing the low-level call normally:
- uint256 gas = gasleft();
- (bool success, bytes memory returndata) = _vault.call{gas: gas}(
+ (bool success, bytes memory returndata) = _vault.call(
Strateg:
The redundant check has been removed: StrategVaultFactory.sol#L339
C4 Pro League:
Reviewed and confirmed
Disclosures
C4 is an open organization governed by participants in the community.
C4 audits incentivize the discovery of exploits, vulnerabilities, and bugs in smart contracts. Security researchers are rewarded at an increasing rate for finding higher-risk issues. Audit submissions are judged by a knowledgeable security researcher and solidity developer and disclosed to sponsoring developers. C4 does not conduct formal verification regarding the provided code but instead provides final verification.
C4 does not provide any guarantee or warranty regarding the security of this project. All smart contract software should be used at the sole risk and responsibility of users. 2024-06-strateg-proleague