Introducción

Mi Trabajo Fin de Grado de ASIR es una única aplicación de escritorio en Python que junta dos mundos que normalmente van por separado: herramientas de análisis de red (escáner de puertos y de hosts) y un chat cliente-servidor cifrado con autenticación, roles y panel de administración. Todo vive en la misma interfaz gráfica, hecha con CustomTkinter, y se accede desde el mismo menú lateral.

Pantalla de login de la aplicación

Puedes ver la ficha del proyecto, con el enlace al repositorio, en Proyectos. Este post cubre la arquitectura técnica completa: cómo funciona el escáner de hosts, el escáner de puertos, y el chat cifrado.

Arquitectura general

La herramienta se divide en dos bloques que se comunican por red:

Cliente y servidor solo se comunican para el chat — el escaneo de puertos y de hosts corre en local, directamente desde el cliente, contra la red a la que esté conectado el equipo.

Herramientas y librerías

Todo el proyecto está desarrollado en Python 3, aprovechando su sintaxis clara y sus librerías — tanto las que vienen integradas como las externas.

Comunicación y cifrado

Análisis de red

Interfaz gráfica

Utilidades

Escáner de hosts: descubrimiento por ARP

Antes de poder escanear puertos hace falta saber qué hay en la red. El escáner de hosts descubre dispositivos activos en un segmento de red enviando peticiones ARP (Address Resolution Protocol) al broadcast con Scapy: construye un paquete con capa Ether (para el broadcast a nivel de enlace) y capa ARP (pidiendo la dirección IP de cada host del rango, en formato CIDR), lo envía con srp (send and receive packets) y recoge las respuestas — cada dispositivo que responde revela su IP (psrc) y su MAC (hwsrc).

Configuración del escáner de hosts

Con esas MAC, la herramienta intenta identificar el fabricante del dispositivo comparando los primeros 6 dígitos de la dirección (el OUI, Organizationally Unique Identifier) contra una tabla propia — por ejemplo, 080027 corresponde a VirtualBox y 001999 a Cisco. Si no hay coincidencia, se marca como “Dispositivo desconocido”.

Los resultados se muestran en dos formatos: una tabla simple con IP, MAC y tipo de dispositivo…

Tabla de resultados del escáner de hosts

…o, si se marca la opción, una topología visual generada con Matplotlib donde cada nodo es un icono según el tipo de dispositivo, todos conectados al escáner en el centro:

Topología de red generada por el escáner de hosts

Escáner de puertos: estados, banners y sistema operativo

El escáner de puertos usa un SYN scan (escaneo “half-open”): envía un paquete TCP con solo el flag SYN activo y clasifica el puerto según la respuesta, sin completar el handshake TCP.

def scan_port_tcp(ip, port, timeout=1, stealth=False, delay=0):
    """Escanear el estado de un puerto TCP."""
    conf.verb = 0
    ip_layer = IP(dst=ip)
    tcp_layer = TCP(dport=port, flags="S", window=1024)

    response = None
    try:
        response = sr1(ip_layer / tcp_layer, timeout=timeout, verbose=0)
        time.sleep(delay)
    except Exception as e:
        print(f"[*] Error al enviar paquetes al puerto {port}: {e}")
        return port, "Error", detect_service(port), "N/A"

    if response is None:
        return port, "Filtrado", detect_service(port), "N/A"

    if response.haslayer(TCP):
        flags = response[TCP].flags
        if flags == 0x12:  # SYN-ACK
            if stealth:
                send(IP(dst=ip) / TCP(dport=port, flags="R"))
            banner = analyze_banner(ip, port)
            return port, "Abierto", detect_service(port), banner
        elif flags == 0x14:  # RST
            return port, "Cerrado", detect_service(port), "N/A"
    elif response.haslayer(ICMP):
        icmp_layer = response[ICMP]
        if icmp_layer.type == 3 and icmp_layer.code in [1, 2, 3, 9, 10, 13]:
            return port, "Filtrado", detect_service(port), "N/A"

    return port, "Desconocido", detect_service(port), "N/A"

La clasificación es directa: SYN-ACK (0x12) significa abierto, RST (0x14) significa cerrado, y si no llega ninguna respuesta (o llega un ICMP tipo 3 “Destination Unreachable” con ciertos códigos) se marca como filtrado — normalmente un firewall descartando el paquete en silencio. En modo sigiloso, si el puerto está abierto se envía un RST inmediatamente después para abortar la conexión antes de completarla, lo que la hace menos detectable.

