От одного npm‑пакета к кластеру Contagious Interview: практическое расследование supply chain‑кампа…
- четверг, 20 августа 2026 г. в 00:00:04
В прошлой статье я рассказывал о случайно попавшем ко мне образце ChainVeil - вредоносном ПО из источника, который на тот момент не был зафиксирован в публичных исследованиях.
На этот раз первым сигналом стал мой собственный supply chain‑мониторинг. Он сообщил о новом npm‑пакете plugin-react-vite. На первый взгляд это выглядело как очередной тайпсквоттинг - ничего особенно необычного.
Но я решил пойти дальше: сначала проверить метаданные пакета, затем код, затем инфраструктуру, а уже после этого попытаться понять, кто стоит за образцом и связан ли он с чем‑то уже известным.
В результате расследование ушло далеко за пределы одного package.json: от подозрительной зависимости я пришел к второму этапу размером около 30 тысяч строк, а затем - к нескольким месяцам активности, связанным пакетам, нескольким npm‑аккаунтам и уже известному кластеру.
Именно этот путь я и хочу разобрать.
Первое, что бросилось в глаза, так это схожесть названия с популярным vite-plugin-react. Это классический тайпсквоттинг - знакомая конструкция, лишь слова переставлены местами.
Но само по себе это мало о чем говорит. Поэтому обратим внимание на package.json:
{ "name": "plugin-react-vite", "version": "2.1.3", "author": "copperadev", "dependencies": { "@primno/dpapi": "^2.0.1", "axios": "^1.11.0", "better-sqlite3": "^12.2.0", "express": "^4.21.2", "node-machine-id": "^1.1.12", "socket.io-client": "^4.8.1" } }
Сразу видно первое несоответствие и, как оказалось, кусочек пазла для дальнейшего расследования: зачем простому React‑плагину все эти зависимости? Уже на этом этапе можно предположить, что ждет внутри кода.
Дальше меня заинтересовал README‑файл - он явно был позаимствован из другого пакета. В нем находилось руководство по установке velora‑kit. Но вы спросите: «Пакет же называется plugin‑react‑vite, что еще за velora?». И тут вы окажетесь правы, я задался ровно таким же вопросом, и именно это несоответствие стало ниточкой, ведущей к целому кластеру пакетов.
А пока вернемся к анализу React‑плагина.
Открыв index.js, я обнаружил незамысловатый загрузчик следующей стадии полезной нагрузки. Код формировал URL вида https://<C2>/icons/106, делал GET‑запрос c заголовком client_id: 106 и получал JSON с обфусцированным кодом в поле credits. На этом этапе я получил C2-адрес в качестве артефакта для атрибуции.
В ходе анализа второй стадии полезной нагрузки мне удалось сократить ~30 000 строк исходного обфусцированного кода до ~400 строк логики. И тут начинается самая интересная часть функционала.
Код работал как на Windows, так и на macOS:
Windows: Читает файл Local State, достает зашифрованный ключ, снимает с него защиту через нативный модуль @primno/dpapi и дешифрует базу Login Data.
macOS: Исполняет системную команду security find-generic-password -s "Chrome Safe Storage", деривирует ключ через PBKDF2 и дешифрует данные через AES-128-CBC.
Для работы с LevelDB‑хранилищами расширений на macOS использовалась библиотека level - данные с определенным префиксом расшифровывались в памяти. А чтобы базы не блокировались запущенным браузером, перед чтением файлы копировались во временные директории.
Вспомним, что это также RAT‑ник - при получении команды wallets код не делает дамп всей базы кошелька на диск. Он читает LevelDB через fs.createReadStream, извлекая Ethereum‑адреса по регулярному выражению /0x[a-fA-F0-9]{40}/g, и передает только список уникальных адресов на C2.
Целевыми расширениями браузера оказались MetaMask, Coinbase, TronLink, Ronin, Trust Wallet, Exodus, Electrum, Guarda и др.
Для рекурсивного поиска файлов .env, hardhat.config.ts запускались нативные утилиты PowerShell или find. Параллельно воровались целые каталоги ~/.aws, ~/.config/gcloud и ~/.azure.
Одновременно с этим, код предоставлял оператору команды удаленного управления:
exec - выполнение произвольных команд.
dir и cd
read_file
ss_upf и ss_upd - загрузка произвольных файлов с зараженной машины на C2.
Стоит отметить, что в коде также реализован бэкдор: при перехвате сигнала SIGINT код форкает процесс express.js с параметром detached: true. Это позволяет сохранить доступ к системе после остановки основного скрипта.
В ходе анализа кода я собрал достаточно много артефактов, по которым можно было начать искать связанные пакеты. Я начал с исследования velora-kit, упомянутого в README. Код пакета оказался идентичен тому, что находился в пакете React‑плагина, а что самое важное - идентичным оказался package.json и содержал строку:
"author": "copperadev"
С этого момента я начал искать данную строку по другим npm‑пакетам, и неожиданно для себя, мне удалось обнаружить несколько совпадений.
Пакет | Автор (package.json) | Maintainer | Дата публикации |
polymarket‑gamma‑api | copperadev | byteranger318 | 2026–03-16 02:51:58 |
rollup‑plugin‑polyfill‑connect | copperadev | julius‑dev | 2026–04-29 18:19:57 |
react‑icon‑svgs | copperadev | julius‑dev | 2026–05-08 18:08:11 |
velora‑kit | copperadev | leonardo0908 | 2026–08-12 01:56:41 |
vexium‑kit | copperadev | leonardo0909 | 2026–08-13 12:58:44 |
tailwind‑toolkit | copperadev | chanstealer318 | 2026–08-13 19:37:45 |
tailwind‑plugin‑kit | copperadev | ninja318 | 2026–08-14 08:35:00 |
config‑helper‑kit | copperadev | nami318 | 2026–08-18 13:02:29 |
plugin‑react‑vite | copperadev | aurorasmith100 | 2026–08-18 17:35:36 |
И вот здесь одиночный пакет начал превращаться в часть кластера. Но совпадение по copperadev не является достаточным доказательством, поэтому вспомните package.json из самого начала статьи:
{ "author": "copperadev", "keywords": [ "react", "helper", "svg" ], "dependencies": { "@primno/dpapi": "^2.0.1", "axios": "^1.11.0", "better-sqlite3": "^12.2.0", "express": "^4.21.2", "node-machine-id": "^1.1.12", "socket.io-client": "^4.8.1" } }
Я не просто так сказал, что он стал кусочком пазла в расследовании - в большинстве пакетов эти поля полностью идентичны, таким образом я получил своеобразный fingerprint этих пакетов.
После этого я вернулся в самое начало - к README пакета. Оказалось, что velora‑kit уже присутствует среди зафиксированных npm‑пакетов кампании Contagious Interview. Большинство представленных выше пакетов по данным исследователей также были связаны с Contagious Interview, в том числе и polymarket‑gamma‑api, появившийся еще в марте этого года. В этот момент связь plugin‑react‑vite с кампанией становится сильнее.
Здесь появляется еще один интересный индикатор - у нескольких аккаунтов повторяется суффикс 318.
Но я не стал интерпретировать это как прямой IOC: такой суффикс вполне может быть случайностью. Но вместе с одинаковым author, одинаковыми зависимостями и повторным использованием одного кода он становится еще одним элементом общей картины.
В результате получается своеобразный граф:

