Перейти к основному содержимому
Блокчейн13ГЛАВА 13Паттерны безопасности: владелец, реентерабельность, выплатыcall_;порядок строк

Паттерны безопасности: владелец, реентерабельность, выплаты

Прочитано0%
Квизы0/5
Тренажёрыне пройден0/1
Уровень 1 · Новичок

Зачем это нужно​

Аудитор читает контракт не строчками, а приёмами. Он не проверяет каждую формулу — он ищет знакомые места: кто владелец, в каком порядке идут строки при выплате, что произойдёт, если получатель откажется принимать деньги.

Модуль В конкурсного задания требует отчёт по информационной безопасности, и это буквально список таких мест. Эта глава — про пять приёмов, которые там ищут. Каждый разобран на прогоне, где неправильный вариант теряет деньги, а правильный — нет.

Это последняя глава блока. После неё — экзамен по всем пяти главам про объекты и связи.

Владелец: одна переменная и один модификатор​

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 больше не вызовет никто
ВЛАДЕНИЕ: ТРИ СТАДИИ, ОДНА ПЕРЕМЕННАЯпосле развёртыванияowner = деплоердеплоерможет всёannaоткатborisоткатпосле передачи annaowner = annaдеплоероткатannaможет всёborisоткатпосле отказа от владенияowner = 0x000…000деплоероткатannaоткатborisоткатпередача владения — одна строка в хранилище; бывший владелец сразу получает такой же отказ, как постороннийотказ от владения необратим: в казне остались заперты 5 ETH, снять их не может уже никто
Передача владения — одна строка в хранилище; отказ от владения запирает деньги навсегда

Два наблюдения.

Бывший владелец теряет права мгновенно и получает ровно тот же отказ, что посторонний. Никакого «переходного периода» нет.

Отказ от владения необратим. После него ни одна функция под 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: ок
ОДИН ВКЛАД В 1 ETH ПРЕВРАЩАЕТСЯ В ЧЕТЫРЕперевод раньше записиcall → balance[x] = 0повторных входов: 3в банке 0.0 ETH · у атакующего 4.0запись есть, денег нет: anna получает откатпроверки → изменения → взаимодействияbalance[x] = 0 → callповторных входов: 1в банке 3.0 ETH · у атакующего 1.0второй вход получает «Нечего снимать»замок nonReentrant даёт тот же результат и внятный текст «Повторный вход запрещён» — ценой лишнего газавнешний вызов отдаёт управление чужому коду — и тот заходит обратно, пока запись ещё не сделанапо тексту функции разницы почти нет: переставлены две строки
Вариант А: три повторных входа, банк пуст, у атакующего вчетверо больше, чем он вносил

Атакующий внёс один эфир, а вынес четыре: пока запись balance не обнулена, каждая повторная проверка amount > 0 проходит.

Самое неприятное — состояние после атаки: у anna в записи по-прежнему 2 ETH, а денег в банке нет. Запись есть, денег нет — и честный вкладчик получает отказ.

Правило, которое это лечит, называется «проверки → изменения → взаимодействия»:

  1. все require — первыми;
  2. все записи в хранилище — вторыми;
  3. внешние вызовы — последними.

В варианте Б обнуление стоит до перевода, поэтому повторный вход упирается в «Нечего снимать».

Замок 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 газа мы измеряли в главе про хранение.

Отвечено вопросов: 0 из 2
Атакующий внёс 1 ETH и вынес 4. Что было не так в коде?
После атаки в записи у anna 2 ETH, а в банке 0. Что она получит при снятии?
Отвечено верно: 0 из 2

Тяни, а не толкай​

Второй классический приём — про выплаты нескольким получателям сразу. Вторая модель рассылает 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 → отказавшийсяоткат: «Получатель отказался»anna 0.0 · boris 0.0 ETHтранзакция атомарна: один отказ останавливает всехкаждый забирает самanna claim() · boris claim() — по 28 725 газадоля отказавшегося лежит в контрактеповторный claim(): «Нечего забирать»никто никого не блокируетсколько газа получает код получателя: transfer — 2 262, call — весь остатокполучатель, который пишет к себе в хранилище, при transfer откатывает всю транзакциючерез call та же операция проходит за 53 866 газа
Один отказавшийся получатель останавливает всю рассылку; когда каждый забирает сам, он мешает только себе

