Разбор атаки на TheDAO: краткий обзор кода

17 июня 2016 года была совершена атака на TheDAO: атакующий увел 3,5 млн. эфиров (на момент написания текста — более 45 млн. долл.). Использовалась уязвимость «гонка на опустошение» или атака, основанная на рекурсивном вызове.

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

Разбор атаки на TheDAO: краткий обзор кода

Этот текст появился благодаря помощи участников сообщества Ethereum — Джои Круг выдвинул некоторые первоначальные соображения и поделился знаниями и информацией. Деннис Петерсон, как обычно, проделал большой объем черновой работы и анализа кода сегодня рано утром, а Ник Джонсон провел теоретические исследования и предложил некоторые возможные решения.

Тим Годдард приложил большие усилия и выявил путаницу «Transfer/transfer», а также первым предположил, почему это проникновение позволило преобразовать 258 эфиров в 3,5 млн., а не «всего лишь» в 7500.

Некоторая справочная информация: на что стоит обратить внимание

Мы начнем с функции splitDAO по двум причинам — по-видимому, атакующий создавал дочерние DAO, и это единственный действующий механизм вывода средств; вторым шагом в случае неуспеха был бы анализ механизма обработки предложений.

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

Механизм реализации заключается в создании предложения о разделении. Предложения о разделении «вызревают» в течение 7 дней, собирая участников. Любые участники, положительно проголосовавшие в разделении, получают право вызвать splitDAO.

Функция splitDAO создает дочерний контракт DAO, если его не существует, передает эфир, контролируемый отделяющимися участниками, в childDAO, выплачивает часть всего накопленного «вознаграждения» отделяющимся участникам в соответствующей пропорции и возвращает управление. Так эта функция была задумана.

Сложности верификации

«Конец» действующей главной ветки кода TheDAO на github недавно подвергся анализу. К сожалению, это НЕ тот код, который развернут по адресу основного контракта TheDAO. Это вызвало много путаницы и выявило реальную проблему, связанную с данным анализом — является ли исследуемый код тем, который фактически выполняется?

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

Код

Ниже я вставил кусок функции splitDAO, чтобы показать некоторые интересные моменты.

function splitDAO(
        uint _proposalID,
        address _newCurator
    ) noEther onlyTokenholders returns (bool _success) {

И так, у нас есть функция. Она не может отправлять эфир. Ее могут вызывать только держатели токенов. Давайте посмотрим, что мы получили.

Строка, в которой говорится о «перемещении эфира». Продолжим исследование...

// Переместить эфир и назначить новые токены
        uint fundsToBeMoved =
            (balances[msg.sender] * p.splitData[0].splitBalance) /
            p.splitData[0].totalSupply;
        if (p.splitData[0].newDAO.createTokenProxy.value(fundsToBeMoved)(msg.sender) == false)
            throw;

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

// Сжигание токенов DAO
        Transfer(msg.sender, 0, balances[msg.sender]);
        withdrawRewardFor(msg.sender); // be nice, and get his rewards
        totalSupply -= balances[msg.sender];
        balances[msg.sender] = 0;
        paidOut[msg.sender] = 0;
        return true;
    }

Это выглядит сомнительным с самого начала. Вызывается функция withdrawRewardFor, а переменные totalSupply, balances и paidOut получают новые значения после вызова.

Как мы установили за последние несколько недель, это антишаблон. Если окажется возможна атака на withdrawRewardFor методом «гонки на опустошение», эта функция будет вызываться до обновления хэш-таблиц balances или paidOut.

Функция withdrawRewardFor уязвима

Давайте посмотрим, что происходит с функцией 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;
    }

Функция не слишком длинная, поэтому я привел весь код. Однако (черная) магия вступает в дело там, где устанавливается вознаграждение. Мы видим, что вызывается функция rewardAccount.payOut. Давайте проверим ее.
rewardAccount — это контракт типа «ManagedAccount». Вот фрагмент соответствующего кода:

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;
        }
    }

Идем дальше. _recipient.call.value()() вызывается без расходования газа. Эту функцию легко атаковать с атакующего кошелька.

Давайте смоделируем нашу атаку

  1. Создаем контракт кошелька, который имеет функцию по умолчанию, вызывающую splitDAO определенное число раз, но не слишком много — мы не хотим опустошить всю DAO, достичь лимита газа по контракту или лимита стека вызовов.
  2. Создаем предложение о разделении с адресом получателя нашего нового контракта кошелька.
  3. Ждем 7 дней, чтобы разделение завершилось.
  4. Вызываем splitDAO.

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

splitDao
      withdrawRewardFor
         payOut
            recipient.call.value()()
               splitDao
                 withdrawRewardFor
                    payOut
                       recipient.call.value()()

И только затем стек вызовов разрешается. Во и все. Это вложенный стек вызовов. Давайте обсудим детали.

Исправлено. Как отметил Мартин Коппелман, на практике злоумышленник просто присоединился к существующему разделению с новым контрактом кошелька два дня назад. Ждать 7 дней не требовалось, любой доступный контракт разделения позволял добиться того же.

Следствие 1: проект The DAO теперь скомпрометирован

Вспомним последние несколько строк функция разделения:

withdrawRewardFor(msg.sender); // веди себя хорошо и получи его вознаграждение
        totalSupply -= balances[msg.sender];
        balances[msg.sender] = 0;
        paidOut[msg.sender] = 0;

