19 июля 2017 года Parity подвергся хакерской атаке, в ходе которой злоумышленникам удалось украсть 153,037 эфиров, что на тот момент составляло около $30 миллионов. Ее назвали «второй крупнейшей атакой в истории сети Ethereum по количеству украденного эфира». Уязвимость, позволившая осуществить атаку, содержалась в коде кошелька с мультиподписью, потому атака также известна как «MultiSig Hack».

parity

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

Далее, из-за устройства MultiSig-кошельков Parity, кошелек перенаправляет все вызовы на другой контракт — контракт библиотеки. Как поясняет Эдуард Карионов, эксперт по токеномике, блокчейну и смарт-контрактам, именно в библиотеке «реализована вся функциональность», что позволяет не писать отдельный код для каждого кошелька: «Каждый отдельный parity-кошелек был просто такой оболочкой, которая делегирует все вызовы в основной контракт библиотеки через delegatecall в его резервной функции», — пишет Карионов.

При этом суть механизма delegatecall состоит в том, что он работает с данными самого кошелька — то есть применяет функциональность библиотеки (ее код) к конкретному кошельку, его балансу и данным владельца. Соответственно, все вызываемые функции должны быть общедоступными, поэтому метод initWallet, отвечающий за инициализацию (подготовку к использованию) кошелька, был также общедоступен: «Предполагается, что метод будет вызываться только один раз во время создания контракта кошелька. То есть я хочу завести новый кошелек, я отправляю в Ethereum транзакцию, где написано "Заведи новый кошелек, который ссылается вот на эту библиотеку", и автоматически в момент создания моего контракта, моего кошелька срабатывает функция initWallet в библиотеке, которая назначает меня в качестве владельца этого кошелька», — пишет Карионов.

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

Parity признали, что взлом стал возможен именно из-за этой ошибки в коде: «Баг находился в двух крайне чувствительных функциях, разработанных для установки кошельков с мультиподписями в ПО кошелька Parity. Функции должны были быть защищены так, чтобы они могли использоваться только в одном случае — при создании контракта. Однако они были не полностью защищены, что позволило хакеру произвольно переустановить параметры владельца и использования», — пояснила команда Parity.

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

На следующий день после атаки команда Parity опубликовала новую версию кода, но в ней также содержалась уязвимость, которая была активирована 6 ноября. В этот день разработчик под ником devops199, не являвшийся членом команды и имевший пустой аккаунт на GitHub, написал, что «случайно убил» контракт. Некоторое время он участвовал в развернувшемся под своим постом обсуждении, и на вопрос о том, зачем он это сделал, devops199 ответил, что он «новичок в Ethereum» и «лишь изучает» систему.

Однако этому верят не все. Основатель Thetta — фреймворка для создания DAO — Антон Акентьев считает, что «непреднамеренно» выполнить все те шаги, что выполнил devops199, невозможно: «@devops199 “случайно” вызвал метод initWallet(), чтобы завладеть библиотекой https://etherscan.io/tx/0x05f71e1b2cb4f03e547739db15d080fd30c989eda04d37ce6264c5686e0722c9, @devops199 “случайно” вызвал метод kill(), чтобы она самоуничтожилась https://etherscan.io/tx/0x47f7cff7a5e671884629c93b368cb18f58a993f4b19c2a53a8662e3f1482f690», — написал Акентьев. В обсуждении на GitHub он провел следующую аналогию:

« вы идете и видите, что дверь банка открыта

 затем вы входите внутрь (вызов первого метода)

 затем вы сжигаете все деньги (вызов второго метода)

Поверит ли ФБР, что это было “непреднамеренно”?»

Внутреннее расследование проекта Cappasity, на момент атаки проводившего токенсейл, также выявило преднамеренность действий хакера. На сегодня аккаунт devops199 удален.

Акентьев также отметил, что в случае второй атаки проблема заключалась в некорректной инициализации контракта библиотеки, из-за чего любой пользователь мог стать его владельцем и уничтожить его.

В официальном объявлении команда Parity сообщила, что пострадали те кошельки с мультиподписями, которые были созданы после 20 июля, то есть использовали модифицированный после первого взлома код: «К сожалению, этот код содержал еще одну уязвимость… — он позволял превратить контракт библиотеки кошелька Parity в обычный кошелек с мультиподписями и стать его владельцем, вызвав функцию initWallet, — написали Parity. — Пользователь уничтожил код библиотеки, что в свою очередь сделало все контракты [кошельков] с мультиподписями неиспользуемыми и заморозило средства, поскольку их логика (все функции для изменения состояния) находилась внутри библиотеки». В результате держатели этих кошельков потеряли возможность выводить токены. Замороженными оказались 513,744 эфира.

