· 17 min de lectura

EtherNet/IP y el protocolo CIP

Cómo funciona la comunicación con PLCs Allen-Bradley: desde la identificación del dispositivo hasta la construcción manual de mensajes CIP

Acabo de regresar de vacaciones y se me apeteció retomar los retos de estas plataformas para practicar mis habilidades de resolución de problemas y hacking. Como el desafío aún está activo y las políticas de ciertas plataformas limitan la publicación de contenido mientras siga en línea, mi idea es ir resolviéndolo y documentar lo que vaya aprendiendo durante el proceso, más que hacer un simple “write-up de solución”.

Nota: He cambiado ligeramente la formulación del escenario original para evitar problemas y minimizar el riesgo de que este contenido aparezca directo con Google dorking. Sé que siempre pueden quedar palabras clave rastreables, pero al menos es una forma básica de “ofuscar” el reto. Más que publicar una guía paso a paso, quiero centrarme en la metodología que seguí para llegar a la resolución.

Escenario: En el curso de un análisis de red, encontramos un equipo de automatización que responde mediante EtherNet/IP (Controlador EtherNet/IP). El desafío requiere que te conectes a este dispositivo, extraigas el dato almacenado en la tag FLAG y confirmes la extracción exitosa de información.

Había escuchado de IP y Ethernet, pero un “controlador EtherNet/IP” ya es otro nivel. Puede que lo haya visto antes, pero necesito refrescar memoria.

¿Qué es un Controlador EtherNet/IP?

Para entender esto con más facilidad, primero hay que ver en qué contexto se usa. Antes, muchas fábricas dependían casi por completo de personas para producir: mover piezas, accionar máquinas, supervisar procesos. Con el tiempo, gran parte de ese trabajo se ha ido automatizando mediante sistemas de control industrial, que hacen tareas repetitivas de forma más eficiente y sin detenerse.

En ese mundo aparecen los PLCs (Controladores Lógicos Programables): son el “cerebro” que ejecuta la lógica de control (cuándo encender un motor, cuándo abrir una válvula, cuándo parar una cinta transportadora). Cuando ese PLC usa el protocolo EtherNet/IP para comunicarse en red con otros dispositivos (sensores, variadores de velocidad, HMIs, etc.), en la jerga se habla de un “controlador EtherNet/IP”.

¿Cómo Funciona un PLC? Un Ejemplo Simple

Imagina una banda transportadora en un almacén:

  1. Un sensor detecta cuando llega una caja
  2. El PLC lee esa señal y ejecuta su programa:
SI sensor_caja = activado ENTONCES  
motor_banda = ENCENDER  
esperar 5 segundos  
motor_banda = APAGAR  
FIN SI
  1. El motor arranca, mueve la caja, y se detiene automáticamente

Ese es el trabajo del PLC: leer entradas (sensores), tomar decisiones lógicas, y controlar salidas (motores, válvulas, luces).

Hardware: ¿Cómo Luce un PLC?

Los PLCs son dispositivos físicos que van desde cajas compactas todo-en-uno hasta sistemas modulares con múltiples slots:

PLC Allen-Bradley

En la industria, especialmente con Allen-Bradley (uno de los fabricantes más comunes), existen diferentes “gamas” según la complejidad del proceso:

FamiliaCapacidadUso TípicoEjemplo
MicroLogixBaja (20-100 I/O)Máquinas simplesBomba de agua, semáforo
CompactLogixMedia (100-500 I/O)Líneas medianasEmpaquetadora, CNC
ControlLogixAlta (miles de I/O)Plantas complejasRefinería, automotriz

Diferencia clave: Mientras más grande el proceso, más puntos de entrada/salida (sensores/actuadores) necesitas controlar, más memoria requieres, y más robustez (como redundancia) es crítica.

Comunicación: Aquí Entra el Protocolo

Un PLC por sí solo puede controlar su maquinaria local, pero en una planta moderna necesitas que múltiples dispositivos se comuniquen entre sí.

Para eso existen múltiples protocolos de comunicación industrial, cada fabricante y aplicación tiene sus propios estándares:

