Overview
Ever find yourself on a computer where you only have access to e-mail? That’s where FTPoverEmail shines. I frequently find myself in situations where I only have access to e-mail, but I still want to interact with my home server in an FTP-like fashion. You may be in a hotel business center or you may be a pentester and you want to drop FTPoverEmail on a box so that you have reached back via a protocol that’s a bit more innocuous. In the future, I plan on adding SSHoverEmail
capability, which makes the application all the more practical for a pentesting scenario and more useful for the common case.
How It Works
I wrote FTPoverEmail in Python. The server reaches out to an email account the user specifies and polls the account via imap for commands. Right now, the server supports the following commands:
FTP cd path
FTP dir [path]
FTP pwd
FTP get [path/]file_name
FTP put [path/]file_name
The server requires the FTP prefix before the command. I added this because I plan to add other protocols in the future and the server needs a way to distinguish between them. The user simply sends any of these commands in the body of an email to the address the user set the server to poll. The subject of the e-mail doesn’t matter. The user can send from any e-mail address they choose and the server responds to that address. I plan to add an authentication feature to add a measure of protection to the service. The response includes any output from the command. If the user performed a get
command, the server includes the file as an attachment in the response. If the user performs a put
command, the server places the file wherever the command specifies and sends a response acknowledging the command completed successfully.
Future Work
Below is a list of features I plan to add:
Support for the following commands:
- FTP delete file_name
- FTP mkdir directory
- FTP rmdir directory
- FTP ls <—– This will just be an alias for dir, but it makes life easier
- FTP mget file_list
- A configuration file where the user can set any pertinent variables.
- Support for all services instead of just gmail.
- A fix for the Windows Phone, which adds some funky formatting I haven’t figured out
- A GUI interface in the browser
- Database support
- SSH over Email capability
The Code
I did my best to comment thoroughly to make the program as readable as possible. Comment if you have questions. Here is a link to the download:
FTPoverEmail.py
# __author__ = 'gecman'
import smtplib
import email
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
from email.mime.base import MIMEBase
from email import encoders
import mimetypes
import os
import imaplib
import time
import sys
# TODO: Windows-1252 encoding adds an = sign which the program can't handle.
# Find a way to take care of it.
# TODO: Test more rapidly sent email
# Global Variables
from_name = ''
to_name = ''
subject = ''
user = "whatever_email@gmail.com"
password = "whatever_email_password"
# sendresponse sends text based responses to the client
# response: The response text to send in the body of the mail
def sendresponse(response):
# Create a response email from the response string
response_email = MIMEText(response)
print(subject)
# Configure the message header fields
response_email['Subject'] = subject
response_email['From'] = to_name
response_email['To'] = from_name
# Declare an instance of the SMTP class and connect to the smtp server
smtp = smtplib.SMTP('smtp.gmail.com')
# Start the TLS connection
smtp.starttls()
# Login to the server
smtp.login(user, password)
# Attempt to send the email in the try block
# noinspection PyBroadException
try:
# Send the response email
smtp.sendmail(to_name, from_name, response_email.as_string())
# Catch all exceptions. I could have only done SMTP exceptions,
# but I'm not sure if they'll add more in the future
# so I thought it better to just catch all of them for this one line.
except:
# The following lines dump the details of the error to stdout
print(
"Server encountered the following error while attempting to
send email: \nType: {0}\nValue: {1}\nTraceback: {2}".format(
str(sys.exc_info()[0]), str(sys.exc_info()[1]), str(sys.exc_info()[2])))
finally:
# Close the smtp connection
smtp.close()
# sendfile Instead of just sending text, sends a file. Primarily used for the FTP get functionality.
# file_name: The name of the file to be attached to the email.
def sendfile(file_name):
# Declare the mime class and message header fields
response = MIMEMultipart()
response['Subject'] = subject
response['From'] = to_name
response['To'] = from_name
# Attach the text portion of the message. Alerts the user of the file name and that it is attached.
response.attach(MIMEText("Grabbed file " + file_name + ". See attachment."))
# Define the major and minor mime type
# attachment = MIMEBase('application', "octet-stream")
# This command uses the mimetypes module to attempt to guess the mimetype of the file.
# The strict=False argument
# tells the mimetypes module to include modules not officially registered with IANA
# The file_type variable will be in the format of a tuple #major type#/#sub type# , #encoding#
file_type = mimetypes.guess_type(file_name, strict=False)
main_type = None
# mimetypes.guess_type returns None if it doesn't recognize the file type.
# In this case I chose to use the generic
# application octect-stream type, which should be safe.
if file_type is None:
attachment = MIMEBase('application', 'octet-stream')
else:
main_type = file_type[0].split('/')[0]
sub_type = file_type[0].split('/')[1]
# This pulls the major type and sub type and splits them around the
# '/' character providing the major type in the
# first argument and the sub type in the second argument
attachment = MIMEBase(main_type, sub_type)
# Read the file from disk and set it as the payload for the email
# The with statement ensures the file is properly closed even if an exception is raised.
try:
f = open(file_name, "rb")
attachment.set_payload(f.read())
f.close()
except OSError:
print(
"Server encountered the following error while attempting to
open the file you asked for: \nType: {0}\nValue: {1}\nTraceback: {2}".format(
str(sys.exc_info()[0]), str(sys.exc_info()[1]), str(sys.exc_info()[2])))
# TODO: Fix the fact that this shows the newlines as \n instead of properly adding newlines
sendresponse(
"Server encountered the following error while attempting to open the file
you asked for: \nType: {0}\nValue: {1}\nTraceback: {2}".format(
str(sys.exc_info()[0]), str(sys.exc_info()[1]), str(sys.exc_info()[2])))
# Check to see if the attachment requires encoding
if main_type == 'application' or main_type == 'audio' or main_type == 'image':
# Encode the attachment
encoders.encode_base64(attachment)
# This sets the header. The output will be in the following format:
# Content-Disposition: attachment; file_name="the_file_name"
# The os.path.basename(file_name) portion strips the leading part of the
# file path and just gives the actual file name
attachment.add_header('Content-Disposition', 'attachment; file_name="%s"'
% os.path.basename(file_name))
# A ttach the attachment to the response message
response.attach(attachment)
# Declare an instance of the SMTP class and connect to the smtp server
smtp = smtplib.SMTP('smtp.gmail.com')
# Start the TLS connection
smtp.starttls()
# Login to the server
smtp.login(user, password)
# Attempt to send the email
# noinspection PyBroadException
try:
# Send the response email
smtp.sendmail(to_name, from_name, response.as_string())
# Close the smtp connection
smtp.close()
# Catch all exceptions. I could have only done SMTP exceptions,
# but I'm not sure if they'll add more in the future
# so I thought it better to just catch all of them for this one line.
except:
# Close the smtp connection
smtp.close()
# The following lines print all of the error details to stdout and
# also send them back to the user
print(
"Server encountered the following error while attempting to
send email: \nType: {0}\nValue: {1}\nTraceback: {2}".format(
str(sys.exc_info()[0]), str(sys.exc_info()[1]), str(sys.exc_info()[2])))
# TODO: Fix the fact that this shows the newlines as \n instead of properly adding newlines
sendresponse(
"Server encountered the following error while attempting to send
email: \nType: {0}\nValue: {1}\nTraceback: {2}".format(
str(sys.exc_info()[0]), str(sys.exc_info()[1]), str(sys.exc_info()[2])))
# Responsible for connecting to the server via IMAP and actually grabbing the e-mail.
# It then passes the text or
# content of the e-mail to proccommand for processing.
def getemail():
# Declare as global variables. They are originally defined at the beginning.
# Without these the values won't
# carry over to other functions.
global from_name
global to_name
global subject
# Connect to Google's imap server
# TODO: This needs to be generic
mail = imaplib.IMAP4_SSL('imap.gmail.com')
# Login to the imap server
mail.login(user, password)
# List all mailbox (folder) names. In gmail these are called labels.
mail.list()
# Select the inbox folder
mail.select("inbox")
# Grab an ordered list of all mail. The search function returns an ordered list
# from newest to oldest. Only grab new mail.
result, data = mail.search(None, "UNSEEN")
# Check to see if there was new mail or not. If there wasn't return None.
if not data[0].split():
return None
# Data[0] is a list of email IDs in a space separated string.
id_list = data[0].split()
# Grab the latest email
# TODO add support for queuing
latest_email_id = id_list[-1]
# Fetch the email body for the given ID
result, data = mail.fetch(latest_email_id, "(RFC822)")
raw_email = data[0][1]
# Grab the body of the email including the raw text, headers, and payloads
# The following line converts the email from a string into an email message object
email_message = email.message_from_bytes(raw_email)
if email_message['To'] is not None:
print(email_message['To'])
to_name = email_message['To']
else:
print("Received email with an empty To address. Ignoring the message.")
return
if email_message['Return-Path'] is not None:
print(email_message['Return-Path'])
from_name = email_message['Return-Path']
else:
print("Received email with an empty Return-Path. Ignoring the message.")
return
if email_message['Subject'] is not None:
# If there is already a Re in the subject don't add it again
if email_message['Subject'].find("Re:") == -1:
subject = "Re: " + email_message['Subject']
else:
subject = email_message['Subject']
print(subject)
else:
subject = ''
# Check to see if this is a multipart email message. If it isn't just return the text portion.
if email_message.get_content_maintype() == 'multipart':
# Used to determine whether a command was found in the email
found_command = False
# Walk the various sections of the email.
# The enumerate function here returns a number for each email part walked through.
# IE 0 for the first part in
# the email, 1 for the next, and so on.
for index, part in enumerate(email_message.walk()):
# The multipart section is just a container so we can skip it.
if part.get_content_maintype() is 'multipart':
continue
# Check to see if we have reached the text body of the message
if part.get_content_type() == 'text/plain':
found_command = True
message = part.get_payload()
proccommand(message.split('\n')[0].rstrip(), index, email_message)
#This does not need to continue after this run
break
# If a command was found and successfully processed simply return
if found_command:
return
else:
sendresponse("Error: No command was found in multipart email. Was it there?")
print("Error: Server encountered a multipart email that did not appear
to have a command in it")
# Return the text payload
elif email_message.get_content_main_type() == 'text':
print(email_message.get_payload())
# Pass the command to proccommand. rstrip removes any whitespace characters from the tail.
proccommand(email_message.get_payload().split('\n')[0].rstrip())
# It wasn't text - do not process the email
else:
print("Error processing email in getemail. Encountered unrecognized content type.")
sendresponse("Error processing email in getemail. Encountered unrecognized content type.")
return
# Processes the command retrieved from the email server
# message: The message from the email server in the form of [type] [command(s)]
# index: This is an index into a multipart email's parts.
# getemail may have already processed some of them and it is
# unnecessary to reprocess those parts
# email_message: This is an email_message passed from getemail.
# In terms of FTP, this is used for the put command
def proccommand(message, index=None, email_message=None):
command = message.split(' ')
# Used for logging to determine whether a command was successful or not
errors = False
# Process an FTP command
if command[0].upper() == "FTP":
# cd command ---------------------------
# TODO: Test cd with shortcuts such as ..
# Process the command "cd"
if command[1].lower() == "cd":
# Ensure the correct number of arguments was passed"
if len(command) != 3:
sendresponse("Error: Incorrect number of arguments for cd")
errors = True
# Make sure the path exists
elif not os.path.exists(command[2]):
sendresponse("Error: The path: \"" + command[2] + "\" does not exist")
errors = True
# Process the "cd" command
else:
os.chdir(command[2])
sendresponse("CD completed successfully. Directory is now: " + os.getcwd())
# dir command ---------------------------
# Process the command "dir"
if command[1].lower() == "dir":
# Ensure the correct number of arguments was passed"
if len(command) < 2 or len(command) > 3:
sendresponse("Error: Incorrect number of arguments for dir")
errors = True
else:
# If no arguments are passed to dir then list the current directory
if len(command) == 2:
response = ""
for file in os.listdir(os.getcwd()):
# Contains newline delimited directory listing at end
response = response + file + "\n"
sendresponse(response)
# Process the dir command with directory argument
else:
# Make sure the path exists
if not os.path.exists(command[2]):
sendresponse("Error: The path: \"" + command[2] + "\" does not exist")
errors = True
# If the path does exist then list the directory
else:
response = ""
for file in os.listdir(command[2]):
# Contains newline delimited directory listing at end
response = response + file + "\n"
sendresponse(response)
# pwd command ---------------------------
# Process the pwd command
if command[1].lower() == "pwd":
# Ensure correct number of arguments was passed
if len(command) != 2:
sendresponse("Error: Incorrect number of arguments for pwd")
errors = True
else:
# Get current directory and respond
sendresponse(os.getcwd())
# get command ---------------------------
# Process the get command
if command[1].lower() == "get":
# Ensure correct number of arguments was passed
if len(command) != 3:
sendresponse("Error: Incorrect number of arguments for get")
errors = True
else:
# Make sure the file exists
if not os.path.exists(command[2]):
sendresponse("Error: The path: \"" + command[2] + "\" does not exist")
errors = True
else:
sendfile(command[2])
# put command ---------------------------
if command[1].lower() == "put":
# Ensure correct number of arguments was passed
if len(command) != 3:
sendresponse("Error: Incorrect number of arguments for put")
errors = True
else:
# There may be a better way to do this, but I'm using this
# to inform the functionality below whether it
# should actually execute. This defaults to true,
# but gets set to False if the path is determined to be
# non-valid
valid_path = True
# The line below splits command[2] into two pieces. For example say the path is
# C:\Users\randomUser\Downloads\tryMe.exe. The split command will split this into
# C:\Users\randomUser\Downloads\ and tryMe.exe.
# The [0] index grabs just the path prefix. In this case
# bool will return true IF there is a path prefix and false if there isn't.
# We don't want to check if the path exists if the user didn't provide
# any path (because a path of '' will return false. If the
# user didn't provide a path we want to just dump the file
# in the current directory whatever it is.
if bool(os.path.split(command[2])[0]):
# Make sure the path exists exists
if not os.path.exists(os.path.split(command[2])[0]):
valid_path = False
sendresponse("Error: The path: \"" + os.path.split(command[2])[0] +
"\" does not exist")
errors = True
# Only execute this if a valid path is supplied (or no path as the case may be)
if valid_path:
# getemail already parsed some of the sections.
# The index comes from the getemail function and
# allows this section to skip the parts of the email that were already processed.
# email_message.walk() is a generator, but we want to index into it
# so we use the list function.
# This turns it into a list, which we are permitted to index into
for part in list(email_message.walk())[index:]:
# The multipart section is just a container so we can skip it.
if part.get_content_maintype() is 'multipart':
continue
# Grab the file name from the section
file_name = part.get_filename()
# Make sure the file name actually exists and if it does write the file.
if bool(file_name):
try:
fp = open(command[2], 'wb')
# Get the payload of the email and write it.
# The decode=True argument basically says if
# the payload is encoded, decode it first otherwise,
# it gets returned as is.
fp.write(part.get_payload(decode=True))
fp.close()
sendresponse("Successfully procced the put command: " + message)
except OSError:
errors = True
print(
"Server encountered the following error while attempting
to write the file you uploaded: \nType:
{0}\nValue: {1}\nTraceback: {2}".format(
str(sys.exc_info()[0]), str(sys.exc_info()[1]),
str(sys.exc_info()[2])))
# TODO: Fix the fact that this shows the newlines as \n
# instead of properly adding newlines
sendresponse(
"Server encountered the following error while attempting to write
the file you uploaded: \nType: {0}\nValue:
{1}\nTraceback: {2}".format(
str(sys.exc_info()[0]), str(sys.exc_info()[1]),
str(sys.exc_info()[2])))
# Break out of the for loop. We should only need to write one file.
break
# If the protocol or desired action did not match anything...
else:
sendresponse("Error: Bad or nonexistent command: " + message)
print("Server received an email which matched no commands.")
# Used server side for logging purposes
if errors:
print("Failed to process command \"" + message + "\"")
else:
print("Successfully processed command: " + message)
# This is the main section
while 1:
getemail()
time.sleep(5)