Clase 154 — Seguridad en multijugador: validación y exploits

Parte: 7 — Multijugador y networking · Fuente: Documentación de Godot 4 (High-level multiplayer, ENet DTLS) + prácticas de servidor autoritativo anti-cheat ⏱️ Duración estimada: 55 min · Nivel: Avanzado


🎯 Objetivo

Interiorizar el principio que sostiene todo el multijugador competitivo: nunca confíes en el cliente. Vas a repasar las amenazas reales —speed hack, teleport, aimbot, packet crafting, replay— y a construir un servidor autoritativo que valida cada acción, aplica límites de movimiento y hace rate limiting de acciones. En el laboratorio añadirás esas validaciones y probarás un "cliente tramposo" cuyos movimientos imposibles el servidor rechaza. También verás cómo cifrar el transporte con ENet DTLS. Al terminar sabrás cerrar las puertas que un jugador malicioso intentaría abrir.

📚 Resultados de aprendizaje

Al finalizar, el alumno podrá:

🗺️ Temas

# Tema Por qué importa
1 Nunca confiar en el cliente Es el axioma de todo anti-cheat serio
2 Speed hack y teleport Se frenan validando movimiento en el servidor
3 Aimbot y validación de disparo El servidor decide impactos, no el cliente
4 Packet crafting y rate limiting Limita frecuencia y forma de las peticiones
5 Replay attacks Reusar paquetes válidos exige nonces/secuencia
6 Servidor autoritativo Centraliza la verdad del estado
7 Cifrado (ENet DTLS) Protege el tráfico de sniffing y manipulación
8 No exponer datos sensibles El cliente solo debe saber lo que puede ver

📖 Definiciones y características

🧰 Herramientas y preparación

