
Представляем вторую часть цикла, посвященного типичным уязвимостям, атакам и проблемным местам, присущим смарт-контрактам на языке Solidity, и платформе Ethereum в целом. Здесь поговорим о некоторых особенностях EVM и о том, какими уязвимостями они могут обернуться.
В первой части мы обсудили front-running attack, различные алгоритмы генерации случайных чисел и отказоустойчивость сети с Proof-of-Authority консенсусом. А здесь список тем шире, но все они имеют прямое отношение к смарт-контрактам. Итак, поехали.
Overflow/underflow
Переполнения в EVM могут быть для типов int и uint всех разрядностей и с множеством операций.
Это выглядит так:
contract _flow { uint public umax = 2**256 - 1; uint public umin = 0; int public max = int(~((uint(1) << 255))); int public min = int((uint(1) << 255)); function overflow() { umax++; max++; // или += 1; // или *= 2; } function underflow() { umin--; min--; // или -= 1; } }
Подобное очень часто можно встретить на просторах репозиторев со смарт-контрактами, ведь всегда есть balance, CAP, price, к которым что-то прибавляют, которые умножают, делят и т.д. Хорошей практикой будет использовать библиотеку SafeMath для типа данных, с которым идет работа. При этом необходимо иметь в виду, что у Zeppelin SafeMath реализована только для uint!
И еще одно. Может не бросаться в глаза, но для array.length тоже используется uint256, который точно так же можно переполнить. Рассмотрим такой пример:
contract Array { uint[] public array; address public owner; function Array() { owner = msg.sender; array.push(0xaa); } function underflow() { array.length--; } function modify(uint index, uint value) { array[index] = value; } }
Как видим, никаких функций для изменения owner нет, однако любой может стать владельцем.
Для начала о Storage. Storage — это адресное пространство длиной 2**256 с размером ячейки 32 байта. Простые типы кладываются в ячейку, поэтому их можно получить по ключу. А для сложных типов, например, массивов, используется хеширование. В первой ячейке, отвечающей за массив, будет его длина, а сами данные начнутся последовательно с ключа, который вычисляется как keccak256(<номер_ячейки_с_длиной>). Storage применяется для хранения данных между транзакциями (вызовами функций), как некий аналог жесткого диска.
Так вот, перейдем к эксплуатации:
- Вызываем underflow до тех пор, пока не произойдет underflow, и длина не станет 2**256
- Поскольку Storage у контракта, адресное пространство тоже имеет длину 2**256. И выходит, что
arrayтеперь занимает его полностью. Но owner всё еще на месте, просто его теперь можно получить по некому индексуarray Вычисляем этот индекс:
hex(2**256 - 0xbabecafe + 1)
, где
0xbabecafe— этоkeyячейки, в которой хранится длинаarray(в примере это будет нулевая ячейка), а 1 — это номер ячейки, в которой хранитсяowner
- Вызываем
modify:
indexполучен на этапе 3.value— это новый адрес дляowner. Ничего страшного, что функция принимает uint, —addressэто тоже число :)
Более подробно можно почитать об этом примерe в solidity_tricks.
ABI encoding/decoding
Для начала отметим, что для того, чтобы посредством транзакции вызвать какую-то функцию смарт-контракта, необходимо указать ее сигнатуру в tx.data. Там же следом должны идти и аргументы, которые принимает функция. Подробно о том, как кодируется каждый из типов, можно почитать в документации.
Необходимо принимать во внимание два момента:
- для динамических типов нет проверок того, что их длина равна количеству присланных элементов
- нет проверки типов (см. пример Type Confusion ниже).
При вызове, функция забирает присланные агрументы посредством вызова инструкции calldataload и далее происходит выполнение основной ее логики. Рассмотрим поведение разных динамических типов на примере:
contract DynamicTypes { uint public strLength; uint public bytsLength; uint public arrayLength; string public str; bytes public byts; address[] public array; // массив может быть и для других простых типов function callme(string _str, bytes _byts, address[] _array) public { strLength = bytes(_str).length; str = _str; bytsLength = _byts.length; byts = _byts; arrayLength = _array.length; array = _array; } }
Вызовем функцию callme с помощью следующего кода:
var modifiedArgs = [ // сигнатура функции - bytes4(sha3("callme(string,bytes,address[])")) '0x5fc059fd', // смещение по которому находится данные об аргументе _str '0000000000000000000000000000000000000000000000000000000000000060', // смещение по которому находится данные об аргументе _byts '00000000000000000000000000000000000000000000000000000000000000a0', // смещение по которому находится данные об аргументе _array '00000000000000000000000000000000000000000000000000000000000000e0', // длина _str 64 байта. Это больше чем есть на самом деле! '0000000000000000000000000000000000000000000000000000000000000040', // сами данные - строка *AAAA* . Длина 4 байта. '4141414100000000000000000000000000000000000000000000000000000000', // длина _byts 64 байта. Это больше чем есть на самом деле! '0000000000000000000000000000000000000000000000000000000000000040', // сами данные - 3 байта 0x42 0x43 0x44 '4243440000000000000000000000000000000000000000000000000000000000', // длина _array 64 байта. Это больше чем есть на самом деле! '0000000000000000000000000000000000000000000000000000000000000040', // первый элемент массива '0000000000000000000000000000000000000000000000000000000000000001', // второй элемент массива '0000000000000000000000000000000000000000000000000000000000000002' ]; modifiedData = modifiedArgs.join(""); // склеиваем все это в одну байтовую последовательность // и отправляем наконец контракту var tx = web3.eth.sendTransaction({ "to" : contractAdd, "data" : modifiedData, "gas" : 1185919 }); // P.S. целиком код можно посмотреть по ссылке выше.
После того, как транзакция будет обработана, мы увидим следующую картину:

Как можно видеть, строка на самом деле не "АААА" (@ на конце это интепретация 0x40 — длины _byts), байт в byts не три, как в данных, которые отправляли (аналогично зацепили 0x40 у следующего аргумента), ну и 64-й элемент из array мы свободно можем получить. Таким образом, чтобы получить данные, EVM берет их длину, отрезает, сколько указано от tx.data, и передает функции. И неважно, что пошел уже следующий аргумент или что tx.data кончился, — дополним нулями :)
И в продолжении темы поговорим о Short address attack.
contract ERC20 { address public who; uint public value; function transfer(address _who, uint _value) public { who = _who; value = _value; } }
Контракт имеет мало общего с оригинальным ERC20 токеном, но главное, у функции transfer будет та же сигнатура, что и в оригинале. Сценарий Short address attack следующий:
- атакущий генерирует специальный адрес c нулевыми байтами на конце, и просит жертву перевести на него токены
- при отсутствии обработки адреса получателя клиентское приложение (кошелек, биржа) может сформировать транзакцию "как есть", и это приведет к непредвиденным последствиям для пользователя.
Вызываем transfer:
// defaultArgs тут только для наглядности, использоваться будет modifiedArgs var defaultArgs = [ '0xa9059cbb', // обратите внимание на 0x00 на конце адреса '0000000000000000000000003a0c7287b9aac3c71ee8b9048c5dfb989f2a4d00', // пользователь хочет перевести 1 токен '0000000000000000000000000000000000000000000000000000000000000001' ]; var modifiedArgs = [ '0xa9059cbb', // атакующий предоставил адрес без нулевого байта на конце '0000000000000000000000003a0c7287b9aac3c71ee8b9048c5dfb989f2a4d', '0000000000000000000000000000000000000000000000000000000000000001' ]; modifiedData = modifiedArgs.join(""); var tx = web3.eth.sendTransaction({ "to" : contractAdd, "data" : modifiedData, "gas" : 1185919 });
Недостающий нулевой байт на конце адреса будет взят из value (парсинг аргументов происходит слева), а само value EVM просто дополнит до 32 байт (опять же нулевым байтом). Другими словами, произойдет байтовый сдвиг значения value, и оно станет равно 256 токенам (0x100), хотя пользователь хотел перевести только 1. В общем случае:
, где z — это количество нулевых байт на конце адреса (то есть может быть и 2, и 3 ...).
Стоит отметить, что хотя атака и называется Short address attack, на самом деле это лишь частный пример. Необязательно привязываться к адресу или функции transfer, точно так же, как и к типу uint у value. Все три составляющие могут произвольно меняться, расширяя классическое представление о short address attack. Более того, слово Short так же относится к частному примеру. Атакующий может предоставить адрес длиннее, чем обычно, и лишние байты станут началом value, а концовка будет обрезана — то есть произойдет сдвиг вправо.
Uninitialized storage pointer
Данная проблема уже затрагивалась на Хабре, поэтому упомянем ее кратко. Здесь для понимания необходимо иметь в виду два момента:
- В соответствии с документацией, каждый сложный тип имеет дополнительную аннотацию о том, где он хранится (Storage или Memory)
- Все локальные переменные по умолчанию хранятся в Storage, а аргументы функций — в Memory.
Теперь пример:
contract Uninitialized { address public owner; // хранится в Storage(нулевая ячейка), инициализирована 0x00 uint public balance; // хранится в Storage(первая ячейка), инициализирована 0 struct Billy { address where; } function rewriteOwner(address _where) public { Billy tmp; // указывает на нулевую ячейку Storage, не инициализирована tmp.where = _where; } function rewriteBoth(bytes s) public { uint8[64] copy; // указывает на нулевую ячейку Storage, не инициализирована for (uint8 i = 0; i < 64; i++) copy[i] = uint8(s[i]); } }
При деплое контракта переменные owner и balance проинициализированы значениями по умолчанию, и нет никакого явного кода, чтобы изменить их. Однако, это возможно. Если вызвать функцию rewriteOwner с каким-нибудь адресом, при присвоении tmp.where = _where будет перезаписан еще и owner. Происходит это потому, что переменная tmp — ссылочный тип, и для нее явно не задано, где хранятся данные, а значит (по умолчанию) tmp ссылается на Storage, причем на нулевую ячейку.
Ситуация полностью аналогична для массива copy в функции rewriteBoth, однако мы упоминаем ее для того, чтобы показать, что ячейки Storage находятся друг за другом, и если 32 байт нулевой ячейки не хватит, то будет перезаписана следующая и т.д.
Для того, чтобы такого не происходило есть два варианта:
- поместить переменную в Memory (ключевое слово
memory) - использовать у функции идентификаторы
pureиview(ну илиconstantпо старому стилю).
Type Confusion
Следующая особенность относится к тому, как EVM работает с типами. Во время исполнения проверок типов нет, все они происходят на уровне компилятора. И, как мы видели на примерах выше, функции вызываются по сигнатуре, а если сигнатура не найдена, то будет вызвана fallback-функция.
Рассмотрим это на примере эпичного сражения из фильма Матрица. Предположим, что в матрице персонажи представлены смарт-контрактами (Neo и Smith). И, для удобства, каждый определил абстрактный класс для взамодействия с другим (в чистом виде синтаксический сахар):
// Итак, вот исходник контракта, который пишет Смит: /* Абстрактный класс Neo, чтобы было удобнее вызывать его функции */ contract Neo { function obtainDamage (uint256 value); } // А вот сам контракт Смита contract Smith { uint public health = 100; function doDamage (address who) { Neo(who).obtainDamage(100); // вызов функции у контракта Neo } function obtainDamage (uint256 value) { health -= value; } }
А вот контракт, который играет роль Neo:
/* Абстрактный класс Смита */ contract Smith { function obtainDamage (uint256 value); } contract Neo { uint8 public health = 100; function () { Smith(msg.sender).obtainDamage(100); } function obtainDamage (uint8 value) { health -= value; } }
Оба деплоят свои контраты в матрицу сеть, узнают адреса друг друга, и начинается сражение:
- Смит нападает первым посредством вызова
doDamageс адресом контакта Нео - EVM ищет сигнатуру
bytes4(sha3("obtainDamage(uint256)")) == 0x7366f929и не находит, поэтому вызывется fallback функция (так Нео увернулся от удара) - внутри fallback Нео наносит ответный удар посредством вызова точно такой же функции у контракта Смита.
Давайте посмотрим внимательнее на функцию obtainDamage в контракте Нео. Ее сигнатура на самом деле равна bytes4(sha3("obtainDamage(uint8)")) == 0x1f26cd3a, поскольку тип value указан другой.

А теперь вопрос «на засыпку». Как в условиях реального проекта ICO, Crypto<put animals here> и других реализовать backdoor?
Рассмотрим на примере ICO. У ICO обычно есть два контракта — ERC20 токен и Crowdsale. Backdoor расположим в контракте токена: например, добавим функцию scoopAndDisappear.
- после деплоя, на ethersсan нужно засабмитить исходники только crowdsale, в которых будет также контракт токена, но бэкдор надо, конечно, вырезать
- для адреса токена ничего не сабмитить, а если будут спрашивать, то можно ответить примерно следующее: "в crowdsale же есть уже контракт токена".
Работать это будет потому, что etherscan компилит тот исходник, который ему предоставили, и сверяет байт-код с тем, что был в транзакции при создании. Если совпадает, то все хорошо. А совпадать будет, потому что контракт токена нужен там только для того, чтобы сделать правильные сигнатуры для вызова (самого байт-кода контракта там нет).
Поэтому важно, чтобы разработчики указывали на etherscan.io исходники всех контрактов, которые применяются в проекте. Исключение может составить разве что случай, когда один контракт создает другой (через конструкцию new). Тогда да — актуальный байт-код будет в транзации создания.
И вот еще один пример backdoor. Ситуация, которая складывается из-за невнимательности людей.
На сегодня все, в следующей серии мы перейдем уже непосредственно к Solidity, и посмотрим, чем он отличается от других языков программирования.

