Digital Security corporate blog
Information Security
Solidity
17 January 2018

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 применяется для хранения данных между транзакциями (вызовами функций), как некий аналог жесткого диска.


Так вот, перейдем к эксплуатации:


  1. Вызываем underflow до тех пор, пока не произойдет underflow, и длина не станет 2**256
  2. Поскольку Storage у контракта, адресное пространство тоже имеет длину 2**256. И выходит, что array теперь занимает его полностью. Но owner всё еще на месте, просто его теперь можно получить по некому индексу array
  3. Вычисляем этот индекс:


    hex(2**256 - 0xbabecafe + 1)

    , где 0xbabecafe — это key ячейки, в которой хранится длина array (в примере это будет нулевая ячейка), а 1 — это номер ячейки, в которой хранится owner


  4. Вызываем 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. В общем случае:


$value = 2^{z * 8}$


, где 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, и посмотрим, чем он отличается от других языков программирования.


+23
4.9k 36
Comments 4
Top of the day