hamburger

(jeśli zgłaszasz przypadek phishingu, zapisz mail (przesuń go z programu pocztowego na pulpit komputera lub wybierz opcję plik/zapisz jako), a następnie załącz)

Podejrzany SMS prześlij na nr 508 700 900

Jeśli zgłoszenie dotyczy bezpieczeństwa dzieci, zgłoś je również pod http://www.dyzurnet.pl
@CERT_OPL

Jak okradziono zdecentralizowaną giełdę Cetus

Jak kosztowna może okazać się nieuwaga przy tworzeniu kodu? W przypadku, gdy chodzi o giełdę kryptowalut niedokładność może kosztować nawet kilkaset milionów dolarów! Artykuł autorstwa Adama Pichlaka o ataku na giełdę Cetus to kolejna publikacja z Raportu CERT Orange Polska 2025, którą publikujemy w całości na naszej stronie. Pamiętajcie jednak, że na łamach raportu znajdziecie teksty dla każdego – nie tylko publikacje tak eksperckie jak poniższa.

Wraz z powstaniem sieci blockchain, realizujących smart kontrakty, pojawiły się również zdecentralizowane giełdy (DEX – Decentralized Exchange). To właśnie na smart kontraktach opiera się logika tych giełd. Sieci takie jak Ethereum, Solana czy omawiany w tym artykule SUI zabezpieczają realizację tych kontraktów tak, by kod zawarty w kontrakcie mógł już w sposób automatyczny, bez ingerencji stron trzecich, wykonać powierzone mu zadania. Jak się okazuje, każdy kij ma dwa końce. Źle napisany kod może doprowadzić do kompletnej porażki, czyli w przypadku zdecentralizowanych giełd utraty milionów, a czasem nawet setek milionów dolarów. Tak się zdarzyło w maju 2025 w przypadku funkcjonującej na blockchainie SUI zdecentralizowanej giełdy Cetus. 

Aby zrozumieć przypadek utraty środków giełdy Cetus funkcjonującej na sieci SUI konieczne jest wskazanie kilku aspektów i różnic pomiędzy giełdami centralnymi (CEX) a zdecentralizowanymi (DEX). 

Giełdy CEX i DEX różnią się w wielu aspektach, ale na potrzeby artykułu poruszymy ten kluczowy dla naszego przypadku aspekt – obsługę zleceń. 

CEX i arkusz zleceń

W tradycyjnych scentralizowanych giełdach cena podyktowana jest zleceniami, które wystawiają klienci. Przykład: 

SPRZEDAŻ SUI USDC 
31 
1.5 30 
KUPNO SUI USDC 
0.5 29 
28 

Sprzedający wystawiają oferty sprzedaży, kupujący oferty kupna. Obie strony robią to bez opłat. Gdy oferta kupna/sprzedaży pokryje się z istniejącą ofertą w arkuszu zleceń (orderbook) ostateczna cena waloru wynika z ostatniej zrealizowanej oferty z arkusza (dopiero tu pojawia się zysk giełdy – wynika z opłaty ze zrealizowanej transakcji). Na przykład, wystawiając ofertę kupna 2 SUI po każdej cenie pokryjemy 1.5 SUI kosztujące 30 i 0.5 z ceny 31, wtedy cena waloru na naszej giełdzie wyniesie 31, a oferta 2 SUI po cenie 31 zmieni się na 1.5 SUI kosztujące 31.  

DEX – AMM i CLMM 

Giełdy zdecentralizowane oparte na smart kontraktach działają “głównie” w nieco inny sposób niż giełdy CEX (głównie – bo zdarzają się giełdy z arkuszami zleceń działające na sieciach szybkich i tanich jak np. Solana). Samo wystawienie oferty wiązałoby się z dużymi kosztami w zależności od używanej platformy – np. w Ethereum przy aktualnej cenie mogłoby być to kilkanaście dolarów i więcej. Co za tym idzie częstotliwość aktualizacji orderbooka – a zatem i płynność – byłyby mocno ograniczone. Dlatego powstał protokół AMM (Automated Market Maker). W jego ramach wprowadzone zostały pule płynności – każdy może zostać LP (Liquidity Provider), zdeponować parę walutową i zarabiać na opłatach związanych z obrotem tej pary. W ten sposób giełdzie dostarczana jest płynność (zdeponowane środki).  Ale jak jest ustalana cena, skoro nie ma tu klasycznego arkusza zleceń? Przy każdej zamianie (swapie) tokenu, DEX dba o to by proporcja zdeponowanych środków się zgadzała (stała k była taka sama), a cenę reprezentuje ilość środków y do x: 