Familia CIP (ODVA/Rockwell - Allen-Bradley):

  • EtherNet/IP: CIP sobre Ethernet TCP/IP
  • DeviceNet: CIP sobre CAN bus (sensores pequeños)
  • ControlNet: CIP sobre coaxial (tiempo real crítico)

Otros fabricantes:

  • Modbus TCP/RTU: Estándar abierto universal (Schneider, muchos otros)
  • PROFINET/PROFIBUS: Siemens
  • EtherCAT: Beckhoff (alta velocidad)
  • CC-Link: Mitsubishi
  • OPC UA: Interoperabilidad entre fabricantes

En este reto tratamos con Allen-Bradley, por lo tanto usaremos protocolos de la familia CIP, específicamente EtherNet/IP.

Encuentro con pycomm3 y cpppo

Haciendo una búsqueda tipo “EtherNet/IP python” o “Allen-Bradley python”, me topé con 2 librerías que parecen ser las mas recomendadas:

  • pycomm3: Una biblioteca moderna pensada específicamente para PLCs Allen-Bradley. Es como el “modo fácil” para hablar con estos dispositivos.
  • cpppo: Una implementación más completa del protocolo EtherNet/IP/CIP. Es más versátil porque trabaja a un nivel más bajo del protocolo, sin asumir tanto sobre qué tan “completo” es el dispositivo que tienes enfrente. Por eso funciona bien con simuladores o PLCs que no implementan todo el estándar al 100%.

Explorando la documentación de pycomm3, descubrí que tiene 3 “modos” diferentes:

  • CIPDriver: El controlador base que maneja los servicios CIP comunes (abrir conexiones, registrar sesiones, etc.). Sirve para cualquier dispositivo EtherNet/IP: drives, switches, medidores, no solo PLCs.
  • LogixDriver: Diseñado para ControlLogix, CompactLogix y Micro800. Este sí trae las funciones específicas de PLCs: leer/escribir tags, obtener la lista completa de tags automáticamente, ajustar la hora del PLC .
  • SLCDriver: Para los PLCs viejitos SLC500 y MicroLogix. Lee/escribe archivos de datos básicos. Está en modo “legacy” con desarrollo limitado.

¿Cómo sé cuál driver debería usar? ¿Es a prueba y error o existe alguna forma inteligente de identificar a qué dispositivo me estoy enfrentando?

Reconocimiento: Identificando el Dispositivo

Antes de elegir qué driver usar, necesitamos identificar con qué tipo de dispositivo estamos tratando. En pentesting, nunca asumimos nada sin verificar.

Fase 1: Escaneo de Puerto

Lo primero es confirmar que el servicio está accesible:

zsh
% nmap -Pn -sV 94.237.51.160 -p42450
PORT      STATE SERVICE VERSION
42450/tcp open  unknown

El puerto está abierto, pero nmap no reconoce el servicio. ¿Por qué?

  • Usa un puerto no estándar (EtherNet/IP normalmente usa el 44818)
  • Los fingerprints de nmap están pensados para servicios web/clásicos, no protocolos industriales

Nota rápida: Sí existen scripts NSE de nmap para EtherNet/IP (como enip-infoenip-enumerate), pero en este caso no arrojan nada útil porque el puerto no es el estándar y el dispositivo tiene capacidades limitadas. Los omitiremos por ahora.

Fase 2: Banner Grabbing con Netcat

Bueno, si nmap no lo reconoce, intentemos la vieja confiable: tirarle texto y ver qué responde.

zsh
% echo "" | nc -vv 94.237.51.160 42450
94-237-51-160.uk-lon1.upcloud.host [94.237.51.160] 42450 (?) open

Conexión exitosa, pero… silencio total. No hay banner, no hay respuesta.

¿Qué pasó? Los protocolos industriales como EtherNet/IP hablan en binario puro. No esperan texto plano como “GET /” o “HELLO”. Si les envías caracteres ASCII aleatorios, simplemente te ignoran o cierran la conexión.

Para hablar con estos dispositivos necesitamos enviar mensajes CIP en formato binario. Y aquí es donde entran las herramientas especializadas.

Fase 3: Identificación del Protocolo Industrial

Aquí es donde las herramientas especializadas entran en juego. Usaremos cpppo para enumeración, ya que lo veo más fácil por línea de comandos que escribir código python3 con pycomm3:

