Attention! S in Ethereum stands for Security. Part 2. EVM features
Представляем вторую часть цикла, посвященного типичным уязвимостям, атакам и проблемным местам, присущим смарт-контрактам на языке 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. целиком код можно посмотреть по ссылке выше.
После того, как транзакция будет обработана, мы увидим следующую картину:
Как можно видеть, строка на самом деле не «АААА» (@ на конце это интепретация 0×40 — длины _byts), байт в byts
не три, как в данных, которые отправляли (аналогично зацепили 0×40 у следующего аргумента), ну и 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 токенам (0×100), хотя пользователь хотел перевести только 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
Рассмотрим на примере ICO. У ICO обычно есть два контракта — ERC20 токен и Crowdsale. Backdoor расположим в контракте токена: например, добавим функцию scoopAndDisappear
.
- после деплоя, на ethersсan нужно засабмитить исходники только crowdsale, в которых будет также контракт токена, но бэкдор надо, конечно, вырезать
- для адреса токена ничего не сабмитить, а если будут спрашивать, то можно ответить примерно следующее: «в crowdsale же есть уже контракт токена».
Работать это будет потому, что etherscan компилит тот исходник, который ему предоставили, и сверяет байт-код с тем, что был в транзакции при создании. Если совпадает, то все хорошо. А совпадать будет, потому что контракт токена нужен там только для того, чтобы сделать правильные сигнатуры для вызова (самого байт-кода контракта там нет).
Поэтому важно, чтобы разработчики указывали на etherscan.io исходники всех контрактов, которые применяются в проекте. Исключение может составить разве что случай, когда один контракт создает другой (через конструкцию new
). Тогда да — актуальный байт-код будет в транзации создания.
И вот еще один пример backdoor. Ситуация, которая складывается из-за невнимательности людей.
На сегодня все, в следующей серии мы перейдем уже непосредственно к Solidity, и посмотрим, чем он отличается от других языков программирования.