Безопасность Solidity: контекст внутренних и внешних вызовов
Вызов функции по знакомому имени ещё не объясняет её контекст. Внутренний переход, this.function(), обычный call к другому адресу и delegatecall используют разные правила. Для безопасности важно знать, чьи поля меняются, кто является непосредственным caller и где появляется возможность исполнения внешнего кода.
Начнём с собственного ContextProbe без средств и административной задачи. Он записывает наблюдаемые адреса для учебного разбора. Метод record открыт намеренно: наблюдение контекста не является защищённой публикацией курса. Мы не выдаём эту модель за подходящий registry setter.
Внутренний переход и self-call
// SPDX-License-Identifier: MIT
pragma solidity 0.8.30;
contract ContextProbe {
address public lastCaller;
address public lastContext;
function record() public {
lastCaller = msg.sender;
lastContext = address(this);
}
function throughInternal() external { record(); }
function throughSelf() external { this.record(); }
}
Это полный небольшой исходник. Пусть его адрес P, а R вызывает throughInternal. Переход record() выполняется внутри той же обработки. Ожидаемо lastCaller=R, lastContext=P. Новый внешний message call не создаётся, хотя имя функции находится на отдельной строке.
Теперь R вызывает throughSelf. Выражение this.record() создаёт внешний вызов к P. В record непосредственным caller становится P, а address(this) остаётся P. Сходство целевого адреса с текущим не отменяет изменения msg.sender. Такой переход способен неожиданно нарушить проверку onlyPublisher, если publisher был R.
Что происходит с модификатором
Внутренний вызов функции, имеющей модификатор, не означает автоматический пропуск его проверок. Код функции и modifier выполняется в соответствующем пути, но sender сохраняет текущий внешний контекст. В self-call sender меняется, поэтому тот же текст условия может получить другой результат.
Если автор специально разрешит address(this) как административный субъект, нужно анализировать все маршруты, которые могут инициировать такой self-call. Свободный wrapper способен передать внешнему X косвенное полномочие. Само происхождение from=this не доказывает безопасного намерения исходного пользователя.
Наследование тоже не создаёт отдельный внешний адрес родителя при внутреннем выполнении. super вызывает выбранную следующую реализацию в той же композиции кода. Поля и контекст относятся к текущему экземпляру. Нельзя рассматривать inherited helper как независимый контракт, который сам установил дополнительный caller.
Вызов другого экземпляра
Если P выполняет обычный вызов B.record(), у B непосредственный msg.sender равен P, а address(this) равен B. Записи изменяются в storage B. Origin обычной цепочки может оставаться R, но уже разобранная ошибка tx.origin не должна использоваться для подмены разрешения P.
R → P → обычный call B
в коде B: caller=P, context=B, storage=B
R → P → delegatecall к коду B
в делегированном коде: caller=R, context=P, storage=P
Вторая строка объясняет модель delegatecall, а не добавляет его в ContextProbe. У нашего исходника нет произвольного адреса исполнения. Такой механизм даёт выбранному коду доступ к storage вызывающего и требует отдельной очень узкой политики. Поздняя глава курса рассматривает его на специальном примере.
Передача управления
Любой обычный внешний вызов к контракту может исполнить его код. Это включает интерфейсный вызов, low-level call и безопасный токенный callback. Целевой код может далее обращаться к другим компонентам либо обратно к P, если доступные методы это позволяют.
Внутренний helper без внешних взаимодействий не создаёт эту границу только от выделения функции. Но он может сам содержать внешний call. Поэтому анализ проводят по фактическому телу и унаследованным вызовам, а не по слову internal на первой строке объявления.
В CourseCatalog нет внешних interactions. Событие Published записывает лог, но не вызывает произвольного подписчика события внутри транзакции. Браузерный listener узнаёт о нём позже через свою инфраструктуру. Нельзя приписывать событию callback-возможность, которой on-chain механизм не содержит.
Возврат и откат
Обычный типизированный внешний вызов может отклониться и распространить ошибку, если она не обработана соответствующим способом. Low-level call возвращает bool и bytes, которые вызывающий должен осмысленно проверить. Успех низкоуровневого call означает успешное исполнение его формы, а не обязательно предметный успех внутри вернувшего false метода.
Если вызывающий затем делает revert, изменения вложенного вызова в общей транзакции тоже откатываются. Но если он поймал ошибку и продолжил, должен быть определён смысл результата. Неясное продолжение после неудачного взаимодействия способно записать «выполнено» без фактического изменения target.
Self-call также имеет внешнюю ABI-границу и возможность отдельной обработки ошибки. Он не является бесплатным способом получить обычный internal переход. При проектировании wrapper нужно объяснить, зачем выбран внешний путь и какие свойства контекста от него требуются.
Где читается состояние
При обычном вызове getter другого экземпляра возвращает его поля. При чтении через прокси с delegatecall implementation-код читает поля прокси. Поэтому адрес getter и адрес файла исходника могут различаться. Пустое состояние прямой implementation не является доказательством пустого пользовательского экземпляра.
Для наблюдения контекста сохраните конкретную сеть и адрес P. В другой сети тот же текст исходника может иметь иной экземпляр. Значение lastContext описывает адрес текущего execution context, а не глобальное имя проекта или адрес автора кода.
Самостоятельная карта переходов
Составьте таблицу для прямого record, throughInternal и throughSelf. Укажите caller, context и место записи. Затем добавьте второй ContextProbe B и мысленно вызовите его из P обычным интерфейсным методом. Сравните результаты со схемой delegatecall, не выполняя его.
После этого вернитесь к publisher R в CourseCatalog. Какие wrappers сохраняют R непосредственным caller, а какие превращают его в P? Ответ зависит от реального перехода, а не от похожего имени publish. Эта карта понадобится, когда в следующем уроке мы введём отдельную модель возврата ETH с настоящей внешней границей.
Также значение msg.value относится к соответствующему контексту вызова. Новый обычный call передаёт value по своей явно указанной политике, а внутренний переход не создаёт отдельный пользовательский платёж. При проектировании начисления нельзя считать один внешний взнос повторно только из-за нескольких внутренних helper.
Для самостоятельного уточнения: contracts.html, introduction-to-smart-contracts.html, security-considerations.html.
Оглавление курса. Листинги, расчёты и наблюдения здесь разобраны как ожидаемое поведение; компиляция, учебные программы и проверки при написании не запускались.