Cuando un puerto está abierto, analyze_banner intenta sacarle más información conectándose de verdad y, según el puerto, enviando una petición específica del protocolo (HEAD por HTTP, USER anonymous por FTP, HELO por SMTP) para capturar el banner de bienvenida del servicio.

Además, os_fingerprinting prueba a identificar el sistema operativo del objetivo con tres técnicas pasivas, aprovechando que cada sistema implementa la pila TCP/IP con valores por defecto distintos:

  1. SYN/ACK: compara el TTL y el tamaño de ventana TCP de la respuesta contra una tabla de huellas conocidas (TTL 64 + ventana 29200 → Linux; TTL 128 + ventana 8192 → Windows; TTL 255 → router Cisco).
  2. ICMP: si la técnica anterior falla (puertos cerrados o filtrados), un simple ping ya revela el TTL — 64 sugiere Linux/Unix, 128 sugiere Windows.
  3. FIN: enviar un paquete con el flag FIN a una conexión no establecida. Linux tiende a ignorarlo (sin respuesta); Windows suele responder con RST.

Todo esto en acción, escaneando una máquina Linux con SSH y Apache expuestos:

Resultado del escáner de puertos con banners y sistema operativo detectado

Para no escanear puerto a puerto de forma secuencial, scan_ports lanza un hilo por puerto (usando scan_port_tcp o su equivalente UDP) y espera a que todos terminen antes de devolver los resultados ordenados por número de puerto — el mismo patrón de threading que usa el servidor de chat para atender a varios clientes a la vez, aplicado aquí para acelerar el escaneo en vez de la concurrencia de usuarios.

La herramienta también deja elegir la velocidad del escaneo, inspirada en los timings clásicos de Nmap:

Para visualizar la distribución de estados, generate_graph_image cuenta cuántos puertos cayeron en cada categoría y genera un gráfico circular con Matplotlib:

def generate_graph_image(results, filename="scan_results.png"):
    """Generar una gráfica circular y guardarla como archivo PNG."""
    statuses = [status for _, status, _, _ in results]
    open_count = statuses.count("Abierto") + statuses.count("open|filtered")
    closed_count = statuses.count("Cerrado") + statuses.count("closed")
    filtered_count = statuses.count("Filtrado") + statuses.count("filtered")

    labels = ["Abiertos", "Cerrados", "Filtrados"]
    sizes = [open_count, closed_count, filtered_count]
    colors = ["green", "red", "yellow"]

    plt.figure(figsize=(6, 6))
    plt.pie(sizes, labels=labels, colors=colors, autopct="%1.1f%%", startangle=140)
    plt.title("Distribución de Estados de Puertos")
    plt.axis("equal")
    plt.savefig(filename)
    plt.close()

Para comprobar que la clasificación “filtrado vs. cerrado” funciona de verdad, hice la prueba con el firewall activado y luego desactivado en la misma máquina. Con el firewall activo, la mayoría de puertos no responden y se marcan como filtrados:

Gráfico circular con la mayoría de puertos filtrados por el firewall

Al desactivar el firewall (sudo ufw disable), esos mismos puertos empiezan a responder con RST y pasan a “cerrado”:

Gráfico circular tras desactivar el firewall: los puertos pasan de filtrado a cerrado

Comunicación cifrada con sockets y SSL

La base del chat es un socket TCP envuelto en un contexto SSL, tanto en el servidor como en el cliente:

server_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
server_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
server_socket.bind((host, port))

context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
context.load_cert_chain(certfile="server-cert.pem", keyfile="server-key.key")
server_socket = context.wrap_socket(server_socket, server_side=True)

El cliente se conecta con un contexto sin verificación de certificado (aceptable para desarrollo, no para producción):

client_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
client_socket = ssl._create_unverified_context().wrap_socket(client_socket, server_hostname='192.168.100.15')
client_socket.connect((host, port))

Para comprobar que el cifrado funciona de verdad, capturé el tráfico del puerto 12345 con tcpdump mientras el chat estaba en marcha. Si estuviera en texto plano se vería el mensaje tal cual; en vez de eso, solo salen caracteres extraños:

Captura de tcpdump mostrando el tráfico cifrado del chat

