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: 1/33
Findings: 10
Award: $14,190.31
🌟 Selected for report: 6
🚀 Solo Findings: 5
🌟 Selected for report: WatchPug
5904.972 USDC - $5,904.97
WatchPug
if (_force && sponsorAmount > totalUnderlying()) { sponsorToTransfer = totalUnderlying(); } else if (!_force) { require( sponsorToTransfer <= totalUnderlying(), "Vault: not enough funds to unsponsor" ); } totalSponsored -= sponsorAmount; underlying.safeTransfer(_to, sponsorToTransfer);
When sponsorAmount > totalUnderlying()
, the contract will transfer totalUnderlying()
to sponsorToTransfer
, even if there are other depositors and totalShares
> 0.
After that, and before others despoiting into the Vault, the Attacker can send 1 wei
underlying token, then cal deposit()
with 0.1 * 1e18 , since newShares = (_amount * _totalShares) / _totalUnderlyingMinusSponsored
and _totalUnderlyingMinusSponsored
is 1
, with a tiny amount of underlying token, newShares
will become extremly large.
As we stated in issue [WP-H10], when the value of totalShares
is manipulated precisely, the attacker can plant a bomb, and the contract will not work when the deposit/withdraw amount reaches a certain value, freezing the user's funds.
However, this issue is not caused by lack of reentrancy protection, therefore it cant be solved by the same solution in [WP-H10].
Consider adding a minimum balance reserve (eg. 1e18 Wei) that cannot be withdrawn by anyone in any case. It can be transferred in alongside with the deployment by the deployer.
This should make it safe or at least make it extremely hard or expensive for the attacker to initiate such an attack.
#0 - naps62
2022-01-13T13:01:11Z
@gabrielpoca @ryuheimat is this new?
#1 - r2moon
2022-01-13T13:02:36Z
it's new
#2 - gabrielpoca
2022-01-13T15:56:50Z
yap, it's interesting. The sponsor really is an issue
#3 - naps62
2022-02-21T17:44:23Z
forceUnsponsor
has since been removed
WatchPug
function _swapUnderlyingToUst() internal { uint256 underlyingBalance = _getUnderlyingBalance(); if (underlyingBalance > 0) { // slither-disable-next-line unused-return curvePool.exchange_underlying( underlyingI, ustI, underlyingBalance, 0 ); } }
The current implementation of doHardWork()
and finishRedeemStable()
-> _swapUnderlyingToUst()
and _swapUstToUnderlying()
provides no parameter for slippage control, making it vulnerable to front-run attacks.
Consider adding a amountOutMin
parameter.
#0 - naps62
2022-01-13T13:19:49Z
duplicate of #7
90.0579 USDC - $90.06
WatchPug
uint256 tokenId = depositors.mint( _msgSender(), amount, claimerId, _lockedUntil );
function _safeMint( address to, uint256 tokenId, bytes memory _data ) internal virtual { _mint(to, tokenId); require( _checkOnERC721Received(address(0), to, tokenId, _data), "ERC721: transfer to non ERC721Receiver implementer" ); }
An early malicious user can send 1 wei
of the vault token to the contract (as its wallet balance) then call deposit()
as the first depositor of the Vault.
Beacuse deposit()
-> depositors.mint()
will mint a NFT to _msgSender()
which can be an contract, this opens the oppotunity of reentrancy attck: the attacker can call deposit()
again in onERC721Received()
.
As a result, the contract can malfunction or even freeze users' funds if the attack is planned sophisticatedly.
In the PoC bellow, we demoed an attack that essentially planted a bomb to the contract, which will be triggered once 1M are deposited and freeze users' funds for overflow due to the extremely large _totalShares
.
1 wei
of underlying token to the contract.deposit()
with 340282366920938496 wei
:In onERC721Received()
, Attacker call deposit()
with 340282366920938496 wei
again:
💣 The bomb is now planted.
deposit()
with 100,000 * 1e18
of underlying token:deposit()
with 1,000,000 * 1e18
of underlying token:💥 The bomb is now triggered!
withdraw()
:The transaction will revert at _computeShares()
due to overflow on _amount * _totalShares
.
Consider using ReentrancyGuard
from OpenZeppelin, add nonReentrant()
modifiers to deposit()
and other important entry functions.
#0 - naps62
2022-01-13T12:58:45Z
duplicate of #3
🌟 Selected for report: WatchPug
1771.4916 USDC - $1,771.49
WatchPug
Even though it's unlikely in practice, but in theory, the underlying contract (EthAnchor
) may suffer investment losses and causing decreasing of the PPS of AUST token. (There are codes that considered this situation in the codebase. eg. handling of depositShares > claimerShares
).
However, when this happens, the late users will suffer more losses than expected than the users that withdraw earlier. The last few users may lose all their funds while the first users can get back 100% of their deposits.
// ### for deposits: d1, d2, d3, the beneficiary are: c1, c2, c2 depositAmount claimerShares d1: + 100e18 c1: + 100e36 d2: + 100e18 c2: + 100e36 d3: + 100e18 c2: + 100e36 depositAmount of d1, d2, d3 = 100e18 c1 claimerShares: 100e36 c2 claimerShares: 200e36 total shares: 300e36 // ### when the PPS of AUST drop by 50% totalUnderlyingMinusSponsored: 300e18 -> 150e18 // ### d2 withdraw c2 claimerShares: 200e36 d2 depositAmount: 100e18 d2 depositShares: 300e36 * 100e18 / 150e18 = 200e36 Shares to reduce: 200e36 c2 claimerShares: 200e36 -> 0 c2 totalPrincipal: 200e18 -> 100e18 totalShares: 300e36 -> 100e36 underlying.safeTransfer(d2, 100e18) totalUnderlyingMinusSponsored: 150e18 -> 50e18
When the strategy is losing money, share / underlying
increases, therefore the computed depositShares
: depositAmount * share / underlying
will increase unexpectedly.
While totalShares
remain unchanged, but the computed depositShares
is increasing, causing distortion of depositShares / totalShares
, eg, ∑ depositShares > totalShares
.
In order to properly handle the investment loss of the strategy, consider adding a new storage variable called totalLoss
to maintain a stable value of share / adjustedUnderlying
.
adjustedUnderlying = underlying + totalLoss
#0 - dmvt
2022-01-27T11:07:33Z
This is a classic medium risk when using the definition provided by Code4rena:
2 — Med: Assets not at direct risk, but the function of the protocol or its availability could be impacted, or leak value with a hypothetical attack path with stated assumptions, but external requirements.
#1 - naps62
2022-02-23T13:41:43Z
Fixed in the PR linked above
🌟 Selected for report: WatchPug
1771.4916 USDC - $1,771.49
WatchPug
NonUSTStrategy
will swap the deposited non-UST assets into UST before depositing to EthAnchor. However, the swap fee is not attributed to the depositor correctly like many other yield farming vaults involving swaps (ZapIn
).
An attacker can exploit it for the swap fees paid by other users by taking a majority share of the liquidity pool.
The swap fee of depositing is not paid by the depositor but evenly distributed among all users.
Given:
FRAX
;The attacker can do the following:
depositAmount
of 1M;If the vault happens to have enough balance (from a recent depositor), the attacker can now receive 1M of FRAX.
A more associated attacker may combine this with issue [WP-M4] and initiate a sandwich attack in step 3 to get even higher profits.
As a result, all other users will suffer fund loss as the swap fee is essentially covered by other users.
Consider changing the way new shares are issued:
deposit()
;In essence, the depositor should be paying for the swap fee and slippage.
#0 - 0xBRM
2022-01-13T15:05:51Z
This is only an issue if we support low liquidity Curve pools We are also adding slippage control as per some other issue which would cause massive transfers using low liquidity pools to revert, fully mitigating this. Likelihood of this happening would also be quite low given that profitability would go down tremendously as curve LPs would move to that pool in order to capture higher base fees, dissuading the attacker from continuing.
That being said, I do agree that the curve swap fee (0.04%) should be paid by each individual depositor.
#1 - dmvt
2022-01-27T11:13:00Z
This requires a number of external factors to line up just right. It is a medium risk according to the definition provided by Code4rena.
2 — Med: Assets not at direct risk, but the function of the protocol or its availability could be impacted, or leak value with a hypothetical attack path with stated assumptions, but external requirements.
#2 - naps62
2022-04-06T08:28:58Z
🌟 Selected for report: WatchPug
1771.4916 USDC - $1,771.49
WatchPug
The current implementation requires trusted key holders (isTrusted[msg.sender]
) to send transactions (initRedeemStable()
) to initialize withdrawals from EthAnchor
before the users can withdraw funds from the contract.
This introduces a high centralization risk, which can cause funds to be frozen in the contract if the key holders lose access to their keys.
Given:
investPerc
= 80%EthAnchor
)If the key holders lose access to their keys ("hit by a bus"). The 800k will be frozen in EthAnchor
as no one can initRedeemStable()
.
See the recommendation on issue [WP-M1].
#0 - 0xBRM
2022-01-13T15:02:11Z
Agree that there should be a way for users to call the uninvest functions themselves, subject to certain rules. Again, not sure I agree with the severity given the likelihood of the event transpiring.
Consensus is for UST vaults, allow depositors to call uninvest. For nonUST vaults that pay per curve swap, add trusted multisig instead of just the backend's EOA.
#1 - dmvt
2022-01-27T11:22:46Z
This issue requires external factors to align in a very negative way, but it would result in a potentially significant loss of funds. Because there is no direct attack path, it doesn't qualify as a high risk issue, but a medium risk per Code4rena definitions.
2 — Med: Assets not at direct risk, but the function of the protocol or its availability could be impacted, or leak value with a hypothetical attack path with stated assumptions, but external requirements.
WatchPug
function _withdraw( address _to, uint256[] memory _ids, bool _force ) internal { uint256 localTotalShares = totalShares(); uint256 localTotalPrincipal = totalUnderlyingMinusSponsored(); uint256 amount; for (uint8 i = 0; i < _ids.length; i++) { amount += _withdrawDeposit( _ids[i], localTotalShares, localTotalPrincipal, _to, _force ); } underlying.safeTransfer(_to, amount); }
In the current implementation, when the user tries to withdraw()
, the contract will directly transfer the underlying token to a specified address.
However, since the contract will invest a certain portion (usually a rather large portion) of the funds into the strategy (and then to a 3rd party: EthAnchor), the balance of underlying token in the Vault contract can often be quite low.
Given:
investPerc
= 80%withdraw()
and their txs will fail.Many withdraw()
transactions will fail due to insufficient underlying token amounts, especially for withdrawals with larger amounts.
Consider adding a new function requestWithdraw(uint amount)
:
Check if the current underlying token balance is enough for the withdraw, if true, fulfill the withdrawal immediately;
Otherwise:
requstedWithdrawlAmount[msg.sender] + amount <= depositAmount
;requstedWithdrawlAmount[msg.sender] += amount
;initRedeemStable(amount)
, return the redeemId
.finishWithdraw(uint id)
with the redeemId
to finalize the withdrawal.This introduces a way for the users to get money back from the underlying contract (EthAnchor
) without relying on a centralized role.
#0 - 0xBRM
2022-01-13T15:00:01Z
This is by design. Partial withdrawals should help with this. Do keep in mind that more rebalances decrease the overall profitability of the vault due to curve's fees. I think partial withdrawals are a good compromise but willing to discuss further.
Current thinking is that
For UST vaults (no curve): users can withdraw immediately as per your solution For non-UST vaults (with curve): users cannot withdraw immediately if the amount of funds is greater than the reserve. Allowing them to would introduce another griefing attack vector (more rebalances, decreased profitability due to curve fees). I suggest making the uninvest function of these vaults callable not only by the backend's EOA, but also by our multisig.
#1 - naps62
2022-01-13T19:43:47Z
Also, although this is a much more comprehensive description, it's a duplicate of #126
#2 - dmvt
2022-01-27T22:13:15Z
Duplicate of #76
🌟 Selected for report: WatchPug
1771.4916 USDC - $1,771.49
WatchPug
function totalUnderlyingMinusSponsored() public view returns (uint256) { // TODO no invested amount yet return totalUnderlying() - totalSponsored; }
As a function that many other functions depended on, totalUnderlyingMinusSponsored()
can revert on underflow when sponsorAmount > totalUnderlying()
which is possible and has been considered elsewhere in this contract:
if (_force && sponsorAmount > totalUnderlying()) { sponsorToTransfer = totalUnderlying(); }
sponsor()
and send 10,000 USDTNonUSTStrategy.sol#doHardWork()
swapped USDT for USTdeposit()
, the tx will revet due to underflow in totalUnderlyingMinusSponsored()
.Change to:
function totalUnderlyingMinusSponsored() public view returns (uint256) { uint256 _totalUnderlying = totalUnderlying(); if (totalSponsored > _totalUnderlying) { return 0; } return _totalUnderlying - totalSponsored; }
🌟 Selected for report: Ruhum
Also found by: Tomio, WatchPug, harleythedog
322.8543 USDC - $322.85
WatchPug
There are ERC20 tokens that charge fee for every transfer()
or transferFrom()
.
Vault.sol#_transferAndCheckUnderlying()
requires that the received amount is the same as the transfer amount, otherwise, it will revert at L587.
function _transferAndCheckUnderlying(address _from, uint256 _amount) internal { uint256 balanceBefore = totalUnderlying(); underlying.safeTransferFrom(_from, address(this), _amount); uint256 balanceAfter = totalUnderlying(); require( balanceAfter == balanceBefore + _amount, "Vault: amount received does not match params" ); }
#0 - naps62
2022-01-13T13:13:08Z
As stated in a prior issue (can't find it right now) we don't intend to support tokens where this would be a problem. Our intent so far is to support USDC, UST and DAI
#1 - dmvt
2022-01-29T12:42:24Z
duplicate of #55
🌟 Selected for report: WatchPug
590.4972 USDC - $590.50
WatchPug
All the non-UST assets are converted to UST for investments, but the deposit amounts are recorded in Vault assets token (eg, BTC).
The current design/implementation of non-UST vaults makes it possible for attackers to profit no matter the price goes up or down, at the expense of other users.
Given:
$45,000
;10 BTC
to the Vault;100 BTC
worth of assets (4.5M of UST) in the Vault.If the price of BTC increases to $60,000
:
The attacker can
withdraw()
and get back10 BTC
. (The Vault will use0.6M UST
to buy BTC and return to the attacker.)
If the price of BTC decreases to $30,000
:
The attacker can
claimYield()
and claim5 BTC
. (The Vault now believes that the attacker is entitled to5 BTC
of yield.)
This can be amplified with:
Consider making NonUSTStrategy not swapping to and investing in UST, but investing in assets that are pegged to or based on the Vault asset, take BTC for example, the Strategy should be investing in ibBTC or other BTC based investments.
#0 - naps62
2022-01-13T13:21:15Z
Our goal is to support only stable coins, so I don't think this is an issue. @ryuheimat ?
#1 - r2moon
2022-01-13T13:22:42Z
yeah, we support stable coins only,
#2 - dmvt
2022-01-27T11:19:33Z
Given that the sponsor will only be supporting stablecoins, this issue should be mitigated. However, this is not made clear in the comments present on the strategy in question. I recommend adding a warning statement to the contract comments and documentation so that anyone who may take over this project later or fork it understands the original intention. Changing this to a low risk per Code4rena definitions.
1 — Low: Low: Assets are not at risk. State handling, function incorrect as to spec, issues with comments.