Уязвимости: как из контрактов уходят деньги
- Зачем это нужно
- Повторный вход: внёс 1, забрал 5
- Лечение — порядок двух строк
- Забыли проверку владельца
- Проверка по инициатору цепочки
- Результат вызова не проверен
privateне значит «секретно»- Что ещё смотрят первым делом
- Слова главы
- Тренажёр: набрать вслепую
- На чемпионате
- Куда углубиться
- Куда дальше: проверенные ресурсы
Зачем это нужно
Контракт неизменен, деньги в нём настоящие, а исходники открыты всем. Это единственная область программирования, где ошибка не «приводит к неудобству», а прямо конвертируется в деньги нападающего.
Задание требует безопасного кода и умения объяснить, почему он безопасен. На защите спрашивают не «знаете ли вы про повторный вход», а «покажите, где в вашем контракте могла быть эта проблема и почему её нет».
В этой главе четыре уязвимости, и каждая выполнена по-настоящему: нападающий контракт разворачивается в сети, нападает и уносит чужие деньги. Потом та же атака проводится против исправленной версии и срывается.
Повторный вход: внёс 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
Вклад — один эфир, унесено пять. Четыре эфира принадлежали другим людям.
Лечение — порядок двух строк
── GoodBank ──
итог : нападение сорвалось
в банке после : 10.0 ETH
у нападающего на счету : 0.0 ETH
кругов повторного входа : 0
Правило, которое стоит помнить как таблицу умножения:
Сначала проверки. Потом запись в хранилище. И только потом вызовы наружу.
При таком порядке повторный вход попадает в контракт, где запись уже обнулена, — и отваливается на проверке amount > 0.
Защёлка (переменная «занято», проверяемая в начале функции) — рабочий способ, и готовая есть в библиотеках. Но она лечит следствие, а не причину: контракт с правильным порядком операций безопасен и без неё. Защёлку ставят вдобавок, а не вместо.
Забыли проверку владельца
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 ограничивает доступ из других контрактов и из наследников. К данным в сети оно отношения не имеет.
Отсюда правило: секрета в сети быть не может. Если что-то должно оставаться тайной — хранят хеш, а само значение раскрывают тогда, когда оно перестаёт быть тайной. Это же касается «случайных» чисел: всё, что лежит в блоке, известно заранее тому, кто этот блок собирает.
Что ещё смотрят первым делом
Короткий список, по которому проходят любой контракт:
| что | вопрос |
|---|---|
| права | у каждой изменяющей функции есть ограничение? |
| порядок | нет ли вызова наружу до записи в хранилище? |
| вызовы | проверен ли признак успеха у каждого? |
| начальная настройка | нельзя ли вызвать дважды? |
| деление | делится ли на ноль; не теряется ли остаток при целочисленном делении? |
| зависимость от времени | не решает ли что-то важное метка времени блока, которую подбирает сборщик? |
| случайность | не берётся ли «случайное» число из блока? |
| внешние данные | что будет, если источник цены соврёт? |
| предел газа | не обходит ли функция список неограниченной длины? |
| приём эфира | что если получатель откажется его принять? |
Переполнение чисел в этом списке отсутствует не случайно: с версии 0.8 компилятор проверяет его сам и откатывает транзакцию. Старые руководства уделяют ему половину места — сегодня это уже история, если только кто-то не обернул арифметику в блок «без проверок».
Слова главы
- Повторный вход — чужой код зовёт вашу функцию, пока состояние не обновлено.
- Порядок операций — проверки, запись, вызовы наружу; именно в таком порядке.
- Защёлка — переменная «занято»; лечит следствие, ставится вдобавок.
- Вызов наружу — любой перевод эфира или вызов чужого контракта; после него управление не ваше.
- Инициатор цепочки — человек, начавший её; проверять по нему нельзя.
- Непосредственный вызывающий — тот, кто позвал; по нему и проверяют.
- Признак успеха — низкоуровневый вызов не откатывает сам, а возвращает его.
- Слот хранилища — читается снаружи кем угодно;
privateот этого не защищает.
Тренажёр: набрать вслепую
Цель: скорость ≥ 90 зн/мин, точность ≥ 88%
require(amount > 0, unicode"нечего выводить");
На чемпионате
Порядок операций — в каждой функции с деньгами. Это то, что проверяют первым и чем легче всего похвастаться: «вот здесь запись идёт до перевода, и поэтому повторный вход невозможен».
Проверки прав — с самого начала, а не «потом». Дописать их в конце забывают все.
Читайте предупреждения компилятора. Два из четырёх случаев этой главы он находит сам.
Напишите тест на нападение. Контракт-нападающий в тестах — сильный ход: он доказывает, что защита работает, а не просто существует.
Не прячьте ничего в private. Если проверяющий спросит «а где у вас секрет хранится» — правильный ответ «нигде, в сети секретов не бывает».
Куда углубиться
- Повторный вход между разными функциями — когда защёлка на одной не спасает.
- Опасность длинных списков — почему раздачу делают запросом, а не рассылкой.
- Манипуляция источником цены — и почему мгновенная цена из обменника не годится.
- Подписи и повторное использование — то, о чём шла речь в прошлой главе, глазами нападающего.
- Известные разборы — публичные отчёты о настоящих потерях; лучший учебник по теме.
Сводная проверка по всей главе: 8 вопросов, 5:00 на всё, без подсказок по ходу. Вопросы показываются по одному, ответ изменить нельзя. Если время выйдет — экзамен завершится с тем, что успел ответить. Пересдавать можно сколько угодно раз.
Куда дальше: проверенные ресурсы
- Solidity: рекомендации по безопасности — от авторов языка.
- Smart Contract Weakness Classification — каталог известных уязвимостей с примерами.
- OpenZeppelin: ReentrancyGuard — готовая защёлка и её ограничения.
- OpenZeppelin: контроль доступа — права как пометки в объявлении функции.
- Rekt News — разборы настоящих потерь; лучший учебник по теме.
Комментарии
Обсуждение живёт в GitHub Discussions и грузится с чужого домена — поэтому только по нажатию.