k = x * y  

x - ilość tokenów A w puli (np. SUI) 
y - ilość tokenów B w puli (np. USDC) 
k - stała 

Tym samym, jeśli w puli jest 100 SUI i 100 USDC (k to 10000) i zakładając, że 1 SUI to 1 USDC (dla uproszczenia), to przy zakupie (swapie) 50 SUI w puli zostanie 50 SUI i 200 USDC. Stała k będzie taka sama jak wcześniej (10000), cena wynosi już 4 USDC za 1 SUI (200/50), a realna cena przy zakupie to 2 USDC (zapłacono 100 USDC zatem 100/50). Dalej całą “pracę” wykonają arbitrażyści, którzy bardzo chętnie wykorzystają okazję, gdy zauważą atrakcyjną cenę SUI/USDC. Tym samym zamienią więcej SUI na rzecz USDC, jeśli będzie to dla nich korzystne. Zdeponowane środki będą się przesuwać jak na wykresie. 

Przeskok ilości zdeponowanych środków przed i po zamianie (swapie) jednego tokenu na drugi – ilość tokenu x wzrosła, a tokenu y zmalała.  

AMM ma bardzo istotną wadę – środki zdeponowane przez LP rozkładają się na cały zakres cen (od 0 do nieskończoności), przez co znaczna część środków nie jest wykorzystywana, a efektywność kapitału jest niska – swapy powodują większą zmianę cen (slippage). Problem ten rozwiązuje CLMM (Concentrated Liquidity Market Maker), gdzie LP może zdecydować w którym zakresie cen (y/x) jego płynność może być wykorzystywana przez DEX-a (i również zarabiać tylko w tym zakresie). Daje to możliwość skondensowania kapitału w konkretnych przedziałach cenowych zamiast do nieskończoności jak to ma miejsce w AMM. W CLMM cena nie jest ciągła a reprezentowana przez “ticki” czyli logarytmiczny krok ceny: 

Wzór matematyczny

Przy zamianie tokenów, gdy skończy się płynność w danym zakresie (przy swapie), następuje przeskok do następnego ticka (ceny) do momentu osiągnięcia określonej ilości środków lub maksymalnej ceny wskazanej przez klienta (sqrtPriceLimit). 

LP deponując środki decyduje, w których tickach (poziomach cenowych) jego płynność “pracuje” (proporcja zdeponowanej pary walutowej jest narzucana przez DEX-a tak, by nie zachwiać ceny udostępniając płynność).

Wzór na obliczenie ilości tokenu na podstawie podanej płynności wygląda jak poniżej: 

Wzór matematyczny

L – płynność 
A – ilość tokenu x 
PL – cena z niższego zakresu tick 
PU – cena z wyższego zakresu tick 

Błyskawiczne pożyczki (Flash Loan) 

DEX-y udostępniają możliwość udzielenia pożyczek i o ile w przypadku zwykłych pożyczek konieczne jest zdeponowanie określonej ilości innego tokenu (o większej wartości niż sama pożyczka) o tyle jest możliwość pożyczki błyskawicznej. Jedynym kosztem jest koszt transakcji plus ewentualna mała opłata, ale jest też pewien bardzo istotny warunek. Pożyczkobiorca musi obrócić pożyczonymi tokenami naraz. Tzn. musi zaciągnąć pożyczkę, wykonać jakąś opłacalną dla niego akcję i zwrócić pożyczkę. Giełda zaakceptuje ją tylko wtedy, gdy zostanie zwrócona w jednej transakcji.  

Podatny kontrakt 

