Platform: Code4rena
Start Date: 10/03/2022
Pot Size: $75,000 USDT
Total HM: 25
Participants: 54
Period: 7 days
Judge: pauliax
Total Solo HM: 10
Id: 97
League: ETH
Rank: 6/54
Findings: 4
Award: $3,139.53
π Selected for report: 2
π Solo Findings: 1
π Selected for report: cmichel
Also found by: PPrieditis, kyliek, pedroais
https://github.com/code-423n4/2022-03-biconomy/blob/04751283f85c9fc94fb644ff2b489ec339cd9ffc/contracts/hyphen/LiquidityProviders.sol#L352 https://github.com/code-423n4/2022-03-biconomy/blob/04751283f85c9fc94fb644ff2b489ec339cd9ffc/contracts/hyphen/token/TokenManager.sol#L101-L103
A liquidity provider's fund can be trapped when a token is no longer supported. There is no validation to check if there are any outstanding shares in LP tokens associated with the token.
A user minted LP tokens by adding liquidity when the _tokenAddress
was still supported. When the user wants to removeLiquidity
later on the token is no longer supported, thus unable to redeem the shares.
When removing the support for a token, check for outstanding liquidity pool shares to make sure no users' fund is trapped.
function removeSupportedToken(address tokenAddress) external tokenChecks(tokenAddress) onlyOwner { tokensInfo[tokenAddress].supportedToken = false; }
#0 - ankurdubey521
2022-03-30T15:46:27Z
Duplicate of #54
#1 - pauliax
2022-04-11T13:06:53Z
#54
560.3084 USDT - $560.31
https://github.com/code-423n4/2022-03-biconomy/blob/04751283f85c9fc94fb644ff2b489ec339cd9ffc/contracts/hyphen/LiquidityPool.sol#L149-L173 https://github.com/code-423n4/2022-03-biconomy/blob/04751283f85c9fc94fb644ff2b489ec339cd9ffc/contracts/hyphen/LiquidityPool.sol#L263-L277
depositErc20
allows an attacker to specify the destination chain to be the same as the source chain and the receiver account to be the same as the caller account. This enables an attacker to drain the incentive pool without rebalancing the pool back to the equilibrium state.
This requires the attacker to have some collateral, to begin with. The profit also depends on how much the attacker has. Assume the attacker has enough assets.
In each chain, when the pool is very deficit (e.g. currentLiquidity
is much less than providedLiquidity
), which often mean there's a good amount in the Incentive pool after some high valued transfers, then do the following.
uint256 liquidityDifference = providedLiquidity - currentLiquidity; if (amount >= liquidityDifference) { rewardAmount = incentivePool[tokenAddress];
depositErc20()
with toChainId
being the same chain and receiver
being msg.sender
.The executor will call sendFundsToUser
to msg.sender. Then a rewardAmount, equivalent to the entire incentive pool (up to 10% of the total pool value), will be added to msg.sender
minus equilibrium fee (~0.01%) and gas fee.
In the end, the pool is back to the deficit state as before, the incentive pool is drained and the exploiter pockets the difference of rewardAmount minus fees.
This attack can be repeated on each deployed chain multiple times whenever the incentive pool is profitable (particularly right after a big transfer).
Disallow toChainId
to be the source chain by validating it in depositErc20
or in sendFundsToUser
validate that fromChainId
is not the same as current chain.
require receiver
is not msg.sender
in depositErc20
.
#0 - tomarsachin2271
2022-03-26T20:36:38Z
If depositor keeps toChainId same as source chain Id, then executor will not pick this deposit transaction on backend as there won't be any mapping for fromChainId => toChainId, so depositor funds will remain in the source chain if he tries to do it and try to drain the incentive pool.
Although this could happen coz of any bug on the UI, so it's better to handle these situations on contract itself. It will increase a gas though a bit while depositing. Will consider this point though.
#1 - pauliax
2022-04-28T20:35:07Z
It is always good to enforce such things on the contract level itself if possible. While there are some precautions, there still exists a hypothetical attack path so I am leaving this as of medium severity.
π Selected for report: kyliek
2075.2164 USDT - $2,075.22
https://github.com/code-423n4/2022-03-biconomy/blob/04751283f85c9fc94fb644ff2b489ec339cd9ffc/contracts/hyphen/LiquidityProviders.sol#L388 https://github.com/code-423n4/2022-03-biconomy/blob/04751283f85c9fc94fb644ff2b489ec339cd9ffc/contracts/hyphen/LiquidityProviders.sol#L392
LP token holders can not redeem their tokens when the pool is in the deficit state, i.e. currentLiquidity << providedLiquidity
. This is due to that LP shares are computed based on providedLiquidity
and the actual available pool balance is based on currentLiquidity
.
When a high valued withdrawal happens in the liquidity pool of the destination chain, the current liquidity will be reduced when the executor calls sendFundsToUser
https://github.com/code-423n4/2022-03-biconomy/blob/04751283f85c9fc94fb644ff2b489ec339cd9ffc/contracts/hyphen/LiquidityPool.sol#L285
and the pool contract balance will also be reduced by the same amount. The pool reached a deficit state with provided liquidity much bigger than current liquidity.
The LP shares are computed based on the value of totalReserve
that is roughly equivalent to totalLiquidity + LpFees
. In a deficit state, totalReserve
could be much bigger than the available pool balance (up to 90% since max fee is 10%).
If the LP token holder wants to redeem his shares,
_decreaseCurrentLiquidity(_tokenAddress, _amount);
will underflow and revert and
_transferFromLiquidityPool(_tokenAddress, _msgSender(), amountToWithdraw);
will revert because there is not enough balance.
This is a tricky problem. On one hand, separating currentLiquidity
from providedLiquidity
made sure that by bridging tokens over, it will not inflate or deflate the pool. On the other hand, decoupling the two made it hard to compute the actual available liquidity to redeem LP shares. One may need to think through this a bit more.
#0 - ankurdubey521
2022-03-30T10:05:28Z
Liquidity Providers will be able to withdraw their funds as long as they're sufficient currentLiquidity
in the pool, as you mentioned. This will be the case when all pools are balanced, ie the current liquidity is very close to the supplied liquidity.
By design, hyphen liquidity pools incentivize people to rebalance the pools by providing rewards from the incentive pool, so we believe this should not be that big of an issue in practice.
#1 - pauliax
2022-05-09T11:39:44Z
A valid concern, and even though per the sponsor's comment it should not be a problem in practice, a hypothetical path of risk still exists so I would like to leave this as of Medium severity issue.
π Selected for report: hickuphh3
Also found by: 0v3rf10w, 0x1f8b, 0xDjango, 0xNazgul, 0xngndev, 0xwags, Cantor_Dust, CertoraInc, Dravee, IllIllI, PPrieditis, Ruhum, TerrierLover, WatchPug, XDms, benk10, berndartmueller, bitbopper, catchup, cmichel, cryptphi, csanuragjain, danb, defsec, gzeon, hagrid, hubble, jayjonah8, kenta, kyliek, minhquanym, rfa, robee, saian, samruna, throttle, ye0lde, z3s
125.7894 USDT - $125.79
@openzeppelin/contracts 4.3.0 -> 4.5.0 @openzeppelin/contracts-upgradeable 4.3.0 -> 4.5.2
pragma solidity 0.8.0
is used throughout. The current version is 0.8.12
. The recommended version is 0.8.11
.
Both LiquidityPool.sol
and liquidityProviders.sol
have function of the name getCurrentLiquidty
that gives two different values.
This is very confusing. We recommend better naming for clarity.
providedLiquidity
, suppliedLiquidity
and totalLiquidity
are used to refer to the same state variable totalLiquidity
for a token.
mapping(address => uint256) public totalLiquidity; // Include Liquidity only
Please use one consistent name for the same variable.
See instances here.
The function getRewardAmount
gives back a named return variable rewardAmount
. However when currentLiquidity >= providedLiquidity
, there is no explicit return value.
transfer
should be avoidedFunction name transfer
is used in multiple standards, particularly ERC721, which is implemented here for LP token. It is very confusing to have a custom transfer
function in LiquidityPool.sol
for some different purpose. This function is called only when LP shares are redeemed. We could use another name for clarity, e.g. redeemLPshares()
.
IERC20
instead of IERC20Upgradeable
Many existing candidate tokens are non-upgradeable ERC20 tokens. It is more appropriate just to use IERC20 instead of IERC20Upgradeable
to avoid potential undiscovered upgradeability complexity.
IERC20Upgradeable baseToken = IERC20Upgradeable(_tokenAddress);
Similarly one can use SafeERC20
instead of SafeERC20Upgradeable
.
Some instances are
This is recommended by OpenZeppelin.
The original proxies included in OpenZeppelin followed the Transparent Proxy Pattern. While this pattern is still provided, our recommendation is now shifting towards UUPS proxies, which are both lightweight and versatile.
see https://docs.openzeppelin.com/contracts/4.x/api/proxy#transparent-vs-uups
_trustedForwarder
Argument _trustedForwarder
in initialize
in LiquidityPool.sol
shadows the state variable of the same name in ERC2771ContextUpgradeable
.