DAO attack
Проект TheDAO был атакован, и хакеру досталось 3.5 миллионов ether (на момент написания статьи это примерно $45 миллионов). Использовалась уязвимость рекурсивного вызова.
Вступление
Мы начнём с функции splitDAO по двум причинам — атакующий создавал дочерние DAO, ведь это единственный действующий механизм для вывода монет; вторая причина состоит в несовершенности этого механизма.
В чём состоит цель этой функции: часть держателей токенов TheDAO решает «разделиться» — или из-за несогласия с предложением, или (как и произошло в короткой, но бурной истории theDao) потому, что они хотят вывести средства.
Совершить это можно с помощью создания предложения по разделению. Предложение по разделению требует 7 дней для «созревания» и голосования. Все участники, проголосовавшие «за» разделение могут вызвать функцию splitDAO.
splitDAO создает дочерний DAO контракт, если он ещё не создан, пересылает ether на childDAO, выплачивая любое накопившееся «вознаграждение» участникам разделения. По крайней мере, план именно такой.
Сложности
Обзоры принимают во внимание последнюю версию кода theDAO на гитхабе. К сожалению, это НЕ тот код, который реально использует theDAO. Это создает довольно много путаницы — код, на который я смотрю, это тот код, который сейчас работает?
Далее мы расскажем об этом больше, но некоторые довольно искушенные исследователи уже успели запутаться.
Код
Возьмем самые интересные места функции splitDAO:
function splitDAO(
uint _proposalID,
address _newCurator
) noEther onlyTokenholders returns (bool _success) {
Хорошо, теперь у нас есть функция. Она не может пересылать ether. Её могут вызвать только держатели жетонов. Давайте посмотрим, как это происходит.
[snip]…
Строка, в которой написано «передать ether». Интересно…
// Передать ether и выпустить новые жетоны
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. Запомним этот момент.
[snip]
// Burn DAO Tokens
Transfer(msg.sender, 0, balances[msg.sender]);
withdrawRewardFor(msg.sender); // получите свое вознаграждение
totalSupply -= balances[msg.sender];
balances[msg.sender] = 0;
paidOut[msg.sender] = 0;
return true;
}
Такое решение выглядит сомнительно с самого начала. Функция withdrawRewardFor вызывается, а затем переменные totalSupply, balances и paidOut определяются.
Это образец того, как НЕ надо делать. Если withdrawRewardFor будет атакована способом Race To Empty, функцию можно вызвать до того, как данные 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()() вызывается без количества газа. Это позволяет легко атаковать её из кошелька.
Давайте создадим нашу атаку
Создайте контракт кошелька, в котором по умолчанию есть функция вызова splitDAO произвольное количество раз, но не слишком много — мы не собираемся полностью опустошить DAO, превысить лимит газа или лимит callstack.
Создайте предложение по разделению с адресом получателя, который совпадает с адресом нашего нового контракта кошелька.
Подождите 7 дней для завершения разделения.
Вызовите splitDAO.
Код будет выглядеть таким образом (предполагается, что запрос кошелька будет выполнен только дважды):
splitDao
withdrawRewardFor
payOut
recipient.call.value()()
splitDao
withdrawRewardFor
payOut
recipient.call.value()()
Как отметил Мартин Коппельман, нападавший просто присоединился к существующему разделению, созданному за два дня до этого. Ему не нужно было ждать 7 дней, может пригодиться любой из существующих контрактов разделения.
Реализация 1: DAO скомпрометировано
Давайте заново вызовем несколько последних строк функции разделения:
withdrawRewardFor(msg.sender); // получите свое вознаграждение
totalSupply -= balances[msg.sender];
balances[msg.sender] = 0;
paidOut[msg.sender] = 0;
Что же происходит по завершении второго вызова splitDAO:
Параметр totalSupply устанавливается равным балансу отправителя. (В нашем случае около 258 ether).
Баланс отправителя устанавливается в ноль.
Баланс отправителя paidOut устанавливается в ноль.
Что же происходит в конце главного вызова splitDAO:
Параметр totalSupply приравнивается к балансу отправителя, который равен 0.
Баланс отправителя снова ставится в 0.
Параметр paidOut снова ставится в 0.
Теперь, theDao считает, что имеет на 258 монет больше, чем на самом деле. Повторим этот запрос много раз, и theDAO будет считать, что у неё на 3.5 миллиона больше монет, чем на самом деле. Есть много дискуссий по поводу этого значения, но почти наверняка вычисления и разделения были другими, чем в нашем примере.
Реализация 2: Дочернее DAO становится богаче на $45 миллионов
Что ещё происходит во время данного вызова? Главное, что происходит – это получение денег дочерним DAO. Вот важный отрывок кода.
// передать ether и получить новые жетоны
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, не влияет соотношение жетонов и ether. Я не собираюсь копать глубоко, но проще говоря, жетоны создаются в subDAO, и ManagedAccount, связанный с msg.sender, пересылает ether с помощью функции fundsToBeMoved. Атакующий смог сделать тридцатикратное увеличение своих средств на атакующем кошельке.
30 * 258!= 3500000, худшая ошибка года
Позвольте, но ведь таким образом 30-кратная атака принесла бы около 7500 ether. Каким образом атакующий смог получить настолько много? Тим Годдард нашел ужасную ошибку, гибельный баг в коде.
Давайте опять посмотрим на строку withdrawRewardFor.
Заметим, что я привел весь код без изменений, что подтверждается данными etherscan.
// Burn DAO Tokens
Transfer(msg.sender, 0, balances[msg.sender]);
withdrawRewardFor(msg.sender); // получите свое вознаграждение
Типичный для языка solidity контракт. Я предположил, что его задачей является передача средств. И действительно, есть такая строка:
event Transfer(address indexed _from, address indexed _to, uint256 _amount);
Все выглядит логично. Но был главный вопрос — где, и на каком этапе жетоны DAO сгорают? Может быть, в коде есть ошибка? Оказывается, у разработчиков получилось две функции transfer — одна начинается с маленькой t, другая – с большой. Вот функция, которая начинается с маленькой t.
function transfer(address _to, uint256 _amount).
(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; }
Это мешает работе рекурсивного вызова, но зато уменьшает количество жетонов у пользователя.
Как атакующий смог вызвать функцию настолько много раз?
Зачастую рекурсивный вызов удается выполнить только один раз; нужные нам балансы в итоге ставятся в 0, и на этом все заканчивается. Однако атакующий смог повторить эту операцию как минимум раз 50. Почему?
Дело в том, как именно совершается перевод. Атакующий кошелек переводит свои жетоны на другой адрес. Мы не видели этот код, потому что он находится в атакующем кошельке, но перемещение жетонов на другой адрес позволяет контракту выполняться. TheDAO не догадывается, что дела идут не так, как надо.
Следующим шагом он переводит жетоны назад и может запустить атаку снова. Для этого у него есть всё необходимое — не потраченные жетоны, адреса в разделении, которые голосовали «за».
Причины и извлеченные уроки
Ошибка стала результатом плохого программирования, и вероятно типографской ошибки в коде.
Вещи, которые должны быть сделаны:
- Необходим функциональный язык с богатой системой типов. Похоже, в данный момент его нет.
- Все вызовы, которые связываются с неизвестными адресами, должны быть ограничены лимитом газа.
- Баланс нужно проверять перед пересылкой средств, а не после.
- Для событий, вероятно, нужно сохранять лог, связанный с именем.
- Функция splitDAO должна отслеживать статус каждого из разделителей, причем не только формально.
Мы ещё увидим, внесут ли ли theDAO изменения в код, чтобы сделать эту транзакцию недействительной. Но главная проблема в том, что при этом они нарушат свои же принципы.
Upvoted you