В декабре CEO Parity Ютта Штайнер обещала, что доступ к средствам будет открыт после планового апргейда через 4−6 месяцев, однако этого не произошло. Проблема так и не решена, а споры относительно способа возврата средств вызвали раскол в сообществе Ethereum, в том числе среди разработчиков: в феврале Йоити Хираи покинул пост редактора кода EIP 867 — предложения по усовершенствованию Ethereum, которое также касалось способов возврата средств.

А в конце апреля, на встрече разработчиков Ethereum, споры разгорелись уже вокруг другого предложения — EIP 999: многие участники встречи были уверены, что принятие EIP 999 спровоцирует раскол сети, то есть произойдет хардфорк, так как два основных клиента Ethereum — Parity и Geth — придерживаются разных взглядов относительно этого предложения: «Мы говорим об одной и той же сети и, по сути, начинаем межплеменную войну. Я не думаю, что мы сможем договориться», — сказал ведущий разработчик Geth Петер Шилагий.

Сегодня большая часть обсуждений сводится к ноябрьской заморозке средств, поскольку — намеренной она была или нет — средства не были похищены: они так и дразнят сообщество, находясь все на тех же адресах. Однако исток этой «межплеменной войны» — 19 июля 2017 года, когда была осуществлена первая атака: именно это привело к необходимости менять код, баг в котором привел к новому инциденту и — без большого преувеличения — сформировал сообщество Ethereum в его сегодняшнем виде.

В марте сооснователь проекта токенизированных цепочек поставок Nuclo Дэвон Уэсли воссоздал код июльского взлома Parity, и DeCenter приводит адаптацию этого материала. Оригинал предполагает, что читатель обладает базовыми познаниями в программировании, а также в блокчейн-программировании и языке написания смарт-контрактов Ethereum — Solidity, поэтому мы предваряем адаптированную версию небольшим словарем:

 Консоль, или CLI (англ. command line interface, интерфейс командной строки) — текстовый интерфейс, в котором можно ввести команду на языке программирования (например, JavaScript), и она будет выполнена. Для непосвященного пользователя — это окошко с черным экраном и строчками кода, но консольный интерфейс может никогда вам не встретиться, так как сегодня все программы и приложения, нацеленные на широкую аудиторию, имеют красивый и удобный графический интерфейс — то есть картинки, кнопки и другие атрибуты дизайна, обеспечивающие интуитивное взаимодействие.

 init (сокращение от initialization) — система инициализации в ряде операционных систем, отвечающая за приведение программы или устройства в состояние готовности к использованию. В контексте статьи «initWallet» будет командой для инициализации кошелька.

 Фреймворк, дословно «каркас» — это ПО, упрощающее создание продуктов, например, смарт-контрактов или децентрализованных приложений.

 Резервная функция, или fallback function, в Solidity — это функция, которая выполняется, когда при вызове к контракту не обнаружен соответствующий идентификатор. Например, если код выглядит так: address.call(bytes4(bytes32(sha3("thisShouldBeAFunction(uint,bytes32)"))), 1, "test"), то идентификатор — thisShouldBeAFunction. Виртуальная машина Ethereum (EVM) попытается вызвать из контракта функцию с таким идентификатором. Если ее не существует, то вызывается резервная функция. В контексте статьи будет идти речь о резервной функции с модификатором «payable»: данный модификатор означает, что функция может принимать эфир. В коде она будет обозначаться так:

где msg.value обозначает, сколько именно пришло эфира.

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

 Менеджер пакетов — ПО для управления пакетами, то есть наборами файлов.

 Среда выполнения — набор инструкций, выполняемых для перевода написанного программистом кода в код, понятный компьютеру (в данной статье — в код, понятный виртуальной машине V8).

Адаптация материала Medium

Баги в Solidity дорого обходятся, подвергая риску вас самих и многих других, потому важно предпринять меры предосторожности при написании и развертывании смарт-контрактов. Мы рассмотрим один из таких багов, «эксплойт для кошелька с мультиподписью», и напишем код его упрощенного сценария, используя два смарт-контракта: контракт кошелька и контракт библиотеки кошелька.

Примечания:

Предполагается, что у вас есть базовое понимание технологий в основе блокчейна Ethereum и языка программирования для написания смарт-контрактов Solidity, который компилируется в байт-код EVM.

