R0 CREW

Известные уязвимости в смарт контрактах

Оригинал: consensys.github.io

Ниже приведены примеры самых известых уязвимостей смарт контрактов.

Состояние гонки

Одна из основных проблем при вызова внешних контрактов заключается в том, что они могут внести изменения в данные, которые вызывающая функция не ожидает. Этот класс ошибок может принимать различные формы, и обе основные ошибки, которые привели к краху The DAO, были ошибками данного рода.

Реентерабельность (Reentrancy)

Это первый тип ошибок, который стоит отметить. Он заключается в том, что функции могут быть вызваны повторно ещё до завершения предыдущего вызова. Рассмотрим пример:

// INSECURE
mapping (address => uint) private userBalances;

function withdrawBalance() public {
    uint amountToWithdraw = userBalances[msg.sender];
    require(msg.sender.call.value(amountToWithdraw)()); На этом этапе выполняется код вызывающего контракта и может снова вызвать withdrawBalance
    userBalances[msg.sender] = 0;
}

Поскольку баланс пользователя устанавливается в 0 только в самом конце функции, второй и последующие вызовы всё равно выполняться успешно, и будут каждый раз выводить средства с баланса, хотя по задумке они доступны только один раз.

Примечание переводчика: Атака происходит следующим образом: поскольку вызов функции call.value()() может переводить средства не только на адреса кошельков, но и на адрес смарт контракта, злоумышленник может создать специальный контракт-эксплойт с необходимой логикой в fullback функции. Поступление средств на этот контракт будет инециировать вызов fallback функции, которая как раз и будет повторно вызывать метод withdrawBalance(), тем самым истощая баланс атакуемого контракта.

Очень похожая ошибка эксплуатировалась в атаке на The DAO.
В данном примере, следует использовать функцию send() вместо вызова call.value()().

Однако, если вы всё таки не можете обойтись без использования внешних вызовов в своем контракте, стоит убедиться в том, что он происходит уже после того как выполнится основная логика функции:

mapping (address => uint) private userBalances;

function withdrawBalance() public {
    uint amountToWithdraw = userBalances[msg.sender];
    userBalances[msg.sender] = 0;
    require(msg.sender.call.value(amountToWithdraw)()); // Баланс пользователя на данный момент уже равен 0
}

Состояние гонки между функциями

Также злоумышленник может выполнить аналогичную атаку, используя две разные функции, которые совместно используют одно и то же состояние.

// INSECURE
mapping (address => uint) private userBalances;

function transfer(address to, uint amount) {
    if (userBalances[msg.sender] >= amount) {
       userBalances[to] += amount;
       userBalances[msg.sender] -= amount;
    }
}

function withdrawBalance() public {
    uint amountToWithdraw = userBalances[msg.sender];
    require(msg.sender.call.value(amountToWithdraw)()); //// На этом этапе выполняется код вызывающего контракта и может быть вызвана функция transfer()
    userBalances[msg.sender] = 0;
}

В данном примере, атакующий вызывает функцию transfer(), во время вызова withdrawBalance(). Поскольку баланс еще не был установлен в 0, злоумышленник получает возможность передать токены, которые уже были выведены через функцию withdrawBalance. Эта уязвимость так же была проэксплуатирована в атаке на The DAO.

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

Подводные камни встречающиеся в решениях направленных против проблем с состояниями гонки

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

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

// INSECURE
mapping (address => uint) private userBalances;
mapping (address => bool) private claimedBonus;
mapping (address => uint) private rewardsForA;

function withdrawReward(address recipient) public {
    uint amountToWithdraw = rewardsForA[recipient];
    rewardsForA[recipient] = 0;
    require(recipient.call.value(amountToWithdraw)());
}

function getFirstWithdrawalBonus(address recipient) public {
    require(!claimedBonus[recipient]); // Каждый участник должен получить бонус только один раз

    rewardsForA[recipient] += 100;
    withdrawReward(recipient); //На этом этапе вызывающий объект сможет снова выполнить getFirstWithdrawalBonus.
    claimedBonus[recipient] = true;
}

