Арифметические переполнения и потери значимости

Предварительные требования: базовое понимание блокчейна Ethereum и смарт-контрактов.

Вступление

Это вторая часть серии статей о защите ваших смарт-контрактов. В первой части обсуждались атаки повторного входа и кражи логики владельца.

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

Кто они такие?

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

Целые числа виртуальной машины Ethereum (EVM) всегда имеют фиксированный размер. Например, unit8 может хранить только значения от 0 до 255 (включительно). Попытка сохранить значение 256 в переменной uint8 приведет к значению 0. Это готово для эксплуатации, если перед выполнением не было сделано никаких проверок.

Потери

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

uint8 myValue = 2;
uint8 subValue = 3;
uint8 result = myValue - subValue;

Здесь наша переменная result не будет равна -1, как мы думаем; result будет равно 255.

При обратном отсчете от 2, EVM принимает значение 1… 0… 255. Это известно как потеря значимости.

Переполнения

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

uint8 myValue = 254;
uint8 addValue = 3;
uint8 result = myValue + addValue;

Здесь наша переменная result не равна 257, как мы думаем. Он рассчитывается до 1, потому что 255 - это максимальное значение uint8.

Считая вверх от 254, EVM идет… 255… 0… 1. Это известно как переполнение.

Пример

Рассмотрите этот фрагмент кода Solidity и, прежде чем переходить к объяснению, можете ли вы определить проблему?

mapping(address => uint) balances;
function withdraw(uint _value) external {
    require(balances[msg.sender] - _value >= 0);
    balances[msg.sender] -= _value;
    msg.sender.transfer(_value);    
}

Заявление require требует, чтобы баланс отправителя за вычетом суммы вывода был больше или равен 0. Это логично для нас, поскольку мы не хотим позволять кому-либо снимать больше, чем есть на его балансе. Однако это уязвимо для эксплуатации недостаточного количества ресурсов.

Если у злоумышленника было два эфира, хранящихся в контракте, и он попытался отозвать десять, оператор require разрешил бы это. Это связано с тем, что вычитание приведет к тому, что результат balances[msg.sender] — _value будет больше или равен 0 из-за потери значимости до максимума. Поскольку целое число без знака, оператор require всегда будет проходить, пока msg.sender имеет баланс.

По этой логике любой, кто в любой момент внес средства на баланс, может полностью очистить договор о средствах.

Профилактические меры

Всегда используйте библиотеки, которые предоставляют более безопасные функции для выполнения базовой арифметики. Библиотека SafeMath OpenZeppelin является наиболее часто используемой в сообществе Solidity. Он предоставляет функции для сложения, вычитания, умножения, деления и модуля.

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

using SafeMath for uint;
function withdraw(uint _value) external {
    require(balances[msg.sender].sub(_value) >= 0);
    balances[msg.sender] -= _value;
    msg.sender.transfer(_value);    
}

В этом сценарии оператор require завершится ошибкой, если мы попытаемся снять сумму, превышающую наш баланс. Это связано с тем, что функция sub() требует, чтобы баланс был больше или равен _value.

Дальнейшее чтение