Перейти к основному содержимому
Блокчейн54ГЛАВА 54Уязвимости: как из контрактов уходят деньгибанк10 → 6атака1 → 5×4записал → отдалchecks · effects · interactions

Уязвимости: как из контрактов уходят деньги

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

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

Контракт неизменен, деньги в нём настоящие, а исходники открыты всем. Это единственная область программирования, где ошибка не «приводит к неудобству», а прямо конвертируется в деньги нападающего.

Задание требует безопасного кода и умения объяснить, почему он безопасен. На защите спрашивают не «знаете ли вы про повторный вход», а «покажите, где в вашем контракте могла быть эта проблема и почему её нет».

В этой главе четыре уязвимости, и каждая выполнена по-настоящему: нападающий контракт разворачивается в сети, нападает и уносит чужие деньги. Потом та же атака проводится против исправленной версии и срывается.

Повторный вход: внёс 1, забрал 5​

function withdraw() external {
uint256 amount = balances[msg.sender];
require(amount > 0, "nothing");
(bool ok, ) = msg.sender.call{value: amount}(""); // отдаём управление ДО записи
require(ok, "transfer failed");
balances[msg.sender] = 0; // слишком поздно
}

Выглядит безобидно: прочитали, отправили, обнулили. Беда в среднем действии — перевод эфира это вызов чужого кода. У получателя может быть функция приёма, и она может позвать withdraw снова.

receive() external payable {
if (rounds < 4 && address(bank).balance >= msg.value) {
rounds++;
bank.withdraw(); // пока запись не обнулена, банк заплатит ещё раз
}
}
── BadBank ──
в банке до нападения : 10.0 ETH
нападающий вносит : 1.0 ETH
итог : нападение прошло
в банке после : 6.0 ETH
у нападающего на счету : 5.0 ETH
запись банка о нападающем : 0.0 ETH
кругов повторного входа : 4
ВНЕС 1 ETH, ЗАБРАЛ 51нападающий вносит 1 ETHзапись банка: 1.02зовёт withdrawбанк читает запись: 1.03банк отправляет 1 ETHуправление ушло к нападающему4тот сразу зовёт withdraw сновазапись всё ещё 1.0 — не обнулена…и так четыре кругав банке было 11, стало 6на счету нападающего оказалось 5 ETH при взносе 1: четыре эфира — чужие вкладыисправленная версия того же банка сорвала нападение на первом же повторном входе
Четыре круга, и четыре эфира чужих вкладов оказались у нападающего

Вклад — один эфир, унесено пять. Четыре эфира принадлежали другим людям.

Лечение — порядок двух строк​

── GoodBank ──
итог : нападение сорвалось
в банке после : 10.0 ETH
у нападающего на счету : 0.0 ETH
кругов повторного входа : 0
ОДИН И ТОТ ЖЕ КОД, РАЗНЫЙ ПОРЯДОК ДВУХ СТРОКуязвимоamount = balances[msg.sender];msg.sender.call{value: amount}("");balances[msg.sender] = 0;чужой код выполняется до записибезопасноamount = balances[msg.sender];balances[msg.sender] = 0;msg.sender.call{value: amount}("");к моменту чужого кода запись уже нольпорядок один и тот же всегда: сначала проверки, потом запись в хранилище, и только потом вызовы наружулюбой перевод эфира — это вызов чужого кода: у получателя может быть своя функция приёма
Тот же контракт, переставлены две строки — и нападение срывается на первом же круге

Правило, которое стоит помнить как таблицу умножения:

Сначала проверки. Потом запись в хранилище. И только потом вызовы наружу.

При таком порядке повторный вход попадает в контракт, где запись уже обнулена, — и отваливается на проверке amount > 0.

Нажми на часть выражения
Важно

Защёлка (переменная «занято», проверяемая в начале функции) — рабочий способ, и готовая есть в библиотеках. Но она лечит следствие, а не причину: контракт с правильным порядком операций безопасен и без неё. Защёлку ставят вдобавок, а не вместо.

Отвечено вопросов: 0 из 2
Почему перевод эфира опасен сам по себе?
Какой порядок операций защищает от повторного входа?
Отвечено верно: 0 из 2

