Click here to Skip to main content
65,938 articles
CodeProject is changing. Read more.
Articles / All-Topics

FTPoverEmail Alpha

0.00/5 (No votes)
24 Aug 2014GPL32 min read 6.5K  
FTPoverEmail Alpha

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 :-D
  • 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)

License

This article, along with any associated source code and files, is licensed under The GNU General Public License (GPLv3)