Platform: Code4rena
Start Date: 05/05/2022
Pot Size: $125,000 DAI
Total HM: 17
Participants: 62
Period: 14 days
Judge: leastwood
Total Solo HM: 15
Id: 120
League: ETH
Rank: 2/62
Findings: 4
Award: $14,655.61
🌟 Selected for report: 2
🚀 Solo Findings: 2
🌟 Selected for report: tintin
Also found by: 0xsomeone, AuditsAreUS, hyh
https://github.com/code-423n4/2022-05-alchemix/blob/de65c34c7b6e4e94662bf508e214dcbf327984f4/contracts-full/AlchemicTokenV2Base.sol#L189-L191 https://github.com/code-423n4/2022-05-alchemix/blob/de65c34c7b6e4e94662bf508e214dcbf327984f4/contracts-full/AlchemicTokenV2Base.sol#L116-L121
It is possible for any whitelisted used to reduce totalMinted
for themselves. This value is used in mint()
to prevent a malicious minter from minting an infinite number of tokens.
By allowing a minter to reduce their own totalMinted
they are able to perform infinite minting.
The attack by a malicious minter is to call mint()
then lowerHasMinted()
in a loop to mint as many tokens as desired.
The minter first calls mint()
which will update their totalMinted
to amount
and mint amount
to an arbitrary receiver address. They minter may set amount = mintCeiling[msg.sender]
to maximise the amount they may mint each loop/
uint256 total = amount + totalMinted[msg.sender]; if (total > mintCeiling[msg.sender]) { revert IllegalState(); } totalMinted[msg.sender] = total;
The malicious minter may then call lowerHasMinted
to reduce totalMinted[msg.sender] = 0
for themselves.
function lowerHasMinted(uint256 amount) external onlyWhitelisted { totalMinted[msg.sender] = totalMinted[msg.sender] - amount; }
The malicious minter may now call mint()
again for mintCeiling[msg.sender]
to mint more tokens for themselves. They then repeat calls to lowerHasMinted()
and mint()
until they have minted the desired number of tokens.
There are two mitigations for this, the first is to remove the function lowerHasMinted()
. The other option is to make lowerHasMinted()
an adminOnly
function preventing minters from adjusting their owner amount.
#0 - 0xfoobar
2022-05-30T06:28:36Z
Duplicate of #166
🌟 Selected for report: AuditsAreUS
The Lido adapter incorrectly calculates the price of WETH in terms of WstETH.
The function returns the price of WstETH in terms of stETH. The underlying token which we desire is WETH. Since stETH does not have the same value as WETH the output price incorrect.
The impact is severe as all the balance calculations require the price of the yield token converted to underlying. The incorrect price may over or understate the harvestable amount which is a core calculation in the protocol.
The function IWstETH(token).getStETHByWstETH()
only converts WstETH to stETH. Thus, price()
returns the value of WstETH in terms of stETH.
function price() external view returns (uint256) { return IWstETH(token).getStETHByWstETH(10**SafeERC20.expectDecimals(token)); }
Add extra steps to price()
to approximate the rate for converting stETH to ETH. This can be done using the same curve pool that is used to convert stETH to ETH in unwrap()
.
#0 - 0xfoobar
2022-05-22T21:42:35Z
Sponsor disputed
The design mechanism relies upon stETH reaching eventual 1:1 redeemability for ETH after the merge and shanghai enables withdrawals. This is core to out like-kind collateral/asset model. We will update the stETH token adapter at that time to do direct redemptions instead of Curve swaps. So in the meantime, the protocol accrues discounted assets.
#1 - 0xleastwood
2022-06-08T14:41:08Z
While the sponsor's comments suggest that this will be a non-issue after the merge/shanghai enables withdrawals, I believe there is legitimacy in the fact that the protocol will accrue discounted assets. It does not lead to the loss of assets, but value can be leaked if WETH
is priced incorrectly. As such, I'm downgrading this to medium severity.
🌟 Selected for report: AuditsAreUS
6389.4401 DAI - $6,389.44
https://github.com/code-423n4/2022-05-alchemix/blob/de65c34c7b6e4e94662bf508e214dcbf327984f4/contracts-full/AlchemistV2.sol#L1290-L1300 https://github.com/code-423n4/2022-05-alchemix/blob/de65c34c7b6e4e94662bf508e214dcbf327984f4/contracts-full/AlchemistV2.sol#L1268 https://github.com/code-423n4/2022-05-alchemix/blob/de65c34c7b6e4e94662bf508e214dcbf327984f4/contracts-full/AlchemistV2.sol#L1532 https://github.com/code-423n4/2022-05-alchemix/blob/de65c34c7b6e4e94662bf508e214dcbf327984f4/contracts-full/AlchemistV2.sol#L899 https://github.com/code-423n4/2022-05-alchemix/blob/de65c34c7b6e4e94662bf508e214dcbf327984f4/contracts-full/AlchemistV2.sol#L1625
It is possible for the contract to become stuck and unable to perform any actions if the totalShares
of a yield token fall to zero while there is some pendingCredit
still to be paid.
It will then be impossible to call deposit or withdraw functions, mints, burns, repay, liquidate, donate or harvest due to division by zero reverts in:
_distributeCredit()
_distributeUnlockedCredit()
_calculateUnrealizedDebt()
_convertSharesToYieldTokens()
donate()
Furthermore, any pendingCredit
amount of tokens are still in the contract will become permanently stuck.
This case may arise under the follow steps
a) deposit()
is called by a user then time passes to earn some yield
b) harvest()
is called by the keeper which calls _distributeCredit()
and increases pendingCredit
c) withdraw()
is called by the user to withdraw all funds
Since there is pendingCredit
the following will have a non-zero balance for unlockedCredit
however yieldTokenParams.totalShares
is zero and thus we get a division by zero which reverts the entire transaction.
function _distributeUnlockedCredit(address yieldToken) internal { YieldTokenParams storage yieldTokenParams = _yieldTokens[yieldToken]; uint256 unlockedCredit = _calculateUnlockedCredit(yieldToken); if (unlockedCredit == 0) { return; } yieldTokenParams.accruedWeight += unlockedCredit * FIXED_POINT_SCALAR / yieldTokenParams.totalShares; yieldTokenParams.distributedCredit += unlockedCredit; }
Each of the other listed functions will reach the same issue by attempting to divide some numerator by the totalShares
which is zero.
Consider preventing totalShares
from over becoming zero once it is set. That is enforce a user to leave at least 1 unit if they are the last user to withdraw.
Another option is to transfer the first 1000 shares to a "burn" account (e.g. 0x000...01), when the first user deposits.
Alternatively, when the last user withdraws, transfer all pending credit to this user and set the required variables to zero to replicate the state before any users have deposited.
#0 - 0xfoobar
2022-05-22T21:34:56Z
Sponsor confirmed
Disagree with severity because given the depth of distinct users using Alchemix, it is unlikely this scenario would occur.
#1 - 0xleastwood
2022-06-03T17:21:17Z
This is an interesting issue. At the moment, it sits somewhere between medium and high risk, so I will need to think about this more before coming to a decision.
#2 - 0xleastwood
2022-06-08T15:56:44Z
After further thought, I think this does not fit the criteria of high severity for the following reasons:
pendingCredit
. However, because rewards are harvested by a keeper, I believe this to be unlikely as migrations will most certainly be coordinated by the protocol and its keepers. As such, users will be aware that they would miss out on rewards if the keeper does not harvest rewards prior to the migration.For these reasons, I believe medium severity to be justified.
🌟 Selected for report: IllIllI
Also found by: 0x1f8b, 0x4non, 0xDjango, 0xNazgul, 0xkatana, 0xsomeone, AuditsAreUS, BouSalman, BowTiedWardens, Cityscape, Funen, GimelSec, Hawkeye, JC, MaratCerby, MiloTruck, Picodes, Ruhum, TerrierLover, WatchPug, Waze, bobirichman, catchup, cccz, cryptphi, csanuragjain, delfin454000, ellahi, fatherOfBlocks, hake, horsefacts, hyh, jayjonah8, joestakey, kebabsec, kenta, mics, oyc_109, robee, samruna, shenwilly, sikorico, simon135, throttle, tintin
712.2508 DAI - $712.25
AlchemistV2
https://github.com/code-423n4/2022-05-alchemix/blob/de65c34c7b6e4e94662bf508e214dcbf327984f4/contracts-full/adapters/fuse/FuseTokenAdapterV1.sol#L66-L85 https://github.com/code-423n4/2022-05-alchemix/blob/de65c34c7b6e4e94662bf508e214dcbf327984f4/contracts-full/AlchemistV2.sol#L1319
Many ERC20 tokens are Fee on Transfer (FoT) in which a fee will be deducted from the amount transferred. The recipient will receive amount - fee
tokens. Similarly rebasing tokens may change value up or down over time or during transfers.
If fee on transfer tokens are used in AlchemistV2 or the adapters the result is the contract may receive less tokens than sent in safeTranferFrom()
.
The impact of these tokens on the wrap()
functions is that they will either
a) spend the fee from the current contract balance
b) revert if there is insufficient balance in the contract
AlchemistV2._wrap()
will first transfer the tokens from the msg.sender
to the current contract. It will then attempt to wrap this exact same amount of tokens. However if a fee has been applied during safeTransferFrom()
then less than amount
of tokens will be received by the contract and adapter.wrap()
will either revert due to having insufficient balance or will spend the Alchemists tokens.
TokenUtils.safeTransferFrom(underlyingToken, msg.sender, address(this), amount); uint256 wrappedShares = adapter.wrap(amount, address(this)); if (wrappedShares < minimumAmountOut) { revert SlippageExceeded(wrappedShares, minimumAmountOut); }
FuseTokenAdapterV1.wrap()
will transfer amount
to this contract and then mint()
the same amount
in fuse. If they are FoT tokens the current contract will receive less than amount
of tokens and therefore either mint()
will fail due to insufficient balance or it will spend tokens from the contract which were not owned by the called.
function wrap( uint256 amount, address recipient ) external onlyAlchemist returns (uint256) { SafeERC20.safeTransferFrom(underlyingToken, msg.sender, address(this), amount); SafeERC20.safeApprove(underlyingToken, token, amount); uint256 startingBalance = IERC20(token).balanceOf(address(this)); uint256 error; if ((error = ICERC20(token).mint(amount)) != NO_ERROR) { revert FuseError(error); } uint256 endingBalance = IERC20(token).balanceOf(address(this)); uint256 mintedAmount = endingBalance - startingBalance; SafeERC20.safeTransfer(token, recipient, mintedAmount); return mintedAmount;
The same principals also apply to YearnTokenAdpater.sol
and VsperAdapterV1.sol
.
Ensure there is sufficient documentation such that underlying tokens which are FoT or rebasing are not added to the protocol as underlying tokens or yield tokens.
#0 - 0xfoobar
2022-05-30T06:11:01Z
Sponsor acknowledged
Alchemix does not deal with fee-on-transfer or rebasing tokens
#1 - 0xleastwood
2022-06-03T17:09:39Z
As Alchemix does not deal with fee-on-transfer tokens, I'm inclined to mark this as QA because it assumes the protocol governance enlists a token type that is not compatible with the protocol's design.