
Слепая подпись: что вы на самом деле одобряете, когда экран не говорит ничего полезного
Есть вполне конкретный момент, в который случается большинство крупных криптопотерь. Не тогда, когда крадут ключ. А тогда, когда человек смотрит на запрос подписи, которого не понимает, решает, что, наверное, всё в порядке, и нажимает «одобрить».
У отрасли есть для этого название: слепая подпись. Это значит поставить свою подпись под данными, которые вы не можете прочитать. И годами кошельки считали это нормой.
Что должен делать экран подписи
Обещание безопасности самостоятельного хранения держится на одном допущении: без вашего одобрения ничего не движется. Мультиподпись два из двух, аппаратные кошельки, изолированные подписанты — всё это машины, превращающие ваше намерение в подпись.
Эта машинерия стоит ровно столько, сколько стоит ваше понимание того, что вы одобрили.
Если экран говорит «Отправить 0,5 ETH на 0x8f3C…», ваше одобрение что-то значит. Вы сверили его с тем, что собирались сделать. Если экран говорит «Взаимодействие с контрактом — данные: 0xa22cb465000000000000000000000000d8dA6…0001», ваше одобрение не значит ровным счётом ничего. Вы соглашаетесь не с транзакцией, а с прямоугольником шестнадцатеричных знаков.
Атакующие знают, какой из этих двух экранов им нужно вам показать.
Что прячется в байтах
Полезная нагрузка EVM-транзакции — это calldata: четырёхбайтовый селектор функции, за которым идут аргументы в ABI-кодировке. Прекрасно читаемо машиной и совершенно непрозрачно для человека. Небольшая горстка этих селекторов — тот путь, по которому уходят деньги.
approve(address,uint256) — селектор 095ea7b3. Даёт контракту разрешение тратить ваши токены. Это не перевод, а постоянное полномочие. В момент подписи ничего не двигается — именно поэтому она и проскакивает. Опустошение происходит позже, по расписанию атакующего.
setApprovalForAll(address,bool) — селектор a22cb465. Аналог для NFT, и хуже: одна подпись даёт оператору власть над всеми токенами этой коллекции, которыми вы владеете, сейчас и в будущем. Никакого поля суммы, которое могло бы вас успокоить.
increaseAllowance(address,uint256) — селектор 39509351. Пополняет уже выданное разрешение. Часто ускользает от внимания, потому что это не тот селектор, за которым всем велели следить.
transferFrom(address,address,uint256) — селектор 23b872dd. Перемещает токены с адреса, который уже выдал разрешение. Та самая подпись, которая наконец тратит то, что разрешил более ранний approve.
Схема, которую стоит усвоить: транзакция, крадущая ваши деньги, редко бывает той, что вы подписали. Вы подписали разрешение. Кража — отдельная транзакция, отправленная позже, которую вы никогда не видите.
Почему «безлимитно» — то самое слово
Почти у всех злоупотреблений разрешениями есть общая черта: сумма фактически бесконечна.
Дапки просят безлимитные одобрения потому, что так удобно: одобрил один раз — больше не спрашивают. В итоге пользователей приучают походя выдавать неограниченные права на трату, а интерфейс, показывающий 115792089237316195423570985008687907853269984665640564039457584007913129639935, не сообщает никому ничего.
Здесь есть тонкость, на которой спотыкаются наивные реализации. Каноническое значение «бесконечного одобрения» — ровно 2²⁵⁶−1, поэтому очевидная проверка сводится к сравнению с этой константой. Но дапки регулярно используют другие астрономически большие числа — половину максимума, 0xff…f0, 10¹⁸ × 10³⁸ — которые безлимитны в любом практическом смысле, но не проходят проверку на точное равенство. Предупреждение, срабатывающее только на точном сторожевом значении, — это предупреждение, которое атакующий обходит, вычтя единицу.
Правильный порог лежит заметно ниже сторожевого значения и заметно выше всего реального. SSP помечает любое разрешение от 2²⁵⁵ и выше — это около 5,8 × 10⁷⁶, что превосходит любую возможную эмиссию ERC-20 на десятки порядков. Ничего законного никогда не помечается ошибочно, а подгонка значения чуть ниже максимума предупреждение не обходит.
Этот порог применяется только к вызовам, выдающим разрешения. При обычном переводе «безлимитно» — понятие бессмысленное: вы перемещаете конкретную сумму, — поэтому там сторожевое значение точного максимума не трогают.
Что делает SSP
SSP расшифровывает calldata на экране одобрения простым языком. Распознанные селекторы отображаются тем, чем они и являются: кто контрагент, какова сумма, безграничны ли выдаваемые права. Сырой шестнадцатеричный код больше не главное содержимое — он живёт за разделом «Дополнительно», для тех, кому он нужен.
Три проектных решения важнее самой расшифровки.
Декодер — только слой отображения. Он никогда не меняет то, что подписывается. Одобрение всегда подписывает ровно исходную полезную нагрузку; помощник лишь заново представляет байты, которые раньше показывались как сырой шестнадцатеричный код. Декодер, способный изменить нагрузку, был бы не защитой, а новой поверхностью атаки: то, что вы читаете, и то, что вы подписываете, обязаны быть одними и теми же байтами, всегда.
Он отказывает в закрытую сторону. Неизвестный селектор, неверная длина, испорченный шестнадцатеричный код, нестандартное дополнение адреса — всё неожиданное не возвращает ничего, и интерфейс откатывается к обобщённому действию с сырым кодом в «Дополнительно». Он никогда не догадывается. Догадывающийся декодер хуже, чем никакого, потому что уверенная неверная сводка опаснее видимого шестнадцатеричного кода: код хотя бы честно сообщает вам, что вы его не понимаете.
Этот инстинкт «отказывать в закрытую» идёт глубже неизвестных функций. Адрес в ABI-кодировке — это двенадцать нулевых байтов и двадцать байтов адреса; слово, где в этих ведущих байтах есть что-то ещё, закодировано неканонично, и SSP считает его подозрительным, вместо того чтобы пытаться истолковать. То же с булевыми значениями: принимаются только канонические кодировки — все нули (ложь) и 31 ноль плюс 0x01 (истина). Неканонические кодировки на экране, выдающем права на трату, — это тревожный флаг, а не задачка для разбора.
Символы и десятичные разряды токенов никогда не угадываются. Человекочитаемая сумма показывается только тогда, когда токен достоверно известен из реестра на устройстве. Иначе вы видите сырое число в базовых единицах. Это намеренно менее красиво: показать «5,0 USDC» для контракта, который всего лишь называет себя USDC, значило бы превратить декодер в машину для вранья — а это ровно тот результат, которого хочет атакующий.
И есть часть, которая структурна, а не косметична. В SSP транзакция собирается в одном месте, а одобряется в другом: составляется в браузерном расширении, независимо расшифровывается и показывается на вашем телефоне, где SSP Key заново вычисляет хеш транзакции на самом устройстве и отказывается подписывать, если он не совпадает с показанным. Слепая подпись на одном устройстве означает, что достаточно одного скомпрометированного экрана. Здесь экран, показывающий расшифрованное действие, и устройство, хранящее второй ключ, — одно и то же устройство, и оно проверяет, а не доверяет.
Что делать со своими одобрениями
Считайте approve и setApprovalForAll опасными. Именно эти подписи стоят людям денег, и выглядят они безобидно ровно потому, что ничего не двигается.
Отказывайтесь от безлимитных одобрений, когда это возможно. Многие дапки принимают ограниченную сумму, если её задать. Это трение — и оно ограничивает ваш риск тем, что вы действительно собирались потратить.
Проверьте, что вы уже выдали. Старые одобрения не истекают. Разрешение, выданное протоколу два года назад, всё ещё живо, и если этот контракт позже скомпрометируют, оно окажется открытой дорогой к вашим токенам. Отзыв разрешений делается быстро, и это самый окупаемый час уборки в самостоятельном хранении.
Когда экран не говорит вам ничего — это и есть сигнал. Если кошелёк не может сказать, что делает транзакция, — это информация, а не неудобство, которое надо прокликать. Правильный ответ на нечитаемый запрос — остановиться, а не щуриться.
Цель никогда не состояла в том, чтобы заставить вас читать шестнадцатеричный код. Она в том, чтобы в момент одобрения вы и ваш кошелёк одинаково понимали, что именно вы одобряете.