# Instalación
pip install cpppo

# Comando de identificación: List Identity
python3 -m cpppo.server.enip.client --list-identity -a 94.237.51.160:42450

Este comando envía un mensaje CIP List Identity (comando 0x63 del protocolo EtherNet/IP). Es básicamente el “¿quién eres?” del mundo industrial.

zsh
% python3 -m cpppo.server.enip.client --list-identity -a 94.237.51.160:42450
List Identity  0 from ('94.237.51.160', 42450): {
'count':                        1,
'item[0].type_id':              12,
'item[0].length':               54,
'item[0].identity_object.version': 1,
'item[0].identity_object.sin_family': 2,
'item[0].identity_object.sin_port': 44818,
'item[0].identity_object.sin_addr': '0.0.0.0',
'item[0].identity_object.vendor_id': 1,
'item[0].identity_object.device_type': 14,
'item[0].identity_object.product_code': 54,
'item[0].identity_object.product_revision': 2836,
'item[0].identity_object.status_word': 12640,
'item[0].identity_object.serial_number': 7079450,
'item[0].identity_object.product_name': '1756-L61/B LOGIX5561',
'item[0].identity_object.state': 255,
}

¡Ahora sí! El dispositivo nos respondió con su “tarjeta de presentación” completa.

Ahora toca interpretar estos datos. Los campos importantes son:

CampoValorSignificado
vendor_id1Rockwell Automation / Allen-Bradley
device_type14Communications Adapter (PLC con capacidad de red)
product_code54Familia 1756 (ControlLogix)
product_name1756-L61/B LOGIX5561Procesador ControlLogix específico
state2550xFF = Modo RUN (operativo)

Conclusión: Estamos frente a un Allen-Bradley ControlLogix 1756-L61, revisión de firmware B (0x0B14).

Ahora que sabemos qué es, podemos tomar una decisión informada sobre qué driver de pycomm3 usar.

¿Qué Driver de pycomm3 Usar?

Ahora que sabemos que es un ControlLogix, la respuesta es clara:

El LogixDriver está hecho a medida para ControlLogix, CompactLogix y Micro800. Los otros drivers quedan descartados:

  • CIPDriver: Demasiado genérico. Está pensado para dispositivos no-PLC como drives, switches, medidores.
  • SLCDriver: Solo para los abuelos SLC-500/MicroLogix (arquitectura antigua de 16 bits).
  • LogixDriver: Para la familia moderna ControlLogix (32 bits, basado en tags).

Verificación Adicional: List Services

Antes de lanzarnos a conectar, hagamos una última verificación para confirmar qué servicios soporta el dispositivo:

zsh
% python3 -m cpppo.server.enip.client --list-services -a 94.237.61.52:42450
List Services  0 from ('94.237.61.52', 42450): {
'count':                        1,
'item[0].type_id':              256,
'item[0].length':               19,
'item[0].communications_service.version': 1,
'item[0].communications_service.capability': 32,
'item[0].communications_service.service_name': 'Communications',
}

¿Qué nos dice esto?

  • count: 1 → Ofrece 1 servicio
  • service_name: ‘Communications’ → Servicio de comunicaciones estándar
  • capability: 32 (0x20 en binario) → Soporta el protocolo de encapsulación estándar de EtherNet/IP

Todo pinta bien. El dispositivo responde correctamente a comandos EtherNet/IP y confirma que soporta comunicación CIP estándar.

Conexión con pycomm3: Primera Prueba

Ahora que confirmamos que el dispositivo habla EtherNet/IP correctamente, intentemos conectarnos con pycomm3:

from pycomm3 import LogixDriver

with LogixDriver('94.237.51.160:42450') as plc:
    resultado = plc.read('FLAG')
    print(resultado.value)

Boom. Error.

pycomm3.exceptions.ResponseError: failed to get attribute list

El stacktrace completo es largo, pero la línea clave está al final: failed to get attribute list. pycomm3 está intentando obtener la lista completa de tags durante la inicialización y el dispositivo le dice “no sé de qué me hablas”.

Intento #2: Deshabilitando init_tags

Quizás si le decimos que no intente obtener la lista de tags automáticamente…

from pycomm3 import LogixDriver