Транзакция атомарна: откат на третьем получателе отменяет и первые два перевода. 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() перестал принимать: «Контракт на паузе».

Порядок строк: шпаргалка​

Любая функция, которая двигает деньги, устроена одинаково:

  1. Модификаторы — onlyOwner, whenNotPaused, nonReentrant.
  2. Проверки — require на аргументы и состояние.
  3. Изменения — записи в хранилище: обнулить долг, уменьшить баланс.
  4. Взаимодействия — внешние вызовы: call, перевод токена.
  5. Событие — emit для журнала.

Пятый пункт не про безопасность, а про то, что хранилище хранит итог, а не историю: без события интерфейс не покажет, что именно произошло.

Отвечено вопросов: 0 из 2
Рассылка троим откатилась на третьем получателе. Что получили первые двое?
Почему transfer делает часть получателей недоступными?
Отвечено верно: 0 из 2

Типичные ошибки​

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 баллов. Функции ликвидации, погашения и вывода из задания — это ровно те функции, которые двигают деньги. Порядок строк в них проверяется на демонстрации косвенно: если баланс «поехал» на шаге с погашением, эксперт увидит это по своим ключам.

Отвечено вопросов: 0 из 2
Как формулировать пункт отчёта по информационной безопасности?
В каком порядке идут строки в функции, которая двигает деньги?
Отвечено верно: 0 из 2

Что дальше​

Блок про объекты и связи закончен: контракт-объект, наследование, договоры между контрактами, библиотеки и приёмы безопасности. Дальше в треке — второй язык, TypeScript: он нужен, чтобы писать скрипты развёртывания, тесты и интерфейс к контрактам. А перед этим — экзамен по блоку.

Отвечено вопросов: 0 из 1
Зачем в замке nonReentrant используют значения 1 и 2, а не false и true?
Отвечено верно: 0 из 1

Углубиться​

  • Готовые реализации — стандартные библиотеки контроля доступа и защиты от повторного входа: что именно они делают и какие ошибки возвращают.
  • Роли вместо одного владельца — когда нужны несколько администраторов с разными правами.
  • Двухшаговая передача владения — новый владелец должен подтвердить приём, иначе адрес с опечаткой запирает контракт.
  • try/catch вокруг внешнего вызова — как продолжить работу, если чужой контракт откатился.
  • Перехват отказа — почему атакующий может прочитать текст вашего require и что это значит для сообщений.

Сводная проверка по всей главе: 8 вопросов, 5:00 на всё, без подсказок по ходу. Вопросы показываются по одному, ответ изменить нельзя. Если время выйдет — экзамен завершится с тем, что успел ответить. Пересдавать можно сколько угодно раз.

Экзамен по блоку «Контракты и связи»​

Пять глав: контракт как объект, наследование, интерфейсы, библиотеки и хеши, паттерны безопасности. Проверка — по всему блоку.

Экзамен по блоку: Контракты и связи

Сводная проверка по 5 главам блока: 15 вопросов, 10:00 на всё, по одному вопросу, без возврата. Пересдавать можно сколько угодно раз.

Готовность: экзамены глав сданы 0 из 5. Блок можно сдавать и так, но шанс выше после всех глав.

Куда дальше: проверенные ресурсы​

Видео по теме

ВидеоSolidity и смарт-контракты Ethereum, урок #16 | Безопасность: Reentrancy, DoSIlya Krukowski
ВидеоSolidity и смарт-контракты Ethereum, урок #7 | События, модификаторы, require/reIlya Krukowski
ВидеоУязвимости смарт-контрактов. Reentrancy. Взлом DAO. Хардфорк ETH #solidity #аудиDima HideCrypto - Web3 | Solidity
ВидеоWhat Is Check-Effects-Interactions?Owen Thurm
ВидеоOwnable | Solidity 0.8Smart Contract Programmer

Комментарии

Обсуждение живёт в GitHub Discussions и грузится с чужого домена — поэтому только по нажатию.