Знаете, когда я сам еще ночами сидел на хакатонах..., ах да, погодите, какие ночи, мы тогда вообще спали по три часа под столом с серверами, мне казалось, что уж базовые уязвимости в смарт-контрактах может закрыть любой статичный анализатор. Чего там сложного? Reentrancy (атака повторного входа) — классика жанра, про нее не писал только ленивый. Паттерн Checks-Effects-Interactions, модификаторы всякие... И тут к нам в команду пару месяцев назад приходит джун и гордо так заявляет: «А мне ChatGPT сгенерировал идеальный контракт стейкинга, я его проверил, он безопасен!»
Я чуть кофе на клавиатуру не пролил.
Давайте честно: современные LLM пишут красивый Solidity. Синтаксис блестящий, импорты аккуратные, OpenZeppelin подключен по последнему слову моды. Но когда дело доходит до тонкой логики распределения состояний, нейросеть начинает уверенно, с железобетонной тупой уверенностью генерировать дыры, которые потом на аудитах выкашивают миллионы долларов. Давайте разберем три неочевидных кейса, где ChatGPT садится в лужу, уверяя вас, что ваш код прошел бы аудит в CertiK.
Кейс №1: Асинхронный стейкинг с наградами за блок и скрытым внешним вызовом
Слушайте, вот вы просили ИИ написать контракт распределения токенов за время нахождения в пуле. Что делает модель? Она честно использует паттерн nonReentrant везде, где видит перевод средств пользователю. Но забывает одну деталь, архитектурную.
Посмотрите на этот кусок, который я вытащил из реального код-ревью после того самого джуна:
// 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;
// Защитили же, ну! Чего тебе еще надо, параноик?
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;
}
// Тот самый шедевр от ChatGPT, который выглядит безопасным
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];
// Внимание: обновление стейта происходит ДО внешнего вызова mint
balances[msg.sender] -= _amount;
lastUpdateBlock[msg.sender] = block.number;
// Эмиссия наград вызывает конструктор токена или хук, а там... сюрприз
rewardToken.mint(msg.sender, reward);
(bool success, ) = msg.sender.call{value: _amount}("");
require(success, "Transfer failed");
}
}Где здесь подвох? Модель свято верит, что раз переменные баланса сброшены до rewardToken.mint(), то атака невозможна. Но rewardToken — это кастомный ERC-20, чей контракт минта содержит хук _beforeTokenTransfer или стандартный ERC-777-подобный callback (если токен сложный или проксированный). В момент вызова mint управление перелетает в сторонний код до того, как ETH уледет пользователю, но во время активной сессии, когда состояние пула уже изменено однобоко, а перерасчет наград для других пользователей поплыл. Это межконтрактный Reentrancy нового поколения, о котором стандартные учебники скромно молчат.
Кейс №2: Кросс-функционный перехват через оракулы ликвидности
Коварство современных эксплойтов в том, что реентранси часто живет не внутри одной функции, а между двумя абсолютно разными точками входа, которые делят общий маппинг. ИИ обожает разделять логику на «чистые» модули, совершенно упуская из виду порядок изменения глобального инварианта.
- Параметр уязвимости: Оценка статического анализатора
- Реальное положение дел: Модификаторы
- Присутствуют (nonReentrant): Бесполезны против кросс-функционного вызова
- Порядок операций: Checks-Effects соблюден локально
- Нарушен глобальный баланс протокола: Реакция ChatGPT
- «Код полностью защищен от повторного входа»: Пропускает перекрестную манипуляцию стейтом
Кейс №3: Контракты с плавающей дюрацией и ERC-4626 хранилищами
ChatGPT просто обожает стандартизированные шаблоны OpenZeppelin для доходных хранилищ (ERC-4626). Он берет их готовые примеры, добавляет кастомную математику расчета долей и выдает код, который компилируется с первой попытки. Только вот функция конвертации акций в активы (convertToAssets) при определенных условиях вызова через промежуточный контракт позволяет запустить многоуровневый возврат управления во время расчета виртуальной ликвидности.
Я сам сидел полночи три недели назад, отлавливая баг в тестовой сети, когда бот-симулятор выкачал из пула всю ликвидность ровно по такой схеме. Нейросеть писала: «Используйте масштабирование множителей для точности». А на выходе получался классический read-only reentrancy, когда внешняя система читает промежуточное, еще не зафиксированное состояние хранилища в момент выполнения депозита.
Продолжаем погружение в этот адский театр абсурда, который нам генерируют языковые модели, когда мы просим их написать «простой и надежный смарт-контракт».
Знаете, что самое забавное в работе бывшего секьюрити-аудитора? Это смотреть на то, как разработчики верят комментариям самого ChatGPT. Нейросеть пишет вверху строчку вроде // Safe against reentrancy attacks thanks to Checks-Effects-Interactions — и всё, у человека отключается критическое мышление. Глаз замыливается, мозг отдыхает, а потом в мейннете происходит знатный фейерверк.
Давайте разберем еще два мощнейших примера, которые нейросети упорно считают эталоном безопасности, хотя на практике они пробиваются на раз-два.
Кейс №4: Многошаговый арбитраж с возвратом через flash-займы
Вот классическая задача, которую часто дают на хакатонах или заказывают джунам: написать контракт, который берет флеш-крипту, гонит ее через DEX, собирает профит, возвращает тело долга и фиксирует комиссионные в пуле ликвидности. Что делает ChatGPT? Он аккуратно расставляет require проверки на остаток баланса в начале и в конце транзакции, искренне полагая, что если дельта сходится, то система неуязвима.
Но дьявол, как всегда, прячется в деталях вызова callback-функции uniswapV2Call (или аналогичного интерфейса других протоколов).
// 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;
// ИИ считает, что этого барьера достаточно для защиты всей логики
modifier lock() {
require(unlocked, "LOCKED");
unlocked = false;
_;
unlocked = true;
}
constructor() {
owner = msg.sender;
}
// Тот самый «безопасный» метод по версии нейросети
function executeFlashLoan(address _pair, uint256 _amountA, bytes calldata _data) external lock {
// Запрос ликвидности у пула
IUniswapV2Pair(_pair).swap(_amountA, 0, address(this), _data);
}
// Callback, куда пул возвращает управление
function uniswapV2Call(address sender, uint amount0, uint amount1, bytes calldata data) external {
// Брутальная ошибка: проверка msg.sender часто опускается ИИ для «красоты» кода
// Вуаля, сюда может постучаться любой левый контракт и инициировать реентранси-петлю
uint256 fee = amount0 * 3 / 997 + 1;
uint256 repayment = amount0 + fee;
// Кастомная логика арбитража, написанная ChatGPT
_executeInternalArbitrage(amount0);
// Возврат долга пулу
// (Здесь забывают, что стейт внешних пулов еще не зафиксирован окончательно)
bool success = IERC20(msg.sender).transfer(msg.sender, repayment);
require(success, "Repayment failed");
}
function _internalArbitrage(uint256 amount) internal {
// Заглушка сложной логики обмена
}
}Видите дыру? Нейросеть вешает модификатор lock на внешнюю точку входа executeFlashLoan, но совершенно забывает, что функция обратного вызова uniswapV2Call должна быть изолирована отдельно или жестко валидировать вызывающий адрес (msg.sender). Злоумышленник может вызвать этот callback напрямую из своего контракта в момент выполнения промежуточных расчетов, когда балансы контракта-обертки еще не приведены к консистентному виду, и выкачать залог подчистую. ChatGPT вам никогда на это не укажет, он лишь улыбнется своими смайликами в чате и скажет: «Код оптимизирован под стандарт ERC».
Кейс №5: Оптимизированные ERC-721 пакетные минтинг-контракты с кэшированием
Современный NFT-рынок требует дешевого газа. ИИ это прекрасно понимает и начинает «оптимизировать» код, внедряя кастомизированные структуры маппинга владельцев и счетчики кэша прямо в памяти контракта.
- Метрика аудита: Вердикт ChatGPT — Реальный статус уязвимости
- Расход газа: Сверхнизкий (Gas Optimized) — Достигается за счет нарушения изоляции стейта
- Патерн хранения: Использование локальных структур в памяти — Уязвимо к манипуляциям через onERC721Received хуки
- Стабильность: Проходит стандартные тесты Hardhat — Падает при глубоком стеке реентранси-вызовов
Когда контракт отправляет NFT через safeMint, стандарт требует вызова функции подтверждения у получателя-смарт-контракта. Если вы делаете пакетный минт (скажем, 10 токенов за раз) и обновляете счетчик общего баланса после отправки каждого токена (или циклом внутри внешнего вызова), злоумышленник перехватывает управление на третьей итерации, когда внутренний массив токенов уже заполнен наполовину, а счетчик транзакций еще думает, что процесс только начался. В итоге мы получаем дублирование индексов и бесконечный выпуск активов из воздуха.
Что с этим делать мне, вами всей индустрии?
Слушайте, я сам пишу код каждый день, но слепо доверять генеративным моделям в крипте — это все равно что играть в русскую рулетку, где в барабане заряжены все патроны, кроме одного. Если вы используете ИИ для генерации протоколов, запомните три жестких правила:
- Никогда не верьте модификаторам из шаблонов. Если нейросеть воткнула
nonReentrant, это значит лишь то, что она знает это слово, но не понимает контекст асинхронности вашего конкретного блокчейна. - Всегда проверяйте
msg.senderв callback-функциях. Любые интерфейсы вродеuniswapV2Call,onERC721Received,tokensReceivedдолжны быть защищены жестче, чем шлюз военного бункера. - Пишите собственные fuzz-тесты (фаззеры) на Foundry. Никакие статические юнит-тесты не найдут кросс-функционный реентранси так хорошо, как пара миллионов рандомных инвариантных прогонов.
Пока я тут распинаюсь про эти уязвимости, меня не покидает одна навязчивая мысль: а ведь мы сами расслабили рынок. Мы привыкли делегировать генерации кода рутинные задачи, забывая, что блокчейн не прощает ошибок компиляции логики. В традиционном веб-развитии вы можете откатить коммит, выкатить патч, извиниться перед пользователями за пятиминутный даунтайм. В EVM-среде ваш баг навсегда останется в неизменяемом леджере как памятник человеческой беспечности и триумфу алгоритмической слепоты.
Давайте добьем оставшуюся часть темы и разберем еще один неочевидный вектор, который ChatGPT генерирует с пугающим постоянством.
Кейс №6: Агрегаторы ликвидности и виртуальные доли в стейблкоин-пулах
Это последний писк моды у разработчиков DeFi — создавать кастомные пулы обмена с динамическим расчетом весов токенов на базе кривых постоянного произведения. Нейросети обожают эту математику, потому что в интернете валяется терабайт открытого кода Curve и Uniswap v2. Модель берет формулу, адаптирует под ваши требования, добавляет функцию addLiquidity и гордо рапортует о готовности продукта к деплою.
Только вот модель понятия не имеет, как работает асинхронный расчет виртуальных балансов при вызове внешних токенов с плавающей комиссией (вроде USDT или кастомных deflationary токенов с налогом на перевод).
Посмотрите на паттерн, который ИИ считает абсолютно надежным:
// 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;
// ИИ считает, что если мы сначала считаем баланс, а потом пишем в маппинг - мы в безопасности
function deposit(address token, uint256 amount) external {
uint256 balanceBefore = ITokenWithFee(token).balanceOf(address(this));
// Внешний вызов перевода токена
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;
// Эмиссия долей на основе фактически пришедших средств
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;
// Сначала уменьшаем стейт...
userShares[msg.sender] -= shares;
totalShares -= shares;
// ...а потом отдаем средства. Классический Checks-Effects-Interactions!
// Но погодите-ка...
(bool success, ) = msg.sender.call{value: 0}(""); // Условный внешний вызов токена перевода
require(success, "Withdraw failed");
}
}Видите ловушку? Нейросеть честно соблюла порядок Checks-Effects-Interactions в функции withdraw. Но она совершенно упустила из виду, что расчет amountToReturn завязан на текущий balanceOf(address(this)) в момент вызова. Если злоумышленник создает контракт-обертку, который при получении управления (или через callback токена) инициирует повторный вход в withdraw до того, как изменится глобальный totalShares в другом связанном пуле или через кросс-контрактный вызов оракула, он вычисляет пропорцию по устаревшему значению ликвидности.
Финальный чек-лист для проверки того, что вам написал ИИ
Хватит уже наступать на одни и те же грабли, надеясь, что нейросеть умнее вас в архитектуре распределенных систем. Перед тем как пустить сгенерированный код в продакшен или даже в тестовую сеть с реальными деньгами, пройдитесь по этому списку:
- Изолируйте любые callback-функции так, будто в них сидит хакер с готовым эксплойтом.
- Проверьте каждый внешний вызов на предмет того, может ли он вернуть управление контракту в момент, когда глобальные инварианты еще не сошлись.
- Забудьте про слепую веру в модификаторы — они защищают только от прямого повторного входа в ту же функцию, но бессильны перед сложными кросс-контрактными цепочками.