Хоть функция getFirstWithdrawalBonus() и не вызывает внешний контракт, вызова withdrawReward() вполне достаточно для того, чтобы функция стала уязвима. Поэтому обращение к функции внутри контракта так же должно считаться ненадежным.

mapping (address => uint) private userBalances;
mapping (address => bool) private claimedBonus;
mapping (address => uint) private rewardsForA;

function untrustedWithdrawReward(address recipient) public {
    uint amountToWithdraw = rewardsForA[recipient];
    rewardsForA[recipient] = 0;
    require(recipient.call.value(amountToWithdraw)());
}

function untrustedGetFirstWithdrawalBonus(address recipient) public {
    require(!claimedBonus[recipient]); // Каждый участник должен получить бонус только один раз

    claimedBonus[recipient] = true;
    rewardsForA[recipient] += 100;
    untrustedWithdrawReward(recipient); // claimedBonus выставляется в true, так что повторный вызов невозможен
}

Так же будет полезным давать подобным функциям названия с префиксом untrusted. Можете заметить что в примере выше обе функции так и были обозначены.

Другим частым решением является использование мьютекса. Он позволяет заблокировать некоторое состояние, которое не сможет быть изменено, пока блокировка не будет снята. Вот простой пример:

// Note: Это приметивный пример. Мьютексы полезны в случае более существенной логики и / или при использовании общего состояния системы.
mapping (address => uint) private balances;
bool private lockBalances;

function deposit() payable public returns (bool) {
    require(!lockBalances);
    lockBalances = true;
    balances[msg.sender] += msg.value;
    lockBalances = false;
    return true;
}

function withdraw(uint amount) payable public returns (bool) {
    require(!lockBalances && amount > 0 && balances[msg.sender] >= amount);
    lockBalances = true;

    if (msg.sender.call(amount)()) { // Обычно это место уязвимо, но мьютекс исправляет это
      balances[msg.sender] -= amount;
    }

    lockBalances = false;
    return true;
}

При попытке вызвать функцию withdraw(), прежде чем завершиться вызов функции deposit(), не пройдет проверка переменной lockBalance и нежелательный вызов будет отброшен. Данный паттерн может быть эффективен, но так же может стать причиной сбоя работы контракта. Например:

// INSECURE
contract StateHolder {
    uint private n;
    address private lockHolder;

    function getLock() {
        require(lockHolder == address(0));
        lockHolder = msg.sender;
    }

    function releaseLock() {
        require(msg.sender == lockHolder);
        lockHolder = address(0);
    }

    function set(uint newState) {
        require(msg.sender == lockHolder);
        n = newState;
    }
}

Атакующий может вызвать функцию getLock(), и никогда не вызвать releaseLock(), тем самым навсегда заблокировав работу контракта. Если вы используете мьютексы, то тщательно проверьте что нет возможности для блокировки вашего контракта, которую потом невозможно будет снять. Так же существуют и другие проблемы с которыми можно столкнуться при использовании мьютексов, и если вы всё таки решили их использовать, ознакомьтесь подробнее с другой литературой по данной теме.

Фронтраннинг / Порядок транзакий (Transaction-Ordering Dependence (TOD) / Front Running)

Выше были приведены примеры, когда злоумышленник выполнял вредоносный код в рамках одной транзакции. Ниже приводится другой тип данной атаки, присущий блокчейнам: он заключается в том, что порядком транзакций (внутри блока) легко можно манипулировать.

Так как транзакция находится в mempool(хранилище транзакций) в течение короткого времени, и можно точно знать, что произойдет, прежде чем она будет включена в блок. Это может быть проблемой для децентрализированных рынков, где можно увидеть транзакцию для покупки некоторых токенов и рыночный ордер, до того как он будет включен в следующий блок. Защита от этого затруднена, поскольку она зависит от конкрутного контракта. Например, можно реализовать пакетные аукционы или использовать схему предварительной фиксации

Временная метка (Timestamp Dependence)

При создании смарт контракта, имейте в виду, то что майнер может манипулировать временной меткой блока.

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

Для более глубокого изучения проблемы смотри секцию рекомендаций.

Целочисленные переполнения

Рассмотрим простешую функцию перевода токенов:

