Platform: Code4rena
Start Date: 16/09/2021
Pot Size: $200,000 SUSHI
Total HM: 26
Participants: 16
Period: 14 days
Judge: alcueca
Total Solo HM: 13
Id: 29
League: ETH
Rank: 2/16
Findings: 12
Award: $40,669.16
🌟 Selected for report: 15
🚀 Solo Findings: 4
cmichel
The IndexPool.flashSwap
function calls ITridentCallee(msg.sender).tridentSwapCallback(context)
before transferring the tokens to the recipient via _tranfer
.
It's very important that the tokens are transferred to the caller before the callback.
The use-case of the flashSwap
is that the caller can already use the tokens to close a loan, trade, or use these funds in a way to get the original amountIn
tokens that they need to pay.
In its current form, the flashSwap
does not perform its expected behavior and is not very useful.
Call _transfer
before the ITridentCallee(msg.sender).tridentSwapCallback(context)
.
cmichel
The IndexPool._compute
function is indented as if the if (n % 2 != 0) output = output * a;
is inside the loop but there are actually not braces around it.
It must be in the loop for the exponentiation by repeated squaring algorithm to work:
function _pow(uint256 a, uint256 n) internal pure returns (uint256 output) { output = n % 2 != 0 ? a : BASE; for (n /= 2; n != 0; n /= 2) // @audit this is not divided by 1e18 again a = a * a; // @audit this is outside of the loop but should not. And output is not divided by 1e18 again if (n % 2 != 0) output = output * a; }
Furthermore, the a
values are treated as floating points with 18 decimals but never normalized. Each time a = a * a
is computed, its magnitude increases by 1e18. It should be a = a * a / 1e18
.
This contract is basically Balancer and this function should equal Balancer's bpowi
.
(And _powApprox
is bpowApprox
, _compute
is bpow
. These seem to be correct.)
Note that the n
parameter in _pow
is an integer number that represents the integer part of the token weight division: tokenInWeight / tokenOutWeight
The AMM does not work correctly when using token weights that are not equal.
All AMMs that will use an iteration of this loop in _pow
will return wrong swap results, or even worse always break as the a = a * a
value overflows the max uint256
type as it increases by 1e18
each time.
for
loop and include the if
. "Normalize" the a
and output
values in _pow
:function _pow(uint256 a, uint256 n) internal pure returns (uint256 output) { output = n % 2 != 0 ? a : BASE; for (n /= 2; n != 0; n /= 2) { // @audit balancer uses bmul here (/ 1e18) and at output a = _mul(a, a); if (n % 2 != 0) output = _mul(output, a); } }
Add tests that cover this loop. The token weights should be different, for example 8e18
and 1e18
such that this loop is executed 3
(n = 8e18/1e18 = 8
) times. The current tests use USDT
/USDC
with equal weights and never execute this loop.
Add a backlink to the original balancer functions such that it's easier for other contributors that might not be familiar with it to know what's going on.
190.6158 SUSHI - $2,382.70
cmichel
The HybridPool
's reserves are stored as Bento "amounts" (not Bento shares) in _updateReserves
because _balance()
converts the current share balance to amount balances.
However, when retrieving the reserve0/1
storage fields in _getReserves
, they are converted to amounts a second time.
The HybridPool
returns wrong reserves which affects all minting/burning and swap functions.
They all return wrong results making the pool eventually economically exploitable or leading to users receiving less tokens than they should.
Imagine the current Bento amount / share price being 1.5
.
The pool's Bento share balance being 1000
.
_updateReserves
will store a reserve of 1.5 * 1000 = 1500
.
When anyone trades using the swap
function, _getReserves()
is called and multiplies it by 1.5
again, leading to using a reserve of 2250 instead of 1500.
A higher reserve for the output token leads to receiving more tokens as the swap output.
Thus the pool lost tokens and the LPs suffer this loss.
Make sure that the reserves are in the correct amounts.
cmichel
The IndexPool.mint
function performs an unsafe cast of ratio
to the uint120
type:
uint120 ratio = uint120(_div(toMint, totalSupply));
Note that toMint
is chosen by the caller and when choosing toMint = 2**120 * totalSupply / BASE
, the ratio
variable will be 2**120
and then truncated to 0 due to the cast.
This allows an attacker to mint LP tokens for free.
They just need to choose the ratio
such that the amountIn = ratio * reserve / BASE
variable passes the require(amountIn >= MIN_BALANCE, "MIN_BALANCE");
check.
For example, when choosing ratio = 2**120 * totalSupply / BASE + 1e16
, an attacker has to pay 1/100th of the current reserves but heavily inflates the LP token supply.
They can then use the inflated LP tokens they received in burn
to withdraw the entire pool reserves.
I created this POC that implements a hardhat test and shows how to steal the pool tokens:
https://gist.github.com/MrToph/0c8b6b5ffac0673b2f72412cf4b0b099
An attacker can inflate the LP token pool supply and mint themselves a lot of LP tokens by providing almost no tokens themselves. The entire pool tokens can be stolen.
Even though Solidity 0.8.x is used, type casts do not throw an error.
A SafeCast
library must be used everywhere a typecast is done.
#0 - maxsam4
2021-11-08T11:22:32Z
cmichel
The IndexPool.burn/burnSingle/swap/flashSwap
functions all perform unsafe type casts to uint120
without checking if the values actually fit into 120 bits.
Unlike mint
, these functions have other require
statements and checks that don't seem to directly lead to exploits.
The safe type casts should still be implemented for additional security.
A SafeCast
library must be used everywhere a typecast is done.
#0 - alcueca
2021-10-27T04:56:43Z
Can't find a duplicate, @maxsam4?
#1 - maxsam4
2021-11-08T11:32:17Z
#2 - alcueca
2021-11-09T04:51:52Z
Thanks
🌟 Selected for report: cmichel
705.9844 SUSHI - $8,824.81
cmichel
The IndexPool.constructor
function already mints INIT_POOL_SUPPLY = 100 * 1e18 = 1e20
LP tokens to the zero address.
When trying to use the pool, someone has to provide the actual initial reserve tokens in mint
.
On the first mint
, the pool reserves are zero and the token amount required to mint is just this ratio
itself: uint120 amountIn = reserve != 0 ? uint120(_mul(ratio, reserve)) : ratio;
Note that the amountIn
is independent of the token which does not make much sense.
This implies that all tokens must be provided in equal "raw amounts", regardless of their decimals and value.
Imagine I want to create a DAI/WBTC pool.
If I want to initialize the pool with 100$ of DAI, amountIn = ratio
needs to be 100*1e18=1e20
as DAI has 18 decimals.
However, I now also need to supply 1e20
of WBTC (which has 8 decimals) and I'd need to pay 1e20/1e8 * priceOfBTC
, over a quadrillion dollars to match it with the 100$ of DAI.
Even in a pool where all tokens have the same decimals and the same value, like USDC <> USDT
, it leads to issues:
mint
with toMint = 1e20
which sets ratio = 1e20 * 1e18 / 1e20 = 1e18
and thus amountIn = 1e18
as well. The total supply increases to 2e20
.1e18
LP tokens as the first minter. This should never be the case. toMint = 1e20
=> ratio = 1e20 * 1e18 / 2e20 = 0.5e18
. Then amountIn = ratio * reserve / 1e18 = 0.5*reserve = 0.5e18
. They only pay half of what the first LP provider had to pay.It's unclear why it's assumed that the pool's tokens are all in equal value - this is not a StableSwap-like pool.
Any pool that uses tokens that don't have the same value and share the same decimals cannot be used because initial liquidity cannot be provided in an economically justifiable way.
It also leads to issues where the second LP supplier has to pay less tokens to receive the exact same amount of LP tokens that the initial minter receives. They can steal from the initial LP provider by burning these tokens again.
Do not mint the initial token supply to the zero address in the constructor.
Do it like Uniswap/Balancer and let the first liquidity provider provide arbitrary token amounts, then mint the initial pool supply.
If reserve == 0
, amountIn
should just take the pool balances that were transferred to this account.
In case the initial mint to the zero address in the constructor was done to prevent the "Uniswap-attack" where the price of a single wei of LP token can be very high and price out LPs, send a small fraction of this initial LP supply (~1000) to the zero address after it was minted to the first supplier in mint
.
🌟 Selected for report: cmichel
705.9844 SUSHI - $8,824.81
cmichel
The ConstantProductPool.burnSingle
function is basically a burn
followed by a swap
and must therefore act the same way as calling these two functions sequentially.
The token amounts to redeem (amount0
, amount1
) are computed on the balance (not the reserve).
However, the swap amount is then computed on the reserves and not the balance.
The burn
function would have updated the reserve
to the balances and therefore balance
should be used here:
amount1 += _getAmountOut(amount0, _reserve0 - amount0, _reserve1 - amount1);
⚠️ The same issue occurs in the
HybridPool.burnSingle
.
For a burn, usually the reserve
should equal the balance
, however if any new tokens are sent to the contract and balance > reserve
, this function will return slightly less swap amounts.
Call _getAmountOut
with the balances instead of the reserves: _getAmountOut(amount0, balance0 - amount0, balance1 - amount1)
#0 - maxsam4
2021-10-20T12:17:33Z
Please bump this to High sev. This bug can actually lead to loss of funds from the pool. The author found the right issue but failed to analyze the full impact. Regardless, I think they deserve "High" for pointing this out.
#1 - alcueca
2021-10-25T06:09:26Z
This is what we come to C4 for :clap: :clap: :clap:
🌟 Selected for report: cmichel
211.7953 SUSHI - $2,647.44
cmichel
The TridentRouter.complexPath
function allows splitting a trade result into several buckets and trade them in a different pool each.
The distribution is defined by the params.percentagePath[i].balancePercentage
values:
for (uint256 i; i < params.percentagePath.length; i++) { uint256 balanceShares = bento.balanceOf(params.percentagePath[i].tokenIn, address(this)); uint256 transferShares = (balanceShares * params.percentagePath[i].balancePercentage) / uint256(10)**8; bento.transfer(params.percentagePath[i].tokenIn, address(this), params.percentagePath[i].pool, transferShares); isWhiteListed(params.percentagePath[i].pool); IPool(params.percentagePath[i].pool).swap(params.percentagePath[i].data); }
However, the base value bento.balanceOf(params.percentagePath[i].tokenIn, address(this));
is recomputed after each iteration instead of caching it before the loop.
This leads to not all tokens being used even though the percentages add up to 100%.
Assume I want to trade 50% DAI to WETH and the other 50% DAI to WBTC.
In the first iteration, balanceShares
is computed and then 50% of it is swapped in the first pool.
However, in the second iteration, balanceShares
is updated again, and only 50% of the remaining (instead of the total) balance, i.e. 25%, is traded.
The final 25% are lost and can be skimmed by anyone afterwards.
Users can lose their funds using complexPath
.
Cache the balanceShares
value once before the second for
loop starts.
#0 - sarangparikh22
2021-10-05T16:04:19Z
This is not the correct way to calculate the complexPath swap parameters. For instance, if we need to swap 50% DAI to WETH and the other 50% DAI to WBTC, we would keep percentages as 50 and 100, instead of 50-50 as described above. If the user enters wrong percentages, they would loose funds.
#1 - alcueca
2021-10-25T04:42:53Z
The format for entering the percentages is not documented. Sustained as Sev 2 as the lack of documentation on this parameter could lead to loss of funds.
95.3079 SUSHI - $1,191.35
cmichel
The TridentRouter._depositToBentoBox
function only uses the ETH
in the contract if it's higher then the desired underlyingAmount
(address(this).balance >= underlyingAmount)
).
Otherwise, the ETH is ignored and the function uses WETH from the user.
Note that the underlyingAmount = bento.toAmount(wETH, amount, true)
is computed from the Bento share price and it might happen that it increases from the time the transaction was submitted to the time the transaction is included in a block.
In that case, it might completely ignore the sent ETH
balance from the user and in addition transfer the same amount of WETH
from the user.
The user can lose their ETH
deposit in the contract.
Each batch must use refundETH
at the end.
Furthermore, we recommend still depositing address(this).balance
ETH into Bento and if it's less than underlyingAmount
use WETH
only for the remaining token difference.
🌟 Selected for report: cmichel
211.7953 SUSHI - $2,647.44
cmichel
The TridentHelper.withdrawFromWETH
(used in TridentRouter.unwrapWETH
) function performs a low-level call to WETH.withdraw(amount)
.
It then checks if the return data
length is more or equal to 32
bytes, however WETH.withdraw
returns void
and has a return value of 0
.
Thus, the function always reverts even if success == true
.
function withdrawFromWETH(uint256 amount) internal { // @audit WETH.withdraw returns nothing, data.length always zero. this always reverts require(success && data.length >= 32, "WITHDRAW_FROM_WETH_FAILED"); }
The unwrapWETH
function is broken and makes all transactions revert.
Batch calls to the router cannot perform any unwrapping of WETH.
Remove the data.length >= 32
from the require and only check if success
is true.
95.3079 SUSHI - $1,191.35
cmichel
The HybridPool.flashSwap
function sends the entire trade fees fee
to the barFeeTo
.
It should only send barFee * fee
to the barFeeTo
address.
LPs are not getting paid at all when this function is used. There is no incentive to provide liquidity.
The flashSwap
function should use the same fee mechanism as swap
and only send barFee * fee / MAX_FEE
to the barFeeTo
. See _handleFee
function.
🌟 Selected for report: cmichel
70.5984 SUSHI - $882.48
cmichel
The IndexPool.burnSingle/swap/flashSwap
functions receive user-supplied tokenIn
/tokenOut
arguments but never check if these tokens are pool tokens.
The functions seem to crash further down the call stack at some point when trying to divide by a token weight of zero, but there should be an explicit check to be sure.
Check that records[token].weight > 0
for all token
s that are supplied by the user.
🌟 Selected for report: cmichel
70.5984 SUSHI - $882.48
cmichel
The pool contracts perform several low-level calls to the master deployer or bento contracts and do not check the success value.
// ConstantProductPool.constructor (, bytes memory _barFee) = _masterDeployer.staticcall(abi.encodeWithSelector(IMasterDeployer.barFee.selector)); (, bytes memory _barFeeTo) = _masterDeployer.staticcall(abi.encodeWithSelector(IMasterDeployer.barFeeTo.selector)); (, bytes memory _bento) = _masterDeployer.staticcall(abi.encodeWithSelector(IMasterDeployer.bento.selector)); // ConstantProductPool._balance (, bytes memory _balance0) = bento.staticcall(abi.encodeWithSelector(0xf7888aec, token0, address(this)));
Occurences:
ConstantProductPool.constructor
ConstantProductPool._balance
HybridPool.constructor
HybridPool.__balance
HybridPool._toAmount
HybridPool._toShare
IndexPool.constructor
IndexPool._balance
*Pool.updateBarFee
While these calls should never fail when the contract addresses are correct, we still recommend checking the success
return value of these low-level calls.
Check the success
return value of all low-level calls and revert if it's false
.
#0 - maxsam4
2021-10-21T06:25:20Z
These calls can either never fail or it doesn't matter if they fail. Acceptable risk.
🌟 Selected for report: cmichel
70.5984 SUSHI - $882.48
cmichel
The ConstantProductPool
is supposed to take a small barFee
on the trading fees.
The trading fees are estimated in ._mintFee
as the growth in liquidity (square root k) between two liquidity provisions. (This is done to be more efficient and to not require a fee transfer on each swap
.)
See the original Uniswap V2 paper - section 2.4, equation (6) with s_m for more information on how it should be done.
The current implementation takes a barFee
on the liquidity growth percentage on the totalSupply
.
If the barFee
is high, it seems that it could be higher than the actual trade fees - but it should never be the case.
The barFee
is taken on the wrong amounts.
See equation (6) in section 2.4 how to derive it for any fee value \phi. It should be something like this (not tested):
liquidity = (_totalSupply * (computed - _kLast)) * MAX_FEE / [(MAX_FEE**2 / fee - MAX_FEE) * computed + MAX_FEE * _kLast]
🌟 Selected for report: cmichel
70.5984 SUSHI - $882.48
cmichel
The ConstantProductPool
computes optimal balanced LP using _nonOptimalMintFee
, which performs something like a swap.
The returned swap fees should be included in the "k
" computation as _handleFees
uses the growth in k
to estimate the swap fees.
// should not reduce fee0 and fee1 uint256 computed = TridentMath.sqrt((balance0 - fee0) * (balance1 - fee1));
⚠️ The same issue occurs in the
HybridPool.mint
.
The non-optimal mint swap fees are not taken into account.
Compute sqrt(k)
on the balance0 * balance1
without deducting the swap fees.
🌟 Selected for report: cmichel
70.5984 SUSHI - $882.48
cmichel
The HybridPool.flashSwap
function skips the tridentSwapCallback
callback call if data.length == 0
.
It should never skip the callback, otherwise the flashSwap
function is useless.
Note that this behavior of the HybridPool
is not in alignment with the flashSwap
behavior of all other pools that indeed always call the callback.
Always make the call to ITridentCallee(msg.sender).tridentSwapCallback(data);
, regardless of the data
variable.
14.5161 SUSHI - $181.45
cmichel
The HybridPool._computeLiquidityFromAdjustedBalances
function should return early if s == 0
as it will always return zero.
Currently, it still performs an expensive loop iteration.
if (s == 0) { // gas: should do an early return here computed = 0; // return 0; }
Return early with a value of 0
if s == 0
.
🌟 Selected for report: cmichel
32.2581 SUSHI - $403.23
cmichel
The HybridPool.burn
function subtracts some computation from balance0
/balance1
, but the result is never used.
balance0 -= _toShare(token0, amount0); balance1 -= _toShare(token1, amount1);
Unless it is used as an underflow check, the computation should be removed the result is not used.