Platform: Code4rena
Start Date: 21/06/2022
Pot Size: $50,000 USDC
Total HM: 31
Participants: 99
Period: 5 days
Judges: moose-code, JasoonS, denhampreen
Total Solo HM: 17
Id: 139
League: ETH
Rank: 6/99
Findings: 4
Award: $2,635.73
π Selected for report: 3
π Solo Findings: 2
π Selected for report: unforgiven
1211.7009 USDC - $1,211.70
https://github.com/code-423n4/2022-06-yieldy/blob/524f3b83522125fb7d4677fa7a7e5ba5a2c0fe67/src/contracts/Yieldy.sol#L78-L102 https://github.com/code-423n4/2022-06-yieldy/blob/524f3b83522125fb7d4677fa7a7e5ba5a2c0fe67/src/contracts/Staking.sol#L698-L719 https://github.com/code-423n4/2022-06-yieldy/blob/524f3b83522125fb7d4677fa7a7e5ba5a2c0fe67/src/contracts/Staking.sol#L401-L417
Function rebase()
in contract Staking
calls Yieldy.rebase(profit, )
and Yieldy.rebase(profit, )
would revert if rebasingCredits / updatedTotalSupply
was equal to 0
. it's possible to transfer some STAKING_TOKEN
directly to Stacking
contract before or after deployment of Staking
and make rebasingCredits / updatedTotalSupply
equal to 0
, and then most of the functionalities of Staking
would not work because they call rebase()
which will revert.
it's possible to perform this DOS for any token by transferring some tokens STAKING_TOKEN
to contract address and then staking 1 wei
in contract.
or for tokens with low precisions and low price it's even possible to perform when totalSupply()
of Yieldy
is low.
also attacker can only make rebasingCredits / updatedTotalSupply
too low and so rounding error would be significant when rebasingCredits / updatedTotalSupply
gets too low and users funds would be lost because of rounding error.
This is rebase()
code in Yieldy
:
function rebase(uint256 _profit, uint256 _epoch) external onlyRole(REBASE_ROLE) { uint256 currentTotalSupply = _totalSupply; require(_totalSupply > 0, "Can't rebase if not circulating"); if (_profit == 0) { emit LogSupply(_epoch, block.timestamp, currentTotalSupply); emit LogRebase(_epoch, 0, getIndex()); } else { uint256 updatedTotalSupply = currentTotalSupply + _profit; if (updatedTotalSupply > MAX_SUPPLY) { updatedTotalSupply = MAX_SUPPLY; } rebasingCreditsPerToken = rebasingCredits / updatedTotalSupply; require(rebasingCreditsPerToken > 0, "Invalid change in supply"); _totalSupply = updatedTotalSupply; _storeRebase(updatedTotalSupply, _profit, _epoch); } }
As you can see if rebasingCredits / updatedTotalSupply == 0
then the code will revert. updatedTotalSupply
is equal to _totalSupply + _profit
and rebasingCredits
is wad
in the first.
Yieldy.rebase(profit,)
is called by Staking.rebase()
:
function rebase() public { // we know about the issues surrounding block.timestamp, using it here will not cause any problems if (epoch.endTime <= block.timestamp) { IYieldy(YIELDY_TOKEN).rebase(epoch.distribute, epoch.number); epoch.endTime = epoch.endTime + epoch.duration; epoch.timestamp = block.timestamp; epoch.number++; uint256 balance = contractBalance(); uint256 staked = IYieldy(YIELDY_TOKEN).totalSupply(); if (balance <= staked) { epoch.distribute = 0; } else { epoch.distribute = balance - staked; } } }
As you can see the value of _profit
is set to epoch.distribute
which is contractBalance() - IYieldy(YIELDY_TOKEN).totalSupply()
and contractBalance()
is sum of STAKING_TOKEN
and TOKE
balance of Staking
contract. so if attacker transfers X
amount of STAKCING_TOKEN
directly to Staking
contract then the value of _profit
which is going to send to Yieldy.rebase(profit,)
would be higher than X
. to exploit this attacker call stake(1 wei)
after Staking
deployment and then transfer STAKING_TOKEN
directly to contract. then the value of rebasingCredits
in Yieldy
would be 2 wad
and the value of _profit
sent to Yieldy.rebase(profit,)
would be bigger than 2 wad
and rebasingCredits / updatedTotalSupply
would be 0
and from now on all calls to Staking.rebase()
would revert and that means functions Stake()
and instantUnstakeReserve()
and instantUnstakeCurve()
wouldnt work anymore.
it's possible to perform this attack for low precision tokens with low price STAKING_TOKEN
too. the only thing attacker needs to do is that in early stage of Staking
deployment sends more than rebasingCredits
of STAKING_TOKEN
token directly to Staking
contract address. then in rebase()
contract send that amount as profit
to Yieldy.rebase()
and that call would revert which will cause most of the logics of Staking
to revert and not work.
and when rebasingCredits / updatedTotalSupply
is low, the rounding error would be high enough that the compounding yield won't show itself. attacker can make rebasingCredits / updatedTotalSupply
too low but not 0
and from then user's funds would be lost because of rounding error (wrong number of Yieldy
token would be mint for user).
VIM
the default initial value of rebasingCredits
should be very high that attacker couldn't perform this attack.
#0 - toshiSat
2022-07-29T21:15:43Z
Changing to acknowledged.
rebasingCredits
is in credits and updatedTotalSupply
is in token amounts. So if even a small amount is staked, the attacker will have to send a large amount to go through with this attack vector for no financial gain.
This seems like a low priority issue mainly because user funds won't get lost as if this were to occur then most likely no one has staked and the worst case scenario we will redeploy the contract. Also, we will be staking on every yieldy as we deploy.
I think we would like to eventually solve this, but for now this seems like it won't fall in our list of fixes for this iteration.
#1 - JasoonS
2022-08-29T16:41:17Z
Downgrading to medium.
π Selected for report: unforgiven
Also found by: IllIllI, TrungOre, asutorufos, hake, robee
158.9994 USDC - $159.00
https://github.com/code-423n4/2022-06-yieldy/blob/524f3b83522125fb7d4677fa7a7e5ba5a2c0fe67/src/contracts/Staking.sol#L419-L445 https://github.com/code-423n4/2022-06-yieldy/blob/524f3b83522125fb7d4677fa7a7e5ba5a2c0fe67/src/contracts/LiquidityReserve.sol#L120-L126
if the token is deflationary then contract will receive less token that requested amount
but contract don't check for the real transferred amount. because this is happening in receiving stacking_token
in addLiquidity()
of LiquidityReserve
and stake()
of Staking
then those logics for minting YIELDY_TOKEN
or LP
token is wrong. (contract receive less than amount
but mint or transfer amount
to user). This can cause other users which staked to lose funds.
This is the related code where transfer happens (stake()
and addLiquidity()
):
function stake(uint256 _amount, address _recipient) public { // if override staking, then don't allow stake require(!isStakingPaused, "Staking is paused"); // amount must be non zero require(_amount > 0, "Must have valid amount"); uint256 yieldyTotalSupply = IYieldy(YIELDY_TOKEN).totalSupply(); // Don't rebase unless tokens are already staked or could get locked out of staking if (yieldyTotalSupply > 0) { rebase(); } IERC20Upgradeable(STAKING_TOKEN).safeTransferFrom( msg.sender, address(this), _amount ); Claim storage info = warmUpInfo[_recipient]; // if claim is available then auto claim tokens if (_isClaimAvailable(_recipient)) { claim(_recipient); } _depositToTokemak(_amount); // skip adding to warmup contract if period is 0 if (warmUpPeriod == 0) { IYieldy(YIELDY_TOKEN).mint(_recipient, _amount); } else { // create a claim and mint tokens so a user can claim them once warm up has passed warmUpInfo[_recipient] = Claim({ amount: info.amount + _amount, credits: info.credits + IYieldy(YIELDY_TOKEN).creditsForTokenBalance(_amount), expiry: epoch.number + warmUpPeriod }); IYieldy(YIELDY_TOKEN).mint(address(this), _amount); }
function addLiquidity(uint256 _amount) external { require(isReserveEnabled, "Not enabled yet"); uint256 stakingTokenBalance = IERC20Upgradeable(stakingToken).balanceOf( address(this) ); uint256 rewardTokenBalance = IERC20Upgradeable(rewardToken).balanceOf( address(this) ); uint256 lrFoxSupply = totalSupply(); uint256 coolDownAmount = IStaking(stakingContract) .coolDownInfo(address(this)) .amount; uint256 totalLockedValue = stakingTokenBalance + rewardTokenBalance + coolDownAmount; uint256 amountToMint = (_amount * lrFoxSupply) / totalLockedValue; IERC20Upgradeable(stakingToken).safeTransferFrom( msg.sender, address(this), _amount ); _mint(msg.sender, amountToMint); }
As you can see contract transfers amount
of STAKE_TOKEN
and assumes it is going to receive that amount and then mint the same amount
of YIELDY_TOKEN
or LP
token.
So user receive more funds which belongs to other users. protocol logics are not suitable for deflationary tokens and funds would be lost.
VIM
check the real amount of tokens that contract receives.
#0 - toshiSat
2022-07-28T19:23:28Z
We will not be supporting deflationary tokens in Yieldy. We will document this
π Selected for report: unforgiven
1211.7009 USDC - $1,211.70
Function addLiquidity()
suppose to do add Liquidity for the staking Token
and receive lrToken
in exchange. to calculate amount of IrToken
codes uses this calculation: amountToMint = (_amount * lrFoxSupply) / totalLockedValue
but it's possible for attacker to manipulate totalLockedValue
(by sending tokens directly to LiquidityReserve
address) and make totalLockedValue/lrFoxSupply
very high in early stage of contract deployment so because of rounding error in calculation of amountToMint
the users would receive very lower IrToken
and users funds would be lost and attacker can steal them.
Attacker can perform this attack by sending tokens before even LiquidityReserve
deployed because the contract address would be predictable and attacker can perform front-run or sandwich attack too.
also it's possible to perform this attack for STAKING_TOKEN
with low precision and low price even if LiquidityReserve
had some balances.
This is addLiquidity()
code in LiquidityReserve
:
function addLiquidity(uint256 _amount) external { require(isReserveEnabled, "Not enabled yet"); uint256 stakingTokenBalance = IERC20Upgradeable(stakingToken).balanceOf( address(this) ); uint256 rewardTokenBalance = IERC20Upgradeable(rewardToken).balanceOf( address(this) ); uint256 lrFoxSupply = totalSupply(); uint256 coolDownAmount = IStaking(stakingContract) .coolDownInfo(address(this)) .amount; uint256 totalLockedValue = stakingTokenBalance + rewardTokenBalance + coolDownAmount; uint256 amountToMint = (_amount * lrFoxSupply) / totalLockedValue; IERC20Upgradeable(stakingToken).safeTransferFrom( msg.sender, address(this), _amount ); _mint(msg.sender, amountToMint); }
As you can see code uses this calculation: amountToMint = (_amount * lrFoxSupply) / totalLockedValue;
to find the amount of IrToken
that is going to mint for user. but attacker can send stakingToken
or rewardToken
directly to LiquidityReserve
address when the there is no liqudity in the contract and make totalLockedValue
very high. then attacker call addLiquidity()
and mint some IrToken
for himself and from then anyone tries to call addLiquidity()
because of rounding error is going to lose some funds (receives less IrToken
than he is supposed to)
VIM
add more precision when calculating IrToken
so this attack wouldn't be feasible to perform.
#0 - toshiSat
2022-06-27T19:34:26Z
sponsor confirmed
#1 - 0xean
2022-06-28T19:08:21Z
ahh the inflation attack..... Wardens love this one.
The contract locks a minimum liquidity amount which blocks the feasibility attack for the most part. Please see enableLiquidityReserve
for the code where the locking occurs.
#2 - moose-code
2022-08-01T14:42:22Z
Some good worthwhile ideas from the warden but after reviewing the enableLiquidityReserve
going to downgrade this to medium. After reading the code and the described attack, its not very clear how the attacker would benefit and bring the contract into this state.
By sending tokens directly to the contract (expensive) and increasing total totalLockedValue, this will decrease the amount the amountToMint for the user but unclear that this cost is worth it or how an attacker could actually benefit (from what I can see).
Think its still worth exploring this vector in more depth as its a creative attack. Warrants medium and further investigation.
π Selected for report: IllIllI
Also found by: 0x1337, 0x1f8b, 0x29A, 0x52, 0xDjango, 0xNazgul, 0xNineDec, 0xc0ffEE, 0xf15ers, 0xmint, Bnke0x0, BowTiedWardens, Chom, ElKu, FudgyDRS, Funen, GalloDaSballo, GimelSec, JC, Kaiziron, Lambda, Limbooo, Metatron, MiloTruck, Noah3o6, Picodes, PumpkingWok, PwnedNoMore, Sm4rty, StErMi, TomJ, TrungOre, UnusualTurtle, Waze, _Adam, aga7hokakological, ak1, antonttc, berndartmueller, cccz, cryptphi, csanuragjain, defsec, delfin454000, dipp, elprofesor, exd0tpy, fatherOfBlocks, hake, hansfriese, hubble, joestakey, kenta, ladboy233, mics, oyc_109, pashov, pedr02b2, reassor, robee, samruna, scaraven, shung, sikorico, simon135, sseefried, tchkvsky, unforgiven, zzzitron
53.3348 USDC - $53.33
users funds are in Staking
contract address and owner
shouldn't be able to instantly withdraw users funds. if owner make mistake or the private key (or DAO) get hacked then user funds can be lost immediately.
This is transferToke()
code in Staking
:
/** @notice transfer TOKE from staking contract to address @dev used so DAO can get TOKE and manually trade to return FOX to the staking contract @param _claimAddress address to send TOKE rewards */ function transferToke(address _claimAddress) external onlyOwner { // _claimAddress can't be 0x0 require(_claimAddress != address(0), "Invalid address"); uint256 totalTokeAmount = IERC20Upgradeable(TOKE_TOKEN).balanceOf( address(this) ); IERC20Upgradeable(TOKE_TOKEN).safeTransfer( _claimAddress, totalTokeAmount ); }
As you can see there is no time-lock or other mechanism to prevent owner
from withdrawing contract TOKE
balance instantly. those tokens belongs to users and this can create risk for users funds and user funds would be lost by simple mistakes or attacks.
VIM
add some mechanism to prevent instant withdrawal of funds.
#0 - toshiSat
2022-06-27T21:31:14Z
sponsor acknowledged: This is by design. This functionality is to allow for the DAO to transfer the TOKE rewards out and convert to the staking token and deposit in as rewards.
#1 - moose-code
2022-07-28T15:31:03Z
This is a governance risk, and intended behaviour of contracts. Going to put as Low/information/QandA