mapping (address => uint256) public balanceOf;

// INSECURE
function transfer(address _to, uint256 _value) {
    /* Проверка баланса отправителя */
    require(balanceOf[msg.sender] >= _value);
    /* Прересчет балансов */
    balanceOf[msg.sender] -= _value;
    balanceOf[_to] += _value;
}

// SECURE
function transfer(address _to, uint256 _value) {
    /* Достаточно ли средств у отправителя, и не произойдет ли переполнение */
    require(balanceOf[msg.sender] >= _value && balanceOf[_to] + _value >= balanceOf[_to]);

    /* Пересчет балансов */
    balanceOf[msg.sender] -= _value;
    balanceOf[_to] += _value;
}

Если баланс достигнет максимального значения (2^256), то он станет равным 0. Чтобы этого избежать, необходима проверка на переполнение. Она может быть необязательной, всё зависит о того, возможно ли что ваша переменная достигнет максимального значения. Например, если любой пользователь может вызывать функцию, которая обновляет значение uint, то она - уязвима. Но если доступ к изменению состояния переменной имеет только доверенный администратор, или пользователь, но увеличивать он может только на 1, то нет никакой возможности достичь предела uint.

То же самое касается и уменьшения значения переменной. Если она достигнет нуля и будет уменьшена, значение переменной перейдет в максимум.

Будьте внимательны с такими типоми данных как uint8, uint16, uint24…и прочие, так как они намного быстрее достигают максимального значения. Существует около 20 случаев переполнения.

Переполнение через нижнюю границу

Статья Дуга Хойта от 2017 года освещает проблему того, как Си-подобное переполнение через нижнюю границу (underflow) может повлиять на хранилище Solidity. Ниже преведена упращенная версия:

contract UnderflowManipulation {
    address public owner;
    uint256 public manipulateMe = 10;
    function UnderflowManipulation() {
        owner = msg.sender;
    }
    
    uint[] public bonusCodes;
    
    function pushBonusCode(uint code) {
        bonusCodes.push(code);
    }
    
    function popBonusCode()  {
        require(bonusCodes.length >=0);  // тавталогия
        bonusCodes.length--; // underflow переполнение  
    }
    
    function modifyBonusCode(uint index, uint update)  { 
        require(index < bonusCodes.length);
        bonusCodes[index] = update; // используйте любой индекс, меньший чем длина массива bonusCodes
    }
}

В общем, на расположение переменной manipulateMe нельзя повлиять, не пройдя через keccak256, что невозможно. Однако, поскольку динамические массивы хранятся последовательно, если злоумышленник хочет изменить manipulateMe, все, что ему нужно сделать, это:

  • Вызывать popBonusCode до переполнения
  • Вычислить место хранения переменной manipulateMe
  • Изменить её значение с помощью modifyBonusCode

На практике этот массив сразу же бросается в глаза как небезопасный, но в более сложной архитектуре смарт-контрактов он может произвольно допускать вредоносные изменения константных переменных.

Вот хорошие статьи на тему решения данной проблемы: Solidity CRUD часть 1 и часть 2.

DoS, манипуляция с revert

Рассмотрим простой аукционный смарт контракт:

// INSECURE
contract Auction {
    address currentLeader;
    uint highestBid;

    function bid() payable {
        require(msg.value > highestBid);

        require(currentLeader.send(highestBid)); // Отправка средств предыдущему лидеру, если не удалась вызов откатывается

        currentLeader = msg.sender;
        highestBid = msg.value;
    }
}

При вызове функции bid(), перед сменой адреса лидера на новый, происходит отправка средств на адрес старого лидера. Если попытка отправить средства заканчивается неудачай, но выполнение фукции прерывается. Это означает, что если злоумышленник может стать лидером и сделать так, что отправка средств на его адрес всегда будет заканчиваться неудачей, он навсегда останется лидером. Здесь можно посмотреть (favor pull over push payments), как исправить данный контракт.

Другой пример, когда контракт проходится по массиву и отправляет на каждый адрес транзакцию. Чаще всего требуется убедится, в том что каждый платеж выполнился успешно, а если нет, то выполнение функции прерывается. Но проблема в том, что это отменит всю систему выплат и цикл никогда не будет завершен. Никто не получает деньги, из-за того, что отправка на один из адресов вызывает ошибку.