UserManager: usuarios, roles y bloqueos

UserManager es la clase que gestiona la autenticación y el control de usuarios mediante archivos de texto plano.

def __init__(self, users_file="chat_users.txt", banned_file="banned_users.txt"):
    self.users_file = users_file
    self.banned_file = banned_file

    if not os.path.exists(users_file):
        with open(users_file, "w") as f:
            f.write("admin|admin123|admin\n")
    if not os.path.exists(banned_file):
        open(banned_file, "w").close()

Si el archivo de usuarios no existe, lo crea y añade un usuario administrador predeterminado; si el archivo de bloqueados no existe, lo crea vacío. Cada línea de chat_users.txt tiene el formato username|password|role, con dos roles posibles: user y admin.

El sistema de bloqueo permite banear usuarios por un tiempo definido, guarda quién lo bloqueó y el motivo, limpia automáticamente los bloqueos que ya han expirado, e impide que un administrador pueda ser bloqueado.

Flujo de autenticación

  1. El cliente solicita conexión al servidor por SSL.
  2. El cliente envía las credenciales en formato username:password.
  3. El servidor comprueba que no haya ya una sesión abierta para ese usuario.
  4. El servidor autentica con user_manager.authenticate.
  5. Si es correcto, responde AUTH_OK:{role} y el cliente inicia la interfaz según el rol recibido.
  6. Si falla, responde ERROR:{motivo} y el cliente muestra el error.

El servidor: hilos y difusión de mensajes

Cada cliente conectado se gestiona en su propio hilo, para poder atender a varios usuarios a la vez sin bloquear el servidor:

while True:
    client_socket, address = server_socket.accept()
    clients.append(client_socket)

    thread = threading.Thread(target=client_thread, args=(client_socket, clients, usernames))
    thread.daemon = True
    thread.start()

Dentro de client_thread, los mensajes normales se censuran y se reenvían a todos los clientes menos al que los envió; los comandos administrativos (/comando argumentos) solo los ejecuta el servidor si el usuario tiene rol admin; y el comando especial !usuarios devuelve la lista de usuarios conectados en ese momento.

Panel de administración

El panel de administración solo aparece en la interfaz si el usuario autenticado tiene rol admin. La diferencia se nota en el propio menú lateral: un admin tiene una pestaña “Admin” que un usuario normal no ve.

Vista de la interfaz para un usuario administrador

Vista de la interfaz para un usuario sin privilegios

Desde el panel se pueden ejecutar acciones como expulsar, banear (con tiempo definido o baneo permanente), desbanear, añadir o eliminar usuarios, y consultar logs o la lista de baneados — todo mediante los comandos que el servidor interpreta y ejecuta a través de user_manager:

Panel de control de administración con las acciones disponibles

Moderación: censura y registro de actividad

Para evitar lenguaje ofensivo, tanto el servidor como el cliente comparten la misma lista de palabras y la misma función de censura:

PALABRAS_SOECES = [
    "mierda", "puta", "puto", "joder", "jodido", "cabrón", "gilipollas",
    "idiota", "imbécil", "cojones", "hostia", "coño", "capullo",
    "hijo de puta", "hdp", "pendejo", "zorra", "maldito", "carajo",
]

def censurar_mensaje(mensaje):
    mensaje_censurado = mensaje
    for palabra in PALABRAS_SOECES:
        patron = r'\b' + re.escape(palabra) + r'\b'
        mensaje_censurado = re.sub(patron, '*' * len(palabra), mensaje_censurado, flags=re.IGNORECASE)
    return mensaje_censurado

\b marca el límite de palabra para que solo coincida con la palabra completa (y no, por ejemplo, con “cojones” dentro de otra palabra más larga), re.escape escapa los caracteres especiales de cada palabra para tratarla de forma literal, y re.IGNORECASE hace que la censura no dependa de mayúsculas o minúsculas.

El cliente censura el mensaje localmente antes de mostrarlo, mientras que el servidor guarda en chat_logs.txt tanto la versión censurada como la original, con marca de tiempo, para tener trazabilidad real de lo que se dijo:

def guardar_log(username, mensaje, mensaje_original=None):
    timestamp = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
    with open("chat_logs.txt", "a", encoding="utf-8") as log_file:
        if mensaje_original and mensaje_original != mensaje:
            log_file.write(f"[{timestamp}] {username}: {mensaje} (original: {mensaje_original})\n")
        else:
            log_file.write(f"[{timestamp}] {username}: {mensaje}\n")

