배경
2025년 5월 22일, 우리는 SUI에서 Cetus 에 대한 공격을 모니터링했습니다.
https://suiscan.xyz/mainnet/tx/DVMG3B2kocLEnVMDuQzTYRgjwuuFSfciawPvXXheB3x
이 공격으로 총 2억 2,300만 달러 의 손실이 발생했습니다.
공격 및 사고 분석
공격자는 먼저 flash_swap을 사용하여 haSUI를 SUI로 교환합니다. flash_swap은 swap과 flashloan의 변형입니다. token0에서 token1로의 flash_swap에서 token1을 먼저 구한 후, repay_flash_swap을 통해 token0을 지불할 수 있습니다.

위의 작업을 통해 공격자는 5,765,124.79 SUI를 얻었고 동일한 거래에서 10,024,321.29 haSUI를 지불해야 했습니다. 그리고 이 풀의 hasUI의 sqrtPriceX64는 18,956,530,795,606,879,104, tick = 545에서 18,425,720,184,762,886, tick = -138185로 변경되었는데, 이는 가격이 1.056에서 0.0000009977로 떨어졌음을 의미하며, 원래 가격의 0.00009%로 크게 떨어졌습니다.
그런 다음 공격자는 open_position을 통해 유동성 포지션을 생성했는데, 해당 포지션의 tickLower는 300000이고 tickUpper는 300200이었습니다.


틱 범위에 해당하는 가격 범위는 다음과 같습니다.

공격자는 이 가격 범위 내에서 10,365,647,984,364,446,732,462,244,378,333,008의 유동성을 추가했습니다.

유동성을 추가하는 코드를 살펴보겠습니다.

유동성을 추가하는 데 필요한 amount_a와 amount_b는 get_amount_by_liquidity를 통해 얻어지는 것을 볼 수 있습니다. 다음으로 get_amount_by_liquidity의 구체적인 구현과 해당 매개변수를 살펴보겠습니다.

arg2는 currentTick이고, currentTick = -138185는 arg0 lowerTick보다 작습니다. 따라서 코드는 다음으로 이동합니다.

arg0가 lowerTick이므로 cetus::tick_math::get_sqrt_price_at_tick(arg0) = 60257519765924248467716150이고, arg1이 upperTick이므로 cetus::tick_math::get_sqrt_price_at_tick(arg1) = 60,863,087,478,126,617,965,993,239입니다.
get_delta_a 함수의 구체적인 구현은 다음과 같습니다.

Cetus의 핵심 문제는 checked_shlw 함수에 있습니다. 이 함수의 구체적인 코드를 살펴보겠습니다.

이 함수의 논리는 매우 간단합니다. 숫자를 한 단어, 즉 64비트만큼 왼쪽으로 이동합니다. 오버플로가 발생하면 0이 반환되고, 오버플로가 발생하지 않으면 왼쪽으로 이동한 후의 숫자가 반환됩니다. 따라서 좌측 이동 후 오버플로가 발생하는지 감지하기 위해서는 input > 2 ^ (256 - 64) - 1인지 판단해야 합니다. 하지만 코드는 input > 0xffffffffffffffff << 192인지 판단합니다. (0xffffffffffffffff << 192) > 2 ^ (256 - 64)이므로, 이 코드는 입력 숫자 2 ^ (256 - 64) < input <(0xffffffffffffffff << 192)를 잘라내어 반환하게 되어 오버플로 감지에 실패합니다. 이 시점에서 공격자가 구성한 데이터는 다음과 같습니다.

따라서 입력 = 10365647984364446732462244378333008 * 605567712202369498277089 =6277101735386680763835789423207666416102355444464034512896 , 입력은 2^(256-64)보다 크지만 입력은 0xffffffffffffffff << 192보다 작습니다. 따라서 왼쪽으로 이동할 때 오버플로가 발생할 수밖에 없지만, 프로그램의 오버플로 감지는 실패합니다. 오버플로가 발생한 후, v1 = 유동성 * (상위 SqrtPriceX64 - 하위 SqrtPriceX64) - 2 ^ (256 - 64) = 491983144293873864340816.
이 값은 lowerSqrtPriceX64 * upperSqrtPriceX64보다 훨씬 작으므로 get_delta_a의 반환 값은 0입니다. 따라서 공격자는 10365647984364446732462244378333008의 유동성을 추가하기 위해 1 token_a(p + 1)만 지불하면 됩니다.

이후 공격자는 remove_liquidity를 통해 추가된 유동성을 제거하고 repay_flash_swap을 통해 flash_swap의 지불되지 않은 토큰을 지불하여 공격을 완료했습니다. 공격자는 5,765,124 SUI와 10,024,321 haSUI의 수익을 냈습니다. 마지막으로 Cetus 팀은 두 개의 PR을 통해 취약점을 수정했습니다.
https://github.com/CetusProtocol/integer-mate/pull/6/files

첫 번째 해결책은 취약점을 완전히 해결하지 못했습니다. mask = 1 << 192 = 2 ^ 256이므로 n >= mask 또는 n > mask - 1만 가능하므로 64비트를 왼쪽으로 이동한 후 오버플로 잘림을 방지하기 위해 완전히 수정할 수 있습니다.
https://github.com/CetusProtocol/integer-mate/pull/7/files

요약하다
이 취약점의 원인은 해당 함수가 좌측 이동 오버플로를 검사할 때 잘못된 마스크 값을 선택하여 오버플로 검사가 실패하고 해당 값이 잘리기 때문입니다. 결국 공격자는 코드 논리에 맞지 않는 작은 토큰으로 엄청난 양의 유동성을 추가하는 결과를 낳았습니다. 프로젝트 소유자는 경제 모델과 코드 운영 로직을 설계할 때 다자간 검증을 실시하고, 계약이 온라인으로 진행되기 전에 교차 감사를 위해 여러 감사 회사를 선택하는 것이 좋습니다.
