Simulación del reto OPC UA (Defcon 31)
Tiempo estimado de lectura: 10 minutos | Dificultad técnica: Intermedia
Conclusiones clave
- La simulación del reto OPC UA ofreció una experiencia de aprendizaje sobre la interconexión entre sistemas de control industrial y la seguridad informática.
- La accesibilidad sin autenticación a servidores OPC-UA permite mayor exploración, pero también plantea riesgos significativos en la seguridad.
- El uso de inteligencia artificial facilita la creación de entornos de simulación para el aprendizaje práctico en seguridad informática.
- La implementación de técnicas de reconstrucción de imágenes a partir de datos binarios resalta la importancia de la manipulación de datos en la investigación en ciberseguridad.
- Las diferencias de rendimiento entre los lectores de QR en dispositivos móviles y aplicaciones de consola subrayan la importancia de elegir las herramientas adecuadas para la decodificación.
Índice
- CTF ICS en Defcon 33
- Simulación OPC UA en Defcon 31
- Implementación del servidor OPC-UA
- Cliente OPC-UA para acceder a nodos
- Reconstrucción del QR y lectura de datos
- Conclusiones finales
CTF ICS en Defcon 33
Recientemente, encontré una publicación en X que celebraba la resolución del CTF ICS de la #Defcon33, donde los participantes lograron tomar el control de un aeropuerto en la simulación «Airport Secured!» 🛫. ¡Felicitaciones por esos logros en el Red Alert ICS CTF durante la @defcon 33! La competencia está intensificándose, y aún hay muchos desafíos de OT que esperan a sus campeones. #RedAlertICSCTF #OTsecurity #ICSCTF #defcon33 #defcon.
Simulación OPC UA en Defcon 31
Aunque es temprano para presentar un análisis detallado, me llevó a reflexionar sobre desafíos similares organizados por Red Alert ICS CTF, como el que tuvo lugar en la Defcon 31, el cual se centró en el estándar de comunicación industrial OPC UA. La breve descripción inicial para los participantes era la siguiente: «Se ha detectado un PLC que envía señales extrañas desde el aeropuerto. Analizar las señales del PLC parece ofrecer una pista sobre cómo tomar control sobre el aeropuerto. Analiza las señales del PLC para obtener el código QR de 35×35. IP: 192.168.50.49 y puerto: 4840». Al conectarse a esa IP y puerto, los participantes se encontraron con que era posible acceder sin necesidad de autenticación, lo que ofrecía un servidor con dos objetos:
- Runaway Approach Light Control (un distractor).
- Approach Light, que contenía los objetos line_00 hasta line_34 (35 valores binarios que se actualizaban aproximadamente cada segundo) y un campo ts correspondiente a un timestamp. Estos valores representaban los píxeles del QR que debían reconstruirse para obtener la flag. ¡Interesante, ¿verdad? ¿Alguien tiene un análisis similar a este? Pero, ¿y si pudiéramos experimentar esto de manera sencilla a través de una simulación? Gracias a la inteligencia artificial, esto se ha vuelto trivial de implementar.
Implementación del servidor OPC-UA
from opcua import ua, Server
from datetime import datetime
import time
import qrcode
from PIL import Image
import random
FLAG_TEXT = "RACTF{4r3_y0u_Abl3_70_DeC0D3_qr}" # texto para generar el QR
QR_SIZE = 35 # 35x35
UPDATE_INTERVAL = 1.0 # segundos entre frames (emula ~1Hz)
HOST = "0.0.0.0"
PORT = 4840
def generate_qr_bits(text, size=35):
"""Genera una matriz size x size con valores 0/1 a partir de un QR."""
qr = qrcode.QRCode(border=0)
qr.add_data(text)
qr.make(fit=True)
img = qr.make_image(fill_color="black", back_color="white").convert("1") # Redimensionamos exactamente a (size, size)
img = img.resize((size, size), Image.NEAREST)
pixels = img.load()
bits = [[1 if pixels[x, y] == 0 else 0 for x in range(size)] for y in range(size)]
return bits
if __name__ == "__main__":
server = Server()
endpoint = f"opc.tcp://{HOST}:{PORT}"
server.set_endpoint(endpoint)
uri = "http://examples.opcua/approach_light"
idx = server.register_namespace(uri)
objects = server.get_objects_node()
approach = objects.add_object(idx, "Approach Light") # Crear objeto Approach Light
# Crear variables line_00 .. line_34 (tipo String para simplicidad)
line_vars = []
for i in range(QR_SIZE):
var = approach.add_variable(idx, f"line_{i:02d}", "0" * QR_SIZE)
var.set_writable() # Permitir que el servidor actualice las variables
line_vars.append(var) # Agregar a la lista de variables de línea
# Variable de timestamp
ts_var = approach.add_variable(idx, "ts", datetime.utcnow().isoformat())
ts_var.set_writable() # Permitir que el servidor actualice el timestamp
# Generar la imagen base
base_bits = generate_qr_bits(FLAG_TEXT, QR_SIZE)
print(f"[server] Starting OPC-UA server at {endpoint}")
server.start()
print("[server] Server started. Approach Light exposed with 35 lines.")
try:
frame = 0
while True:
# Crear frame exacto sin ruido
noisy = [row.copy() for row in base_bits]
# Actualizar variables como strings '011001...'
for y, row in enumerate(noisy):
s = ''.join(str(b) for b in row)
line_vars[y].set_value(ua.Variant(s, ua.VariantType.String))
ts = datetime.utcnow().isoformat() + "Z"
ts_var.set_value(ua.Variant(ts, ua.VariantType.String))
frame += 1
print(f"[server] frame {frame} published ts={ts}")
time.sleep(UPDATE_INTERVAL)
except KeyboardInterrupt:
print("[server] shutting down...")
finally:
server.stop()
print("[server] stopped")
Cliente OPC-UA para acceder a nodos
from opcua import Client
ENDPOINT = "opc.tcp://localhost:4840"
def print_node_tree(node, indent=0, max_depth=2):
if indent > max_depth:
return
try:
name = node.get_display_name().Text
print(" " * indent + f"- {name}")
for child in node.get_children():
print_node_tree(child, indent + 1, max_depth)
except Exception as e:
print(" " * indent + f"[error reading node: {e}]")
if __name__ == "__main__":
print(f"Conectando a {ENDPOINT}...")
client = Client(ENDPOINT)
client.connect()
print("Conectado.")
root = client.get_root_node()
objects = root.get_child(["0:Objects"])
print("Lista de nodos bajo 'Objects':")
print_node_tree(objects)
client.disconnect()
print("Desconectado.")
Reconstrucción del QR y lectura de datos
from opcua import Client
import time
ENDPOINT = "opc.tcp://localhost:4840"
if __name__ == "__main__":
print(f"Conectando a {ENDPOINT}...")
client = Client(ENDPOINT)
client.connect()
print("Conectado.")
root = client.get_root_node()
objects = root.get_child(["0:Objects"])
approach = None
for child in objects.get_children():
name = child.get_display_name().Text
if name == "Approach Light":
approach = child
break
if approach is None:
print("No se encontró el nodo 'Approach Light'")
client.disconnect()
exit(1)
# Leemos 3 líneas y timestamp 3 veces para monitorear cambios
for i in range(3):
line0 = approach.get_child([f"{approach.nodeid.NamespaceIndex}:line_00"]).get_value()
line1 = approach.get_child([f"{approach.nodeid.NamespaceIndex}:line_01"]).get_value()
ts = approach.get_child([f"{approach.nodeid.NamespaceIndex}:ts"]).get_value()
print(f"Frame {i+1} ts={ts}")
print(f"line_00: {line0}")
print(f"line_01: {line1}")
print("-" * 30)
time.sleep(1)
client.disconnect()
print("Desconectado.")
from opcua import Client
from PIL import Image
import numpy as np
ENDPOINT = "opc.tcp://localhost:4840"
QR_SIZE = 35
SCALE = 10 # Escalar la imagen para mejorar la visualización
def fetch_lines(client, approach, ns):
lines = []
for i in range(QR_SIZE):
node = approach.get_child([f"{ns}:line_{i:02d}"])
val = node.get_value()
if len(val) != QR_SIZE:
print(f"[warn] Línea {i:02d} no tiene longitud {QR_SIZE}: {len(val)}")
else:
print(f"line_{i:02d}: {val[:20]}...") # Mostrar solo los primeros 20 bits
lines.append(val)
return lines
def build_qr_image(lines):
img_array = np.zeros((QR_SIZE, QR_SIZE), dtype=np.uint8)
for i, line in enumerate(lines):
for j, c in enumerate(line):
img_array[i, j] = 255 if c == '1' else 0
img = Image.fromarray(img_array, mode='L')
img = img.resize((QR_SIZE * SCALE, QR_SIZE * SCALE), Image.NEAREST)
img.save("qr_recon.png")
print("[*] Imagen QR reconstruida y guardada como 'qr_recon.png' (escalada para mejor visualización).")
def main():
print(f"[client] Conectando a {ENDPOINT}...")
client = Client(ENDPOINT)
client.connect()
print("[client] Conectado.")
root = client.get_root_node()
objects = root.get_child(["0:Objects"])
approach = None
for child in objects.get_children():
if child.get_display_name().Text == "Approach Light":
approach = child
break
if approach is None:
print("[client] 'Approach Light' no encontrado.")
client.disconnect()
return
children = approach.get_children()
ns = children[0].get_browse_name().NamespaceIndex
print(f"[client] Namespace detectado: {ns}")
lines = fetch_lines(client, approach, ns)
build_qr_image(lines)
client.disconnect()
print("[client] Desconectado.")
if __name__ == "__main__":
main()
Conclusiones finales
Al ejecutar este script, se ejecuta la magia y generamos el QR. Curiosamente, al leerlo desde la consola con zbarimg
u otro script utilizando numpy
, opencv
, etc., es probable que fallemos en obtener la flag. Sin embargo, si lo leemos a través del móvil, la efectividad es mayor. Esto es bastante común; a veces, los lectores de consola presentan menos tolerancia a imperfecciones o ruido en el QR, mientras que las aplicaciones móviles suelen implementar algoritmos más robustos para decodificar, incluso si la imagen no es perfecta.
Espero que esta exposición haya sido interesante y, por unos momentos, hayas podido experimentar la emoción de ser uno de los afortunados participantes de Defcon 31.
Referencias
- Ejemplo de referencia de seguridad 1
- Ejemplo de referencia de seguridad 2
- Ejemplo de referencia de seguridad 3