Trabajarás sobre la API de alto nivel de Godot 4 (@rpc, multiplayer.is_server(), multiplayer.get_remote_sender_id()) con un servidor autoritativo. Para el cifrado, Godot expone DTLS en ENetMultiplayerPeer mediante set_dtls_enabled y certificados; revisa la documentación de ENetConnection y la guía SSL/TLS de Godot (https://docs.godotengine.org/en/stable/tutorials/networking/ssl_certificates.html). El principio de servidor autoritativo está descrito en la guía de multijugador (https://docs.godotengine.org/en/stable/tutorials/networking/high_level_multiplayer.html). Si usas un backend, Nakama (https://heroiclabs.com/docs/) permite lógica autoritativa en el servidor y GodotSteam enruta por la red de Valve. Prepara un servidor con un jugador por peer y un cliente que puedas manipular para simular trampas.

🧪 Laboratorio guiado

Construiremos un servidor que recibe intención de movimiento, la valida contra límites físicos y aplica rate limiting a los disparos. Un cliente tramposo que pida un teleport será rechazado y registrado.

Paso 1 — El cliente envía intención, no posición. Manda un vector de dirección; el servidor decide el resultado.

# cliente_jugador.gd
extends CharacterBody2D

func _physics_process(_delta: float) -> void:
    if not es_mio():
        return
    var dir := Input.get_vector("izq", "der", "arriba", "abajo")
    # Enviamos intención al servidor, NUNCA la posición final.
    pedir_mover.rpc_id(1, dir)

func es_mio() -> bool:
    return get_multiplayer_authority() == multiplayer.get_unique_id()

@rpc("any_peer", "call_remote", "unreliable_ordered")
func pedir_mover(_dir: Vector2) -> void:
    pass  # vive en el servidor

Paso 2 — El servidor valida el movimiento. Límite de velocidad y de distancia por tick; posición fuera de rango se rechaza.

# servidor_juego.gd (nodo autoritativo por jugador)
extends Node

const VEL_MAX := 200.0          # px/seg permitidos
const MARGEN := 1.15            # tolerancia por jitter/latencia
var _posiciones: Dictionary = {} # peer_id -> Vector2

@rpc("any_peer", "call_remote", "unreliable_ordered")
func pedir_mover(dir: Vector2) -> void:
    if not multiplayer.is_server():
        return
    var id := multiplayer.get_remote_sender_id()
    var pos_actual: Vector2 = _posiciones.get(id, Vector2.ZERO)
    var dt := 1.0 / Engine.physics_ticks_per_second
    var desplazamiento := dir.normalized() * VEL_MAX * dt
    var distancia := desplazamiento.length()
    var max_permitido := VEL_MAX * dt * MARGEN
    if distancia > max_permitido:
        push_warning("Movimiento sospechoso de %d: %.1f > %.1f" % [id, distancia, max_permitido])
        return  # rechazado; no aplicamos el paso
    var nueva := pos_actual + desplazamiento
    _posiciones[id] = nueva
    aplicar_posicion.rpc(id, nueva)  # broadcast autoritativo

@rpc("authority", "call_remote", "unreliable_ordered")
func aplicar_posicion(_id: int, _pos: Vector2) -> void:
    pass  # los clientes actualizan el nodo correspondiente

Paso 3 — Rate limiting de disparos. El servidor descarta disparos que llegan demasiado seguidos.

# En servidor_juego.gd
const CADENCIA_MIN_MS := 200     # máximo 5 disparos/seg
var _ultimo_disparo: Dictionary = {} # peer_id -> ms

@rpc("any_peer", "call_remote", "reliable")
func pedir_disparo(objetivo: Vector2) -> void:
    if not multiplayer.is_server():
        return
    var id := multiplayer.get_remote_sender_id()
    var ahora := Time.get_ticks_msec()
    var previo: int = _ultimo_disparo.get(id, -CADENCIA_MIN_MS)
    if ahora - previo < CADENCIA_MIN_MS:
        push_warning("Rate limit: disparo de %d descartado" % id)
        return
    _ultimo_disparo[id] = ahora
    # Aquí el servidor valida línea de visión antes de aceptar el impacto.
    print("Disparo válido de %d hacia %s" % [id, objetivo])

Paso 4 — Cliente tramposo y cifrado. Un cliente que intenta teleportarse es rechazado; y activamos DTLS.

# cliente_tramposo.gd — para PROBAR el rechazo (nunca en producción)
func intentar_teleport() -> void:
    # Envía una dirección gigante: el servidor lo detecta como imposible.
    pedir_mover.rpc_id(1, Vector2(9999, 9999))

# servidor con transporte cifrado (esquema)
func crear_servidor_seguro() -> void:
    var peer := ENetMultiplayerPeer.new()
    peer.create_server(9000, 32)
    # Con DTLS, se asocian certificados y clave al host ENet subyacente.
    # peer.host.dtls_server_setup(tls_options)   # ver docs de ENetConnection/TLSOptions
    multiplayer.multiplayer_peer = peer

Observable: al ejecutar intentar_teleport(), el servidor imprime Movimiento sospechoso de <id>: 9999.0 > ... y no actualiza la posición; los movimientos legítimos sí se aplican y difunden. Con rate limiting, disparos en ráfaga imprimen Rate limit: disparo ... descartado.

✍️ Ejercicios

  1. Añade un límite de posición al mapa: rechaza cualquier nueva fuera de los bounds del nivel.
  2. Registra por peer cuántos movimientos sospechosos acumula y expúlsalo tras 10.
  3. Valida el disparo por línea de visión con un raycast en el servidor antes de aceptar el impacto.
  4. Añade un número de secuencia a pedir_mover y descarta paquetes con secuencia repetida o antigua (anti-replay).
  5. Cifra el canal con DTLS siguiendo la doc y comprueba que un cliente sin certificado no conecta.
  6. Explica por qué enviar la vida del jugador desde el cliente es una vulnerabilidad y cómo lo corriges.

📝 Reto verificable

Entrega un servidor autoritativo que valide el movimiento por velocidad máxima, aplique rate limiting a una acción y rechace explícitamente a un cliente tramposo, dejando traza en el log de cada rechazo. El cliente solo debe enviar intención (input), nunca estado final.

Criterio de aceptación: un cliente normal se mueve y dispara con normalidad; un cliente que envía un desplazamiento imposible es rechazado (posición no cambia) y queda registrado en el log del servidor; disparos por encima de la cadencia permitida se descartan; en ningún caso el cliente puede fijar directamente su posición o vida.

⚠️ Errores comunes

Síntoma Causa y arreglo
El cliente mueve su posición y todos lo ven El cliente es autoritativo; mueve la lógica al servidor y envía solo input
Jugadores legítimos rechazados con lag Umbral de distancia sin margen; añade tolerancia por latencia/jitter
Spam de acciones tumba el servidor Falta rate limiting; limita por peer con marca de tiempo
Un paquete capturado repite una acción No hay anti-replay; añade secuencia o nonce y descarta repetidos
Datos sensibles visibles en el cliente Envías estado que el jugador no debería ver; filtra por relevancia y usa rpc_id

❓ Preguntas frecuentes

¿El anti-cheat elimina todas las trampas? No existe blindaje perfecto, pero un servidor autoritativo con validación y rate limiting elimina la mayoría de exploits comunes y encarece el resto.

¿Validar todo no sobrecarga el servidor? Las comprobaciones de movimiento y cadencia son baratas (comparaciones y aritmética). El coste real está en cosas como raycasts de disparo, que se optimizan.

¿Necesito cifrar si ya valido en el servidor? La validación evita que el cliente mienta; el cifrado (DTLS) evita que un tercero espíe o manipule el tráfico. Son capas complementarias.

¿Dónde guardo el estado autoritativo? En el servidor, en estructuras que el cliente nunca escribe. El cliente recibe solo lo que puede percibir legítimamente.

🔗 Referencias

⬅️ Clase anterior

Clase 153 - Testing de red: simular latencia y pérdida

➡️ Siguiente clase

Continúa con 155 — Capstone Parte 7: un juego en red mínimo cliente-servidor, donde integrarás todo lo aprendido.