Если вы не знаете, что такое смарт-контракты или Solidity, загляните в материалы по ссылкам:

 Simple Smart Contract overview.

 Introduction to Solidity.

 Я использую Mac — простите, и я вас предупредил.

 NodeJS — среда выполнения, использующая JavaScript-движок V8, вам понадобится версия 6.9.1 или позднейшие.

 Пакеты NPM (менеджер пакетов, входящий в состав Node.js. — DeCenter):

Первый пакет, который мы устанавливаем, — Ganache, «Ethereum-клиент на базе NodeJS для тестирования и разработки». Ganache — это приватный блокчейн с собственным генезисным блоком, который полностью повторяет функционал основного блокчейна Ethereum (используется разработчиками для тестирования. — DeCenter).

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

Два контракта, которые мы используем, являются упрощенными примерами, а НЕ настоящими контрактами, на которые была осуществлена атака. Эти два примера взяты из блога «Hacking, Distributed», где дано отличное подробное объяснение июльской атаки.

Начинаем:

Мы выполним несколько команд для скаффолдинга (в данном случае «структуризации». — DeCenter) нашего проекта.

Вышеприведенные команды создают нашу папку проекта и затем сохраняют изменения, внесенные в проект.

Выполнение этой команды создаст в проекте структуру каталогов, приведенную ниже:

В корне нашего текущего проекта файл с заголовком truffle.js (предпоследняя строка на изображении выше. — DeCenter), поместите этот фрагмент кода в данный файл и сохраните. Это конфигурационный файл — он укажет инструментам truffle, с каким блокчейном им работать.

Эта команда создаст файл package.json в нашем корневом каталоге. Это позволит нам установить пакеты из NPM сюда в корневой каталог.

Это классный, очень полезный пакет. Это простой модуль для создания, управления и подписания Ethereum-транзакций.

Запускаем наш приватный блокчейн для тестирования

В новом консольном окне выполняем приведенную ниже команду:

Эта команда запустит совершенно новый приватный тестовый блокчейн с собственным генезисным блоком. Запуская блокчейн Ganache, вы получаете HD-кошелек (иерархически-детерминированный кошелек, имеющий seed-фразу и последовательно генерирующий бесконечное число адресов, привязанных к этому кошельку. — DeCenter). Этот кошелек будет иметь 10 аккаунтов, привязанных к нему и доступных для использования.

Часть команды -u 0 разблокирует первый аккаунт, и мы сможем создавать и подписывать транзакции.

Когда команда будет выполнена, вы увидите 10 аккаунтов.

Развертываем наши контракты

Ранее мы создали файл contracts/WalletLibrary.sol. Теперь поместим в него этот фрагмент кода нашего контракта.

Это контракт, с которым могут взаимодействовать другие контракты. Это не сам кошелек, и он не хранит никаких средств. Он создан только для того, чтобы контракты могли делегировать ему определенный набор функциональности (через delegatecall. — DeCenter). Он представляет собой сокращенную версию оригинального контракта, в котором была уязвимость. Оригинального контракта больше не существует из-за другой уязвимости, которая его уничтожила (речь о ноябрьской заморозке средств. — DeCenter). Этот контракт имеет два метода, которые могут использоваться любыми обращающимися к нему контрактами, — это метод вывода (withdraw) и смены владельца (changeOwner). Оба этих метода могут быть активированы только владельцем вызывающего контракта, что должно делать их безопасными, правильно? Не совсем. Посмотрим, в чем их уязвимость.

Контракт кошелька:

Ранее мы создали файл contracts/Wallet.sol. Теперь поместим в него этот фрагмент кода контракта.

В этом контракте много чего происходит. Объявления первых двух переменных:

 owner: Это владелец контракта кошелька. Он задается параметром _owner (строка 7 в приведенном фрагменте кода).

 _walletLibrary: Это адрес контракта библиотеки кошелька. Он нужен нам, чтобы мы знали, куда делегировать наши вызовы.

Мы вызываем функцию-конструктор Wallet (строка 7) при развертывании контракта. При вызове конструктора мы задаем переменные owner и  _walletLibrary. Конструктор задает переменную owner, перенаправляя вызов контракту WalletLibrary через опкод _walletLibrary.delegatecall(PARAMS).

Delegatecall

Уязвимость была не только в контракте библиотеки кошелька, но и в контракте самого кошелька, и она была связана с тем, как используется DELEGATECALL в резервной функции контракта кошелька.