with LogixDriver('94.237.51.160:42450', init_tags=False) as plc:
    resultado = plc.read('FLAG')
    print(resultado)

Resultado:

FLAG, None, None, Tag doesn't exist - FLAG

Ahora conecta, pero dice que el tag FLAG no existe. ¿Eh?

¿Qué Está Pasando Aquí?

El problema tiene dos partes:

  1. El dispositivo no implementa Get Attribute List (servicio CIP clase 0x6B - Symbol Object). Este servicio es lo que pycomm3 usa para preguntarle al PLC “dame todos los tags que tienes”. El simulador CTF simplemente no tiene esa funcionalidad implementada.
  2. pycomm3 tiene un problema de lógica: Cuando le dices init_tags=False, no intenta cargar la lista de tags… pero cuando intentas leer un tag, asume que si no está en su caché interno, entonces no existe. Es como un Catch-22: necesita la caché para validar tags, pero no puede llenar la caché porque el dispositivo no lo permite.

Este comportamiento es común en:

  • Simuladores de CTF (implementación mínima del protocolo)
  • Dispositivos industriales legacy (firmware antiguo)
  • Honeypots ICS (funcionalidad limitada a propósito)

Volviendo a cpppo

Sin enumeración automática de tags, toca hacer las cosas a la antigua: probar manualmente. Volvemos a cpppo, que no asume nada y simplemente envía el mensaje CIP que le pidas:

zsh
% python3 -m cpppo.server.enip.client --print -a 94.237.51.160:42450 FLAG
			FLAG              == [72]: 'OK'

¡Ahí está! El tag SÍ existe. pycomm3 no estaba mintiendo sobre que el tag no existe, simplemente su lógica interna no le permite verificarlo sin tener una lista completa de tags primero.

La diferencia clave con cpppo: No valida nada localmente. Envía directamente el mensaje “Read Tag FLAG” al dispositivo. Si responde con datos, perfecto. Si responde con error, también perfecto (al menos sabemos que no existe). No hace suposiciones.

Interpretando la Respuesta

Ahora, [72]: 'OK' se ve… raro. ¿Un solo número? Veamos qué está pasando realmente agregando verbosidad máxima (-vvv) y filtrando por read_tag:

zsh
% python3 -m cpppo.server.enip.client -vvv --print -a 94.237.51.160:42450 FLAG 2>&1 | grep "read_tag"
...
'read_tag.type':                195,    # 0xC3 = INT (entero de 16 bits)
'read_tag.data':                [72],   # Un solo valor
...

Entonces el tag FLAG es de tipo INT (entero), y nos devolvió el valor 72. Pero… ¿es solo un entero o es el primer elemento de un array?

Probando como Array

Cuando lees un array en CIP sin especificar índices, por defecto solo te devuelve el primer elemento. Probemos pedir más:

zsh
% python3 -m cpppo.server.enip.client --print -a 94.237.51.160:42450 'FLAG[0-10]'
FLAG[0][  0-10 ]+  0 == [72, 84, 66, 123, 51, 116, 104, 51, 114, 110, 51]: 'OK'

¡Bingo! Ahora sí vemos más datos. Esto ya empieza a parecer interesante. Los valores son números pequeños (0-255), que en el contexto de texto suelen ser… códigos ASCII.

Probemos con un rango mayor ('FLAG[0-30]'):

zsh
% python3 -m cpppo.server.enip.client --print -a 94.237.51.160:42450 'FLAG[0-30]'
FLAG[0][  0-30 ]+  0 == [72, 84, 66, 123, 51, 116, 104, 51, 114, 110, 51, 116, 49, 112, 95, 112, 119, 110, 51, 100, 125, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]: 'OK'

Perfecto. Vemos el patrón: números ASCII hasta llegar al 125 (que es } en ASCII), y luego puro 0 (bytes nulos, padding).

Caracteres ASCII imprimibles

Decodificando la Bandera

Los valores son códigos ASCII. Podemos usar un one-liner de bash para decodificar:

zsh
% python3 -m cpppo.server.enip.client --print -a 94.237.51.160:42450 'FLAG[0-30]' | cut -d '=' -f 3 | grep -oP '\d+' | awk '{printf("%c",$1)}'