Cliente: interfaz gráfica con CustomTkinter

El cliente usa CustomTkinter para la interfaz y un hilo aparte para recibir mensajes sin bloquear la ventana:

thread = threading.Thread(target=receive_message, args=(client_socket, text_widget, username))
thread.daemon = True
thread.start()

La ventana principal cambia entre vistas según el menú lateral: Inicio (bienvenida), Chat, Herramientas de escaneo (puertos y hosts) y Administración (solo visible para admins). El cliente también identifica sus propios mensajes reenviados por el servidor para no duplicarlos en pantalla, y hace auto-scroll cada vez que llega un mensaje nuevo.

Lo que no funcionó: fragmentación IPv4

No todo salió bien. Uno de los objetivos iniciales era implementar fragmentación manual de paquetes IPv4 — dividir un paquete grande en fragmentos más pequeños, útil para evadir firewalls o IDS que no reensamblan correctamente. Lo intenté por dos caminos distintos y ninguno funcionó.

Con Scapy, dividiendo el payload y ajustando manualmente los flags MF (More Fragments) y el Fragment Offset:

def fragment_packet(ip_layer, tcp_layer, fragment_size=512):
    tcp_payload = bytes(tcp_layer)
    total_length = len(tcp_payload)

    fragments = [
        ip_layer / tcp_payload[i:i + fragment_size]
        for i in range(0, total_length, fragment_size)
    ]

    for i, fragment in enumerate(fragments):
        fragment[IP].flags = "MF" if i < len(fragments) - 1 else 0
        fragment[IP].frag = i * (fragment_size // 8)

    return fragments

Con raw sockets, construyendo la cabecera IP a mano, byte a byte, con struct.pack:

def create_ipv4_fragment(dst_ip, payload, frag_offset, mf_flag):
    version_ihl = (4 << 4) | 5
    total_length = 20 + len(payload)
    identification = 54321
    flags_frag_offset = (mf_flag << 13) | frag_offset
    ttl = 64
    protocol = socket.IPPROTO_TCP
    checksum = 0
    src_ip = socket.inet_aton("192.168.56.1")
    dst_ip = socket.inet_aton(dst_ip)

    ip_header = struct.pack(
        "!BBHHHBBH4s4s", version_ihl, 0, total_length, identification,
        flags_frag_offset, ttl, protocol, checksum, src_ip, dst_ip
    )
    checksum = calculate_checksum(ip_header)
    ip_header = struct.pack(
        "!BBHHHBBH4s4s", version_ihl, 0, total_length, identification,
        flags_frag_offset, ttl, protocol, checksum, src_ip, dst_ip
    )
    return ip_header + payload

En ambos casos, los fragmentos generados nunca aparecieron en las capturas de tcpdump — ni con los flags MF ni con el Fragment Offset esperado. Probé con permisos de superusuario, revisé la configuración, y aun así nada. La conclusión a la que llegué es que, al ser Python un lenguaje de alto nivel, probablemente hay algún mecanismo interno (del propio Scapy, o del kernel reensamblando fragmentos antes de enviarlos) que impide controlar esto con la precisión de un lenguaje de más bajo nivel como C. Herramientas externas como hping3 sí consiguen generar fragmentos reales, lo que confirma que el problema no era conceptual sino de la capa donde estaba trabajando.

Conclusión

Desarrollar esta herramienta ha sido un reto importante, tanto a nivel técnico como personal. Desde el principio me propuse que todo estuviera hecho en Python al 100%, y a pesar de las limitaciones propias de un lenguaje de alto nivel, conseguí implementar algo bastante completo: escaneo de puertos con fingerprinting de SO, descubrimiento de hosts con topología visual, y un chat cifrado con roles, censura y logs — todo integrado en una sola herramienta con interfaz gráfica.

Me he encontrado con muchos errores y momentos de frustración por el camino (la fragmentación de paquetes es el ejemplo más claro), pero también con muchísimas oportunidades de aprender sobre redes, interfaces gráficas, cifrado y gestión de hilos. No todas las ideas iniciales llegaron a buen puerto, pero creo que he sabido adaptarme y buscar alternativas cuando hacía falta — y eso, al final, es tan parte del proyecto como el código que sí funcionó.