«Похоже на идею CALLCODE (одна из разновидностей вызова к контракту библиотеки для  выполнения ее кода применительно к определенному контракту. — DeCenter), за исключением того, что он передает отправителя и стоимость (количество пересылаемого эфира. — DeCenter) от родительской сущности к дочерней, то есть вызов имеет того же отправителя и ту же стоимость, что и оригинальный вызов. Это означает, что контракт может хранить и передавать информацию, используя msg.sender (данные об отправителе. — DeCenter) и msg.value (данные о количестве эфира. — DeCenter) своего родительского контракта. Это хорошо для контрактов, которые создают контракты, но не повторяют дополнительную информацию, что экономит газ. См. комментарии к EIP 7». — Homestead Docs

DELEGATECALL не только распространяет свои свойства на msg (сообщение, передаваемое системой. — DeCenter), но и делится содержимым хранилища тех контрактов, которые осуществляют вызов. Это означает, что контракты, получающие DELEGATECALL, могут манипулировать внутренним содержимым контрактов, осуществляющих вызов. Это не всегда плохо, иногда нам именно это и нужно (как в строке 9), но также это может иметь негативные последствия.

Развертывание Truffle

Здесь наш скрипт развертывания. Всего 10 строчек, и мы готовы! Truffle — довольно удобный инструмент, который делает за нас большую часть работы. Мы импортируем наши контракты библиотеки кошелька и кошелька. Первый deployer (строка 5−6) развертывает контракт библиотеки кошелька. Второй (строка 8) — контракт кошелька с двумя параметрами: только что развернутым адресом контракта библиотеки и адресом владельца контракта кошелька. Это лишь небольшая часть того, что происходит под капотом.

Команда развертывания

У нас уже должно быть открыто консольное окно с запущенным блокчейном Ganache. Теперь откроем новое консольное окно и запустим команду развертывания Truffle.

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

В данном случае адрес контракта кошелька — 0x6f0147644dfbd1b335f6a5de432b4de566a8d69d, адрес библиотеки — 0xdbcd830c1ec91a003f6475c63b4391ce73abe2af, но у вас они будут уже другими.

Значение переменной _walletLibrary в контракте кошелька будет таким же, как и адрес контракта библиотеки кошелька. Скопируйте и сохраните где-нибудь это значение — оно нам понадобится для осуществления атаки.

Взаимодействие с кошельком:

Откройте третье консольное окно и запустите команду:

Выполнение этой команды приводит нас к NodeJS repl (цикл REPL, read-eval-print loop, «чтение — вычисление — вывод», позволяет запускать код покомандно и мгновенно видеть результат его выполнения. — DeCenter), и Truffle предоставляет нам доступ к двум глобальным переменным (переменные, которые видны всей программе и могут использоваться любым участком кода (в противоположность локальным переменным). — DeCenter).

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

Этот объект и является нашим контрактом кошелька. Он имеет двоичный интерфейс приложений (ABI), байткод, опкоды рантайма, методы контракта и другую полезную информацию.

У меня эта команда генерирует такой адрес: 0x6ba7132c9cc09956785ff7de95b2d410858a94c2

Вышеприведенная команда проверяет, кто является текущим владельцем контракта, то есть кто его контролирует.

Эта команда генерирует у меня такой адрес: 0x6ba7132c9cc09956785ff7de95b2d410858a94c2

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

Атаки, уязвимости?

До настоящего момента мы не говорили подробно об атаке или уязвимостях обоих контрактов. Давайте их обсудим. Вот список факторов, которые привели к атаке:

 Длина и сложность контракта;

 Сложные взаимодействия между резервными функциями Solidity;

 Прозрачность (публичный характер) функций Solidity по умолчанию;

 Механизм Delegatecall;

 Пересылка данных вызова.

В случае нашего контракта у кошелька есть 3 способа выполнить команду _walletLibrary.delegatecall(PARAMS). Но если два из соответствующих вызовов контролируются и ведут себя, как того ожидает разработчик, то третий способ выполнения delegatecall — резервная функция с модификатором «payable». Эта функция пересылает данные в контракт библиотеки. Здесь и рассыпаются ожидания разработчиков. Программист не может повлиять на то, что будет пересылать пользователь.

Внутри метода withdraw в контракте кошелька мы выполняем delegatecall. Фрагмент кода, приведенный выше, передает этому вызову два параметра. Первый — 4 байта из 256 байт — это оператор, возвращающий результат хэширования метода withdraw(uint), второй — это количество, параметр, который передается методу withdraw(uint). Таким образом, delegatecall должен вызвать в контракте библиотеки метод withdraw. Это то, чего мы ожидаем.

