Platform: Code4rena
Start Date: 03/07/2023
Pot Size: $40,000 USDC
Total HM: 14
Participants: 74
Period: 7 days
Judge: alcueca
Total Solo HM: 9
Id: 259
League: ETH
Rank: 8/74
Findings: 2
Award: $986.09
🌟 Selected for report: 1
🚀 Solo Findings: 0
🌟 Selected for report: qpzm
Also found by: CRIMSON-RAT-REACH
968.5667 USDC - $968.57
https://github.com/code-423n4/2023-07-basin/blob/main/src/functions/ConstantProduct2.sol#L65-L66 https://github.com/code-423n4/2023-07-basin/blob/main/src/Well.sol#L590-L598 https://github.com/code-423n4/2023-07-basin/blob/main/src/Well.sol#L695-L702 https://github.com/code-423n4/2023-07-basin/blob/main/src/Well.sol#L541 https://github.com/code-423n4/2023-07-basin/blob/main/src/Well.sol#L562 https://github.com/code-423n4/2023-07-basin/blob/main/src/Well.sol#L582
Let reserves
returned by Well._getReserves()
as x, y and Well.tokenSupply()
as k.
They must maintain the invariant x * y * EXP_PRECISION = k ** 2
.
However, the reserves can increase without updating the token supply if a user transfers one token of the well and call Well.sync()
.
We can sync the reserves and balances using Well.sync
, but there is no way to sync Well.tokenSupply() ** 2
to x * y * EXP_PRECISION
.
ConstantProduct2.calcReserve
assumes Well.tokenSupply
equals to reserves[0] * reserves[1]
.
This exception breaks the assumption and reverts normal transactions.
For example, when Well.totalSupply
is less than reserves[0] * reserves[1]
, Well.removeLiquidityImbalanced may revert.
TestHelper.sol
. https://github.com/code-423n4/2023-07-basin/blob/main/test/TestHelper.sol#L107function setupWell(Call memory _wellFunction, Call[] memory _pumps, IERC20[] memory _tokens) internal { // ... // @audit comment out the line 107 to see apparently // Add initial liquidity from TestHelper // addLiquidityEqualAmount(address(this), initialLiquidity); }
Well.AddLiquidity.t.sol
as below. https://github.com/code-423n4/2023-07-basin/blob/main/test/Well.AddLiquidity.t.sol#L9contract WellAddLiquidityTest is LiquidityHelper { function setUp() public { setupWell(2); } // @audit add this test function test_tokenSupplyError() public { IERC20[] memory tokens = well.tokens(); Balances memory userBalance; Balances memory wellBalance = getBalances(address(well), well); console.log(wellBalance.lpSupply); // 0 mintTokens(user, 10000000e18); vm.startPrank(user); tokens[0].transfer(address(well), 100); tokens[1].transfer(address(well), 100); vm.stopPrank(); userBalance = getBalances(user, well); console.log(userBalance.lp); // 0 addLiquidityEqualAmount(user, 1); userBalance = getBalances(user, well); console.log(userBalance.lp); // 1e6 well.sync(); // reserves = [101, 101] uint256[] memory amounts = new uint256[](tokens.length); amounts[0] = 1; // FAIL: Arithmetic over/underflow vm.prank(user); well.removeLiquidityImbalanced(type(uint256).max, amounts, user, type(uint256).max); } }
function removeLiquidityImbalanced( uint256 maxLpAmountIn, uint256[] calldata tokenAmountsOut, address recipient, uint256 deadline ) external nonReentrant expire(deadline) returns (uint256 lpAmountIn) { IERC20[] memory _tokens = tokens(); uint256[] memory reserves = _updatePumps(_tokens.length); for (uint256 i; i < _tokens.length; ++i) { _tokens[i].safeTransfer(recipient, tokenAmountsOut[i]); reserves[i] = reserves[i] - tokenAmountsOut[i]; } // @audit // 1e6 - sqrt(101 * (101 - 1) * 1000000 ** 2) // <=> 1e6 - 100498756 lpAmountIn = totalSupply() - _calcLpTokenSupply(wellFunction(), reserves); if (lpAmountIn > maxLpAmountIn) { revert SlippageIn(lpAmountIn, maxLpAmountIn); } _burn(msg.sender, lpAmountIn); _setReserves(_tokens, reserves); emit RemoveLiquidity(lpAmountIn, tokenAmountsOut, recipient); }
Manual review.
In Well.sync()
, mint (reserves[0] * reserves[1] * ConstantProduct2.EXP_PRECISION).sqrt() - totalSupply()
Well tokens to msg.sender
.
This keeps the invariant that Well.tokenSupply() ** 2
equals to reserves[0] * reserves[1] * ConstantProduct2.EXP_PRECISION
as long as the swap fee is 0.
Under/Overflow
#0 - c4-pre-sort
2023-07-11T15:19:54Z
141345 marked the issue as primary issue
#1 - c4-pre-sort
2023-07-13T06:06:43Z
141345 marked the issue as duplicate of #210
#2 - c4-judge
2023-08-05T21:13:54Z
alcueca marked the issue as not a duplicate
#3 - c4-judge
2023-08-05T21:14:01Z
alcueca changed the severity to 2 (Med Risk)
#4 - c4-judge
2023-08-05T21:15:25Z
alcueca changed the severity to 3 (High Risk)
#5 - c4-judge
2023-08-05T21:16:42Z
alcueca marked the issue as primary issue
#6 - c4-judge
2023-08-05T21:17:13Z
alcueca marked the issue as selected for report
#7 - trust1995
2023-08-09T14:37:08Z
This is a good find. However it is hard to find rationalization for HIGH impact. Root issue is: attacker can make it so that there is more funds in the pool than there are supposed to be. The loser of the donation + sync() transaction is the attacker! LP providers will be able to redeem their shares with either:
removeLiquidityOneToken()
removeLiquidity()
Specifically, removeLiquidityImbalanced
will revert because of underflow, but there is no loss or freeze of funds. Therefore it seems clear that maximum severity would be Medium, because a one specific functionality is blocked.
For High, according to the C4 severity guidelines warden must demonstrate a viable loss of funds or breaking the core of the protocol. That is not the case here.
#8 - alcueca
2023-08-15T20:30:56Z
After careful consideration, I'm going to disagree with @trust1995, here is why.
The nature of a DoS is that it is temporary. Either because it takes resources from the attacker to maintain the DoS, or because the victim can eventually change its configuration to stop the DoS.
In this case, the DoS is permanent. The attacker can disable a certain functionality permanently and in all pools. The only possible remedy for Moonwell would be to convince all LPs to remove liquidity, and then to add it again in fixed Wells, which is completely unrealistic.
To me, losing forever a feature is worse than not being able to operate at all for a short period of time. You don't ever fully recover. Moreover, the invariant of the pools is also broken forever.
There are no severities between Medium and High, so if I have to choose, I'll have to choose High for this one.
#9 - trust1995
2023-08-15T22:18:10Z
I appreciate the thought you have put into the submission. However, I believe "losing a feature, forever" is not a HIGH severity rationalization, and there is no case law in C4 to support that it is. I refer you to the two leading severity standards, the C4 standard and the Immunefi standard.. Here is how C4 differentiates between Med and High:
2 — Med: Assets not at direct risk, but the function of the protocol or its availability could be impacted, or leak value with a hypothetical attack path with stated assumptions, but external requirements. 3 — High: Assets can be stolen/lost/compromised directly (or indirectly if there is a valid attack path that does not have hand-wavy hypotheticals).
Clearly this is a "function of the protocol or its availability could be impacted" scenario.
Here's how Immunefi would classify the issue:
Low - Contract fails to deliver promised returns, but doesn't lose value: This is when the code doesn't work as intended (i.e. there is some logic error but that logic error doesn't affect the protocol's funds or user funds). Another example would be an external function that is meant to return a value does not return the correct thing, however, this value is not used elsewhere in application logic.
The last thing I would say is the following plot twist - we are actually NOT even losing a functionality forever. There is an easy bypass to calling removeLiquidityImbalanced()
. Instead, simply call the two functions:
In a worst case scenario, the frontend can implement imbalanced withdrawals with these two calls or just the 2nd call.
Most judges would consider it QA as there is basically no impact, inconvenience at most. To call it a HIGH would be, from my professional opinion, unthinkable.
#10 - lokithe5th
2023-08-16T10:33:44Z
Please forgive me if this is inappropriate @alcueca and @trust1995 , but I might add the following evidence for consideration:
Fixing the imbalance is trivial, and will likely happen by accident if another user adds liquidity. This is because of the same mechanism as described in this PoC.
This boils down to a quirk of this specific implementation: when there is a discrepancy between the reserves and the totalSupply
, it is corrected in the next call to addLiquidity
or swapFrom
.
However, it must be noted that this may open up the contract to more consistent DoS attacks: the attacker can donate and force underflow, and then regain their attack funds by calling addLiquidity
or swapFrom
, but at the cost of removing the block. This would be a risky attack, as any user can front-run and steal the attacker's donation.
#11 - alcueca
2023-08-22T13:19:00Z
Thanks @lokithe5th, it is true that the DoS is not permanent and easily reversed. Downgraded to Medium.
#12 - c4-judge
2023-08-22T13:19:12Z
alcueca changed the severity to 2 (Med Risk)
#13 - publiuss
2023-08-23T00:37:33Z
This was addressed by modifying the sync()
function to to have the signature sync(address recipient, uint256 minLpAmountOut)
and mint LP tokens to recipient
address to prevent the invariant from breaking. It now behaves like a shift(...)
for adding liquidity instead of swapping.
🌟 Selected for report: 0xprinc
Also found by: 0x11singh99, 0xAnah, 0xWaitress, 0xkazim, 2997ms, 33audits, 404Notfound, 8olidity, CRIMSON-RAT-REACH, CyberPunks, DanielWang888, Deekshith99, Eeyore, Eurovickk, Inspecktor, JGcarv, John, Jorgect, Kaysoft, LosPollosHermanos, MohammedRizwan, Qeew, QiuhaoLi, Rolezn, TheSavageTeddy, Topmark, Trust, Udsen, a3yip6, alexzoid, bigtone, codegpt, erebus, fatherOfBlocks, ginlee, glcanvas, hunter_w3b, josephdara, kaveyjoe, kutugu, mahdirostami, max10afternoon, oakcobalt, peanuts, pfapostol, ptsanev, qpzm, radev_sw, ravikiranweb3, sces60107, seth_lawson, te_aut, twcctop, zhaojie, ziyou-
17.5208 USDC - $17.52
First, comment out the line 436 in TestHelper.sol
.
https://github.com/code-423n4/2023-07-basin/blob/main/test/TestHelper.sol#L107
function setupWell(Call memory _wellFunction, Call[] memory _pumps, IERC20[] memory _tokens) internal { // ... // @audit Comment out the line 436 // Add initial liquidity from TestHelper // addLiquidityEqualAmount(address(this), initialLiquidity); }
Well._addLiquidity
does not revert when ConstantProduct2.calcLpTokenSupply
returns 0.
Since 0 * 100 is 0, we mint 0 LP token.
Add this test in Well.AddLiquidity.t.sol
.
function test_addLiquidity0() public { uint256[] memory amounts = new uint256[](tokens.length); amounts[0] = 100; // @audit this is the first minting of Well token well.addLiquidity(amounts, 0, user, type(uint256).max); Balances memory userBalance = getBalances(user, well); assertEq(userBalance.lp, 0); }
https://github.com/code-423n4/2023-07-basin/blob/main/src/Well.sol#L441
+ require(lpAmountOut > 0, "lpAmountOut should be nonzero"); _mint(recipient, lpAmountOut);
Well.decimals()
18 is misleadingCurve 3pool uses 18 decimals and 1e18 LP tokens are almost equivalent to 1DAI according to the return value of get_virtual_price()
.
https://github.com/curvefi/curve-contract/blob/master/contracts/tokens/CurveTokenV3.vy#L50-L56
https://github.com/curvefi/curve-contract/blob/master/contracts/pools/3pool/StableSwap3Pool.vy#L229
UniswapV2Pair
uses 18 decimals for LP tokens and 1e18 UniswapV2Pair token is equivalent to the sum of 1e18 tokenA and 1e18 tokenB when the pair is in balance when both tokens uses decimals 18.
For example, DAI-USDC Pair https://etherscan.io/address/0xae461ca67b15dc8dc81ce7615e0320da1a9ab8d5 returns 18 in function decimals()
.
On the other hand, the Well contract uses 18 decimals, but its decimals are, in fact, 24 because it uses x * y * 1e12
as the token supply. If you addLiquidity with 1 tokenA and 1 tokenB, Well.totalSupply()
returns 1e6.
Save x * y * 1e12
in a new state variable _tokenSupply
and override tokenSupply()
to return truncated least significant 6 digits.
#0 - c4-pre-sort
2023-07-13T14:35:07Z
141345 marked the issue as high quality report
#1 - c4-pre-sort
2023-07-14T05:43:46Z
141345 marked the issue as low quality report
#2 - c4-judge
2023-08-04T21:42:15Z
alcueca marked the issue as grade-b
#3 - c4-judge
2023-08-04T21:42:24Z
alcueca marked the issue as grade-a