Siemanko! Skoro to czytasz, to albo masz totalną zajawkę na punkcie bezpieczeństwa smart kontraktów, albo właśnie kminisz architekturę nowego protokołu DeFi i lekko trzęsą Ci się portki na samą myśl, że jutro wszystko może pójść z dymem przez jakiegoś głupiego buga.
Słuchaj, siedzę w tym temacie nie od dziś. Przeszedłem drogę od hackathonów, gdzie na kolanie pisaliśmy exploity, lecąc na samej pizzie i energetykach, aż po fotel CTO w giełdzie krypto. I powiem Ci jedno: większość grubych akcji i hacków, które badałem (a parę udało mi się wyłapać jeszcze na etapie audytu), wcale nie wynika z tego, że padła kryptografia. To nie wina tego, że kompilator Solidity nagle zwariował. Wszystko rozbija się o fundamentalny brak ogaru w kwestii tego, jak standardy ERC zachowują się na styku wzajemnych interakcji.
Przywykliśmy traktować standardy jak świętość i gwarancję bezpieczeństwa. Ale diabeł tkwi w szczegółach implementacji i ukrytych efektach ubocznych. Rozłóżmy na czynniki pierwsze tematy, na których architekci najczęściej wywalają się na głupi ryj, i zobaczmy, jak zaprojektować system tak, żeby w końcu spać spokojnie.
1. Ukryte zagrożenie w ERC-20: Niebezpieczna klasyka
Mogłoby się wydawać, że ERC-20 zostało już prześwietlone na wylot. Co tam może pójść nie tak? Dosłownie wszystko, jeśli integrujesz w swoim protokole obce tokeny bez sztywnej weryfikacji.
Problem braku zwracanej wartości (The No-Return Dilemma)
Według specyfikacji funkcje transfer i transferFrom powinny zwracać wartość typu bool. W realiach jednak cała masa starych, ciężkich tokenów (pozdro dla kontraktów USDT i BNB w ich starszych wersjach) w ogóle tego nie robi. Po udanej operacji nie zwracają po prostu nic.
Jeśli Twój kontrakt spodziewa się wartości bool przez standardowy interfejs w ten sposób:
// Nigdy tak nie rób, jeśli pracujesz z losowymi tokenami!
IERC20(token).transferFrom(msg.sender, address(this), amount);To przy próbie interakcji z takim USDT transakcja z automatu wywali revert, ponieważ EVM będzie szukać zwracanej wartości na stosie, a tam pusto. Albo co gorsza, jeśli w ogóle nie sprawdzasz wyniku (używając token.transfer(...) zamiast opakować to w require(token.transfer(...))), to niektóre tokeny w razie błędu zwrócą false zamiast rzucić revertem, a Twój kontrakt poleci dalej z koksem, jakby nigdy nic. Efekt? User właśnie zrespawnował sobie balans z powietrza.
Rozwiązanie: Raz na zawsze zapomnij o bezpośrednim wywoływaniu transfer i transferFrom. Używaj biblioteki SafeERC20 od OpenZeppelin i jej metod safeTransfer / safeTransferFrom. Ona pod maską sprawdza niskopoziomowy zwrot danych (low-level return) i prawidłowo ogarnia ułomne kontrakty.
Weird ERC-20 Tokens: Kiedy standard zmienia się w dynię
Łap krótką ściągawkę z tokenów, które zachowują się zupełnie inaczej, niż piszą w podręcznikach. Jako architekt musisz brać to pod uwagę przy projektowaniu logiki pul płynności.
| Typ tokena (Weird ERC-20) | O co kaman? | Czym to grozi architekturze? |
|---|---|---|
| Deflationary / Fee-on-Transfer (np. STA, PAXG) | Pobierają prowizję bezpośrednio w trakcie transferu. | Myślałeś, że kontrakt dostał 100 tokenów, a na balans wpadło 99. Wewnętrzne rozliczanie płynności się rozjeżdża i powstaje dziura budżetowa. |
| Upgradable Proxies (np. USDC, USDT) | Logika tokena może być w każdej chwili zmieniona przez adminów. | Wpadają czarne listy (Blacklists). Jeśli adres Twojego kontraktu dostanie bana, cała płynność zostaje uwięziona w środku na amen. |
| Rebasing Tokens (np. AMPL) | Balans na portfelach zmienia się dynamicznie (podaż dostosowuje się, by stabilizować cenę). | Stan konta w kontrakcie może samoczynnie wzrosnąć lub zmaleć, bez jakiegokolwiek wywołania funkcji transferu. |
2. ERC-721 i ERC-1155: Pułapka onERC721Received i Reentrancy
O, to jest mój "ulubiony" motyw. Ile to już marketplaców NFT i protokołów lendingowych poszło z torbami przez funkcje bezpiecznego transferu!
Kiedy wywołujesz safeTransferFrom w ERC-721 lub ERC-1155, kontrakt tokena sprawdza, czy odbiorca jest smart kontraktem. Jeśli tak, odpala u odbiorcy hook onERC721Received lub onERC1155Received.
Po co? Żeby upewnić się, że dany kontrakt w ogóle ogarnia NFT i tokeny tam nie utkną.
Gdzie jest haczyk? Ten hook przekazuje kontrolę do zewnętrznego, niezaufanego kodu dokładnie w samym środku Twojej transakcji, zanim jeszcze zdążysz zaktualizować stan wewnętrzny własnego kontraktu!
Kod podatnego mintu / marketplace'u
Obczaj ten kawałek kodu. Napisałem go celowo tak, żeby pokazać klasyczny błąd architektoniczny – złamanie wzorca Checks-Effects-Interactions.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts/token/ERC721/IERC721.sol";
contract VulnerableNFCLending {
// Przechowujemy info o zastawach: User => ID tokena => Czy zastaw aktywny
mapping(address => mapping(uint256 => bool)) public hasCollateral;
IERC721 public nftToken;
constructor(address _nft) {
nftToken = IERC721(_nft);
}
// User chce wziąć pożyczkę pod zastaw NFT
function depositCollateral(uint256 tokenId) external {
// 1. Interactions: Przelewamy NFT na kontrakt
// safeTransferFrom odpala hook onERC721Received na kontrakcie odbiorcy?
// Czekaj, nie, odbiorcą tutaj jesteśmy MY. Ale jeśli kontrakt wywołuje safeMint...
// Dobra, zmieńmy kontekst na sytuację, gdy to my oddajemy NFT albo gdy agresor przejmuje kontrolę.
// Przepiszmy scenariusz: Kontrakt oddaje NFT z powrotem (np. przy wypłacie zastawu)
// albo jest to kontrakt mintujący, który najpierw transferuje, a potem aktualizuje stan.
}
}Pokażę Ci to lepiej na czystym przykładzie z dziurawym mintem, tak będzie o wiele bardziej przejrzyście. Załóżmy, że mamy kontrakt, który pozwala na darmowy mint tylko 1 NFT na jeden portfel.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts/token/ERC721/ERC721.sol";
contract VulnerableMint is ERC721 {
mapping(address => bool) public hasMinted;
uint256 public currentTokenId;
constructor() ERC721("DangerNFT", "DNFT") {}
function freeMint() external {
// Checks
require(!hasMinted[msg.sender], "Brother, you already have one!");
// Interactions (Wewnątrz _safeMint zaszyte jest wywołanie zewnętrznego kontraktu!)
_safeMint(msg.sender, currentTokenId);
currentTokenId++;
// Effects (Aktualizacja stanu następuje ZDECYDOWANIE ZA PÓŹNO)
hasMinted[msg.sender] = true;
}
}A teraz kontrakt atakującego. On po prostu przechwytuje ten hook, widzi, że zmienna hasMinted na głównym kontrakcie wciąż ma wartość false, i uderza w funkcję freeMint raz za razem, dopóki nie wyczyści całego limitu albo nie zje mu całego gazu.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
interface IVulnerableMint {
function freeMint() external;
}
contract Attacker {
IVulnerableMint public target;
uint256 count;
constructor(address _target) {
target = IVulnerableMint(_target);
}
function attack() external {
target.freeMint();
}
// Ten sam nieszczęsny hook, który jest triggerowany przez standard ERC-721
function onERC721Received(
address,
address,
uint256,
bytes calldata
) external returns (bytes4) {
if (count < 5) {
count++;
// Reentrancy! Stan wewnętrzny ofiary nie został jeszcze zaktualizowany
target.freeMint();
}
return this.onERC721Received.selector;
}
}Kurde, ile ja razy widziałem ten motyw podczas audytów. Deweloperzy myślą sobie: "Eee tam, to tylko transfer NFT, a nie wysyłka natywnego ETH, co złego może się stać?". A no to, że zostaniesz opędzlowany do zera.
Zasada architekta: Najpierw aktualizuj stan (hasMinted[msg.sender] = true;), a dopiero potem wywołuj jakiekolwiek metody mintowania czy transferu. I zawsze, słyszysz, zawsze wrzucaj modyfikator nonReentrant od OpenZeppelin na funkcje, które operują na transferach NFT.
Lecimy dalej. Skoro rozłożyliśmy już na czynniki pierwsze temat Reentrancy przez hooki, czas rozkminić świeższy i znacznie bardziej wyrafinowany problem. Mało kto zawraca sobie nim głowę na etapie projektowania architektury – dopóki nagle wszystko nie jebnie na produkcji.
3. ERC-2612 (Permit): Widmowe approve'y i ataki Front-runningowe
Standard ERC-2612 przyniósł światu Web3 ogromną ulgę w postaci funkcji permit. Pozwala ona użytkownikom podpisać offline wiadomość (EIP-712) dającą zielone światło na wydanie tokenów, a koszt gazu przerzucić na relayera lub sam protokół. UX zaliczył potężny skok: zamiast klepać dwie transakcje (approve + transferFrom), user ogarnia wszystko jednym strzałem.
Architekci często jednak zapominają, jak ten podpis działa pod maską, i sadzą krytyczne babole w samej logice.
Front-running podpisu (Signature Front-running)
Wyobraź sobie klasyczną funkcję depozytu do smart kontraktu z wykorzystaniem permit:
function depositWithPermit(
uint256 amount,
uint256 deadline,
uint8 v,
bytes32 r,
bytes32 s
) external {
// Najpierw odpalamy permit na bazie przekazanego podpisu użytkownika
IERC20Permit(token).permit(msg.sender, address(this), amount, deadline, v, r, s);
// Potem ściągamy tokeny
IERC20(token).transferFrom(msg.sender, address(this), amount);
// Naliczamy wewnętrzne punkty / udziały w puli
_mintShares(msg.sender, amount);
}Gdzie tu tkwi błąd? Każdy bot MEV śledzący publiczny mempool widzi tę transakcję jak na dłoni. Bot może bez problemu wyciągnąć prawidłowy podpis (v, r, s) oraz parametry z Twojej transakcji, skraść je i wywołać token.permit(...) bezpośrednio na kontrakcie tokena, ustawiając wyższy Gas Price niż użytkownik.
Jego transakcja przejdzie jako pierwsza. Podpis zostanie pomyślnie skonsumowany, a allowance ustawiony. Chwilę później wchodzi transakcja uczciwego usera. Ale ponieważ podpis został już wykorzystany, nonce użytkownika w kontrakcie tokena poszedł w górę! Wywołanie permit wewnątrz depositWithPermit wypluje revert, bo podpis stracił ważność.
Efekt? Transakcja użytkownika rypnie, gość przepali gaz na marne, a jego UX zostanie zdewastowany. Jeśli to była krytyczna dopłata zabezpieczenia (margin) do pozycji long, przez to opóźnienie user może z miejsca wyłapać likwidację.
Jak zabezpieczyć architekturę?
Zapakuj wywołanie permit w blok try/catch. Jeśli podpis został już podbity i wrzucony do sieci przez front-runnera, kontrakt tokena ma już ustawiony odpowiedni allowance. Twój kontrakt powinien po prostu zignorować błąd zduplikowanego podpisu i lecieć dalej z koksem, wykonując transferFrom.
try IERC20Permit(token).permit(msg.sender, address(this), amount, deadline, v, r, s) {}
catch {
// Jeśli wywaliło błąd – możliwe, że podpis został już sfrontrunnowany.
// Sprawdzamy, czy obecny allowance wystarczy do wykonania operacji.
require(IERC20(token).allowance(msg.sender, address(this)) >= amount, "Permit failed and allowance insufficient");
}Problem "Widmowego Permita" (Phantom Permit)
A to już czysto insiderowy ból dupy, o którym mało gdzie się pisze. Co się stanie, jeśli Twój protokół DeFi obsługuje dowolne tokeny, a użytkownik spróbuje wywołać depositWithPermit dla tokena, który NIE wspiera ERC-2612?
Pomyślisz zapewne: "No, kontrakt się wywali, przecież nie ma tam funkcji permit". Otóż nie zawsze!
Jeśli kontrakt tokena ma zaimplementowaną generyczną funkcję fallback() lub receive(), która nie robi revertu przy wywołaniu nieznanego selektora (co zdarza się w niektórych kontraktach proxy lub starych tokenach WETH), to wywołanie permit zakończy się sukcesem (zwróci success = true), mimo że żadne uprawnienie do dysponowania środkami faktycznie nie powstanie.
Następnie kontrakt przejdzie do transferFrom, które wyłoży się, o ile nie było tam jakiegoś starego approve'a. Jeśli jednak ten exploit zbiegnie się w czasie z logiką, gdzie allowance jest weryfikowany w inny sposób, można srogo dostać po kieszeni i zostać reked. Zawsze upewniaj się, czy dany token realnie wspiera IERC20Permit (sprawdzając jego interfejs przez ERC-165) albo twardo trzymaj się whitelistowania tokenów.
4. ERC-3156 (Flash Loans): Ryzyko manipulacji balansem wewnątrz jednej transakcji
Fleszloany to potężne narzędzie, ale całkowicie niszczą założenia czasowe, do których przywykli tradycyjni architekci. W obrębie jednej, atomowej transakcji napastnik może pożyczyć miliony dolarów, wykręcić krzywą akcję i oddać hajs na koniec.
Najbardziej hardkorowym błędem architektonicznym jest tutaj poleganie na funkcji balanceOf(address(this)) do wyliczania wartości udziałów w puli lub ceny aktywa.
// KATASTROFALNY BŁĄD W ARCHITEKTURZE
function getSharePrice() public view returns (uint256) {
// Cena udziału zależy od aktualnego stanu konta tokenów na kontrakcie
return token.balanceOf(address(this)) / totalShares;
}Jeśli Twój kontrakt pozwala brać Flash Loan na te same tokeny, to w momencie, gdy pożyczkobiorca zgarnia środki, balans kontraktu spada praktycznie do zera. Jeśli w tym samym ułamku sekundy (wewnątrz callbacku onFlashLoan) Twój protokół pozwala na wykonywanie innych operacji (np. likwidacji czy naliczania nagród), wycena udziałów zostanie potężnie zniekształcona.
Hacker bierze flash loana -> balans puli szoruje po dnie -> cena udziału drastycznie spada -> hacker z drugiego portfela skupuje tanio jak barszcz udziały w puli -> oddaje flash loana -> balans puli wraca do normy -> hacker sella udziały po standardowej cenie. I tyle, pula wyczyszczona do zera.
Złota zasada: Nigdy nie opieraj się na balanceOf(address(this)) przy krytycznych obliczeniach matematycznych czy ekonomicznych, jeśli ten balans można chwilowo zmienić bez trwałej modyfikacji stanu logicznego systemu. Stosuj wewnętrzną księgowość (internal accounting) przez zmienną typu uint256 internalReserve, aktualizowaną wyłącznie przy w pełni kontrolowanych depozytach i wypłatach.
Dobra, przejdźmy do rzeczy, od których mi – jako gościowi odpowiedzialnemu za bezpieczeństwo infrastruktury giełdowej – autentycznie jeżą się włosy na głowie. Pogadajmy o nowych i stosunkowo świeżych standardach, gdzie te miny nie zostały jeszcze tak mocno rozdeptane przez buty setek deweloperów.
5. ERC-4337 (Account Abstraction): Pułapki na poziomie paczkowania transakcji i Paymastera
Abstrakcja konta to sztos, bez dwóch zdań. Odchodzimy od zwykłych EOA na rzecz smart kontraktów działających jako portfele użytkowników. Koniec z seed frazami, wjeżdża social recovery i opcja płacenia za gas w stablecoinach dzięki Paymasterom.
Ale z perspektywy architekta protokołu, który integruje się z ERC-4337, otwiera się tutaj całe spektrum bardzo specyficznych wektorów ataku.
podatność sygnatury w validateUserOp
W ERC-4337 kluczowym punktem niestandardowej walidacji portfela jest metoda validateUserOp. Ma ona za zadanie sprawdzić podpis transakcji i zwrócić odpowiedni status.
// Uproszczony przykład logiki walidacji w smart portfelu
function validateUserOp(
UserOperation calldata userOp,
bytes32 userOpHash,
uint256 missingAccountFunds
) external returns (uint256 validationData) {
// Krytyczny błąd: Ufamy wywołaniu z JAKIEGOKOLWIEK adresu?
// Nie, Bundler wywołuje to przez EntryPoint. Ale jeśli zapomnimy o checku...
require(msg.sender == entryPoint, "Only EntryPoint can trigger validation");
// Własna walidacja podpisu
if (_verifySignature(userOp, userOpHash)) {
// Zwracamy 0, jeśli walidacja przeszła pomyślnie
return 0;
}
// Zwracamy SIG_VALIDATION_FAILED (zwykle 1) przy błędzie
return 1;
}Czekaj, widzisz w czym tkwi haczyk? Według specyfikacji ERC-4337, jeśli walidacja podpisu się wywali, funkcja NIE może zrobić revertu. Musi zwrócić specjalną, spakowaną wartość (kod błędu), żeby EntryPoint (główny kontrakt-dyrygent) ogarnął, że transakcja jest trefna. Dzięki temu nie pobierze gasu z portfela, a po prostu odrzuci operację już na poziomie Bundlera.
Jeśli jako architekt z przyzwyczajenia wklepiesz tam require(isValid, "Invalid signature");, to przy masowym wysyłaniu paczki transakcji (batch), jedna uwalona TX przez twardy revert wysadzi w powietrze cały pakiet Bundlera. W efekcie Twój portfel albo Paymaster dostanie bana u bundlerów, a Twoi użytkownicy zostaną całkowicie odcięci od możliwości robienia transakcji. Logika walidacji musi być atomowa i rygorystycznie trzymać się matematyki zwracanych wartości ERC-4337, a nie klasycznych wzorców z Solidity.
Atak na Paymastera (Drenaż gasu)
Jeśli Twój protokół DeFi bawi się w Paymastera (na przykład dotujesz gas swoim użytkownikom, żeby mogli handlować bez opłat), musisz na sztywno odizolować etap walidacji od świata zewnętrznego.
W kontrakcie Paymastera siedzi metoda validatePaymasterUserOp. Wewnątrz niej **kategorycznie zabrania się** korzystania z dynamicznego stanu (dynamic state), który mógłby ulec zmianie między momentem symulacji transakcji przez bundlera a momentem wbicia jej do bloku. Przykładowo, nie możesz wywoływać wyroczni cenowych (Chainlink) bezpośrednio w walidacji paymastera, żeby policzyć, ile tokenów ściągnąć od gościa za gas.
Dlaczego? Atakujący może puścić transakcję – podczas symulacji oracle pokaże określoną cenę (walidacja przejdzie), ale tuż przed samym zatwierdzeniem bloku haker zmanipuluje cenę wyroczni (flasbloanem albo szybkim trade'em). Walidacja na chainie zacznie się sypać, ale bundler wziął już transakcję na warsztat i przepalił gas. Kasa zejdzie z Twojego paymastera, a user nie zapłaci ani grosza. Twój depozyt na gas zostanie doszczętnie wyczyszczony w kilka godzin.
6. ERC-4626 (Tokenized Vaults): Front-running pierwszego depozytu (Inflation Attack)
ERC-4626 to standard dla tokenizowanych skarbców (staking, yield poole, lending). Ujednolicił on metody deposit, mint, withdraw i redeem. To genialna sprawa, bo teraz jakikolwiek agregator typu Yearn może podpiąć nowy pool w 5 minut.
Jednak w samym matematycznym projekcie tego standardu zaszyto bombę zegarową znaną jako **Inflation Attack** (atak inflacyjny). Uderza ona w poole w tym niefortunnym momencie, kiedy dopiero co startują w sieci, a ich stan konta wynosi równe zero.
Mechanika ataku
Wzór na wyliczenie liczby udziałów (shares), jakie dostaje użytkownik w zamian za wpłacone aktywa (assets), wygląda zazwyczaj tak:
$$\text{shares} = \frac{\text{assets} \times \text{totalShares}}{\text{totalAssets}}$$
Gdy pool jest pusty (totalShares == 0), to domyślnie shares == assets. Czyli przelicznik leci 1 do 1.
A teraz patrz na ręce hakera:
- Uczciwy użytkownik wysyła transakcję
depositna kwotę 1000 USDC do nowiutkiego, pustego poola ERC-4626. - Haker widzi to w mempoolu i robi front-runa (podbija gas fee). Deponuje w poolu zaledwie 1 wei USDC. Pool mintuje mu dokładnie 1 wei udziałów. W tym momencie
totalShares = 1, atotalAssets = 1. - Następnie, w tej samej atomowej transakcji, haker robi bezpośredni przelew (zwykłym
transfer, a nie przez funkcjędeposit) grubej kasy – powiedzmy 10 000 USDC – prosto na adres kontraktu poola. - Co stało się z matematyką poola?
totalSharesto wciąż 1, aletotalAssetswynosi teraz 10 001 USDC (bezpośredni transfer napompował balans kontraktu, ale nie wyemitował nowych udziałów). Cena za jeden share wystrzeliła w kosmos. Na koniec wykonuje się transakcja uczciwego usera na 1000 USDC. Kontrakt przelicza udziały według wzoru:
$$\text{shares} = \frac{1000 \times 1}{10\,001} = 0$$
Przez zaokrąglanie w dół w Solidity (dzielenie całkowitoliczbowe), użytkownik dostaje okrągłe 0 udziałów! Mimo to jego 1000 USDC bezpowrotnie trafia na balans poola.
- Haker robi
withdrawswojego jedynego udziału (1 wei shares) i czyści pool do zera: zabiera swoje 10 000 USDC, swoje 1 wei oraz 1000 USDC skradzione ofierze.
Rozwiązanie architektoniczne: Można się przed tym obronić na dwa sposoby. Pierwszy – przy tworzeniu poola wymusić miętowo "martwą płynność" (dead shares) na adres zero (zablokować tam pierwsze 1000 wei udziałów, dokładnie tak, jak rozwiązano to w Uniswap V2). Drugi – użyć zaktualizowanych bibliotek OpenZeppelin, które mają wbudowaną ochronę przez wirtualne offsety (virtual assets i virtual shares). Dzięki temu mianownik ułamka nigdy nie zamieni się w zero lub jedynkę podczas manipulacji balansem.
Dobra, to teraz wejdźmy poziom wyżej. Przeanalizowaliśmy już konkretne tokeny, NFT, permity i skarbce. Ale jak to wszystko spiąć w jedną, spójną architekturę i nie oszaleć przy integracji?
Kiedy projektujesz duży system – na przykład agregator yieldów albo cross-chain bridge – musisz żonglować wieloma standardami naraz. I właśnie wtedy odpala się efekt synergii podatności: dwa rozwiązania, które osobno są w 100% bezpieczne, po połączeniu mogą otworzyć krytyczną dziurę w Twoim protokole.
7. Macierz ryzyka architektonicznego przy projektowaniu systemów
Żebyś miał przed oczami pełen obraz sytuacji, wrzucam tę tabelę. Potraktuj ją jako gotową checklistę na Twój najbliższy architecture review. Zapisz to sobie w Notion albo po prostu wydrukuj.
| Standard ERC | Główne ukryte zagrożenie | Efekt w logice biznesowej | Jak to załatać na poziomie designu? |
|---|---|---|---|
| ERC-20 | Brak zwracanej wartości / Niestandardowy transfer | Wywalenie transakcji (revert) albo ciche przepuszczenie błędu | Używać wyłącznie SafeERC20 od OpenZeppelin. |
| ERC-20 (Weird) | Fee-on-Transfer / Zmiana balansu (Rebase) | Rozjazd między wewnętrzną księgowością systemu a realnym stanem konta na kontrakcie | Liczyć różnicę stanów konta za pomocą balanceAfter - balanceBefore zamiast ufać argumentowi amount. |
| ERC-721 / 1155 | Przejęcie kontroli nad flow transakcji przez hooki onERC...Received | Reentrancy (atak ponownego wejścia) przed aktualizacją wewnętrznego stanu (state) | Rygorystyczne trzymanie się wzorca Checks-Effects-Interactions + modyfikator nonReentrant. |
| ERC-2612 | Frontrunning podpisów w mempoolu | DoSy (Denial of Service) uderzające w uczciwych użytkowników | Zamykać wywołania permit w blokach try/catch. |
| ERC-3156 | Chwilowe osuszenie puli z płynności (Flash Loan) | Manipulacja cenami spotowymi opartymi bezpośrednio na balanceOf | Używać wewnętrznych zmiennych rezerwowych (internal reserves) zamiast bezpośredniego sprawdzania salda kontraktu. |
| ERC-4337 | Twardy revert przy walidacji paczek (batching) | Ban fabryki portfeli lub kontraktu u bundlerów | Zwracać specyficzne, magiczne stałe błędów zamiast kraszowania transakcji przez require. |
| ERC-4626 | Inflation Attack (Atak inflacyjny na pierwszy depozyt) | Zaokrąglanie udziałów (shares) do zera, kradzież środków pierwszego deponenta | Mintować „martwe udziały” (dead shares) na address(0) przy inicjalizacji puli lub wdrożyć wirtualne offsety. |
8. Przemyślenia na boku i złote zasady bezpiecznej architektury
Wiesz co? Po trzech latach na krześle CTO zrozumiałem jedno. Najbezpieczniejszy kod to ten, którego w ogóle nie napisano. Im bardziej przekombinowany jest Twój diagram architektury, tym więcej w nim ukrytych zależności. A to drastycznie podnosi szansę, że jakiś dzieciak z hackathonu znajdzie lukę, o której Ty nawet nie pomyślałeś, pijąc poranną kawę.
Gdybym miał zostawić Cię z zaledwie trzema zasadami, które uchronią Twój projekt przed trafieniem na jedynkę Rekt News, brzmiałyby one tak:
- Nigdy nie ufaj zewnętrznym kontraktom. Nieważne, że to najpopularniejszy token o statusie blue-chipa. Jutro jego admini zaktualizują proxy, wrzucą kogoś na czarną listę i Twój system po prostu zastygnie. Pisz kod z założeniem, że każdy zewnętrzny token to złośliwy, nieprzewidywalny podmiot, który tylko czeka, żeby Cię zdrenować.
- Najpierw state, potem transfery. Będę to powtarzał jak zepsuta płyta. To jest absolutna baza wbijana do głowy na pierwszym roku każdego sensownego kursu smart kontraktów. A ludzie z uporem maniaka dalej wysyłają tokeny zanim zaktualizują cyferki w mappingach. Najpierw zmieniasz stany i uprawnienia wewnątrz swojego kontraktu, zapisujesz to w blockchainie, a zewnętrzne
transfer,safeMintczycallwywołujesz jako absolutnie ostatni krok. - Odizoluj matematykę od surowego balansu kontraktu. Stan konta Twojego kontraktu w EVM jest publiczny i banalnie prosty do zmanipulowania. Ktoś może Ci wstrzyknąć miliony dolarów we flash loanie albo po prostu odpalić
selfdestructna innym kontrakcie, wymuszając przelanie Etheru na Twój adres. Jeśli Twoja logika naliczania nagród albo wyceny udziałów zależy od tego, ile tokenów fizycznie leży w tym momencie na kontrakcie – już przegrałeś. Twoja wewnętrzna księgowość musi być odizolowana niczym kabina pilota.
I to by było na tyle. Przelecieliśmy przez najważniejsze punkty zapalne w standardach ERC. Nawet topowy team devów może zaliczyć twarde lądowanie, jeśli architekt zawczasu nie podłoży bezpiecznych poduszek w odpowiednich miejscach systemu.