Забыли проверку владельца​

function setRate(uint256 v) external { rate = v; } // проверки нет
── 1. забыли проверку владельца ──
владелец контракта : 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266
посторонний поставил ставку 9999 → rate = 9999
та же функция с проверкой: reverted with reason string 'not owner'

Скучно, очевидно — и при этом самая частая настоящая уязвимость. Функция добавляется на скорую руку, проверка дописывается «потом», и это «потом» не наступает.

Что проверять в своём контракте:

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

Готовые наборы прав из библиотек тем и хороши, что пометка о праве видна в строке объявления функции, а не спрятана внутри.

Проверка по инициатору цепочки​

require(tx.origin == owner, "not owner"); // уязвимо

Есть два похожих значения: тот, кто позвал непосредственно и тот, кто начал всю цепочку. Проверять второе — приглашение к обману.

── 2. проверка по инициатору цепочки ──
в хранилище : 3.0 ETH
ВЛАДЕЛЕЦ сам нажимает «забрать бонус» на чужом сайте…
в хранилище стало : 0.0 ETH
у постороннего + : 3.0 ETH
проверка прошла: инициатор цепочки — владелец, хотя звал его посредник

Ход прост: злоумышленник делает контракт с безобидной функцией «забрать бонус», внутри которой вызов вашего хранилища. Владелец нажимает кнопку на чужом сайте — и его же собственная подпись опустошает хранилище.

Проверять надо непосредственного вызывающего. Тогда посредник не проходит: звал-то он.

Результат вызова не проверен​

to.call{value: amount}(""); // признак успеха выброшен
paid += amount; // считаем, что заплатили
── 3. результат низкоуровневого вызова не проверен ──
получатель эфир не принимает
контракт считает, что выплатил: 1.0 ETH
на самом деле у получателя : 0.0 ETH
версия с проверкой: reverted with reason string 'payment failed'

Низкоуровневый вызов не откатывает транзакцию сам — он возвращает признак. Выбросили признак — получили расхождение между учётом и действительностью.

Это та же мысль, что была в главе про прокси: delegatecall, как и call, возвращает признак, а не бросает.

Компилятор это видит:

Warning: Return value of low-level calls not used.
--> ./contracts/audit/Misc.sol:39:9:
|
39 | to.call{value: amount}("");

Предупреждения компилятора — не шум. Два из четырёх разобранных здесь случаев он находит сам.

private не значит «секретно»​

uint256 private pin;
bytes32 private passHash;
── 4. private не значит «секретно» ──
слот 0: 0x000000000000000000000000f39fd6e51aad88f6f4ce6ab8827279cfffb92266
слот 1: 0x00000000000000000000000000000000000000000000000000000000000758ec
слот 2: 0x9e72890ae3467cac7f1db82584af2c95a8bdbcf9b4d113e49c671988e4209a46
слот 1 как число : 481516 ← и есть pin
слот 2 совпал с хешем: true
PRIVATE НЕ ЗНАЧИТ «СЕКРЕТНО»uint256 private pin; bytes32 private passHash;слот 00x…f39fd6e51aad88f6f4ce6ab8827279cfffb92266ownerслот 10x…000758ec = 481516тот самый private pinслот 20x9e72890ae3467cac7f1db82584af2c95…private passHash, совпал побайтнохранилище контракта читает кто угодно одним запросом к узлу: слово private прячет данные только от других контрактовсекрета в сети быть не может; хранят только хеш, а само значение раскрывают, когда оно уже не тайна
Обычный запрос к узлу — и приватное поле прочитано

Хранилище контракта читает кто угодно одним запросом к узлу: номер слота известен из порядка объявления полей, который мы разбирали в главе про прокси.

Слово private ограничивает доступ из других контрактов и из наследников. К данным в сети оно отношения не имеет.

Отсюда правило: секрета в сети быть не может. Если что-то должно оставаться тайной — хранят хеш, а само значение раскрывают тогда, когда оно перестаёт быть тайной. Это же касается «случайных» чисел: всё, что лежит в блоке, известно заранее тому, кто этот блок собирает.