Вот что происходит в конце второго вызова splitDAO:

  1. totalSupply уменьшается на баланс отправителя. (В данном случае 258 эфиров.)
  2. Устанавливается нулевой баланс отправителя.
  3. Устанавливается нулевой баланс paidOut отправителя.

Вот что происходит в конце вызова верхнего уровня splitDAO:

  1. totalSupply уменьшается на баланс отправителя, который теперь равен 0.
  2. Заново устанавливается нулевой баланс отправителя.
  3. Устанавливается нулевой баланс paidOut.

Так что TheDAO считает, что имеется на 258 монет больше, чем на самом деле.

На момент написания этого текста система TheDAO полагает, что у нее на 3,5 млн. монет больше, чем их есть фактически. Люди спорят насчет того, что это означает на самом деле, но почти наверняка следствием является некорректность расчетов других разделений и вознаграждений.

Следствие 2: дочерняя структура DAO имеет на 45 млн. долл. больше, чем должна была

Что же еще произошло во время этого вложенного вызова? Главным образом, дочерняя организация DAO получила средства.

Ниже приведена характерная часть кода.

// Переместить эфир и назначить новые токены
        uint fundsToBeMoved =
            (balances[msg.sender] * p.splitData[0].splitBalance) /
            p.splitData[0].totalSupply;
        if (p.splitData[0].newDAO.createTokenProxy.value(fundsToBeMoved)(msg.sender) == false)
            throw;

Токены переводятся в newDAO в соответствии с долей пользователя в суммарном количестве эфира TheDAO/отношением токенов. Я не буду углубляться в детали, но в целом токены создаются в subDAO, и контракт ManagedAccount, привязанный к msg.sender, получает эфир fundsToBeMoved.

Атакующему удалось сделать это 30 раз на каждый вызов со своего атакующего кошелька.

30 * 258 != 3 500 000 или худшая опечатка на одну букву года

ОБНОВЛЕНИЕ. Согласно актуальным утверждениям @LefterisJP это была не опечатка, а в худшем случае просто пропущенный вызов. Я оставил этот параграф для справки, но обновил некорректные утверждения.

30-кратная атака могла бы привести к выводу около 7500 эфиров. Почему же атака имела намного более масштабные последствия? Тим Годдард обнаружил эту ужасную ошибку в коде — опечатку, злонамеренную атаку или ошибку реорганизации.

Вернемся к строкам withdrawRewardFor. Обратите внимание, что я опубликовал весь код с отступами, как он был размещен в разделе верификации etherscan.

// Сжигание токенов DAO
        Transfer(msg.sender, 0, balances[msg.sender]);
        withdrawRewardFor(msg.sender); // веди себя хорошо и получи его вознаграждение

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

event Transfer(address indexed _from, address indexed _to, uint256 _amount);

Мне показалось, что это законно. И я перешел к другим частям кода. Тим же, как более внимательный человек, задал важный вопрос: каким образом здесь сжигаются токены DAO? Это просто ошибочный комментарий?

Это был бы правильный комментарий, ЕСЛИ БЫ вызывалась функция transfer (со строчной t). Вот функция transfer, начинающаяся со строчной t:

function transfer(address _to, uint256 _amount) noEther returns (bool success) {
        if (balances[msg.sender] >= _amount && _amount > 0) {
            balances[msg.sender] -= _amount;
            balances[_to] += _amount;
            Transfer(msg.sender, _to, _amount);
            return true;
        } else {
           return false;
        }
    }

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

if (!transfer(0 , balances[msg.sender])) { throw; }

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

ПРИМЕЧАНИЕ. Это оказалось досужим домыслом; такой код возник не в результате опечатки разработчиков TheDAO. Однако затруднения с учетом токенов остаются. Двигаемся дальше.

Как атакующему удалось вызвать эту функцию несколько раз?

Большинство атак с рекурсивным вызовом, которые мы рассмотрели, разрешились, и с ними больше не связаны уязвимости для дальнейшего использования; в конце вызова устанавливаются нулевые балансы, вот и все.
Однако атакующий смог выполнить эту атаку много раз, не менее 50. В чем же разница?

Это пропущенный вызов transfer. В нижней части стека вызовов атакующий кошелек переводит свои токены на другой адрес. Мы не видим этот код, так как он находится в атакующем кошельке, но выведя эти токены, он делает возможным разрешение контракта. TheDAO ничего не знает о том, что что-то пошло не так.

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

Фундаментальные причины и полученный опыт

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

В будущем хорошо бы принять следующие меры:

  1. Требуется чисто функциональный язык с развитой системой типов. Если прямо сейчас это невозможно:
  2. Все вызовы с передачей на недоверенные адреса должны иметь лимит газа
  3. Балансы должны уменьшаться до передачи, а не после
  4. Вероятно, события должны иметь префикс Log в имени.
  5. Функция splitDAO должна быть снабжена взаимными исключениями (флагами) и постоянно отслеживать статус каждого возможного разделения, не просто путем отслеживания токенов.

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

Как обычно, вы можете связаться со мной по вопросам аудита и другой работы, связанной с Solidity; можно обратиться и к другим людям, работающим в этой сфере, лучше место для этого — gitter. Кроме того, по моим данным, Тим работает в Aura Security.

Peter Vessenes

Категория: 
Безопасность
10
Средняя: 10 (2 оценок)
0
Ваша оценка: Нет
1229 / 0
Аватар пользователя admin
Публикацию добавил: admin
Дата публикации: вт, 06/21/2016 - 13:40

Что еще почитать:

Добавить комментарий