Это первая уязвимость. Когда кто-то, владелец или нет, отправляет транзакцию на контракт нашего кошелька и пытается вызвать функцию, которой не существует, то вызывается резервная функция с модификатором payable, которая принимает эфир по умолчанию. Эта функция выполняет delegatecall к контракту библиотеки и направляет ему значение переменной msg.data. Потом будет проверено, существует ли в контракте библиотеки оригинальная функция, которую хотел вызвать пользователь. Теперь вы видите атаку?

Вторая уязвимость связана с тем, как был применен метод контракта библиотеки: initWallet(address).

Функция initWallet(address) в контракте нашей библиотеки незащищена:

 Она не выполняет проверку, чтобы посмотреть, определен ли уже владелец контракта.

 Она не содержит модификатор internal, который бы приказывал ей работать только в собственном хранилище контракта при поступлении вызова, пересылающего данные (то есть она общедоступна, публична. — DeCenter).

Комбинация двух этих уязвимостей привела к MultiSig-атаке.

Атака по шагам:

 Хакер посылает транзакцию на контракт кошелька с помощью метода initWallet(address).

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

 Функция с модификатором payable вызвана командой msg.data, которая была направлена контракту библиотеки через delegatecall.

 Библиотека проверяет, есть ли в ней метод initWallet().

 Она находит метод и выполняет его, как запрограммировано.

 Метод initWallet() устанавливает владельца, так что хакер делает владельцем самого себя, указав свой адрес.

 Теперь хакер может делать все, что захочет, с контрактом кошелька.

Помните, что когда мы используем delegatecall, мы не только пересылаем данные, но и сообщаем библиотеке, какое хранилище использовать, какое хранилище соответствует вызывающему контракту. В нашем случае вызывающий контракт — это кошелек, и он имеет переменную — owner, и эта переменная задается методом initWallet(address). Готово!

Как эта атака выглядит в коде?

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

Метод sha3() хэширует ‘initWallet(address)’ и возвращает его в виде 256-байтного хэша, из которого мы берем первые 4 байта, они используются для идентификатора метода (method_id).

Мы присоединяем 24 нуля впереди адреса хакера. Адреса в Ethereum состоят из 20 байт, параметры — из 32 байт, так что мы восполняем необходимые 12 байт нулями. В случае с адресом хакера мы убираем 0x из его начала.

Когда мы проверяем переменную данных, мы видим значение, указанное выше. Первые 4 байта — идентификатор метода initWallet(address), а следующие 32 байта — это параметры, которые передаются этому методу.

Здесь мы задаем параметры транзакции, необходимые для того, чтобы отправить наши «данные атаки» на контракт кошелька.

Мы используем пакет ethereumjs-tx, который мы установили ранее.

Создаем новый образец транзакции.

Трансформируем приватный ключ в буфер (область памяти для временного хранения данных, принцип работы можно представить на примере обычного буфера обмена. — DeCenter). Открываем консоль, на которой запущен блокчейн Ganache, и проматываем на самый верх. Вы обнаружите свой приватный ключ в списке приватных ключей. Мы используем аккаунт номер два (выделен голубым) и приватный ключ, соответствующий этому аккаунту — второй в списке.

Передаем буфер приватного ключа методу .sign(KEY), чтобы подписать транзакцию, которую мы создали. И транзакция готова.

Отсылаем нашу хакерскую транзакцию, чтобы она была обработана (при эмитации, коей является Ganache, этап обработки отсутствует).

Когда транзакция выполнена, ее хэш вернется в виде, приведенном выше.

Давайте проверим, как дела у нашего владельца:

Если посмотреть на адрес владельца, который вы скопировали раньше (0x6ba7132c9cc09956785ff7de95b2d410858a94c2. — DeCenter), он будет отличаться. Теперь хакер контролирует контракт кошелька и все его средства, и первоначальный владелец не может ничего сделать.

Заключение

Это эксплойт кошелька с мультиподписью в действии. Вкратце — сочетание двух уязвимостей дало возможность осуществить атаку. Функция кошелька, по умолчанию принимающая эфир и перенаправляющая данные, и метод контракта библиотеки initWallet(address) оказались не защищены. Разработчики кошельков, взаимодействующих с контрактом библиотеки, могли проверять значение переменной msg.data и разрешать передачу только определенных значений. Они могли внедрить проверки или модификаторы, чтобы не допустить такой атаки. Но что сделано — то сделано, и контракты взламываются постоянно. Команда Parity — это группа действительно умных людей, так что это могло случиться с каждым.