address[] private refundAddresses;
mapping (address => uint) public refunds;

// bad
function refundAll() public {
    for(uint x; x < refundAddresses.length; x++) { // количество проходов цикла основывается на количестве адресов в массиве refundAddresses
        require(refundAddresses[x].send(refunds[refundAddresses[x]])) // плохо вдвойне, теперь один сбой при отправке задержит все средства
    }
}

Опять же рекомендуется использовать данное решение.

DoS, манипуляция с количестом газа

Возможно, вы заметили еще одну проблему в предыдущем примере: выплачивая средства всем сразу, вы рискуете упереться в лимит газа блока. Каждый блок Ethereum может обрабатывать определенный максимальный объем вычислений. Если вы попытаетесь превысить этот лимит, ваша транзакция не выполнится.

В таком случае могут возникунть проблемы, даже при отсутсвии атакующего. Однако, ещё хуже, если злоумышленник сможет манипулировать количеством необходимого для контракта газа, например добавив множество адресов, каждому из которых отправляется небольшое количество денег. Тогда он с легкостью сможет заставить контракт превысить лимит, полностью блокируя возможность рассылки.

Еще одна причина использовать favor pull over push payments.

Если же вам всё таки необходимо пройтись циклом по массиву неизвестной длины, учитывайте что это может занять несколько блоков, и следовательно потребует нескольких транзакций. Так же, вам необходимо следить за тем за состоянием выполнения цикла, чтобы в случае сбоя возобновить работу с последней успешной отправки, как в следующем примере:

struct Payee {
    address addr;
    uint256 value;
}

Payee[] payees;
uint256 nextPayeeIndex;

function payOut() {
    uint256 i = nextPayeeIndex;
    while (i < payees.length && msg.gas > 200000) {
      payees[i].addr.send(payees[i].value);
      i++;
    }
    nextPayeeIndex = i;
}

Вам нужно будет убедиться, что ничего плохого не произойдет, если другие транзакции будут обработаны во время ожидания следующей итерации функции payOut(). Поэтому используйте этот шаблон только в случае крайней необходимости.

Принудительная отправка эфира на адрес контракта

Существует возможность отправить эфир на адрес контракта, не вызывая при этом его fallback функцию. Это важно учитывать, если вы помещаете важной логику в функцию fallback или при выполнении вычислений на основе текущего баланса контракта. Рассмотрим следующий пример:

contract Vulnerable {
    function () payable {
        revert();
    }
    
    function somethingBad() {
        require(this.balance > 0);
        // Что-то плохое
    }
}

Логика контракта, запрещает отправлять средства на адрес контракта и, следовательно, подразумевается что Что-то плохое никогда не выполниться. Однако существует несколько способов принудительной отправки эфира в контракт и, следовательно, сделать его баланс больше нуля.

Метод контракта selfdestruct позволяет пользователю указать бенефициара для отправки любого лишнего эфира. Отмечу что, метод selfdestruct не вызываетfallback функцию.

Так же существует возможность предварительно вычислить адрес контракта, еще до развертывания в сети.

Разработчики смарт контрактов должны знать, что Ether может быть отправлен на контракт и в соответвии с этим продумывать логику. В конечном итоге, предположим, что невозможно ограничить поступление эфира на адрес вашего контракта.

Устаревшие атаки

Здесь перечислены атаки, которые больше не возможны, после обновления протокола и повышения его надежности. Оставим их для истории.

Вызов в глубину (устаревшая)

По состоянию на EIP 150 hardfork, атаки типа вызов в глубину не актуальны* (весь газ будет потребляться пока не будет достигнут лимит в 1024 вызова).

Другие уязвимости

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

Сноска:

[1] По поводу использования названия состояние гонки могут возникнуть вопросы, поскольку в Ethereum отсутсвует парлеллизм. Тем не менее здесь существуют процессы, использующие общие ресуры и состояния, поэтому к Ethereum применительны некоторые общие проблемы и их решения.

© Translated by f0x0f special for r0 Crew