Что такое уязвимость рекурсивного вызова?

Что такое уязвимость рекурсивного вызова?

Какие меры я могу принять при создании смарт-контрактов, DAO или DAPP, чтобы защитить себя?

Ответы (3)

Простое объяснение

  1. Злоумышленник создает контракт кошелька ( 0xc0ee9db1a9e07ca63e4ff0d5fb6f86bf68d47b89 в атаке 17/06/2016) со значением по умолчанию (или запасным вариантом) function ()для многократного вызова функции DAO splitDAO(...). Ниже приведено простое значение по умолчанию function ():

    function () {
       // Note that the following statement can only be called recursively
       // a limited number of times to prevent running out of gas or
       // exceeding the call stack
       call TheDAO.splitDAO(...)
    }
    
  2. Злоумышленник создает (или присоединяется) предложение о разделении (#59 в атаке 17.06.2016) с адресом получателя, установленным в соответствии с контрактом кошелька, созданным выше.

  3. Злоумышленник голосует за предложение о разделении.

  4. По истечении срока действия предложения о разделении злоумышленник вызывает splitDAO(...)функцию DAO.

    а. Функция splitDAO(...)вызывает контракт кошелька по умолчанию function ()как часть отправки эфиров получателю.

    б. Контракт кошелька по умолчанию снова function ()вызывает DAO splitDAO(...), что повторяет цикл от a. выше.

    в. Контракт кошелька по умолчанию function ()должен гарантировать, что ошибка не будет выдана, поскольку транзакции будут откатываться, если стек вызовов или газ будут превышены.


Ниже приведены фрагменты исходного кода DAO, задействованные в этом типе атаки:

DAO.splitDAO(...):

Проблема в следующем коде заключается в том, что платеж производится (инструкция withdrawRewardFor(msg.sender);) до сброса переменных, которые отслеживают платежи, которые получатель имеет право получать ( balances[msg.sender] = 0;и paidOut[msg.sender] = 0;).

    function splitDAO(
        uint _proposalID,
        address _newCurator
    ) noEther onlyTokenholders returns (bool _success) {
        ...     
        withdrawRewardFor(msg.sender); // be nice, and get his rewards
        totalSupply -= balances[msg.sender];
        balances[msg.sender] = 0;
        paidOut[msg.sender] = 0;
        return true;
    }


DAO.withdrawRewardFor(...):

    function withdrawRewardFor(address _account) noEther internal returns (bool _success) {
        if ((balanceOf(_account) * rewardAccount.accumulatedInput()) / totalSupply < paidOut[_account])
            throw;

        uint reward =
            (balanceOf(_account) * rewardAccount.accumulatedInput()) / totalSupply - paidOut[_account];
        if (!rewardAccount.payOut(_account, reward))
            throw;
        paidOut[_account] += reward;
        return true;
    }


ManagedAccount.payOut(...):

Оператор _recipient.call.value(_amount)()отправляет эфиры на счет получателя, в этом случае вызывается контракт кошелька по умолчанию function (), что позволяет DAO.splitDAO(...)рекурсивно вызывать функцию.

    function payOut(address _recipient, uint _amount) returns (bool) {
        if (msg.sender != owner || msg.value > 0 || (payOwnerOnly && _recipient != owner))
            throw;
        if (_recipient.call.value(_amount)()) {
            PayOut(_recipient, _amount);
            return true;
        } else {
            return false;
        }
    }        


Смотрите также:



Дополнительная справочная информация

Вот оригинальный пост в блоге от Питера Вессенеса, в котором описывается уязвимость рекурсивных вызовов в DAO: Больше атак на Ethereum: Race-To-Empty is the Real Deal с предлагаемым решением этой проблемы.

Из поста:

Уязвимость

Вот некоторый код; посмотрите, сможете ли вы найти проблему.

function getBalance(address user) constant returns(uint) {  
  return userBalances[user];
}

function addToBalance() {  
  userBalances[msg.sender] += msg.amount;
}

function withdrawBalance() {  
  amountToWithdraw = userBalances[msg.sender];
  if (!(msg.sender.call.value(amountToWithdraw)())) { throw; }
  userBalances[msg.sender] = 0;
}

Вот в чем проблема: msg.sender может иметь функцию по умолчанию, которая выглядит так.

function () {  
 // To be called by a vulnerable contract with a withdraw function.
 // This will double withdraw.

 vulnerableContract v;
 uint times;
 if (times == 0 && attackModeIsOn) {
   times = 1;
   v.withdraw();

  } else { times = 0; }
}

Что случается? Стек вызовов выглядит так:

   vulnerableContract.withdraw run 1
     attacker default function run 1
       vulnerableContract.withdraw run 2
         attacker default function run 2

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

Когда код разрешается, баланс пользователя будет установлен на 0, сколько бы раз ни вызывался контракт.

И предлагаемые исправления из поста:

Подход к исправлению 1: правильно оформить заказ

Рекомендуемый подход в примерах обновленной Solidity, которые скоро будут опубликованы, заключается в использовании такого кода:

function withdrawBalance() {  
  amountToWithdraw = userBalances[msg.sender];
  userBalances[msg.sender] = 0;
  if (amountToWithdraw > 0) {
    if (!(msg.sender.send(amountToWithdraw))) { throw; }
  }
}

а также

Подход к исправлению 2: мьютексы

Вместо этого рассмотрите этот код.

function withdrawBalance() {  
  if ( withdrawMutex[msg.sender] == true) { throw; }
  withdrawMutex[msg.sender] = true;
  amountToWithdraw = userBalances[msg.sender];
  if (amountToWithdraw > 0) {
    if (!(msg.sender.send(amountToWithdraw))) { throw; }
  }
  userBalances[msg.sender] = 0;
  withdrawMutex[msg.sender] = false;
}



И из сообщения пользователя eththrowa в сообщении на форуме The DAO. Ошибка, обнаруженная в контракте токена MKR, также влияет на DAO — позволит пользователям красть вознаграждение от DAO, вызывая рекурсивно :

Этот баг: https://www.reddit.com/r/ethereum/comments/4nmohu/from_the_maker_dao_slack_today_we_discovered_a/57 также присутствует в коде DAO — конкретно здесь, в функции removeRewardFor DAO.sol:

if (!rewardAccount.payOut(_account, reward))
   throw;
paidOut[_account] += reward;
return true;

а здесь в manageAccount.sol

function payOut(address _recipient, uint _amount) returns (bool) {
        if (msg.sender != owner || msg.value > 0 || (payOwnerOnly && _recipient != owner))
            throw;
        if (_recipient.call.value(_amount)()) {
            PayOut(_recipient, _amount);
            return true;
        } else {
            return false;
        }
    }

Это позволит пользователю многократно истощать свои права, рекурсивно вызывая контракт. Как ни странно, команда slockit обнаружила эту ошибку здесь, в разделе предложений:

// we are setting this here before the CALL() value transfer to
// assure that in the case of a malicious recipient contract trying
// to call executeProposal() recursively money can't be transferred
// multiple times out of the DAO
p.proposalPassed = true;

но пропустил это в разделе вознаграждений. Очевидно, что в DAO еще нет никаких вознаграждений, поэтому сегодня это не проблема, которая может стоить денег.



В : Какие меры я могу принять при создании смарт-контрактов, DAO или DAPP, чтобы убедиться, что я не уязвим?

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

Из блога Ethereum КРИТИЧЕСКОЕ ОБНОВЛЕНИЕ Re: Уязвимость DAO :

Авторы контрактов должны позаботиться о том, чтобы (1) быть очень осторожными с ошибками рекурсивных вызовов и прислушиваться к советам сообщества разработчиков контрактов Ethereum, которые, вероятно, появятся на следующей неделе, по устранению таких ошибок, и (2) избегать создания контрактов, которые содержат стоимостью более ~ 10 миллионов долларов, за исключением контрактов на субтокены и других систем, ценность которых сама по себе определяется общественным консенсусом за пределами платформы Ethereum, и которые могут быть легко «хардфоркнуты» через консенсус сообщества в случае возникновения ошибки. (например, MKR), по крайней мере, до тех пор, пока сообщество не приобретет больше опыта в устранении ошибок и/или не будут разработаны более совершенные инструменты.

Тема на Reddit Можем ли мы никогда больше не указывать 100 миллионов в контракте без формальных доказательств правильности? предложить какое-то формальное доказательство правильности (но все же могут быть ошибки).

В ближайшие несколько недель будет больше рекомендаций - я обновлю этот ответ.

Некоторые ресурсы:

«Атакующий создает раздельное предложение с адресом получателя, чтобы начать контракт кошелька, созданный выше». Не удалось разобрать предложение. Это не имеет грамматического смысла.
Как это c. The wallet contract's default function () must ensure that an error is not thrown as the transactions will be rolled back if the call stack or gas is exceeded.возможно? Если газ превышен, не должен ли EVM выдавать ошибку несмотря ни на что? @BokkyPooBah

Если ваш код выглядит так в псевдокоде:

function do:
   if (pool has mymoney = true)
     split(mymoney) 
     pool has mymoney = false

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

Исправить просто, обратные две операции:

function do:
   if (pool has mymoney = true)
     pool= pool - mymoney // 2
     split(mymoney) //1

См. этот коммит, например, исправления

Разве транзакции на EVM не выполняются атомарно, поэтому состояние гонки не должно иметь никакого влияния? @Роланд Кофлер

«Уязвимость рекурсивных вызовов» — неоднозначный термин, которого следует избегать, поскольку он неточен и может означать две вещи.

Повторная атака

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

http://forum.ethereum.org/discussion/1317/reentrant-contracts

https://github.com/LeastAuthority/ethereum-analyses/blob/master/GasEcon.md

Атака глубины вызова (больше невозможна с EIP 150)

В Ethereum также возможна «атака глубины вызовов» (один из способов ее выполнения — рекурсивные вызовы).

Как атака на глубину стека приводит к тому, что send() молча терпит неудачу?

Атака стека вызовов

Если кто-то читает это сейчас, просто сообщаю вам, что атака глубины вызовов больше невозможна после обновления EIP 150.