Znacie to, kiedy samemu zarzekało się po nocach na hackathonach... ach tak, czekйте, jakie noce, spaliśmy wtedy po trzy godziny pod stołem z serwerami, wydawało mi się, że podstawowe podatności w smart kontraktach ogarnie każdy statyczny analizator. Co w tym trudnego? Reentrancy (atak ponownego wejścia) — klasyka gatunku, pisał o tym każdy, komu się chciało. Wzorzec Checks-Effects-Interactions, jakieś modyfikatory... I nagle wpada do nas пару месяцев назад (znaczy, kilka miesięcy temu) nowy junior do zespołu i z dumą oświadcza: «A mi ChatGPT wygenerował idealny kontrakt stakingowy, sprawdziłem go, jest bezpieczny!»
Prawie wylałem kawę na klawiaturę.
Bądźmy szczerzy: nowoczesne LLM-y piszą świetnie wyglądającego Solidity. Składnia błyszczy, importy poukładane, OpenZeppelin wpięty zgodnie z najnowszymi trendami. Ale kiedy dochodzi do subtelnej logiki zarządzania stanem, sieć neuronowa zaczyna z absolutną, betonową i tępą pewnością siebie generować dziury, które potem podczas audytów czyszczą projekty z milionów dolarów. Rozłóżmy na czynniki pierwsze trzy nieoczywiste przypadki, w których ChatGPT daje ciała, zapewniając przy tym, że Wasz kod bez problemu przeszedłby audyt w CertiK.
Przypadek nr 1: Asynchroniczny staking z nagrodami za blok i ukrytym zewnętrznym wywołaniem
Słuchajcie, proście AI o napisanie kontraktu dystrybuującego tokeny za czas spędzony w puli. Co robi model? Sumiennie wrzuca wzorzec nonReentrant wszędzie tam, gdzie widzi przelew środków do użytkownika. Zapomina jednak o jednym, czysto architektonicznym detalu.
Spójrzcie na ten kawałek kodu, który wyciągnąłem z realnego code review po akcji tego właśnie juniora:
// SPDX-License-Identifier: MIT
pragma solidity 0.8.24;
interface IERC20Mintable {
function mint(address to, uint256 amount) external;
}
contract SecureStakingVault {
IERC20Mintable public rewardToken;
mapping(address => uint256) public balances;
mapping(address => uint256) public lastUpdateBlock;
uint256 public rewardRatePerBlock = 10 * 10**18;
bool private locked;
// Zabezpieczone, no nie?! Czego chcieć więcej, ty paranoiku?
modifier noReentry() {
require(!locked, "LOCKED");
locked = true;
_;
locked = false;
}
constructor(address _token) {
rewardToken = IERC20Mintable(_token);
}
function stake() external payable {
require(msg.value > 0, "Zero stake");
balances[msg.sender] += msg.value;
lastUpdateBlock[msg.sender] = block.number;
}
// To samo arcydzieło od ChatGPT, które wygląda na w pełni bezpieczne
function harvestAndUnstake(uint256 _amount) external noReentry {
require(balances[msg.sender] >= _amount, "Insufficient balance");
uint256 blocksPassed = block.number - lastUpdateBlock[msg.sender];
uint256 reward = blocksPassed * rewardRatePerBlock * _amount / balances[msg.sender];
// Uwaga: aktualizacja stanu następuje PRZED zewnętrznym wywołaniem mint
balances[msg.sender] -= _amount;
lastUpdateBlock[msg.sender] = block.number;
// Emisja nagród wywołuje konstruktor tokena lub hook, a tam... niespodzianka
rewardToken.mint(msg.sender, reward);
(bool success, ) = msg.sender.call{value: _amount}("");
require(success, "Transfer failed");
}
}Gdzie tu haczyk? Model święcie wierzy, że skoro zmienne salda zostały zresetowane przed rewardToken.mint(), to atak jest niemożliwy. Ale rewardToken to niestandardowe ERC-20, którego kontrakt mintowania zawiera hook _beforeTokenTransfer albo callback w stylu ERC-777 (jeśli token jest skomplikowany lub obsługiwany przez proxy). W momencie wywołania mint sterowanie przechodzi do zewnętrznego kodu zanim jeszcze ETH trafi do użytkownika, ale dzieje się to w trakcie aktywnej sesji, kiedy stan puli został już jednostronnie zmodyfikowany, a przeliczenie nagród dla innych użytkowników zdążyło się rozjechać. To międzykondraktowy Reentrancy nowej generacji, o którym podręczniki z reguły milczą.
Przypadek nr 2: Przechwytywanie międzyfunkcyjne poprzez wyrocznie płynności
Podstępność współczesnych exploitów polega na tym, że reentrancy często czai się nie wewnątrz jednej funkcji, lecz między dwoma zupełnie różnymi punktami wejścia, które dzielą tę samą mapę (mapping). AI uwielbia dzielić logikę na „czyste” moduły, całkowicie pomijając przy tym kolejność modyfikacji globalnego niezmiennika.
- Parametr podatności: Ocena analizatora statycznego
- Rzeczywisty stan rzeczy: Modyfikatory
- Obecne (nonReentrant): Bezużyteczne przeciwko wywołaniom międzyfunkcyjnym
- Kolejność operacji: Checks-Effects zachowane lokalnie
- Naruszone globalne saldo protokołu: Reakcja ChatGPT
- «Kod jest w pełni chroniony przed ponownym wejściem»: Przepuszcza manipulację stanem cross-function
Przypadek nr 3: Kontrakty o zmiennym czasie trwania i skarbce ERC-4626
ChatGPT po prostu uwielbia standaryzowane szablony OpenZeppelin dla skarbców zyskownych (ERC-4626). Biera gotowe przykłady, dokłada niestandardową matematykę obliczania udziałów i wypluwa kod, który kompiluje się za pierwszym razem. Tyle że funkcja konwersji akcji na aktywa (convertToAssets) w określonych warunkach wywołania przez kontrakt pośredniczący pozwala na odpalenie wielopoziomowego powrotu sterowania w trakcie obliczania wirtualnej płynności.
Siedziałem sam do północy trzy tygodnie temu, debugując błąd w sieci testowej, kiedy bot symulacyjny wyciągnął z puli całą płynność dokładnie według takiego schematu. Sieć neuronowa pisała: «Użyj skalowania mnożników dla zachowania precyzji». A w efekcie dostawaliśmy klasyczny read-only reentrancy, w którym zewnętrzny system odczytuje tymczasowy, jeszcze niezatwierdzony stan skarbca w momencie wykonywania depozytu.
Kontynuujemy nasze zanurzenie w ten piekielny teatr absurdu, który generują nam modele językowe, gdy prosimy je o napisanie „prostego i niezawodnego smart kontraktu”.
Wiecie, co jest najzabawniejsze w pracy byłego audytora bezpieczeństwa? Obserwowanie, jak deweloperzy ślepo wierzą komentarzom samego ChatGPT. Sieć neuronowa wrzuca na górę linijkę w stylu // Safe against reentrancy attacks thanks to Checks-Effects-Interactions — i tyle, u człowieka wyłącza się krytyczne myślenie. Klapki na oczach, mózg w trybie stand-by, a chwilę później w mainnecie mamy potężny pokaz sztucznych ogni.
Weźmy na warsztat kolejne dwa potężne przykłady, które modele AI uporczywie uważają za wzór bezpieczeństwa, mimo że w praktyce pękają od pierwszego dotknięcia.
Przypadek nr 4: Wieloetapowy arbitraż z rozliczeniem przez flash loan
Oto klasyczne zadanie, które często ląduje na hackatonach albo jest zlecane juniorom: napisać kontrakt, który bierze flash crypto, puszcza przez DEX, zgarnia profit, spłaca kapitał i zapisuje prowizję w puli płynności. Co robi ChatGPT? Grzecznie wstawia sprawdzenia require dla stanu salda na początku i na końcu transakcji, święcie wierząc, że jeśli delta się zgadza, to system jest nie do ruszenia.
Ale diabeł, jak zawsze, tkwi w detalach wywołania funkcji zwrotnej (callback) uniswapV2Call (lub analogicznego interfejsu w innych protokołach).
// SPDX-License-Identifier: MIT
pragma solidity 0.8.24;
interface IUniswapV2Pair {
function swap(uint amount0Out, uint amount1Out, address to, bytes calldata data) external;
}
contract AIArbitrageBotHolder {
address private immutable owner;
bool private unlocked = true;
// AI uważa, że ta barierka wystarczy do zabezpieczenia całej logiki
modifier lock() {
require(unlocked, "LOCKED");
unlocked = false;
_;
unlocked = true;
}
constructor() {
owner = msg.sender;
}
// Ta sama „bezpieczna” metoda według sieci neuronowej
function executeFlashLoan(address _pair, uint256 _amountA, bytes calldata _data) external lock {
// Żądanie płynności od puli
IUniswapV2Pair(_pair).swap(_amountA, 0, address(this), _data);
}
// Callback, do którego pula zwraca sterowanie
function uniswapV2Call(address sender, uint amount0, uint amount1, bytes calldata data) external {
// Brutalny błąd: sprawdzenie msg.sender jest często pomijane przez AI dla „czystości” kodu
// Voila, tutaj może uderzyć dowolny lewy kontrakt i zainicjować pętlę reentrancy
uint256 fee = amount0 * 3 / 997 + 1;
uint256 repayment = amount0 + fee;
// Niestandardowa logika arbitrażu napisana przez ChatGPT
_executeInternalArbitrage(amount0);
// Zwrot długu do puli
// (Tutaj zapominają, że stan zewnętrznych pul nie został jeszcze ostatecznie zatwierdzony)
bool success = IERC20(msg.sender).transfer(msg.sender, repayment);
require(success, "Repayment failed");
}
function _internalArbitrage(uint256 amount) internal {
// Zaślepka złożonej logiki wymiany
}
}Widzicie tę dziurę? Sieć neuronowa zakłada modyfikator lock na zewnętrzny punkt wejścia executeFlashLoan, ale zupełnie zapomina, że funkcja zwrotna uniswapV2Call musi być odizolowana osobno lub rygorystycznie walidować adres wywołujący (msg.sender). Atakujący może wywołać ten callback bezpośrednio ze swojego kontraktu w trakcie obliczeń pośrednich, gdy salda kontraktu owijającego nie zostały jeszcze doprowadzone do stanu spójnego, i doszczętnie wydrenować zabezpieczenie. ChatGPT nigdy wam na to nie zwróci uwagi — uśmiechnie się tylko swoimi emotkami na czacie i rzuci: „Kod zoptymalizowany pod standard ERC”.
Przypadek nr 5: Zoptymalizowane kontrakty masowego mintingu ERC-721 z mechanizmem buforowania
Nowoczesny rynek NFT wymaga taniego gazu. AI doskonale to rozumie i zaczyna „optymalizować” kod, wdrażając niestandardowe struktury mapowania właścicieli oraz liczniki cache bezpośrednio w pamięci kontraktu.
- Metryka audytu: Werdykt ChatGPT — Rzeczywisty status podatności
- Wzorzec przechowywania: Użycie lokalnych struktur w pamięci — Podatne na manipulacje poprzez hooki onERC721Received
- Stabilność: Przechodzi standardowe testy Hardhat — Wykłada się przy głębokim stosie wywołań reentrancy
Gdy kontrakt wysyła NFT przez safeMint, standard wymaga wywołania funkcji potwierdzenia u odbiorcy będącego smart kontraktem. Jeśli robicie minting paczkowy (powiedzmy 10 tokenów naraz) i aktualizujecie licznik ogólnego salda po wysłaniu każdego tokena (albo w pętli wewnątrz zewnętrznego wywołania), atakujący przejmuje kontrolę na trzeciej iteracji, kiedy wewnętrzna tablica tokenów jest wypełniona dopiero w połowie, a licznik transakcji wciąż myśli, że proces dopiero się zaczął. W efekcie otrzymujemy duplikację indeksów i nieskończoną generację aktywów znikąd.
Co z tym począć – ja, wy, cała branża?
Słuchйте, sam piszę kod codziennie, ale ślepe zaufanie do modeli generatywnych w świecie crypto to jak gra w rosyjską ruletkę, w której bębenek ma załadowane wszystkie naboju oprócz jednego. Jeśli użyvate AI do generowania protokołów, zapamiętajcie trzy żelazne zasady:
- Nigdy nie wierzcie modyfikatorom z szablonów. Jeśli sieć neuronowa wkleiła
nonReentrant, oznacza to tylko tyle, że zna to słowo, ale nie rozumie asynchronicznego kontekstu waszego konkretnego blockchaina. - Zawsze sprawdzajte
msg.senderw funkcjach callback. Wszelkie interfejsy typuuniswapV2Call,onERC721Received,tokensReceivedmuszą być chronione lepiej niż brama do wojskowego bunkra. - Piszcie własne testy fuzujące (fuzzer) w Foundry. Żadne statyczne testy jednostkowe nie wyłapią międzyfunkcyjnego reentrancy tak dobrze, jak kilka milionów losowych przebiegów inwariantnych.
Gdy tak sobie tutaj ględzę o tych podatностях, nie daje mi spokoju jedna nachalna myśl: przecież to sami my rozpuściliśmy rynek. Przyzwyczailiśmy się do delegowania rutynowych zadań generatorom kodu, zapominając, że blockchain nie wybacza błędów kompilacji logiki. W tradycyjnym webdevie możesz zrobić rollback commita, wypuścić patcha, przeprosić użytkowników za pięciominutowy downtime. W środowisku EVM twój bug zostanie na zawsze w niezmiennym ledgerze jako pomnik ludzkiej bezmyślności i triumfu algorytmicznej ślepoty.
Dociągnijmy resztę tego tematu i omówmy jeszcze jeden nieoczywisty wektor, który ChatGPT generuje z przerażającą regularnością.
Przypadek nr 6: Agregatory płynności i wirtualne udziały w pulach stablecoinów
To ostatni krzyk mody wśród deweloperów DeFi — tworzenie niestandardowych pul wymiany z dynamicznym obliczaniem wag tokenów w oparciu o krzywe stałego iloczynu. Sieci neuronowe uwielbiają tę matmę, bo w sieci walają się terabajty open-source'owego kodu Curve i Uniswap v2. Model bierze formułę, dopasowuje ją do twoich wymagań, dokłada funkcję addLiquidity i z dumą melduje gotowość produktu do wdrożenia.
Tylko że model nie ma absolutnie pojęcia, jak działa asynchroniczne obliczanie wirtualnych sald przy wywoływaniu zewnętrznych tokenów z płynną prowizją (jak USDT czy niestandardowe tokeny deflacyjne z podatkiem od przelewu).
Spójrz na ten wzorzec, który AI uważa za całkowicie niezawodny:
// SPDX-License-Identifier: MIT
pragma solidity 0.8.24;
interface ITokenWithFee {
function transferFrom(address sender, address recipient, amount) external returns (bool);
function balanceOf(address account) external view returns (uint256);
}
contract AIWarpPool {
mapping(address => uint256) public userShares;
uint256 public totalShares;
// AI uważa, że jeśli najpierw sprawdzimy saldo, a potem zapiszemy do mappingu – jesteśmy bezpieczni
function deposit(address token, uint256 amount) external {
uint256 balanceBefore = ITokenWithFee(token).balanceOf(address(this));
// Zewnętrzne wywołanie przelewu tokena
bool success = ITokenWithFee(token).transferFrom(msg.sender, address(this), amount);
require(success, "Transfer failed");
uint256 balanceAfter = ITokenWithFee(token).balanceOf(address(this));
uint256 realReceived = balanceAfter - balanceBefore;
// Emisja udziałów na podstawie faktycznie otrzymanych środków
uint256 shares = totalShares == 0 ? realReceived : (realReceived * totalShares) / balanceBefore;
userShares[msg.sender] += shares;
totalShares += shares;
}
function withdraw(uint256 shares) external {
require(userShares[msg.sender] >= shares, "Not enough shares");
uint256 amountToReturn = (shares * ITokenWithFee(msg.sender).balanceOf(address(this))) / totalShares;
// Najpierw zmniejszamy stan...
userShares[msg.sender] -= shares;
totalShares -= shares;
// ...a potem oddajemy środki. Klasyczny Checks-Effects-Interactions!
// Ale sekunda...
(bool success, ) = msg.sender.call{value: 0}(""); // Warunkowe zewnętrzne wywołanie przelewu tokena
require(success, "Withdraw failed");
}
}Widzisz tę pułapkę? Sieć neuronowa uczciwie zachowała kolejność Checks-Effects-Interactions w funkcji withdraw. Zupełnie jednak pominęła fakt, że wyliczenie amountToReturn jest powiązane z aktualnym balanceOf(address(this)) w momencie wywołania. Jeśli atakujący stworzy kontrakt-wrapper, który po otrzymaniu kontroli (lub poprzez callback tokena) zainicjuje reentrancy do withdraw, zanim globalne totalShares zmieni się w innej powiązanej puli lub przez cross-contractowe wywołanie wyroczni, obliczy on proporcję na podstawie przestarzałej wartości płynności.
Ostatnia lista kontrolna do weryfikacji tego, co napisało ci AI
Koniec z wchodzeniem ciągle na te same grabie z nadzieją, że sieć neuronowa zna się na architekturze systemów rozproszonych lepiej od ciebie. Zanim wypuścisz wygenerowany kod na produkcję albo chociażby do sieci testowej z prawdziwymi środkami, przejdź przez tę listę:
- Izoluj wszelkie funkcje callback tak, jakby siedział w nich haker z gotowym exploitem.
- Sprawdź każde zewnętrzne wywołanie pod kątem tego, czy może ono zwrócić kontrolę do kontraktu w momencie, kiedy globalne niezmienniki nie są jeszcze spójne.
- Zapomnij o ślepej wierze w modyfikatory — chronią one wyłącznie przed bezpośrednim ponownym wejściem (reentrancy) do tej samej funkcji, ale są bezradne wobec skomplikowanych łańcuchów międzykondraktowych.