Output:

████████████████████████████████

¡Y ahí está la bandera!

Desglose del comando:

  • cut -d '=' -f 3: Extrae todo después del tercer = (donde están el array de números)
  • grep -oP '\d+': Saca solo los números (uno por línea)
  • awk '\{printf("%c",$1)\}': Convierte cada número a su caracter ASCII

Plot Twist: CIPDriver y el Protocolo CIP

Después de resolver con cpppo, me quedé pensando: ¿realmente no hay forma de hacerlo con pycomm3? Probé LogixDriver que falló, pero ¿qué tal el más básico?

El problema es que pycomm3 con ControlLogix es una “caja negra”. Pero el CIPDriver es diferente: es el controlador más básico que permite construir mensajes CIP manualmente. Aquí es donde necesito entender qué está pasando realmente en el cable.

Entendiendo el Protocolo CIP: Conceptos Clave

Ahora viene lo importante que mencioné al inicio: EtherNet/IP no es un protocolo monolítico, es CIP (Common Industrial Protocol) transportado a través de Ethernet TCP/IP.

CIP es un protocolo de aplicación independiente del transporte físico. Múltiples “sabores” transportan CIP:

mindmap
  root((CIP<br/>Common Industrial<br/>Protocol))
    Detalles(Capa 7 OSI)
      ::icon(fa fa-info-circle)
      Definicion
        Objetos
        Servicios
        Tipos de datos
      Independencia
        Independiente del<br/>transporte fisico
    Implementaciones
      EtherNet/IP
        (Sobre Ethernet TCP/IP)
      DeviceNet
        (Sobre CAN bus)
      ControlNet
        (Sobre Red Deterministica)
      CompoNet
        (Sobre Fibra/Cobre)

Analogía con HTTP:

  • HTTP define GET, POST, status codes (la semántica)
  • HTTP sobre TCP/IP = Web tradicional (cómo viaja)
  • HTTP sobre QUIC = HTTP/3 (cómo viaja, diferente transporte)

Lo mismo pasa con CIP:

  • CIP define Read Tag, Write Tag, objetos (la semántica)
  • CIP sobre Ethernet = EtherNet/IP (cómo viaja por Ethernet)
  • CIP sobre CAN = DeviceNet (cómo viaja por CAN)

Cuando digo “mensaje CIP”, me refiero al contenido del mensaje. Cuando digo “EtherNet/IP”, me refiero a cómo ese mensaje viaja por la red.

La Estructura de un Mensaje CIP

CIP estructura sus mensajes como peticiones a objetos con servicios específicos. Es como una API REST diseñada en los 90s para fábricas:

Mensaje CIP = {
    "service": 0x4C,              # ¿QUÉ quiero hacer?
    "class_code": 0x6B,           # ¿En QUÉ tipo de objeto?
    "instance": b'\x91\x04FLAG',  # ¿En QUÉ instancia específica?
    "attribute": 1,               # (Opcional) ¿Qué propiedad?
    "data": b'...'                # (Opcional) Datos a enviar
}

En términos REST sería:

POST /api/objects/Symbol/FLAG/read

Pero CIP lo hace con códigos binarios en lugar de URLs. Veamos cada componente:

Service Code: El “¿QUÉ?”

Los Service Codes son como los verbos HTTP. Definen la operación. Aquí están los más comunes:

ServiceHexNombreQué Hace
0x011Get Attributes AllObtiene TODOS los atributos de un objeto
0x0E14Get Attribute SingleObtiene UN atributo específico
0x1016Set Attribute SingleModifica UN atributo
0x4C76Read TagLee el valor de un tag
0x4D77Write TagEscribe un valor en un tag

Class Code: El “¿DÓNDE?”

Los Class Codes identifican el tipo de objeto CIP al que te diriges. Piensa en ellas como “tablas de una base de datos”:

ClassHexNombreContenido
0x011IdentityInfo del dispositivo (vendor, modelo, serial)
0x022Message RouterEnrutamiento de mensajes
0x044AssemblyDatos I/O en tiempo real
0x066Connection ManagerGestión de sesiones
0x6B107SymbolTags del usuario (aquí vive FLAG)
0xF5245TCP/IP InterfaceConfiguración de red
0xF6246Ethernet LinkEstado del hardware

