Паттерны безопасности: владелец, реентерабельность, выплаты
- Зачем это нужно
- Владелец: одна переменная и один модификатор
- Три стадии владения
- Внешний вызов отдаёт управление чужому коду
- Порядок строк и есть защита
- Замок nonReentrant
- Тяни, а не толкай
- Сколько газа получает код получателя
- Аварийный стоп
- Порядок строк: шпаргалка
- Типичные ошибки
- Слова главы
- Тренажёр: набрать вслепую
- На чемпионате
- Что дальше
- Углубиться
- Экзамен по блоку «Контракты и связи»
- Куда дальше: проверенные ресурсы
Зачем это нужно
Аудитор читает контракт не строчками, а приёмами. Он не проверяет каждую формулу — он ищет знакомые места: кто владелец, в каком порядке идут строки при выплате, что произойдёт, если получатель откажется принимать деньги.
Модуль В конкурсного задания требует отчёт по информационной безопасности, и это буквально список таких мест. Эта глава — про пять приёмов, которые там ищут. Каждый разобран на прогоне, где неправильный вариант теряет деньги, а правильный — нет.
Это последняя глава блока. После неё — экзамен по всем пяти главам про объекты и связи.
Владелец: одна переменная и один модификатор
address public owner;
constructor() { owner = msg.sender; }
modifier onlyOwner() {
require(msg.sender == owner, unicode"Только владелец");
_;
}
Никакой магии: обычная переменная и обычная проверка. Всё, что мы уже знаем, собрано в приём, у которого есть общепринятое имя.
К нему прилагаются две функции:
function transferOwnership(address newOwner) public onlyOwner {
require(newOwner != address(0), unicode"Новый владелец — нулевой адрес");
owner = newOwner;
emit OwnershipTransferred(msg.sender, newOwner);
}
function renounceOwnership() public onlyOwner {
owner = address(0); // необратимо
}
Проверка на нулевой адрес обязательна: у нулевого адреса нет владельца ключа, и передача владения ему равносильна отказу от владения — но по недосмотру.
Три стадии владения
Третья модель строит таблицу «кто что может» на трёх стадиях жизни контракта:
── сразу после деплоя ── owner: 0xf39F…2266
кто setPaused withdrawAll transferOwnership
deployer ок ок ок
anna откат откат откат
boris откат откат откат
── transferOwnership(anna) от deployer ── owner: 0x7099…79C8
сначала пробуем address(0): откат «Новый владелец — нулевой адрес»
deployer откат откат откат
anna ок ок ок
boris откат откат откат
── renounceOwnership() от anna ── owner: 0x000…0000
deployer откат откат откат
anna откат откат откат
boris откат откат откат
в казне заперто: 5.0 ETH — withdrawAll больше не вызовет никто
Два наблюдения.
Бывший владелец теряет права мгновенно и получает ровно тот же отказ, что посторонний. Никакого «переходного периода» нет.
Отказ от владения необратим. После него ни одна функция под onlyOwner не вызовется никогда. В модели в казне остались заперты 5 ETH. Это не ошибка контракта — это его нормальное поведение, и именно поэтому такую функцию либо не пишут вовсе, либо снабжают подтверждением в два шага.
Внешний вызов отдаёт управление чужому коду
Ключевая мысль всей главы: когда контракт отправляет деньги другому контракту, тот получает управление и может вызвать вас обратно — пока вы не закончили работу.
Для этого у контракта-получателя есть особая функция:
receive() external payable {
// выполняется, когда контракту присылают эфир без данных
}
Внутри неё можно делать что угодно — в том числе снова вызвать того, кто прислал деньги. Отсюда название приёма: повторный вход (reentrancy).
Порядок строк и есть защита
Сравним две функции. Найдите разницу:
// вариант А
function withdraw() public {
uint256 amount = balance[msg.sender];
require(amount > 0, unicode"Нечего снимать");
(bool ok, ) = msg.sender.call{value: amount}("");
require(ok, unicode"Перевод не прошёл");
balance[msg.sender] = 0;
}
// вариант Б
function withdraw() public {
uint256 amount = balance[msg.sender];
require(amount > 0, unicode"Нечего снимать");
balance[msg.sender] = 0;
(bool ok, ) = msg.sender.call{value: amount}("");
require(ok, unicode"Перевод не прошёл");
}
Переставлены две строки. Первая модель запускает атаку на оба варианта — атакующий контракт в своей receive() снова вызывает withdraw:
── BankBad: в банке 3.0 ETH, атакующий вносит 1 ETH ──
повторных входов через receive(): 3
в банке осталось: 0.0 ETH
у атакующего контракта: 4.0 ETH
запись balance[anna]: 2.0 ETH
газ на атаку: 92520
anna снимает свои 2 ETH: reverted with reason string 'Перевод не прошёл'
── BankCEI: в банке 3.0 ETH, атакующий вносит 1 ETH ──
повторных входов через receive(): 1
в банке осталось: 3.0 ETH
у атакующего контракта: 1.0 ETH
текст отказа банка: «Нечего снимать»
anna снимает свои 2 ETH: ок
── BankGuarded: в банке 3.0 ETH, атакующий вносит 1 ETH ──
повторных входов через receive(): 1
в банке осталось: 3.0 ETH
текст отказа банка: «Повторный вход запрещён»
газ на атаку: 137430
anna снимает свои 2 ETH: ок
Атакующий внёс один эфир, а вынес четыре: пока запись balance не обнулена, каждая повторная проверка amount > 0 проходит.
Самое неприятное — состояние после атаки: у anna в записи по-прежнему 2 ETH, а денег в банке нет. Запись есть, денег нет — и честный вкладчик получает отказ.
Правило, которое это лечит, называется «проверки → изменения → взаимодействия»:
- все
require— первыми; - все записи в хранилище — вторыми;
- внешние вызовы — последними.
В варианте Б обнуление стоит до перевода, поэтому повторный вход упирается в «Нечего снимать».
Замок nonReentrant
Второй способ — явный замок:
uint256 private lock = 1;
modifier nonReentrant() {
require(lock == 1, unicode"Повторный вход запрещён");
lock = 2;
_;
lock = 1;
}
Здесь работает то, что мы видели в главе про функции: код после _; выполняется после тела. Флаг ставится до вызова и снимается после — повторный вход упирается в проверку.
Замок даёт внятный текст ошибки, но стоит газа: 137 430 против 90 767. И он не заменяет правильный порядок строк, а дополняет его.
Числа 1 и 2 вместо false/true — не причуда: запись ненулевого значения поверх ненулевого дешевле, чем превращение нуля в единицу. Ту самую разницу в 17 100 газа мы измеряли в главе про хранение.
Тяни, а не толкай
Второй классический приём — про выплаты нескольким получателям сразу. Вторая модель рассылает 3 ETH троим, и третий получатель — контракт, который отказывается принимать:
── «толкай»: рассылка 3 ETH троим, третий — контракт с revert в receive() ──
payAll: reverted with reason string 'Получатель отказался'
в контракте осталось: 3.0 ETH
anna получила: 0.0 ETH
boris получил: 0.0 ETH
── «тяни»: те же трое, каждый забирает сам ──
anna claim(): ок, газ 28725
boris claim(): ок, газ 28725
причитается Refuser: 1.0 ETH — лежат в контракте, никому не мешают
anna claim() второй раз: reverted with reason string 'Нечего забирать'
Транзакция атомарна: откат на третьем получателе отменяет и первые два перевода. Anna и boris не получили ничего, хотя их адреса шли первыми в списке.
Приём «тяни, а не толкай»: контракт записывает долг, а получатель забирает сам:
function claim() public {
uint256 amount = owed[msg.sender];
require(amount > 0, unicode"Нечего забирать");
owed[msg.sender] = 0; // изменение
(bool ok, ) = msg.sender.call{value: amount}(""); // взаимодействие
require(ok, unicode"Перевод не прошёл");
}
Это тот же порядок «проверки → изменения → взаимодействия», и он же решает проблему рассылки: отказавшийся получатель мешает только себе.
Сколько газа получает код получателя
Та же модель замеряет, сколько газа достаётся коду получателя при двух способах перевода:
── transfer против call: сколько газа получает receive() ──
transfer → gasleft() внутри receive(): 2262
call → gasleft() внутри receive(): 16484147
── получатель пишет в storage при получении ──
transfer: Transaction reverted: contract call run out of gas
call: ок, газ всей транзакции 53866
Две тысячи против шестнадцати миллионов. Получателю, который при приёме денег делает хоть что-то содержательное, двух тысяч не хватает — и вся транзакция откатывается.
Это та же история, что в главе про функции, но теперь видно её последствие целиком: перевод через transfer делает часть получателей недоступными. Правильный способ — call с обязательной проверкой результата и с защитой от повторного входа.
Аварийный стоп
Пятый приём — выключатель:
bool public paused;
modifier whenNotPaused() {
require(!paused, unicode"Контракт на паузе");
_;
}
function setPaused(bool value) public onlyOwner { paused = value; }
Модификатор вешают на функции, которые принимают деньги и меняют состояние; функции возврата средств обычно оставляют работающими — чтобы пользователи могли забрать своё даже во время паузы.
В модели после паузы deposit() перестал принимать: «Контракт на паузе».
Порядок строк: шпаргалка
Любая функция, которая двигает деньги, устроена одинаково:
- Модификаторы —
onlyOwner,whenNotPaused,nonReentrant. - Проверки —
requireна аргументы и состояние. - Изменения — записи в хранилище: обнулить долг, уменьшить баланс.
- Взаимодействия — внешние вызовы:
call, перевод токена. - Событие —
emitдля журнала.
Пятый пункт не про безопасность, а про то, что хранилище хранит итог, а не историю: без события интерфейс не покажет, что именно произошло.
Типичные ошибки
transfer на обычном address. Нужен payable(адрес).
Модификатор без _; — или с _; перед проверкой: тогда тело выполнится до неё.
receive() без payable или без external. Форма строгая: receive() external payable { … }.
Внешний вызов до записи в хранилище. Классический повторный вход.
Результат call не проверен. Перевод мог не пройти, а контракт считает, что прошёл.
Рассылка денег циклом по списку. Один отказавшийся останавливает всех; плюс растущий список однажды перестаёт помещаться в транзакцию.
Передача владения без проверки нулевого адреса. Владение теряется по недосмотру.
Отказ от владения без подтверждения. Необратимо; деньги в контракте остаются заперты.
Приём денег без аварийного стопа. Останавливать нечего, если что-то пойдёт не так.
Слова главы
- Владелец (owner) и
onlyOwner— адрес в хранилище плюс модификатор. transferOwnership/renounceOwnership— передача и отказ; отказ необратим.- Нулевой адрес —
address(0): у него нет владельца ключа. receive()— функция приёма эфира без данных; формаreceive() external payable.- Повторный вход (reentrancy) — получатель вызывает вас обратно, пока вы не дописали изменения.
- Проверки → изменения → взаимодействия — порядок строк, который и есть защита.
nonReentrant— замок-флаг; ставится до тела, снимается после.- «Тяни, а не толкай» — контракт записывает долг, получатель забирает сам.
- Аварийный стоп —
pausedплюсwhenNotPausedна приёме денег.
Тренажёр: набрать вслепую
Цель: скорость ≥ 100 зн/мин, точность ≥ 88%
require(msg.sender == owner, unicode"Только владелец");
На чемпионате
Модуль В, 14 баллов. Контракт для аудита каждый год свой, но приёмы ищут одни и те же. Что писать в отчёте по информационной безопасности:
- функции, меняющие состояние, без проверки прав — с номерами строк;
- внешний вызов раньше записи в хранилище — с указанием, что именно можно вынести повторным входом;
transferвместоcall— получатель-контракт не сможет принять средства;- непроверенный результат перевода;
- рассылка в цикле — отказ одного получателя останавливает всех, плюс отказ по газу при росте списка;
- отсутствие аварийного стопа там, где контракт принимает деньги.
Формулировка пункта: строка → что произойдёт → что сделать. За вредное предложение снимают, поэтому пишите то, что можете обосновать.
Модуль А, 37 баллов. Функции ликвидации, погашения и вывода из задания — это ровно те функции, которые двигают деньги. Порядок строк в них проверяется на демонстрации косвенно: если баланс «поехал» на шаге с погашением, эксперт увидит это по своим ключам.
Что дальше
Блок про объекты и связи закончен: контракт-объект, наследование, договоры между контрактами, библиотеки и приёмы безопасности. Дальше в треке — второй язык, TypeScript: он нужен, чтобы писать скрипты развёртывания, тесты и интерфейс к контрактам. А перед этим — экзамен по блоку.
Углубиться
- Готовые реализации — стандартные библиотеки контроля доступа и защиты от повторного входа: что именно они делают и какие ошибки возвращают.
- Роли вместо одного владельца — когда нужны несколько администраторов с разными правами.
- Двухшаговая передача владения — новый владелец должен подтвердить приём, иначе адрес с опечаткой запирает контракт.
try/catchвокруг внешнего вызова — как продолжить работу, если чужой контракт откатился.- Перехват отказа — почему атакующий может прочитать текст вашего
requireи что это значит для сообщений.
Сводная проверка по всей главе: 8 вопросов, 5:00 на всё, без подсказок по ходу. Вопросы показываются по одному, ответ изменить нельзя. Если время выйдет — экзамен завершится с тем, что успел ответить. Пересдавать можно сколько угодно раз.
Экзамен по блоку «Контракты и связи»
Пять глав: контракт как объект, наследование, интерфейсы, библиотеки и хеши, паттерны безопасности. Проверка — по всему блоку.
Сводная проверка по 5 главам блока: 15 вопросов, 10:00 на всё, по одному вопросу, без возврата. Пересдавать можно сколько угодно раз.
Готовность: экзамены глав сданы 0 из 5. Блок можно сдавать и так, но шанс выше после всех глав.
Куда дальше: проверенные ресурсы
- Рекомендации по безопасности — docs.soliditylang.org — повторный вход, порядок строк, аварийный стоп.
- Функции получения эфира —
receiveиfallback. - Реестр известных атак — SWC — каталог уязвимостей с примерами.
- Ownable и ReentrancyGuard в OpenZeppelin — готовые реализации приёмов главы.
- Solidity by Example: Re-Entrancy — атака и защита рядом, коротким кодом.
Комментарии
Обсуждение живёт в GitHub Discussions и грузится с чужого домена — поэтому только по нажатию.