Błąd KAWPOW w Ravencoin: nagłówek bloku, który sam wybierał sprawdzenie proof of work
Drugi tydzień sierpnia Ravencoin spędził na cofaniu swojego łańcucha o mniej więcej trzy dni. Przyczyną nie był kartel górników, exploit portfela ani zepsuta funkcja skrótu. Było nią jedno pole w nagłówku bloku, któremu węzeł ufał, wybierając, które z dwóch sprawdzeń proof of work (PoW) uruchomić. Każdy fragment kodu cytowany poniżej pochodzi z kodu źródłowego konsensusu samego Ravencoin, a nie z ogłoszeń, a tam, gdzie ogłoszenia mówią coś, czego kod źródłowy nie pokazuje, ten artykuł mówi to wprost zamiast zgadywać.
Co zawiera nagłówek KAWPOW
KAWPOW to proof of work Ravencoin, wariant ProgPoW, na który łańcuch przeszedł w maju 2020 roku, aby monetę nadal można było kopać na zwykłych kartach GPU. Ta zmiana zmieniła nagłówek bloku. Przed czasem aktywacji nagłówek kończy się zwykłym 32-bitowym nonce, którego używa każdy łańcuch wywodzący się z Bitcoina. Po nim serializer zapisuje w tym miejscu trzy pola, w tej kolejności: nHeight, potem nNonce64, potem mix_hash.
Dwa z nich niczym się nie wyróżniają. nNonce64 to po prostu szerszy nonce. mix_hash to pośredni wynik tej części algorytmu, która wymaga dużo pamięci, przenoszony w nagłówku, aby weryfikator miał wartość do porównania, a nie obliczenie do powtórzenia od zera. Ciekawe jest trzecie. nHeight to blok deklarujący we własnym nagłówku, na jakiej wysokości jego zdaniem się znajduje.
Rzeczywista pozycja bloku w łańcuchu nie jest kwestią opinii. Wyznacza ją hashPrevBlock: blok, na którym się buduje, decyduje o tym, gdzie się trafi, i nie da się w tej sprawie skłamać, pozostając jednocześnie dołączonym do czubka łańcucha. nHeight to druga, zapisywalna kopia tego samego faktu, a kopia faktu jest warta zawsze tylko tyle, ile sprawdzenie porównujące ją z oryginałem.
Dwie funkcje skrótu, z których jedna niczego nie weryfikuje
Plik hash.cpp Ravencoin definiuje dwie funkcje będące punktami wejścia KAWPOW, a ich sygnatury opowiadają większość historii, zanim przeczyta się choć jedną linię ich ciał. Pierwsza to uint256 KAWPOWHash(const CBlockHeader& blockHeader, uint256& mix_hash). Druga to uint256 KAWPOWHash_OnlyMix(const CBlockHeader& blockHeader).
Pierwsza przyjmuje mix_hash jako parametr wyjściowy, ponieważ go wytwarza. Wybiera zbiór danych przez ethash::get_epoch_number(blockHeader.nHeight), buduje kontekst tej epoki, jeśli jeszcze go nie ma, a następnie uruchamia na nim progpow::hash. Zwracany mix jest produktem ubocznym wykonanej pracy. To ścieżka wymagająca dużo pamięci, a to wymaganie pamięciowe jest całym powodem, dla którego istnieje KAWPOW, a nie zwykły skrót.
Druga nie przyjmuje takiego parametru, ponieważ niczego nie wytwarza. Zużywa mix leżący już w nagłówku, przez progpow::hash_no_verify(blockHeader.nHeight, header_hash, to_hash256(blockHeader.mix_hash.GetHex()), blockHeader.nNonce64). Biblioteka źródłowa nazwała tę funkcję hash_no_verify i nazwa jest dokładna. Wykonuje ona ostatni krok mieszania na przekazanej jej wartości i zwraca wynik. Nigdy nie buduje zbioru danych, więc nigdy nie ustala, że przekazana jej wartość mogła powstać tylko w wyniku wykonanej pracy.
Żadna z tych funkcji nie jest błędem. Tania istnieje z dobrego powodu: węzeł synchronizujący miliony starych bloków, które rozstrzygnął już wpisany na sztywno punkt kontrolny, nie powinien dla każdej epoki odbudowywać zbioru danych o rozmiarze wielu gigabajtów tylko po to, by ponownie potwierdzić historię, której nikt nie kwestionuje. Jedyne pytanie, na które taki projekt musi odpowiedzieć, brzmi: którym blokom wolno pójść tą drogą.
Bramka i to, czemu ufała
Na to pytanie odpowiada CheckBlockHeader w validation.cpp, a komentarz tuż nad odpowiedzią jasno wyraża zamiar: „If we are checking a KAWPOW block below a know checkpoint height. We can validate the proof of work using the mix_hash”. Zamiar jest słuszny. Implementacja sprawdza jednak warunek block.nHeight <= (uint32_t)pcheckpoint->nHeight, a block.nHeight to liczba, którą blok zapisał sam o sobie.
Linia poniżej wywołuje ogólną funkcję CheckProofOfWork(uint256 hash, unsigned int nBits, const Consensus::Params&). Warto zwrócić uwagę, czego ta sygnatura nie zawiera. Nie ma w niej ani mix, ani nonce, ani nagłówka. Otrzymuje gotowy skrót i cel, porównuje je i nie ma jak się dowiedzieć, która z dwóch funkcji wytworzyła otrzymaną liczbę. Dla bloku KAWPOW CBlockHeader::GetHash() zwraca KAWPOWHash_OnlyMix(*this), więc otrzymuje ona liczbę tanią.
Składając to w całość: blok, który zadeklarował wystarczająco niskie nHeight, był walidowany pytaniem, czy wartość wyprowadzona z jego własnego mix hash leży poniżej celu. Nigdzie na tej ścieżce nic nie pytało, skąd wziął się mix, i nigdzie na tej ścieżce nic nie porównywało zadeklarowanej wysokości z pozycją, którą blok faktycznie zajmował.
Jak nisko to wystarczająco nisko, okazuje się wcale nie subtelne. Najwyższy punkt kontrolny w wydanych parametrach łańcucha mainnet to 2 383 550. Pierwszy sfałszowany blok znajdował się na wysokości 4 487 776. Jego nagłówek musiał deklarować pozycję mniej więcej 2,1 mln bloków za rzeczywistą, a gałąź, którą w ten sposób wybierał, nigdy nie patrzyła na tę różnicę.
Co dał skrót, a czego nie dał
Warto być tu precyzyjnym, ponieważ większość relacji opisywała to niedbale, a niedbała wersja sprawia, że błąd wydaje się zarazem prostszy i dziwniejszy, niż jest.
Skrót nie obniżał trudności. nBits jest walidowane względem tego, czego wymaga rzeczywisty poprzedni blok, a rzeczywisty poprzedni blok jest wybierany przez hashPrevBlock, którego atakujący nie może sfałszować, jeśli ma trafić na czubek łańcucha. Sfałszowany blok musiał spełnić dokładnie ten sam cel co uczciwy blok wykopany w tej samej minucie.
Zmieniła się cena pojedynczej próby. Uczciwy górnik płaci za każdą próbę pełnym przejściem przez zbiór danych liczony w gigabajtach, co jest całym sensem algorytmu wymagającego dużo pamięci i powodem, dla którego KAWPOW od sześciu lat trzyma ASIC z dala od Ravencoin. Skrót kosztuje skrót nagłówka plus jeden ostatni krok mieszania na wartości dowolnie wybranej przez atakującego. Ta sama loteria, te same szanse na każdy los, ale losy tańsze o rzędy wielkości. 2Miners nazwał wyniki blokami niemal bez pracy i w porównaniu z tym, ile KAWPOW ma kosztować, to uczciwy opis.
Co wydarzyło się w sieci
Pierwszy znany nieprawidłowy blok ma wysokość 4 487 776 i znacznik czasu 2026-08-07 15:44:01 UTC. Wykorzystywanie błędu trwało do awaryjnego wydania 10 sierpnia. Na jednym zbadanym odcinku 2089 bloków 2Miners naliczył 96 dotkniętych, więc sfałszowane bloki są rozrzucone po gałęzi, a nie leżą razem w jednej serii, i dlatego naprawą musiała być reorganizacja, a nie chirurgiczne usunięcie.
Dla operatorów węzłów widoczny objaw był gorszy niż po cichu zaakceptowany nieprawidłowy blok. Węzły padały na tych blokach, a po restarcie przestawały się synchronizować. Tron Black opisał od tego czasu ten mechanizm i jest to zgrabny przykład szkód ubocznych. Indeks bloków przechowuje wysokość, na której blok faktycznie się znajduje, a nie wysokość zadeklarowaną w jego nagłówku. Przy restarcie węzeł odtwarza każdy nagłówek z indeksu i ponownie oblicza jego skrót, więc dla sfałszowanego bloku haszuje rzeczywistą wysokość tam, gdzie nagłówek niósł fałszywą. Skrót przestaje się zgadzać, sprawdzenie proof of work kończy się niepowodzeniem, a LoadBlockIndexGuts przerywa działanie. Blok został raz zaakceptowany, a potem stał się niemożliwy do wczytania, i dlatego drugi objaw przetrwał pierwszy.
Kilka giełd wstrzymało wpłaty i wypłaty RVN, dopóki gałąź nie została rozstrzygnięta. To zwykła i właściwa reakcja na łańcuch, którego najnowsza historia ma zaraz zostać przepisana, i warto się jej spodziewać, zamiast odczytywać ją jako wyrok na monetę.
Poprawka i jej pochodzenie
Pierwsza poprawka ukazała się 10 sierpnia jako wersja 4.6.1.1-hf1 i wydał ją 2Miners, pool wydobywczy, a nie projekt Ravencoin. Zatrzymała szkody u każdego, kto ją zainstalował. Dzień później zastąpiło ją nowsze wydanie.
Wersja 4.8.0, opublikowana 11 sierpnia w repozytorium Ravencoin, to wydanie, które należy uruchomić. Nie należy poprzestawać na żadnym wcześniejszym. Odrzuca ona bloki, których zadeklarowana w nagłówku wysokość nie zgadza się z ich rzeczywistą pozycją, od 4 487 776 wzwyż, ustawia punkt kontrolny na 4 487 775 jako ostatnim czystym bloku, automatycznie odbudowuje uszkodzony stan łańcucha zamiast przerywać działanie i zawiera poprawkę przepełnienia przy transferze aktywów, której awaryjny hotfix nie miał, wraz z innymi zmianami z głównej gałęzi.
Naprawą jest tylko pierwsza z tych rzeczy. Wystarczy odrzucić blok, którego zadeklarowana wysokość nie zgadza się z miejscem, w którym faktycznie się znajduje, i bramka się zamyka. Punkt kontrolny i automatyczna odbudowa to odzyskiwanie po awarii, a punkt kontrolny jest szczególnie topornym narzędziem: to wpisane na sztywno twierdzenie, że jeden konkretny blok jest właściwy, a takich rzeczy łańcuch chce mieć jak najmniej. Jest tu dlatego, że uczciwą gałąź trzeba było utrwalić szybciej, niż zrobiłby to sam hashrate.
Sam punkt odzyskiwania był kwestią oceny, a nie oczywistym wyborem. Tron Black napisał, że prosił poole o rozważenie późniejszego punktu odcięcia, aby reorganizacja była płytsza, a sfałszowane bloki obsłużyć jako wyjątki. Odmówiły i wybrały najczystszą możliwą linię. W systemie proof of work taki spór rozstrzyga się sam: gdy większość uczciwego hashrate'u odrzuca jakąś historię, ta historia przegrywa, a dwa największe poole miały razem większość.
Co naprawdę oznacza trzydniowa reorganizacja
Ponieważ podatne węzły zaakceptowały sfałszowane bloki, gałąź, która je zawierała, nabudowała na sobie prawdziwy łańcuch. Odrzucenie jej oznacza reorganizację, a przy jednominutowym docelowym czasie bloku Ravencoin odcinek od 4 487 776 do poprawki to mniej więcej trzy dni bloków.
Dla posiadaczy RVN praktyczne znaczenie jest takie, że transakcje potwierdzone na odrzuconej gałęzi przestają być potwierdzone. Większość z nich wraca do mempool i zostaje wykopana ponownie na łańcuchu, który przetrwał, ale nie jest to gwarantowane, a jedna ich klasa w ogóle nie może wrócić. Transakcja, która wydała coinbase odrzuconego bloku, wydaje monety, które na łańcuchu, który przetrwał, już nie istnieją, i to samo dotyczy wszystkiego, co na niej zbudowano. Takie transakcje są po prostu nieprawidłowe, a nie oczekujące. Dlatego wstrzymanie operacji przez giełdy było właściwą decyzją, a nie przesadną reakcją, i dlatego aktywność po 4 487 775 wymagała ostrożności, dopóki nie ustalił się jeden czubek łańcucha.
Jeden szczegół warto znać, jeśli w tamtym tygodniu węzeł odmawiał ruszenia dalej. Węzły Ravencoin mają regułę zakazu długiej reorganizacji, która istnieje po to, by niespodziewany atak większości nie przepisał głębokiej historii. Większość nieprzerwanie działających starych węzłów w końcu spadła poniżej liczby peerów, od której ta reguła zależy, i wtedy zgodnie z projektem zaakceptowała dłuższy łańcuch. Kilka tego nie zrobiło, ponieważ nigdy nie straciły wystarczająco wielu peerów i ponieważ według reguł w obecnym brzmieniu nic nie zostało naruszone. To zabezpieczenie działające dokładnie zgodnie ze specyfikacją wobec problemu, dla którego nigdy nie zostało zaprojektowane. Podział wziął się z błędu walidacji, a nie z ataku na większość, a reguła nie potrafi odróżnić jednego od drugiego.
Co zrobić, kopiąc RVN
Kto prowadzi własny węzeł Ravencoin, powinien uruchomić 4.8.0, a w szczególności nie jedno z wcześniejszych wydań, które naprawiały tylko część problemu. Należy zatrzymać węzeł, podmienić plik binarny i uruchomić go ponownie. Nie trzeba wykonywać reindeksacji ani żadnego ręcznego kroku: oprogramowanie samo odbudowuje to, czego potrzebuje. Węzeł przechodzący z awaryjnego hotfixa uruchamia się szybko. Węzeł przechodzący z oprogramowania bez poprawki musi odtworzyć i odbudować dane, co na zwykłym sprzęcie trwa kilka godzin, a błędy bad-blk-height przez cały ten czas oznaczają, że poprawka działa, a nie że jest w niej usterka.
Przy kopaniu przez pool nie trzeba niczego zmieniać po swojej stronie. Żadnego nowego adresu, żadnego nowego portu, żadnej aktualizacji programu do kopania. Sam KAWPOW nigdy nie był tu problemem: algorytm robił dokładnie to, do czego został zaprojektowany, a usterka tkwiła w kodzie decydującym, kiedy go uruchomić. Hashrate skierowany na starą gałąź był prawdziwą pracą. Po prostu trafił na bloki, które zostały odrzucone razem z rozrzuconymi między nimi sfałszowanymi.
Jest jednolinijkowy sposób, by potwierdzić, że węzeł trafił na właściwy łańcuch, i warto go użyć, zamiast to zakładać. Należy sprawdzić skrót bloku 4 494 143. Na kanonicznym łańcuchu kończy się on na 4c67d6a4, a niezależne eksploratory Ravencoin pokazywały ten sam łańcuch, gdy projekt ogłosił odzyskanie sieci.
Kształt tego błędu
Po odrzuceniu szczegółów zostaje kształt, który powtarza się nieustannie, w łańcuchach i daleko poza nimi. Wartość, która przyszła z zewnątrz, posłużyła do decyzji, jak dokładnie ta sama wartość zostanie sprawdzona. Nagłówek podawał własną wysokość, a węzeł użył tej deklaracji, aby wybrać, które sprawdzenie proof of work nagłówek będzie musiał następnie przejść.
Poprawna wersja nie jest skomplikowana, a poprawka to w gruncie rzeczy jedna jej linia: porównać zadeklarowaną wysokość z pozycją, którą blok faktycznie zajmuje, zanim pozwoli mu się o czymkolwiek decydować. Ciekawsza połowa to pytanie, dlaczego jej brakowało. Skrót został napisany dla bloków już zaufanych, a w ramach tego założenia bramka brzmi jak stwierdzenie faktu, a nie jak pytanie, na które ktoś mógłby odpowiedzieć nieuczciwie. Przestaje tak brzmieć w chwili, gdy niezaufany blok może wypełnić to pole, a nic w kodzie nie oznaczyło momentu, w którym to się zmieniło.
Dwie rzeczy dotyczące sposobu wykrycia warto odnotować. Kod Ravencoin przechodził profesjonalne audyty bezpieczeństwa i nie wychwyciły one ani tego błędu, ani przepełnienia aktywów naprawionego razem z nim. Tron Black napisał, że mocno podejrzewa, iż oba zostały ujawnione przez przegląd bezpieczeństwa wykonany przez czołowy model AI. Niezależnie od narzędzi pasuje to do kształtu błędu: usterka jest niewidoczna, dopóki czyta się skrót na jego własnych warunkach, i pojawia się dopiero wtedy, gdy zapyta się, kto może wypełnić pole, któremu skrót ufa.
Ostatnia rzecz warta uwagi to kto się poruszył i w jakiej kolejności. Pool wydobywczy przeczytał ścieżkę walidacji, zrozumiał ją i w ciągu trzech dni wydał awaryjny plik binarny. Dwa największe poole zgodziły się następnie kopać łańcuch, który odrzucił całe okno ataku, zamiast wybrać wygodne cięcie, a poproszone o płytsze, odmówiły. Projekt dzień później wydał wersję, która zastępuje hotfix i naprawia więcej niż on. Które z nich było pierwsze, ma mniejsze znaczenie niż to, że wydarzyło się jedno i drugie, oraz to, że sprawę faktycznie rozstrzygnęło nie żadne ogłoszenie, lecz nagromadzony ciężar prawdziwej pracy po jednej stronie podziału. Tak wygląda decentralizacja w dniu, w którym musi udźwignąć ciężar, zamiast leżeć w whitepaperze.
Skąd pochodzą te liczby
Aby każdy mógł to sprawdzić, zamiast wierzyć nam na słowo. Układ nagłówka, dwie funkcje skrótu i ich sygnatury, wybór epoki oraz gałąź walidacji z jej komentarzem pochodzą z kodu źródłowego samego Ravencoin, z plików src/primitives/block.h, src/primitives/block.cpp, src/hash.cpp, src/hash.h, src/pow.h i src/validation.cpp. Lista punktów kontrolnych, w tym 2 383 550 jako najwyższy w mainnecie, pochodzi z src/chainparams.cpp. Pierwsza nieprawidłowa wysokość i jej znacznik czasu pochodzą z komunikatu sieciowego samego projektu Ravencoin. Ścieżka awarii indeksu bloków, transakcje zależne od coinbase, które nie mogą wrócić, zachowanie reguły zakazu długiej reorganizacji, odrzucony płytszy punkt odzyskiwania, uwagi o audycie i przeglądzie AI, zawartość 4.8.0 oraz sprawdzenie bloku 4 494 143 pochodzą z relacji Trona Blacka dla projektu Ravencoin. Wersja awaryjnego hotfixa i liczba 96 dotkniętych bloków na 2089 pochodzą z wydania 2Miners i doniesień wokół niego. Wszystko, czego źródła nie podają, zostało pominięte w tym artykule, a nie dopowiedziane.