├── .gitignore ├── utils.py ├── http_helper.py ├── fuzzLogger.py ├── README.md ├── terminal.py └── emissary.py /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | *.pyo 3 | *.swp 4 | *~ 5 | -------------------------------------------------------------------------------- /utils.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """ 3 | Utility methods. 4 | Copyleft 2011 Ian Gallagher 5 | Some methods taken directly, or with modification from the Scapy project 6 | (http://www.secdev.org/projects/scapy) - noted accordingly 7 | """ 8 | 9 | from textwrap import TextWrapper 10 | from terminal import TerminalController 11 | 12 | def color_string(data, color): 13 | # Setup a TerminalController for formatted output 14 | term = TerminalController() 15 | 16 | result = term.render("${" + color.upper() + "}" + data + "${NORMAL}") 17 | return(result) 18 | 19 | def sane(x): 20 | """ 21 | From Scapy's utils.py 22 | """ 23 | r="" 24 | for i in x: 25 | j = ord(i) 26 | if (j < 32) or (j >= 127): 27 | r=r+"." 28 | else: 29 | r=r+i 30 | return ' '.join((r[:8], r[8:])).strip() 31 | 32 | def indent(data, spaces): 33 | return '\n'.join(map(lambda x: ' ' * spaces + x, data.split('\n'))) 34 | 35 | def hexdump(x, indent=False): 36 | """ 37 | From Scapy's utils.py, modified to return a string instead of print directly, 38 | and just use sane() instead of sane_color() 39 | """ 40 | result = "" 41 | 42 | x=str(x) 43 | l = len(x) 44 | i = 0 45 | while i < l: 46 | result += "%08x " % i 47 | for j in range(16): 48 | if i+j < l: 49 | result += "%02x " % ord(x[i+j]) 50 | else: 51 | result += " " 52 | if j%16 == 7: 53 | result += " " 54 | result += " " 55 | result += sane(x[i:i+16]) + "\n" 56 | i += 16 57 | 58 | if indent: 59 | """ 60 | Print hexdump indented 4 spaces, and blue - same as Wireshark 61 | """ 62 | indent_count = 4 # Same as Wireshark's hex display for following TCP streams 63 | tw = TextWrapper(width = 78 + indent_count, initial_indent = ' ' * indent_count, subsequent_indent = ' ' * indent_count) 64 | 65 | result = tw.fill(result) 66 | result = color_string(result, "CYAN") 67 | 68 | return(result) 69 | else: 70 | """ 71 | Print hexdump left aligned, and red - same as Wireshark 72 | """ 73 | result = color_string(result.strip(), "RED") 74 | return(result) 75 | 76 | if __name__ == "__main__": 77 | import os 78 | 79 | print(hexdump('\xff'*68)) 80 | print(hexdump('\x41'*61, indent=True)) 81 | 82 | # vim: tabstop=8 expandtab shiftwidth=4 softtabstop=4 83 | -------------------------------------------------------------------------------- /http_helper.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | import gzip 4 | from cStringIO import StringIO 5 | 6 | 7 | def http_split(data): 8 | """ 9 | Return a tuple of headers and body from an HTTP message. (dict, string) 10 | The HTTP response message (e.g. "HTTP/1.1 200 OK") is lost in using this method. 11 | """ 12 | 13 | assert len(data) > 0, "data must be non-zero length" 14 | assert data.find('\r\n\r\n') > 0, "data does not appear to be a complete HTTP response message" 15 | 16 | # Place header and body portion into their own strings (no further data structure yet) 17 | headers, body = data.split('\r\n\r\n') 18 | 19 | return headers, body 20 | 21 | def http_headers_dict(headers_data): 22 | """ 23 | Return a dictionary of Header Key-Value pairs given a string containing \r\n delimited HTTP 24 | headers 25 | """ 26 | 27 | # Format headers into a dictionary (ditching the HTTP xxx response message in the meantime) 28 | headers_dict = dict(item.split(':', 1) for item in headers_data.split('\r\n')[1:]) 29 | 30 | return headers_dict 31 | 32 | def http_is_gzip(data): 33 | """ 34 | Returns true if (a rudimentary) check for the HTTP response being gzip-encoded is true 35 | """ 36 | 37 | try: 38 | # If http_split returns an AssertionError, the data likely isn't an HTTP Response 39 | headers, body = http_split(data) 40 | headers = http_headers_dict(headers) 41 | except AssertionError, ex: 42 | return False 43 | 44 | # Make keys and values lower-case (for simplified comparisons) 45 | headers = dict((k.lower(), v.lower()) for k,v in headers.iteritems()) 46 | 47 | # Check if content-encoding header exists 48 | if not headers.has_key('content-encoding'): 49 | return False 50 | 51 | # Check if content-encoding contains gzip 52 | return headers['content-encoding'].find('gzip') > 0 53 | 54 | def http_gunzip(gzdata): 55 | """ 56 | Return the uncompressed data given a gzip-compressed data string 57 | """ 58 | 59 | return gzip.GzipFile(fileobj=StringIO(gzdata)).read() 60 | 61 | def http_gzip(plaintext, level=9): 62 | """ 63 | Return the gzip binary data for use in HTTP messages given a plaintext string, and optionaly 64 | a compression level (default is level 9) 65 | """ 66 | 67 | gzip_mine = StringIO() 68 | gzipper = gzip.GzipFile(fileobj=gzip_mine, mode='wb', compresslevel=level) 69 | gzipper.write(plaintext) 70 | gzipper.close() 71 | 72 | gzip_mine.seek(0) 73 | my_gz = gzip_mine.read() 74 | 75 | return my_gz 76 | 77 | def http_reconstruct_message(headers, body): 78 | """ 79 | Given a string of headers and a string of an HTTP body, return them joined with \r\n\r\n per the HTTP spec 80 | This method is not intelligent, it will not avoid duplicate \r\n\r\n strings if the input already contains them 81 | """ 82 | 83 | return '\r\n\r\n'.join( (headers, body) ) 84 | -------------------------------------------------------------------------------- /fuzzLogger.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | from psycopg2 import * 4 | from datetime import datetime 5 | from hashlib import sha256 6 | 7 | 8 | class logData: 9 | """ Simply store request and responsne data and let the caller know if we have that data before logging""" 10 | 11 | def __init__(self): 12 | self.request_data = "" 13 | self.request_time = "" 14 | 15 | self.response_data = "" 16 | self.response_time = "" 17 | 18 | def has_request(self): 19 | if(self.request_data and self.request_time): 20 | return True 21 | else: 22 | return False 23 | 24 | def has_response(self): 25 | if(self.response_data and self.response_time): 26 | return True 27 | else: 28 | return False 29 | 30 | def has_all(self): 31 | if(self.has_request() and self.has_response()): 32 | return True 33 | else: 34 | return False 35 | 36 | def clear_request(self): 37 | self.request_data = "" 38 | self.request_time = "" 39 | 40 | def clear_response(self): 41 | self.response_data = "" 42 | self.response_time = "" 43 | 44 | def clear_all(self): 45 | self.clear_request() 46 | self.clear_response() 47 | 48 | class postgresLogger: 49 | 50 | def __init__(self, dbhost, dbname, dbuser, dbpass): 51 | self.connection = connect("dbname='" + dbname + "' user='" + dbuser + "' host='" + dbhost + "' password='" + dbpass + "'") 52 | self.curs = self.connection.cursor() 53 | self.run_number = 0 54 | self.log_id_num = 0 55 | 56 | 57 | def log_run_info(self, company_name, product_name, notes=''): 58 | if(not (isinstance(company_name, str))): 59 | raise TypeError, "company_name must be of type string" 60 | if(not (isinstance(product_name, str))): 61 | raise TypeError, "product_name must be of type string" 62 | if(not (isinstance(notes, str))): 63 | raise TypeError, "notes must be of type string" 64 | 65 | table = "fuzzdata.run_info" 66 | columns = "run_number, run_starttime, company_name, product_name, notes" 67 | # bigserial, timestamp notz, char varying, char varying, char varying 68 | 69 | values_insert = { 'run_starttime':postgres_datetime_ms(), 'company_name':company_name, 'product_name':product_name, 'notes':notes } 70 | statement_lock = "LOCK " + table + " IN EXCLUSIVE MODE" 71 | statement_insert = "INSERT INTO " + table + " (" + columns + ") VALUES ( DEFAULT, %(run_starttime)s, %(company_name)s, %(product_name)s, %(notes)s )" 72 | statement_select = "SELECT run_number FROM " + table + " ORDER BY run_number DESC LIMIT 1" 73 | 74 | try: 75 | # Lock the table, so we get the proper run number back 76 | self.curs.execute(statement_lock) 77 | # Insert our data 78 | self.curs.execute(statement_insert, values_insert) 79 | # Get our run number 80 | self.curs.execute(statement_select) 81 | self.run_number = self.curs.fetchone()[0]; 82 | # Commit and release the table lock 83 | finally: 84 | self.connection.commit() 85 | 86 | return(self.run_number) 87 | 88 | #def log_iteration_data(self, request_data, response_data, request_time): 89 | def log_iteration_data(self, logdata): 90 | #if(not logdata.has_all()): 91 | # raise Exception, "logdata object doesn't have all needed items for logging!" 92 | if(not (isinstance(self.run_number, int) or isinstance(self.run_number, long))): 93 | raise TypeError, "run_number must be of type int or long" 94 | if(not (isinstance(logdata.request_data, str))): 95 | raise TypeError, "request_data must be of type string" 96 | if(not (isinstance(logdata.response_data, str))): 97 | raise TypeError, "response_data must be of type string" 98 | if(not (isinstance(logdata.request_time, str))): 99 | raise TypeError, "request_time must be of type string" 100 | 101 | # Binarify (Postgres) data which is to be stored as a byte array 102 | # Request data 103 | request_digest = Binary(digest_data(logdata.request_data)) 104 | request_data = Binary(logdata.request_data) 105 | if(not logdata.request_time): 106 | request_time = postgres_datetime_ms() 107 | else: 108 | request_time = logdata.request_time 109 | 110 | # Response data 111 | response_digest = Binary(digest_data(logdata.response_data)) 112 | response_data = Binary(logdata.response_data) 113 | if(not logdata.response_time): 114 | response_time = postgres_datetime_ms() 115 | else: 116 | response_time = logdata.response_time 117 | 118 | table = "fuzzdata.log_data" 119 | columns = "id, run_number, request_data, request_digest, response_data, response_digest, request_time" 120 | # bigserial, bigint, bytea, bytea, bytea, bytea, timestamp notz 121 | 122 | values_insert = { 'run_number':self.run_number, 'request_data':request_data, 'request_digest':request_digest, 'response_data':response_data, 'response_digest':response_digest, 'request_time':request_time } 123 | statement_lock = "LOCK " + table + " IN EXCLUSIVE MODE" 124 | statement_insert = "INSERT INTO " + table + " (" + columns + ") VALUES ( DEFAULT, %(run_number)s, %(request_data)s, %(request_digest)s, %(response_data)s, %(response_digest)s, %(request_time)s )" 125 | statement_select = "SELECT id FROM " + table + " ORDER BY id DESC LIMIT 1" 126 | 127 | try: 128 | # Lock the table, so we get the proper run number back 129 | self.curs.execute(statement_lock) 130 | # Insert our data 131 | self.curs.execute(statement_insert, values_insert) 132 | # Get our log ID number 133 | self.curs.execute(statement_select) 134 | self.log_id_num = self.curs.fetchone()[0]; 135 | finally: 136 | # Commit and release the table lock 137 | self.connection.commit() 138 | 139 | return(self.log_id_num) 140 | 141 | def postgres_datetime_ms(): 142 | """Return a Postgres DateTime object that has microsecond resolution. Move to a toolbox library later""" 143 | now = datetime.now() 144 | timestamp = Timestamp(now.year, now.month, now.day, now.hour, now.minute, now.second, None) 145 | timestamp = str(timestamp).strip("'") 146 | return "'" + timestamp + "." + str(now.microsecond) + "'" 147 | 148 | def digest_data(data): 149 | """Simply return the SHA256 of supplied data (returned in binary, not hex)""" 150 | return sha256(data).hexdigest() 151 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## Emissary - A generic TCP payload proxy 2 | This is a simple but flexible generic TCP payload proxy. The reasoning behind its creation was to have something similar to Portswigger's Burp Proxy, for TCP instead of just HTTP. Currently there is no GUI and it is just a command-line based application. Upon starting the proxy, you are presented with an IPython shell and you can manipulate various settings on the fly, such as search and replace operations, the level of information displayed, etc. Lots of work needs to be done, but it's a functional and useful tool at the moment. 3 | 4 | The socket connections are managed by the asyncore module, and the proxy can handle truly asynchronous connections, and it seems to do so with quite good performance (I don't have any numbers at the moment - but I don't notice any performance hits when passing any interactive traffic through it such as HTTP, RDP, SSH, etc.) 5 | 6 | Aside from some custom fuzzing and data logging code that I have not yet released, I believe IPython (>= 0.11) is the only dependency for this code to run outside of standard Python modules. The custom modules referenced are not loaded by default, so it should not be an issue. I intend the fuzzing and data logging to be modular, so you should be able to fit something in if you need to (I hope to get those components online soon as well, however.) 7 | 8 | 9 | **Basic usage is as follows:** 10 | 11 | Usage: emissary.py [options] 12 | 13 | Options: 14 | -h, --help show this help message and exit 15 | -l LOCAL_ADDR, --local-addr=LOCAL_ADDR 16 | Local address to bind to 17 | -p LOCAL_PORT, --local-port=LOCAL_PORT 18 | Local port to bind to 19 | -r REMOTE_ADDR, --remote-addr=REMOTE_ADDR 20 | Remote address to bind to 21 | -P REMOTE_PORT, --remote-port=REMOTE_PORT 22 | Remote port to bind to 23 | --search-request=SEARCH_REQUEST 24 | String that if found will be replaced by --replace- 25 | request's value 26 | --replace-request=REPLACE_REQUEST 27 | String to replace the value of --search-request 28 | --search-response=SEARCH_RESPONSE 29 | String that if found will be replaced by --replace- 30 | request's value 31 | --replace-response=REPLACE_RESPONSE 32 | String to replace the value of --search-request 33 | --regex-request Requests: Use regular expressions for search and 34 | replace instead of string constants 35 | --regex-response Responses: Use regular expressions for search and 36 | replace instead of string constants 37 | --fuzz-request Fuzz the request which the proxy gets from the 38 | connecting client prior to sending it to 39 | the remote host 40 | --fuzz-response Fuzz the response which the proxy gets from the remote 41 | host prior to sending it to the conecting 42 | client 43 | -i RUN_INFO, --run-info=RUN_INFO 44 | Additional information string to add to database 45 | run_info entry 46 | -d DEBUG, --debug=DEBUG 47 | Debug level (0-5, 0: No debugging; 1: Simple 48 | conneciton information; 2: Simple data 49 | information; 3: Listener data display; 4: 50 | Sender data display; 5: All data display) 51 | 52 | ### Example of setting up a proxy to an SSH server (mine...) 53 | Below you can see us setting up the proxy to listen on localhost, port 2222, and connect to the host 173.203.94.5 port 22 - the debug level is set to 4, which shows full communication from the "sender" side (the socket that connects to the server): 54 | 55 | $ ./emissary.py -l 127.0.0.1 -p 2222 -r 173.203.94.5 -P 22 --debug=4 56 | 57 | Setting up asynch. TCP proxy with the following settings: 58 | Local binding Address: 127.0.0.1 59 | Local binding Port: 2222 60 | 61 | Remote host address: 173.203.94.5 62 | Remote host port: 22 63 | 64 | Debug: Level 4 (Show sender data and size of sent/received messages) 65 | 66 | Fuzzing (Maybe you wanted --fuzz-request or --fuzz-response?) 67 | Listener running... 68 | 69 | In [1]: Connection established... 70 | Sender: 39 bytes read 71 | 00000000 53 53 48 2d 32 2e 30 2d 4f 70 65 6e 53 53 48 5f SSH-2.0- OpenSSH_ 72 | 00000010 35 2e 33 70 31 20 44 65 62 69 61 6e 2d 33 75 62 5.3p1 De bian-3ub 73 | 00000020 75 6e 74 75 37 0d 0a untu7.. 74 | Sender: 20 bytes sent 75 | 00000000 43 6c 69 65 6e 74 2d 62 65 69 6e 67 2d 4d 69 54 Client-b eing-MiT 76 | 00000010 4d 64 21 0a Md!. 77 | Sender: 19 bytes read 78 | 00000000 50 72 6f 74 6f 63 6f 6c 20 6d 69 73 6d 61 74 63 Protocol mismatc 79 | 00000010 68 2e 0a h.. 80 | Sender: 0 bytes read 81 | 82 | 83 | 84 | In [1]: 85 | 86 | ### Simple search and replace of TCP data 87 | You can perform simple search and replace operations via the command line, or through the IPython shell. Below, we search for "OpenSSH" in responses from the server, and replace them with "PwnedSSH": 88 | 89 | $ python emissary.py -l 127.0.0.1 -p 2222 -r 173.203.94.5 -P 22 --debug=4 --search-response="OpenSSH" --replace-response="PwnedSSH" 90 | 91 | Setting up asynch. TCP proxy with the following settings: 92 | Local binding Address: 127.0.0.1 93 | Local binding Port: 2222 94 | 95 | Remote host address: 173.203.94.5 96 | Remote host port: 22 97 | 98 | Debug: Level 4 (Show sender data and size of sent/received messages) 99 | 100 | Running string search/replace on RESPONSES with search/replace: 's/OpenSSH/PwnedSSH' 101 | Fuzzing (Maybe you wanted --fuzz-request or --fuzz-response?) 102 | Listener running... 103 | 104 | In [1]: Connection established... 105 | Sender: 39 bytes read 106 | 00000000 53 53 48 2d 32 2e 30 2d 4f 70 65 6e 53 53 48 5f SSH-2.0- OpenSSH_ 107 | 00000010 35 2e 33 70 31 20 44 65 62 69 61 6e 2d 33 75 62 5.3p1 De bian-3ub 108 | 00000020 75 6e 74 75 37 0d 0a untu7.. 109 | Replacing literal 'OpenSSH' with 'PwnedSSH': 110 | Sender: 8 bytes sent 111 | 00000000 4f 68 4e 6f 65 73 21 0a OhNoes!. 112 | Sender: 19 bytes read 113 | 00000000 50 72 6f 74 6f 63 6f 6c 20 6d 69 73 6d 61 74 63 Protocol mismatc 114 | 00000010 68 2e 0a h.. 115 | Sender: 0 bytes read 116 | 117 | This can also be done with regular expressions by adding the argument `--regex-response` - the same holds true for `--search-request` of course. 118 | 119 | You can manipulate the search/replace functionality via the interactive shell through the local variable **sr_request** or **sr_response**, respectively. The structure of this variable is a three item array, the first being a boolean that determines if the search/replace is regular expression based or not, the second being the search term, and the third being the replacement. 120 | 121 | 122 | The name _emissary_ was chosen because the internal name of "TcpProxy" was boring, and because it's at least a decent fit considering what the tool does. 123 | -------------------------------------------------------------------------------- /terminal.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # encoding: utf-8 3 | # from http://aspn.activestate.com/ASPN/Cookbook/Python/Recipe/475116 4 | 5 | import sys, re 6 | 7 | class TerminalController: 8 | """ 9 | A class that can be used to portably generate formatted output to 10 | a terminal. 11 | 12 | `TerminalController` defines a set of instance variables whose 13 | values are initialized to the control sequence necessary to 14 | perform a given action. These can be simply included in normal 15 | output to the terminal: 16 | 17 | >>> term = TerminalController() 18 | >>> print 'This is '+term.GREEN+'green'+term.NORMAL 19 | 20 | Alternatively, the `render()` method can used, which replaces 21 | '${action}' with the string required to perform 'action': 22 | 23 | >>> term = TerminalController() 24 | >>> print term.render('This is ${GREEN}green${NORMAL}') 25 | 26 | If the terminal doesn't support a given action, then the value of 27 | the corresponding instance variable will be set to ''. As a 28 | result, the above code will still work on terminals that do not 29 | support color, except that their output will not be colored. 30 | Also, this means that you can test whether the terminal supports a 31 | given action by simply testing the truth value of the 32 | corresponding instance variable: 33 | 34 | >>> term = TerminalController() 35 | >>> if term.CLEAR_SCREEN: 36 | ... print 'This terminal supports clearning the screen.' 37 | 38 | Finally, if the width and height of the terminal are known, then 39 | they will be stored in the `COLS` and `LINES` attributes. 40 | """ 41 | # Cursor movement: 42 | BOL = '' #: Move the cursor to the beginning of the line 43 | UP = '' #: Move the cursor up one line 44 | DOWN = '' #: Move the cursor down one line 45 | LEFT = '' #: Move the cursor left one char 46 | RIGHT = '' #: Move the cursor right one char 47 | 48 | # Deletion: 49 | CLEAR_SCREEN = '' #: Clear the screen and move to home position 50 | CLEAR_EOL = '' #: Clear to the end of the line. 51 | CLEAR_BOL = '' #: Clear to the beginning of the line. 52 | CLEAR_EOS = '' #: Clear to the end of the screen 53 | 54 | # Output modes: 55 | BOLD = '' #: Turn on bold mode 56 | BLINK = '' #: Turn on blink mode 57 | DIM = '' #: Turn on half-bright mode 58 | REVERSE = '' #: Turn on reverse-video mode 59 | NORMAL = '' #: Turn off all modes 60 | 61 | # Cursor display: 62 | HIDE_CURSOR = '' #: Make the cursor invisible 63 | SHOW_CURSOR = '' #: Make the cursor visible 64 | 65 | # Terminal size: 66 | COLS = None #: Width of the terminal (None for unknown) 67 | LINES = None #: Height of the terminal (None for unknown) 68 | 69 | # Foreground colors: 70 | BLACK = BLUE = GREEN = CYAN = RED = MAGENTA = YELLOW = WHITE = '' 71 | 72 | # Background colors: 73 | BG_BLACK = BG_BLUE = BG_GREEN = BG_CYAN = '' 74 | BG_RED = BG_MAGENTA = BG_YELLOW = BG_WHITE = '' 75 | 76 | _STRING_CAPABILITIES = """ 77 | BOL=cr UP=cuu1 DOWN=cud1 LEFT=cub1 RIGHT=cuf1 78 | CLEAR_SCREEN=clear CLEAR_EOL=el CLEAR_BOL=el1 CLEAR_EOS=ed BOLD=bold 79 | BLINK=blink DIM=dim REVERSE=rev UNDERLINE=smul NORMAL=sgr0 80 | HIDE_CURSOR=cinvis SHOW_CURSOR=cnorm""".split() 81 | _COLORS = """BLACK BLUE GREEN CYAN RED MAGENTA YELLOW WHITE""".split() 82 | _ANSICOLORS = "BLACK RED GREEN YELLOW BLUE MAGENTA CYAN WHITE".split() 83 | 84 | def __init__(self, term_stream=sys.stdout): 85 | """ 86 | Create a `TerminalController` and initialize its attributes 87 | with appropriate values for the current terminal. 88 | `term_stream` is the stream that will be used for terminal 89 | output; if this stream is not a tty, then the terminal is 90 | assumed to be a dumb terminal (i.e., have no capabilities). 91 | """ 92 | # Curses isn't available on all platforms 93 | try: import curses 94 | except: return 95 | 96 | # If the stream isn't a tty, then assume it has no capabilities. 97 | if not term_stream.isatty(): return 98 | 99 | # Check the terminal type. If we fail, then assume that the 100 | # terminal has no capabilities. 101 | try: curses.setupterm() 102 | except: return 103 | 104 | # Look up numeric capabilities. 105 | self.COLS = curses.tigetnum('cols') 106 | self.LINES = curses.tigetnum('lines') 107 | 108 | # Look up string capabilities. 109 | for capability in self._STRING_CAPABILITIES: 110 | (attrib, cap_name) = capability.split('=') 111 | setattr(self, attrib, self._tigetstr(cap_name) or '') 112 | 113 | # Colors 114 | set_fg = self._tigetstr('setf') 115 | if set_fg: 116 | for i,color in zip(range(len(self._COLORS)), self._COLORS): 117 | setattr(self, color, curses.tparm(set_fg, i) or '') 118 | set_fg_ansi = self._tigetstr('setaf') 119 | if set_fg_ansi: 120 | for i,color in zip(range(len(self._ANSICOLORS)), self._ANSICOLORS): 121 | setattr(self, color, curses.tparm(set_fg_ansi, i) or '') 122 | set_bg = self._tigetstr('setb') 123 | if set_bg: 124 | for i,color in zip(range(len(self._COLORS)), self._COLORS): 125 | setattr(self, 'BG_'+color, curses.tparm(set_bg, i) or '') 126 | set_bg_ansi = self._tigetstr('setab') 127 | if set_bg_ansi: 128 | for i,color in zip(range(len(self._ANSICOLORS)), self._ANSICOLORS): 129 | setattr(self, 'BG_'+color, curses.tparm(set_bg_ansi, i) or '') 130 | 131 | def _tigetstr(self, cap_name): 132 | # String capabilities can include "delays" of the form "$<2>". 133 | # For any modern terminal, we should be able to just ignore 134 | # these, so strip them out. 135 | import curses 136 | cap = curses.tigetstr(cap_name) or '' 137 | return re.sub(r'\$<\d+>[/*]?', '', cap) 138 | 139 | def render(self, template): 140 | """ 141 | Replace each $-substitutions in the given template string with 142 | the corresponding terminal control string (if it's defined) or 143 | '' (if it's not). 144 | """ 145 | return re.sub(r'\$\$|\${\w+}', self._render_sub, template) 146 | 147 | def _render_sub(self, match): 148 | s = match.group() 149 | if s == '$$': return s 150 | else: return getattr(self, s[2:-1]) 151 | 152 | ####################################################################### 153 | # Example use case: progress bar 154 | ####################################################################### 155 | 156 | class ProgressBar: 157 | """ 158 | A 3-line progress bar, which looks like:: 159 | 160 | Header 161 | 20% [===========----------------------------------] 162 | progress message 163 | 164 | The progress bar is colored, if the terminal supports color 165 | output; and adjusts to the width of the terminal. 166 | """ 167 | BAR = '%3d%% ${GREEN}[${BOLD}%s%s${NORMAL}${GREEN}]${NORMAL}\n' 168 | HEADER = '${BOLD}${CYAN}%s${NORMAL}\n\n' 169 | 170 | def __init__(self, term, header): 171 | self.term = term 172 | if not (self.term.CLEAR_EOL and self.term.UP and self.term.BOL): 173 | raise ValueError("Terminal isn't capable enough -- you " 174 | "should use a simpler progress dispaly.") 175 | self.width = self.term.COLS or 75 176 | self.bar = term.render(self.BAR) 177 | self.header = self.term.render(self.HEADER % header.center(self.width)) 178 | self.cleared = 1 #: true if we haven't drawn the bar yet. 179 | self.update(0, '') 180 | 181 | def update(self, percent, message): 182 | if self.cleared: 183 | sys.stdout.write(self.header) 184 | self.cleared = 0 185 | n = int((self.width-10)*percent) 186 | sys.stdout.write( 187 | self.term.BOL + self.term.UP + self.term.CLEAR_EOL + 188 | (self.bar % (100*percent, '='*n, '-'*(self.width-10-n))) + 189 | self.term.CLEAR_EOL + message.center(self.width)) 190 | 191 | def clear(self): 192 | if not self.cleared: 193 | sys.stdout.write(self.term.BOL + self.term.CLEAR_EOL + 194 | self.term.UP + self.term.CLEAR_EOL + 195 | self.term.UP + self.term.CLEAR_EOL) 196 | self.cleared = 1 197 | -------------------------------------------------------------------------------- /emissary.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | # Shall we log our session? (Requires postgresql and such setup) 4 | logging_enabled = False 5 | fuzzing_enabled = False 6 | 7 | dump_width = 90 8 | 9 | # This will enable logging data to a PostgreSQL database, enter at your own risk. 10 | # This should be replaced with a better, generic logging system. TODO. 11 | if logging_enabled: 12 | from fuzzLogger import * 13 | 14 | # This currently relies on some private fuzzing code - things should be changed 15 | # to use a more generic fuzzing system. TODO. 16 | if fuzzing_enabled: 17 | sys.path.append('../fuzzerCore') 18 | import datafuzzer 19 | 20 | # Global imports 21 | import sys, os, re, socket, asyncore 22 | from threading import Thread 23 | 24 | # Local imports 25 | from terminal import TerminalController 26 | from http_helper import * 27 | from utils import hexdump, indent 28 | 29 | class forwarder(asyncore.dispatcher): 30 | def __init__(self, ip, port, remoteip,remoteport,backlog=5): 31 | asyncore.dispatcher.__init__(self) 32 | self.remoteip=remoteip 33 | self.remoteport=remoteport 34 | self.create_socket(socket.AF_INET,socket.SOCK_STREAM) 35 | self.set_reuse_addr() 36 | self.bind((ip,port)) 37 | self.listen(backlog) 38 | if fuzzing_enabled: 39 | self.fuzzer = datafuzzer.DataFuzzer() 40 | else: 41 | self.fuzzer = None 42 | 43 | def handle_accept(self): 44 | conn, addr = self.accept() 45 | 46 | if(debug > 0): 47 | print(term.render('${BOLD}Connection established...${NORMAL}')) 48 | 49 | # share a single logdata object between sender and reciever 50 | # so that logdata is always populated with a request AND response 51 | # if fuzz_request and fuzz_response both True, will clobber non fuzzed data 52 | if logging_enabled: 53 | logdata = logData() 54 | else: 55 | logdata = None 56 | sender(receiver(conn, self.fuzzer, logdata), self.remoteip,self.remoteport, self.fuzzer, logdata) 57 | 58 | class receiver(asyncore.dispatcher): 59 | def __init__(self, conn, fuzzer, logdata): 60 | asyncore.dispatcher.__init__(self,conn) 61 | self.from_remote_buffer='' 62 | self.to_remote_buffer='' 63 | self.sender=None 64 | self.fuzzer = fuzzer 65 | self.logdata = logdata 66 | 67 | def handle_connect(self): 68 | pass 69 | 70 | def handle_read(self): 71 | read = self.recv(4096) 72 | debug_str = "" 73 | if(debug == 1 or debug == 3 or debug == 5): 74 | debug_str += term.render(' ${CYAN}Listener: %i bytes read:${NORMAL}\n') % len(read) 75 | if(debug == 3 or debug >= 5): 76 | debug_str += hexdump(read, indent=True) 77 | if debug_str: 78 | if debug >= 5: 79 | debug_str = indent(debug_str, dump_width) 80 | print(debug_str) 81 | 82 | self.from_remote_buffer += read 83 | 84 | 85 | def writable(self): 86 | return (len(self.to_remote_buffer) > 0) 87 | 88 | def handle_write(self): 89 | # This conditional stuff could really stand to be cleaned up 90 | sent = "" 91 | modified_data = self.to_remote_buffer 92 | found_gzip = False 93 | headers = '' 94 | 95 | # De-gzip HTTP responses 96 | #if http_is_gzip(modified_data): 97 | # found_gzip = True 98 | 99 | if found_gzip: 100 | print(term.render('${YELLOW}GZIP HTTP Response! Uncompressing...${NORMAL}')) 101 | 102 | headers, compressed_body = http_split(modified_data) 103 | # FIXME: Setting modified_data to the http body here ensures that headers will not be altered below - this may be undesirable! 104 | modified_data = http_gunzip(compressed_body) 105 | 106 | # Perform search/replace as appropriate 107 | if sr_response: 108 | # Check if regex 109 | if sr_response[0]: 110 | if len(re.findall(sr_response[1], modified_data)) > 0: 111 | modified_data = re.sub(sr_response[1], sr_response[2], modified_data) 112 | debug_str = term.render("${YELLOW}Listener: Replacing regex %s with %s:${NORMAL}" % (repr(sr_response[1]), repr(sr_response[2]))) 113 | if debug >= 5: 114 | debug_str = indent(debug_str, dump_width) 115 | print(debug_str) 116 | else: 117 | if sr_response[1] in modified_data: 118 | modified_data = modified_data.replace(sr_response[1], sr_response[2]) 119 | debug_str = term.render("${YELLOW}Listener: Replacing literal %s with %s:${NORMAL}" % (repr(sr_response[1]), repr(sr_response[2]))) 120 | if debug >= 5: 121 | debug_str = indent(debug_str, dump_width) 122 | print(debug_str) 123 | 124 | # Check if we want to fuzz the request or not 125 | if(fuzz_response): 126 | modified_data = self.fuzzer.fuzz(modified_data) 127 | 128 | # Reconstruct HTTP gzip message if we were dealing with compressed data 129 | if found_gzip: 130 | print(term.render('${YELLOW}Constructing compressed GZIP HTTP Response!${NORMAL}')) 131 | compressed_modified_data = http_gzip(modified_data) 132 | # Fixup the Content-Length header 133 | compressed_len = len(compressed_modified_data) 134 | headers = re.sub('(Content-Length\s*:[^\d]*)(\d+)', '\\1 %d' % compressed_len, headers) 135 | 136 | modified_data = http_reconstruct_message(headers, compressed_modified_data) 137 | 138 | # Send the (potentially) modified response data onward 139 | sent = self.send(modified_data) 140 | 141 | # Store RESPONSE and time of response for logging 142 | # Msg received from the server (i.e. SMTP response "220 OK") 143 | if self.logdata: 144 | self.logdata.response_data += modified_data 145 | if(not self.logdata.response_time): 146 | self.logdata.response_time = postgres_datetime_ms() 147 | 148 | 149 | debug_str = "" 150 | if(debug == 1 or debug == 3 or debug == 5): 151 | debug_str += term.render('${RED}Listener: %i bytes sent:${NORMAL}\n') % sent 152 | if(debug == 3 or debug >= 5): 153 | debug_str += hexdump(modified_data, indent=False) 154 | if debug_str: 155 | if debug >= 5: 156 | debug_str = indent(debug_str, dump_width) 157 | print(debug_str) 158 | 159 | self.to_remote_buffer = self.to_remote_buffer[sent:] 160 | 161 | def handle_close(self): 162 | self.close() 163 | if self.sender: 164 | self.sender.close() 165 | if(debug > 0): 166 | print(term.render('${BOLD}Connection closed...${NORMAL}')) 167 | # commit logdata to database and remove so it doesnt get logged twice (in other close) 168 | if self.logdata: 169 | #logger.log_iteration_data(self.logdata) 170 | self.logdata = None 171 | 172 | class sender(asyncore.dispatcher): 173 | def __init__(self, receiver, remoteaddr, remoteport, fuzzer, logdata): 174 | asyncore.dispatcher.__init__(self) 175 | self.receiver=receiver 176 | receiver.sender=self 177 | self.create_socket(socket.AF_INET, socket.SOCK_STREAM) 178 | self.connect((remoteaddr, remoteport)) 179 | self.fuzzer = fuzzer 180 | self.logdata = logdata 181 | 182 | def handle_connect(self): 183 | pass 184 | 185 | def handle_read(self): 186 | read = self.recv(4096) 187 | if(debug == 1 or debug == 4 or debug == 5): 188 | print(term.render(' ${BOLD}${CYAN}Sender: %i bytes read:${NORMAL}') % len(read)) 189 | if(debug == 4 or debug >= 5): 190 | print hexdump(read, indent=True) 191 | 192 | self.receiver.to_remote_buffer += read 193 | 194 | def writable(self): 195 | return (len(self.receiver.from_remote_buffer) > 0) 196 | 197 | def handle_write(self): 198 | # This conditional stuff could really stand to be cleaned up 199 | sent = "" 200 | modified_data = self.receiver.from_remote_buffer 201 | 202 | # Perform search/replace as appropriate 203 | if sr_request: 204 | # Check if regex 205 | if sr_request[0]: 206 | if len(re.findall(sr_request[1], self.receiver.from_remote_buffer)) > 0: 207 | modified_data = re.sub(sr_request[1], sr_request[2], self.receiver.from_remote_buffer) 208 | print(term.render("${YELLOW}Sender: Replacing regex %s with %s:${NORMAL}" % (repr(sr_request[1]), repr(sr_request[2])))) 209 | else: 210 | if sr_request[1] in self.receiver.from_remote_buffer: 211 | modified_data = self.receiver.from_remote_buffer.replace(sr_request[1], sr_request[2]) 212 | print(term.render("${YELLOW}Sender: Replacing literal %s with %s:${NORMAL}" % (repr(sr_request[1]), repr(sr_request[2])))) 213 | 214 | # Check if we want to fuzz the request or not 215 | if(fuzz_request): 216 | modified_data = self.fuzzer.mutate(modified_data) 217 | 218 | sent = self.send(modified_data) 219 | 220 | # Store REQUEST and time of request for logging 221 | # Msg received from the server (i.e. SMTP request "EHLO foobar.com") 222 | if self.logdata: 223 | self.logdata.request_data += modified_data 224 | if(not self.logdata.request_time): 225 | self.logdata.request_time = postgres_datetime_ms() 226 | 227 | if(debug == 1 or debug == 4 or debug == 5): 228 | print(term.render('${BOLD}${RED}Sender: %i bytes sent:${NORMAL}') % sent) 229 | if(debug == 4 or debug >= 5): 230 | print hexdump(modified_data, indent=False) 231 | self.receiver.from_remote_buffer = self.receiver.from_remote_buffer[sent:] 232 | 233 | def handle_close(self): 234 | self.close() 235 | self.receiver.close() 236 | # commit logdata to database and remove so it doesnt get logged twice (in other close) 237 | if self.logdata: 238 | #logger.log_iteration_data(self.logdata) 239 | self.logdata = None 240 | 241 | 242 | def main(): 243 | import optparse 244 | parser = optparse.OptionParser() 245 | # Shall we fuzz the request, response, or both? 246 | # Set via optparse in main 247 | global sr_request # search/replace tuple for requests - (True, [search, replace]) where true means to use regex 248 | global sr_response # search/replace tuple for responses - (True, [search, replace]) where true means to use regex 249 | global fuzz_request 250 | global fuzz_response 251 | 252 | # Other module-wide variables 253 | global debug 254 | global term 255 | global logger 256 | global fwdr 257 | 258 | parser.add_option( '-l','--local-addr', dest='local_addr',default='127.0.0.1', help='Local address to bind to') 259 | parser.add_option( '-p','--local-port', type='int',dest='local_port',default=1234, help='Local port to bind to') 260 | parser.add_option( '-r','--remote-addr',dest='remote_addr', help='Remote address to bind to') 261 | parser.add_option( '-P','--remote-port', type='int',dest='remote_port',default=80, help='Remote port to bind to') 262 | 263 | parser.add_option( '--search-request', dest='search_request',default='', help='String that if found will be replaced by --replace-request\'s value') 264 | parser.add_option( '--replace-request', dest='replace_request',default='', help='String to replace the value of --search-request') 265 | parser.add_option( '--search-response', dest='search_response',default='', help='String that if found will be replaced by --replace-request\'s value') 266 | parser.add_option( '--replace-response', dest='replace_response',default='', help='String to replace the value of --search-request') 267 | 268 | parser.add_option( '--regex-request', action='store_true' ,dest='request_use_regex', help='Requests: Use regular expressions for search and replace instead of string constants') 269 | parser.add_option( '--regex-response', action='store_true' ,dest='response_use_regex', help='Responses: Use regular expressions for search and replace instead of string constants') 270 | 271 | parser.add_option( '--fuzz-request', action='store_true' ,dest='fuzz_request', help='Fuzz the request which the proxy gets from the connecting client \ 272 | prior to sending it to the remote host') 273 | parser.add_option( '--fuzz-response', action='store_true' ,dest='fuzz_response', help='Fuzz the response which the proxy gets from the remote host prior \ 274 | to sending it to the conecting client') 275 | 276 | parser.add_option( '-i','--run-info', dest='run_info',default='', help='Additional information string to add to database run_info entry') 277 | 278 | parser.add_option( '-d','--debug', type='int',dest='debug',default=0, help='Debug level (0-5, 0: No debugging; 1: Simple conneciton \ 279 | information; 2: Simple data information; 3: Listener data display; 4: \ 280 | Sender data display; 5: All data display)') 281 | 282 | (options, args) = parser.parse_args() 283 | 284 | if not options.remote_addr or not options.remote_port: 285 | parser.print_help() 286 | exit(1) 287 | 288 | # Validate options for search/replace 289 | if (options.search_request and not options.replace_request) or (options.replace_request and not options.search_request): 290 | print >>sys.stderr, "Both --search-request and --replace-request must be provided together" 291 | exit(1) 292 | 293 | if (options.search_response and not options.replace_response) or (options.replace_response and not options.search_response): 294 | print >>sys.stderr, "Both --search-response and --replace-response must be provided together" 295 | exit(1) 296 | 297 | # Setup a TerminalController for formatted output 298 | term = TerminalController() 299 | 300 | # Print the current run information 301 | print(term.render("""\nSetting up asynch. TCP proxy with the following settings: 302 | ${GREEN}Local binding Address: %s 303 | Local binding Port: %s${NORMAL} 304 | 305 | ${RED}Remote host address: %s 306 | Remote host port: %s${NORMAL} 307 | """) % (options.local_addr, options.local_port, options.remote_addr, options.remote_port)) 308 | 309 | # Set the debug value 310 | debug = options.debug 311 | 312 | # If run info was passed in on the command line, use that for the run_info table 313 | # additional info field (It will have what's being fuzzed prepended to it as well) 314 | run_additional_info = options.run_info 315 | 316 | # Print the selected debug value 317 | if(debug > 0): 318 | if(debug == 1): 319 | print(" Debug: Level 1 (Show simple connection information)") 320 | elif(debug == 2): 321 | print(" Debug: Level 2 (Show simple data information, such as the size of sent/received messages)") 322 | elif(debug == 3): 323 | print(" Debug: Level 3 (Show listener data and size of sent/received messages)") 324 | elif(debug == 4): 325 | print(" Debug: Level 4 (Show sender data and size of sent/received messages)") 326 | elif(debug == 5): 327 | print(" Debug: Level 5 (Show all possible information, including the size of sent/received messages, and their data for listener and sender)") 328 | print("") 329 | 330 | # Display and setup search/replace things 331 | if options.search_request and options.replace_request: 332 | sr_request = [None, options.search_request.decode('string-escape'), options.replace_request.decode('string-escape')] 333 | # Check if we want to use regex instead of string constants 334 | if options.request_use_regex: 335 | # Use regex instead of string replace 336 | print(term.render("Running regex search/replace on ${BOLD}REQUESTS${NORMAL} with regex: 's/%s/%s'" % (sr_request[1], sr_request[2]))) 337 | sr_request[0] = True 338 | else: 339 | print(term.render("Running string search/replace on ${BOLD}REQUESTS${NORMAL} with search/replace: 's/%s/%s'" % (sr_request[1], sr_request[2]))) 340 | sr_request[0] = False 341 | else: 342 | sr_request = None 343 | 344 | if options.search_response and options.replace_response: 345 | sr_response = [None, options.search_response.decode('string-escape'), options.replace_response.decode('string-escape')] 346 | # Check if we want to use regex instead of string constants 347 | if options.response_use_regex: 348 | print(term.render("Running regex search/replace on ${BOLD}RESPONSES${NORMAL} with regex: 's/%s/%s'" % (sr_response[1], sr_response[2]))) 349 | sr_response[0] = True 350 | else: 351 | print(term.render("Running string search/replace on ${BOLD}RESPONSES${NORMAL} with search/replace: 's/%s/%s'" % (sr_response[1], sr_response[2]))) 352 | sr_response[0] = False 353 | else: 354 | sr_response = None 355 | 356 | # Setup which to fuzz - request, response, neither, both? 357 | if(options.fuzz_request): 358 | fuzz_request = options.fuzz_request 359 | run_additional_info = "Fuzzing REQUESTS; " + run_additional_info 360 | print(term.render("Fuzzing ${BOLD}REQUESTS${NORMAL}")) 361 | else: 362 | fuzz_request = False 363 | 364 | if(options.fuzz_response): 365 | fuzz_response = options.fuzz_response 366 | run_additional_info = "Fuzzing RESPONSES; " + run_additional_info 367 | print(term.render("Fuzzing ${BOLD}RESPONSES${NORMAL}")) 368 | else: 369 | fuzz_response = False 370 | 371 | if(not(options.fuzz_response or options.fuzz_request)): 372 | run_additional_info = "Fuzzing NONE; " + run_additional_info 373 | print(term.render("Fuzzing ${BOLD}${NORMAL} (Maybe you wanted ${BOLD}--fuzz-request or --fuzz-response${NORMAL}?)")) 374 | 375 | if(fuzz_request and fuzz_response): 376 | print(term.render("${YELLOW}\nWARNING! WARNING!\n${BOLD}Fuzzing BOTH the request and response is probably a bad idea, ensure this is what you want to do!${NORMAL}${YELLOW}\nWARNING! WARNING!\n${NORMAL}")) 377 | 378 | # host, db, username, passwd 379 | if logging_enabled: 380 | logger = postgresLogger("postgreshost", "dbname", "dbuser", "dbpass") 381 | 382 | logger.log_run_info("CompanyName", "ProjectName-v1.2.3", run_additional_info) 383 | 384 | # create object that spawns reciever/sender pairs upon connection 385 | fwdr = forwarder(options.local_addr,options.local_port,options.remote_addr,options.remote_port) 386 | print("Listener running...") 387 | #asyncore.loop() 388 | 389 | # A quick hack to be able to control fuzz on/off while running 390 | # separate asyncore.loop into its own thread so we can have terminal control 391 | asyncThread = Thread(target=asyncore.loop) 392 | asyncThread.start() 393 | 394 | # start a console (ipython) 395 | from IPython.terminal.interactiveshell import TerminalInteractiveShell 396 | shell = TerminalInteractiveShell(user_ns=globals()) 397 | shell.mainloop() 398 | 399 | # cleanup otherwise thread wont die and program hangs 400 | fwdr.close() 401 | #asyncore.close_all() 402 | asyncThread._Thread__stop() 403 | 404 | 405 | if __name__=='__main__': 406 | main() 407 | --------------------------------------------------------------------------------