Я уже рассказал про код в пакете React‑плагина, но что насчет остальных пакетов на графе?
После поверхностной деобфускации кода всех представленных выше пакетов оказалось, что логика во всех них практически идентична. Единственным существенным отличием был C2-адрес: в пакете polymarket-gamma-api он отличался от того, что использовался в августовских релизах. Однако по структуре, функционалу конечной полезной нагрузки, способу ее загрузки и используемым модулям код всех пакетов был одинаков.
Изначально это навело меня на мысль о Malware‑as‑a-Service, где эти числа могли бы быть идентификаторами разных покупателей панели. Однако, как вы видели, предположение оказалось неверным. Тот факт, что за всеми этими пакетами с разными client_id стоит один и тот же автор, разрушает гипотезу MaaS. На данный момент я предполагаю, что это внутренние идентификаторы волн кампании или версий сборки одной и той же группы разработчиков.
Я не начинал это расследование с мыслью, что это Lazarus, чтобы потом подгонять факты под эту гипотезу. Атрибуция сложилась как пазл, когда разрозненные артефакты начали указывать в одну сторону.
Почему с высокой долей уверенности можно связать этот кластер с кампанией Contagious Interview, которая в публичных исследованиях связывается с активностью Lazarus?
Единая кодовая база и метаданные: Одинаковый автор, идентичные наборы зависимостей и общая архитектура многоступенчатой загрузки.
Тактика обхода модерации: разные пакеты публиковались через разные maintainer‑аккаунты, при этом поле author и другие метаданные сохранялись. Такая схема затрудняет кластеризацию пакетов по одному npm‑аккаунту и может усложнять реакцию на блокировку отдельных публикаций.
Совпадение профилей: Вектор доставки, цель и механика полностью совпадают с публичными отчетами исследователей о Contagious Interview.
Если вы сталкивались с похожими случаями подозрительной активности в npm‑пакетах или у вас есть вопросы по разбору кода, добро пожаловать в комментарии. Будет интересно обсудить.