Analogía de filesystem:

  • Clase 0x01 (Identity) es como /sys/devices/info
  • Clase 0x6B (Symbol) es como /var/plc/tags/ donde están tus variables
  • Clase 0xF5 (TCP/IP) es como `/etc/network/interfaces

Instance: El “¿CUÁL?”

Identifica una instancia concreta dentro de una clase.

Para tags (Clase 0x6B):

instance = b'\x91' + longitud + nombre_ascii

# Ejemplo: TAG "FLAG" (4 caracteres)
instance = b'\x91\x04FLAG'

# Desglose byte por byte:
# \x91     = Segment: ANSI Extended Symbol (significa "viene un nombre de tag después")
# \x04     = Longitud: 4 bytes
# FLAG     = El nombre del tag en ASCII

Es como decir: “Dame la instancia cuyo nombre es ‘FLAG’”.

Para otros objetos:

instance = 1  # Primera instancia del objeto Identity
              # instance = 2  # Segunda instancia, etc.

Primer Intento: Service 0x4C (Read Tag)

Ahora que entiendo la estructura, intentemos leer el tag FLAG usando el servicio diseñado específicamente para eso:

from pycomm3 import CIPDriver

with CIPDriver('94.237.51.160:42450') as plc:
    resultado = plc.generic_message(
        service=0x4C,                 # Read Tag Service
        class_code=0x6B,              # Symbol Class (tags)
        instance=b'\x91\x04FLAG'      # Instance: tag llamado "FLAG"
    )
    print(resultado.value)

Resultado:

None

No funciona. El simulador dice “no entiendo ese servicio” (respuesta vacía).

¿Por qué? El simulador CTF tiene una implementación incompleta del protocolo. No soporta el servicio 0x4C (Read Tag) porque era un reto que requería pensar fuera de la caja.

Segundo Intento: Service 0x01 (Get Attributes All)

Aquí es donde viene la creatividad. El servicio 0x01 (Get Attributes All) está diseñado para obtener metadatos de un objeto (información sobre el objeto, no su valor). Pero… ¿qué tal si lo intentamos de todas formas?

from pycomm3 import CIPDriver

with CIPDriver('94.237.51.160:42450') as plc:
    resultado = plc.generic_message(
        service=0x01,                 # Get Attributes All
        class_code=0x6B,              # Symbol Class
        instance=b'\x91\x04FLAG'      # Tag "FLAG"
    )
    print(resultado.value)

Resultado:

b'H\x00T\x00B\x00{\x003\x00t\x00h\x003\x00r\x00n\x003\x00t\x001\x00p\x00_\x00p\x00w\x00n\x003\x00d\x00}\x00...'

¡Funcionó! Pero hay un problema: los datos están en UTF-16 LE (cada carácter ASCII está separado por \x00).

Por ejemplo:

  • H\x00 = ‘H’
  • T\x00 = ‘T’
  • B\x00 = ‘B’
  • {\x00 = ’{’

Es así porque cuando el PLC devuelve “atributos”, usa una codificación de 16 bits. Necesitamos decodificar esto.

Solución Completa

from pycomm3 import CIPDriver

with CIPDriver('94.237.51.160:42450') as plc:
    resultado = plc.generic_message(
        service=0x01,                 # Get Attributes All
        class_code=0x6B,              # Symbol Class (tags)
        instance=b'\x91\x04FLAG'      # Tag "FLAG"
    )
    
    # Los datos vienen en UTF-16 LE, decodificar
    raw_bytes = resultado.value
    flag = raw_bytes.decode('utf-16-le').rstrip('\x00')
    
    print(flag)

Output:

████████████████████████████████

¿Por qué pasó esto? El simulador CTF fue programado de forma incompleta a propósito. El reto estaba en descubrir que un servicio “incorrecto” (para el propósito) resultaba siendo el que funcionaba. Es una lección sobre no asumir nada: siempre prueba diferentes servicios cuando uno no funciona.

Espero que esto haya sido útil. Fue un buen recordatorio de que incluso en un campo especializado como el ICS/SCADA, los principios fundamentales (enumeración, probing, análisis de protocolo) siguen siendo exactamente los mismos.