Platform: Code4rena
Start Date: 09/09/2021
Pot Size: $100,000 SUSHI
Total HM: 4
Participants: 11
Period: 7 days
Judge: ghoulsol
Total Solo HM: 3
Id: 28
League: ETH
Rank: 1/11
Findings: 4
Award: $24,749.25
π Selected for report: 9
π Solo Findings: 1
cmichel
The PostAuctionLauncher.finalize
function takes the raised payment token amounts and uses previously provided auction token amounts to provide liquidity to a Sushiswap pool after an auction has successfully been finalized
.
It provides this liquidity at a pre-defined rate and does not take into account the actual reserves ratio of a pool that might already exist. It assumes that it is the first to provide liquidity but this does not have to be the case.
An attacker that has a tiny amount of auction tokens can steal large quantities of the tokens used for providing liquidity.
The reason is that in Uniswap V2 / Sushiswap you receive liquidity tokens that correspond to the worst (minimum) ratio that was provided:
// https://github.com/sushiswap/sushiswap/blob/canary/contracts/uniswapv2/UniswapV2Pair.sol#L136 // amount0, amount1 are the tokens provided for LPing liquidity = Math.min(amount0.mul(_totalSupply) / _reserve0, amount1.mul(_totalSupply) / _reserve1); // writing it in math terms and factoring out _totalSupply to make it easier to read // liquidityToMint = _totalSupply * Math.min(amount0 / _reserve0, amount1 / _reserve1)
Let's say there are two tokens, the auction token A and the payment token B. For simplicity, assume that the auction has decided that they are worth equal amounts, i.e., tokenPrice = 1.0
, 1.0 A = 1.0 B
. We also use a liquidityPercent = 100%
.
(The attack works with any tokenPrice
and any liquidityPercent
as the pool's current reserve ratio can be chosen by the attacker and is independent of any of these auction / PostAuctionLauncher
values.)
Let's say the PostAuctionLauncher
will provide 10.0A
and 10.0B
to the pool.
market.finalize()
has already been called.PostAuctionLauncher.finalize()
transaction in the mempool0.0001A <> 10.0B
. They receive the initial LP token supply.PostAuctionLauncher.finalize()
provides 10.0 A <> 10.0B
liquidity which is very different from the current reserves ratio and leads to receiving a horrible rate. They will receive liquidity = _totalSupply * Math.min(amount0 / _reserve0, amount1 / _reserve1) = _totalSupply * Math.min(10.0A/0.0001A=100_000, 10.0B/10.0B=1) = 1*_totalSupply
.PostAuctionLauncher
now each own 50% of the total LP supply.5.00005A <> 10.0B
.The attacker's profit is ~5.0A
.
(By further increasing the initial B liquidity, the attacker can steal almost the entire A
amount that was provided by PostAuctionLauncher
.)
An attacker can steal the auction tokens provided by the PostAuctionLauncher
to the liquidity pool.
They can even do this risk-free and atomically in a flash-bundle by sandwiching the PostAuctionLauncher.finalize
.
Note that this attack is symmetric in the tokens, i.e., if the attacker would rather steal the payment tokens, they can provide liquidity with a large number of auction tokens and a small number of payment tokens
Note that rebalancing the low-liquidity pool for a better rate to provide liquidity at doesn't work. Furthermore, as stated this attack can be done atomically and at the time of transaction submission, the pool might not even exist yet.
It seems like it would be enough to have a standard min. LP return "slippage" check (require(liquidity >= minLpReturn)
) in finalize(uint256 minLpReturn)
or checking that the contract owns X% of the pool LP total supply after LP provision.
(Could even just use the SushiswapRouter
with its in-built min return check instead of directly minting on the pool contract.)
The function must then be callable with special roles only.
This will prevent the attack as the transaction will revert when receiving bad rates. If the attacker creates the pool and provides liquidity non-atomically, it'll just get arbed to the auction token price and liquidity can then be provided at the expected rate again.
#0 - Clearwood
2021-09-16T04:35:44Z
Duplicate of #14
π Selected for report: cmichel
1495.7685 SUSHI - $15,002.56
cmichel
When minting / transferring / burning tokens, the SushiToken._beforeTokenTransfer
function is called and supposed to correctly shift the voting power due to the increase/decrease in tokens for the from
and two
accounts.
However, it does not correctly do that, it tries to shift the votes from the from
account, instead of the _delegates[from]
account.
This can lead to transfers reverting.
Imagine the following transactions on the SushiToken
contract.
We'll illustrate the corresponding _moveDelegates
calls and written checkpoints for each.
mint(A, 1000) = transfer(0, A, 1000)
=> _moveDelegates(0, delegates[A]=0)
=> no checkpoints are written to anyone because delegatees are still zero_moveDelegates(0, A')
=> writeCheckpoint(A', 1000)
transfer(A, B, 1000)
=> _moveDelegates(A, delegates[B] = B')
=> underflows when subtracting amount=1000
from A's non-existent checkpoint (defaults to 0 votes)It should subtract from A's delegatee A'
's checkpoint instead.
Users that delegated votes will be unable to transfer any of their tokens.
In SushiToken._beforeTokenTransfer
, change the _moveDelegates
call to be from _delegates[from]
instead:
function _beforeTokenTransfer(address from, address to, uint256 amount) internal override { _moveDelegates(_delegates[from], _delegates[to], amount); super._beforeTokenTransfer(from, to, amount); }
This is also how the original code from Compound does it.
#0 - maxsam4
2021-09-16T04:42:19Z
This is a known issue in Sushi token but was kept unchanged in MISO for "preservation of history :)". That was not necessarily a wise choice lol. I think 1 severity should be fine for this as this was an intentional thing. The delegate feature is not supposed to be used in these tokens. We might create a new token type with this bug fixed.
#1 - ghoul-sol
2021-10-05T17:58:42Z
We have crazy wallets on the blockchain that will call every possible function available to them and that's why I'm keeping this as is. Even intentional, the issue stands so the warden should get credit for it.
π Selected for report: cmichel
149.5769 SUSHI - $1,500.26
cmichel
The index of the item in the userItem.lockToItems[_tokenAddress]
array is tracked in lockedItem[_id].userIndex
(see lockTokens
):
uint256 userIndex = userItem.lockToItems[_tokenAddress].length - 1; lockedItem[_id].userIndex = userIndex;
However, when withdrawing tokens this link breaks, as the withdrawn item is removed (swapped with the last element in this array):
if(userItem.amount == 0) { uint256[] storage userItems = users[msg.sender].lockToItems[_tokenAddress]; // @audit swaps last element (which stores item ID) with element at _index userItems[_index] = userItems[userItems.length -1]; userItems.pop(); // @audit swapping needs to change lockedItem[ userItems[_index] ].userIndex = index; }
After withdrawing tokens, the swapped item's lockedItem[_id].userIndex
is not the last element anymore, but takes the old item's _index
now.
The link is broken and getLockedItemAtId
returns wrong data which, when used to get the index for withdrawTokens
, will then refer to the wrong token and revert.
After deleting the element in withdrawTokens
, update the remaining swapped element's userIndex
:
if(userItem.amount == 0) { uint256[] storage userItems = users[msg.sender].lockToItems[_tokenAddress]; userItems[_index] = userItems[userItems.length -1]; userItems.pop(); // ADD THIS // userItems[_index] refers to the previously last item's ID lockedItem[ userItems[_index] ].userIndex = index; }
#0 - maxsam4
2021-09-16T05:18:58Z
This functionality is never used onchain so the risk is only applicable to UIs.
ps. we're no longer using this contract.
#1 - ghoul-sol
2021-10-05T18:35:33Z
no funds at risk, issues with UI, it's a low risk
40.3858 SUSHI - $405.07
cmichel
The address.transfer
function is used to send ETH to an account. It is restricted to a low amount of GAS and might fail if GAS costs change in the future or if a smart contract's fallback function handler implements anything non-trivial.
Occurences:
MISOFarmFactory.deployFarm
MISOLauncher.deployLauncher
MISOMarket.deployMarket
MISOTokenFactory.deployToken
ListFactory.deployPointList
MISOAccessFactory.deployAccessControl
CrowdSale.commitEth
DutchAuction.commitEth
HyperbolicAuction.commitEth
MISORecipe0*.prepareMiso
SafeTransfer._tokenPayment
WETH9.withdraw
Consider using the lower-level .call{value: value}
instead and check it's success return value.
40.3858 SUSHI - $405.07
cmichel
The DutchAuction._currentPrice()
is computed by multiplying with priceDrop()
.
However, priceDrop()
already performs a division and the final current price, therefore, loses precision.
Note that priceDrop()
could even return 0
for ultra-low prices or very long auctions.
Imagine the actual payment per auction token price is 10^-12
=> startPrice
and endPrice
are set with 18 decimals as ~10^6
, but for auctions over a year (31,536,000 seconds > 10^6
) it'll then return 0.
Precision can be lost leading to less accurate token auction results or even completely breaking the auction if the price is very low and the auctions are very long.
Perform all multiplications before divisions:
uint256 priceDiff = block.timestamp.sub(uint256(marketInfo.startTime)).mul( uint256(_marketPrice.startPrice.sub(_marketPrice.minimumPrice)) ) / uint256(_marketInfo.endTime.sub(_marketInfo.startTime));
#0 - Clearwood
2021-10-05T18:09:35Z
Great work here π
#1 - Clearwood
2021-10-05T18:12:40Z
π Selected for report: cmichel
149.5769 SUSHI - $1,500.26
cmichel
Although the contract is supposed to accept LP tokens only, it does not restrict the tokens used and it could be that a fee-on-transfer token is used as the pool.lpToken
.
This has led to many attacks, see here for write-ups.
The gist is that the "pool" token balance in the contract can be reduced to a few wei by continuously depositing/withdrawing tokens due to accounting errors with not using pre-post transfer balances to get the actual amount.
(Attacker deposits 1000, is credited 1000, but pool actually only receives 900. Attacker can withdraw 1000, pool balance diff is -100.)
Then pool.accRewardsPerShare = pool.accRewardsPerShare.add(rewardsAccum.mul(1e12).div(lpSupply));
is a really high value and it's multiplied by the wrong user.amount
.
Document that pool.lpToken
should never be a fee-on-transfer token.
Use pre-and-post balances to compute the actual deposited amount.
#0 - Clearwood
2021-09-21T19:04:03Z
Considering that lpTokens should be Sushi LP tokens in most cases, this is an edge case A comment might be helpful.
#1 - ghoul-sol
2021-10-05T23:58:46Z
I think low risk is fair
π Selected for report: cmichel
cmichel
Some tokens (like USDT) don't correctly implement the EIP20 standard and their transfer
/transferFrom
function return void
instead of a success boolean. Calling these functions with the correct EIP20 function signatures will always revert.
The MISORecipe03.createLiquidityFarm
uses a standard ERC20(rewardToken).transferFrom(msg.sender, self, tokensToFarm)
call for the rewardToken
which could be a non-compliant token like USDT
Tokens that don't correctly implement the latest EIP20 spec, like USDT, will be unusable in this recipe as they revert the transaction because of the missing return value.
We recommend using OpenZeppelinβs SafeERC20
versions with the safeTransfer
and safeTransferFrom
functions that handle the return value check as well as non-standard-compliant tokens.
π Selected for report: cmichel
149.5769 SUSHI - $1,500.26
cmichel
Some tokens don't correctly implement the EIP20 standard and their approve
function returns void
instead of a success boolean. Calling these functions with the correct EIP20 function signatures will always revert.
Calls to .approve
with user-defined tokens are made in:
MISOLauncher.createLauncher
MISOMarket.createMarket
Tokens that don't correctly implement the latest EIP20 spec, like USDT, will be unusable in the mentioned contracts as they revert the transaction because of the missing return value.
We recommend using OpenZeppelinβs SafeERC20
versions with the safeApprove
function that handle the return value check as well as non-standard-compliant tokens.
#0 - Clearwood
2021-09-21T18:59:37Z
Would be useful in generalization but does not provide the risk for loss of funds, just limits the usability of the contracts
#1 - Clearwood
2021-10-05T18:27:19Z
#2 - ghoul-sol
2021-10-06T00:00:06Z
I think this is worth low risk.
6.2284 SUSHI - $62.47
cmichel
The DutchAuction.clearingPrice
function can save gas by caching the computed prices instead of recomputing it.
Cache the values:
function clearingPrice() public view returns (uint256) { /// @dev If auction successful, return tokenPrice uint256 _tokenPrice = tokenPrice(); uint256 _currentPrice = priceFunction(); return _tokenPrice > _currentPrice ? _tokenPrice : _currentPrice; }
#0 - Clearwood
2021-10-05T19:11:53Z
π Selected for report: cmichel
13.8408 SUSHI - $138.82
cmichel
The SushiToken.delegateBySig
function can save gas by not taking the unnecessary nonce
parameter.
It currently validates that the provided nonce == sigNonces[signatory]
but this is unnecessary as the nonce
is already part of the signed message digest.
A shorter function payload leads to gas improvements.
Remove the nonce
parameter from the function, and use the sigNonces[signatory]
when creating the message digest.
bytes32 structHash = keccak256( abi.encode( DELEGATION_TYPEHASH, delegatee, // @audit make sure to increment here when removing the require check below sigNonces[signatory]++, nonce, expiry ) );