Poniżej znajduje się funkcja, do której trafia kod odpowiedzialny za dodanie płynności. Funkcja add_liquidity ostatecznie wywołuje funkcje get_delta_a, która pokazuje, ile środków powinno się pojawić przy zadeklarowanej przez LP płynności: 

public fun get_delta_a( 
    sqrt_price_0: u128, 
    sqrt_price_1: u128, 
    liquidity: u128, 
    round_up: bool, 
): u64 { 
    let sqrt_price_diff = if (sqrt_price_0 > sqrt_price_1) { 
        sqrt_price_0 - sqrt_price_1 
    } else { 
        sqrt_price_1 - sqrt_price_0 
    }; 
    if (sqrt_price_diff == 0 || liquidity == 0) { 
        return 0 
    }; 

    let (numberator, overflowing) = math_u256::checked_shlw( 
        full_math_u128::full_mul(liquidity, sqrt_price_diff), 
    ); 
    if (overflowing) { 
        abort EMULTIPLICATION_OVERFLOW 
    }; 
    let denominator = full_math_u128::full_mul(sqrt_price_0, sqrt_price_1); 
    let quotient = math_u256::div_round(numberator, denominator, round_up); 
    assert!(quotient <= std::u64::max_value!() as u256, EAMOUNT_CAST_TO_U64_OVERFLOW); 
    (quotient as u64) 

Najważniejszą funkcją jest tutaj check_shlw. To w niej jest podatność – przepełnienie liczby całkowitej: 

public fun checked_shlw(n: u256): (u256, bool) { 
        let mask = 0xffffffffffffffff << 192; 
        if (n > mask) { 
              (0, true) 
         } else { 
              ((n << 64), false) 
        } 

Funkcja ta jest odpowiedzialna za zwiększenie precyzji poprzez przesunięcie w lewo o 64 bity (kontrakt nie używa liczb zmiennoprzecinkowych, obliczenia wykonywane są na liczbach całkowitych bez znaku), czyli wartością zwracaną jest argument przesunięty o 64 bity w lewo lub 0 wraz z informacją czy nastąpiło przepełnienie. Sprawdzenie jest błędne ze względu na to, że 0xffffffffffffffff << 192 to górna wartość liczby 256 bitowej. Tymczasem kod powinien upewnić się, że argument nie jest liczbą, która mieści się w najstarszych 64 bitach, aby ostateczna wartość po przesunięciu nie została ucięta. Poprawne sprawdzenie wystąpiłoby przy porównaniu argumentu z wartością 1 << 192. Wtedy, jeśli argument jest równy lub większy od tej wartości, funkcja checked_shlw zwróci zmienną overflow jako true, w efekcie czego proces dodania płynności zakończyłby się niepowodzeniem.  

Wynik obliczeń w funkcji get_delta_a w przypadku istnienia podatności wyniesie 1, ponieważ licznik (numberator) będzie mniejszy (bo przepełniony – ucięta wartość) od mianownika (denominator) co w konsekwencji z funkcją div_round i zaokrągleniem (round_up) na true da wynik 1. 

Konsekwencje? Wystarczyłoby, żeby atakujący zdeponował tylko jedną jednostkę tokenu jako dostawca płynności (LP), by osiągnąć ogromne ilości płynności z perspektywy kontraktu, a następnie móc zwracać rzekomą wcześniej wprowadzoną płynność i w ten sam sposób drenować pule, jedną po drugiej (haSUI/SUI, SCA/USDC itd.). 

W dniu wystąpienia ataku deweloperzy wdrożyli poprawkę:  

diff --git a/sui/sources/math_u256.move b/sui/sources/math_u256.move 
index 01d1412..25bfd30 100644 
--- a/sui/sources/math_u256.move 
+++ b/sui/sources/math_u256.move 
@@ -16,7 +16,7 @@ module integer_mate::math_u256 { 
     } 

     public fun checked_shlw(n: u256): (u256, bool) { 
-        let mask = 0xffffffffffffffff << 192; 
+        let mask = 1 << 192; 

         if (n > mask) { 
             (0, true) 
         } else { 
diff --git a/sui/sources/math_u256.move b/sui/sources/math_u256.move 
index 25bfd30..2775033 100644 
--- a/sui/sources/math_u256.move 
+++ b/sui/sources/math_u256.move 
@@ -17,7 +17,7 @@ module integer_mate::math_u256 { 

     public fun checked_shlw(n: u256): (u256, bool) { 
         let mask = 1 << 192; 
-        if (n > mask) { 
+        if (n >= mask) { 

             (0, true) 
         } else { 
             ((n << 64), false) 

Przebieg ataku na giełdę Cetus

Poniżej lista zdarzeń wygenerowana dla pierwszej puli, którą zaatakowano – haSUI/SUI: 

  1. Atakujący generuje SwapEvent. W rzeczywistości jest to flash_swap czyli jedna z form wspomnianej wcześniej pożyczki błyskawicznej, którą musi spłacić w jednym z wyżej kroków – podczas jednej transakcji. Atakujący pożycza SUI i musi oddać odpowiednią ilość haSUI.  
Atak na giełdę Cetus - porównanie kwotu pożyczonej i kwoty do oddania.

Pożyczona ilość to amount_out (SUI), kwota do oddania to amount_in (haSUI). 

  1. Otwarcie pozycji. Potrzebuje oddzielnego wywołania by stworzyć NFT, w którym zapisze jej szczegóły – m. in. dolny i górny tick. 
  1. Tu wykonuje się przepełnienie. Atakujący deponuje jedną jednostkę tokenu i otrzymuje ogromne ilości płynności: 

Odpowiednio dobrane parametry liquidity i tick dzięki przepełnieniu dają możliwość wpłacenia tylko jednej jednostki tokenu – amount_a=1. 

  1. RemoveLiquidity. Atakujący wybiera taką ilość tokenu A, aby w pierwszej kolejności spłacić wcześniejszą pożyczkę: 

Na tym etapie atakujący jest już w posiadaniu 5,765,124.790450508 SUI, ponieważ spłacił pożyczkę wybierając 10,024,321.275017082 haSUI z puli. 

  1. RemoveLiquidity. Atakujący pobiera 1 haSUI na rzecz spłaty add_liquidity: 
  1. RemoveLiquidity. Atakujący “wyciąga” z puli 10,024,321.275017081 haSUI 

Przestępca na tej jednej puli zdobył łącznie w przybliżeniu 5765125 SUI i 10024321 haSUI. 

Podsumowanie ataku na Cetus

Łączne środki wykradzione przez atakującego to aż ok. 223 miliony dolarów! Zdążył jednak wyprowadzić „tylko” ok. 60 milionów dolarów, ponieważ całą resztę udało się zamrozić na poziomie sieci SUI. Atakujący sprytnie wykorzystał podatność przepełnienia w kontrakcie giełdy Cetus. Sprawę komplikuje fakt, że kontrakty w sieci SUI bazują na języku Move, który słynie przede wszystkim z odporności na przepełnienia arytmetyczne. Niefortunnie dla giełdy oprócz błędnych mechanizmów bezpieczeństwa zastosowanych w krytycznych funkcjach okazało się, że przepełnienia z udziałem przesunięć bitowych nie są brane pod uwagę w mechanizmach bezpieczeństwa języka Move. Istotne jest również to, że Cetus przechodził wiele audytów w przeszłości (ostatni miał miejsce miesiąc przed zdarzeniem!) i żaden nie wykrył tej podatności. 

Atak oprócz samej giełdy trafił przede wszystkim w dostawców płynności (LP) i ich zdeponowane środki. Przykład ten dobitnie pokazuje, że nawet w pozornie bezpiecznym, zdecentralizowanym miejscu, które regularnie dokonuje audytów bezpieczeństwa zgromadzone środki mogą zostać zagrożone. 

Właściciele Cetus zaproponowali atakującemu możliwość zatrzymania 6 milionów dolarów, jeśli oddałby resztę zrabowanych środków. Po czasie i braku prób nawiązania kontaktu z jego strony, właściciele Cetus zaproponowali 5 milionów dolarów za informacje o przestępcy, które pomogłyby go schwytać organom ścigania.

Adam Pichlak


Dodaj komentarz

Twój adres email nie zostanie opublikowany. Wymagane pola są oznaczone *

Zobacz także