Отвечено вопросов: 0 из 2
Чем проверка по инициатору цепочки отличается от проверки вызывающего?
Что защищает private-поле контракта?
Отвечено верно: 0 из 2

Что ещё смотрят первым делом​

Короткий список, по которому проходят любой контракт:

чтовопрос
правау каждой изменяющей функции есть ограничение?
порядокнет ли вызова наружу до записи в хранилище?
вызовыпроверен ли признак успеха у каждого?
начальная настройканельзя ли вызвать дважды?
делениеделится ли на ноль; не теряется ли остаток при целочисленном делении?
зависимость от временине решает ли что-то важное метка времени блока, которую подбирает сборщик?
случайностьне берётся ли «случайное» число из блока?
внешние данныечто будет, если источник цены соврёт?
предел газане обходит ли функция список неограниченной длины?
приём эфирачто если получатель откажется его принять?
Интересный факт

Переполнение чисел в этом списке отсутствует не случайно: с версии 0.8 компилятор проверяет его сам и откатывает транзакцию. Старые руководства уделяют ему половину места — сегодня это уже история, если только кто-то не обернул арифметику в блок «без проверок».

Отвечено вопросов: 0 из 2
Низкоуровневый вызов вернул ложь, а мы её не проверили. Что произойдёт?
Можно ли взять «случайное» число из данных блока?
Отвечено верно: 0 из 2

Слова главы​

  • Повторный вход — чужой код зовёт вашу функцию, пока состояние не обновлено.
  • Порядок операций — проверки, запись, вызовы наружу; именно в таком порядке.
  • Защёлка — переменная «занято»; лечит следствие, ставится вдобавок.
  • Вызов наружу — любой перевод эфира или вызов чужого контракта; после него управление не ваше.
  • Инициатор цепочки — человек, начавший её; проверять по нему нельзя.
  • Непосредственный вызывающий — тот, кто позвал; по нему и проверяют.
  • Признак успеха — низкоуровневый вызов не откатывает сам, а возвращает его.
  • Слот хранилища — читается снаружи кем угодно; private от этого не защищает.

Тренажёр: набрать вслепую​

Цель: скорость ≥ 90 зн/мин, точность ≥ 88%

require(amount > 0, unicode"нечего выводить");

На чемпионате​

Порядок операций — в каждой функции с деньгами. Это то, что проверяют первым и чем легче всего похвастаться: «вот здесь запись идёт до перевода, и поэтому повторный вход невозможен».

Проверки прав — с самого начала, а не «потом». Дописать их в конце забывают все.

Читайте предупреждения компилятора. Два из четырёх случаев этой главы он находит сам.

Напишите тест на нападение. Контракт-нападающий в тестах — сильный ход: он доказывает, что защита работает, а не просто существует.

Не прячьте ничего в private. Если проверяющий спросит «а где у вас секрет хранится» — правильный ответ «нигде, в сети секретов не бывает».

Куда углубиться​

  • Повторный вход между разными функциями — когда защёлка на одной не спасает.
  • Опасность длинных списков — почему раздачу делают запросом, а не рассылкой.
  • Манипуляция источником цены — и почему мгновенная цена из обменника не годится.
  • Подписи и повторное использование — то, о чём шла речь в прошлой главе, глазами нападающего.
  • Известные разборы — публичные отчёты о настоящих потерях; лучший учебник по теме.

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

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

Видео по теме

ВидеоУязвимости смарт-контрактов. Reentrancy. Взлом DAO. Хардфорк ETH #solidity #аудитсмартконтрактовDima HideCrypto - Web3 | Solidity
ВидеоУязвимости смарт-контрактов. Delegatecall. Взлом кошелька Parity. #solidity #аудитсмартконтрактовDima HideCrypto - Web3 | Solidity
ВидеоУязвимости смарт-контрактов. Denial of service. DOS. #solidity #уязвимостисмартконтрактовDima HideCrypto - Web3 | Solidity
ВидеоАтаки на смарт-контракты и как их избежать. Безопасность в Ethereum.Future Markt
ВидеоВорую эфир со смарт-контракта Solidity в рамках тестового на аудитора безопасности смарт-контрактовБлокчейн-разработка с Юлией Бедросовой

Комментарии

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