Commit fd60886f4fe72d4b4612c6b029fadb343bfb5093

Authored by decalage2
1 parent 9afc59ab

added new script mraptor_milter

oletools/mraptor.py
... ... @@ -8,7 +8,7 @@ documents (e.g. Word, Excel), to detect malicious macros.
8 8 Supported formats:
9 9 - Word 97-2003 (.doc, .dot), Word 2007+ (.docm, .dotm)
10 10 - Excel 97-2003 (.xls), Excel 2007+ (.xlsm, .xlsb)
11   -- PowerPoint 2007+ (.pptm, .ppsm)
  11 +- PowerPoint 97-2003 (.ppt), PowerPoint 2007+ (.pptm, .ppsm)
12 12 - Word 2003 XML (.xml)
13 13 - Word/Excel Single File Web Page / MHTML (.mht)
14 14  
... ...
oletools/mraptor_milter.py 0 → 100644
  1 +#!/usr/bin/env python
  2 +"""
  3 +mraptor_milter
  4 +
  5 +mraptor_milter is a milter script for the Sendmail and Postfix e-mail
  6 +servers. It parses MS Office documents (e.g. Word, Excel) to detect
  7 +malicious macros. Documents with malicious macros are removed and
  8 +replaced by harmless text files.
  9 +
  10 +Supported formats:
  11 +- Word 97-2003 (.doc, .dot), Word 2007+ (.docm, .dotm)
  12 +- Excel 97-2003 (.xls), Excel 2007+ (.xlsm, .xlsb)
  13 +- PowerPoint 97-2003 (.ppt), PowerPoint 2007+ (.pptm, .ppsm)
  14 +- Word 2003 XML (.xml)
  15 +- Word/Excel Single File Web Page / MHTML (.mht)
  16 +
  17 +Author: Philippe Lagadec - http://www.decalage.info
  18 +License: BSD, see source code or documentation
  19 +
  20 +mraptor_milter is part of the python-oletools package:
  21 +http://www.decalage.info/python/oletools
  22 +"""
  23 +
  24 +# === LICENSE ==================================================================
  25 +
  26 +# mraptor_milter is copyright (c) 2016 Philippe Lagadec (http://www.decalage.info)
  27 +# All rights reserved.
  28 +#
  29 +# Redistribution and use in source and binary forms, with or without modification,
  30 +# are permitted provided that the following conditions are met:
  31 +#
  32 +# * Redistributions of source code must retain the above copyright notice, this
  33 +# list of conditions and the following disclaimer.
  34 +# * Redistributions in binary form must reproduce the above copyright notice,
  35 +# this list of conditions and the following disclaimer in the documentation
  36 +# and/or other materials provided with the distribution.
  37 +#
  38 +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
  39 +# ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
  40 +# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
  41 +# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
  42 +# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
  43 +# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
  44 +# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
  45 +# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
  46 +# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
  47 +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
  48 +
  49 +# --- CHANGELOG --------------------------------------------------------------
  50 +# 2016-08-08 v0.01 PL: - first version
  51 +
  52 +
  53 +# --- IMPORTS ----------------------------------------------------------------
  54 +
  55 +import Milter
  56 +import io
  57 +import time
  58 +import email
  59 +import sys
  60 +import os
  61 +import logging
  62 +
  63 +from socket import AF_INET6
  64 +
  65 +from oletools import olevba, mraptor
  66 +
  67 +from Milter.utils import parse_addr
  68 +
  69 +
  70 +# --- CONSTANTS --------------------------------------------------------------
  71 +
  72 +# TODO: read parameters from a config file
  73 +# at postfix smtpd_milters = inet:127.0.0.1:25252
  74 +SOCKET = "inet:25252@127.0.0.1" # bind to unix or tcp socket "inet:port@ip" or "/<path>/<to>/<something>.sock"
  75 +TIMEOUT = 30 # Milter timeout in seconds
  76 +# CFG_DIR = "/etc/macromilter/"
  77 +# LOG_DIR = "/var/log/macromilter/"
  78 +
  79 +
  80 +# === CLASSES ================================================================
  81 +
  82 +# Inspired from https://github.com/jmehnle/pymilter/blob/master/milter-template.py
  83 +
  84 +class MacroRaptorMilter(Milter.Base):
  85 + '''
  86 + '''
  87 + def __init__(self):
  88 + # A new instance with each new connection.
  89 + # each connection runs in its own thread and has its own myMilter
  90 + # instance. Python code must be thread safe. This is trivial if only stuff
  91 + # in myMilter instances is referenced.
  92 + self.id = Milter.uniqueID() # Integer incremented with each call.
  93 + self.message = None
  94 + self.IP = None
  95 + self.port = None
  96 + self.flow = None
  97 + self.scope = None
  98 + self.IPname = None # Name from a reverse IP lookup
  99 +
  100 + @Milter.noreply
  101 + def connect(self, IPname, family, hostaddr):
  102 + '''
  103 + New connection (may contain several messages)
  104 + :param IPname: Name from a reverse IP lookup
  105 + :param family: IP version 4 (AF_INET) or 6 (AF_INET6)
  106 + :param hostaddr: tuple (IP, port [, flow, scope])
  107 + :return: Milter.CONTINUE
  108 + '''
  109 + # Examples:
  110 + # (self, 'ip068.subnet71.example.com', AF_INET, ('215.183.71.68', 4720) )
  111 + # (self, 'ip6.mxout.example.com', AF_INET6,
  112 + # ('3ffe:80e8:d8::1', 4720, 1, 0) )
  113 + self.IP = hostaddr[0]
  114 + self.port = hostaddr[1]
  115 + if family == AF_INET6:
  116 + self.flow = hostaddr[2]
  117 + self.scope = hostaddr[3]
  118 + else:
  119 + self.flow = None
  120 + self.scope = None
  121 + self.IPname = IPname # Name from a reverse IP lookup
  122 + self.message = None # content
  123 + logging.info("[%d] connect from host %s at %s" % (self.id, IPname, hostaddr))
  124 + return Milter.CONTINUE
  125 +
  126 + @Milter.noreply
  127 + def envfrom(self, mailfrom, *rest):
  128 + '''
  129 + Mail From - Called at the beginning of each message within a connection
  130 + :param mailfrom:
  131 + :param str:
  132 + :return: Milter.CONTINUE
  133 + '''
  134 + self.message = io.BytesIO()
  135 + # NOTE: self.message is only an *internal* copy of message data. You
  136 + # must use addheader, chgheader, replacebody to change the message
  137 + # on the MTA.
  138 + self.canon_from = '@'.join(parse_addr(mailfrom))
  139 + self.message.write('From %s %s\n' % (self.canon_from, time.ctime()))
  140 + logging.debug('[%d] Mail From %s %s\n' % (self.id, self.canon_from, time.ctime()))
  141 + logging.debug('[%d] mailfrom=%r, rest=%r' % (self.id, mailfrom, rest))
  142 + return Milter.CONTINUE
  143 +
  144 + @Milter.noreply
  145 + def envrcpt(self, to, *rest):
  146 + '''
  147 + RCPT TO
  148 + :param to:
  149 + :param str:
  150 + :return: Milter.CONTINUE
  151 + '''
  152 + logging.debug('[%d] RCPT TO %r, rest=%r\n' % (self.id, to, rest))
  153 + return Milter.CONTINUE
  154 +
  155 + @Milter.noreply
  156 + def header(self, header_field, header_field_value):
  157 + '''
  158 + Add header
  159 + :param header_field:
  160 + :param header_field_value:
  161 + :return: Milter.CONTINUE
  162 + '''
  163 + self.message.write("%s: %s\n" % (header_field, header_field_value))
  164 + return Milter.CONTINUE
  165 +
  166 + @Milter.noreply
  167 + def eoh(self):
  168 + '''
  169 + End of headers
  170 + :return: Milter.CONTINUE
  171 + '''
  172 + self.message.write("\n")
  173 + return Milter.CONTINUE
  174 +
  175 + @Milter.noreply
  176 + def body(self, chunk):
  177 + '''
  178 + Message body (chunked)
  179 + :param chunk:
  180 + :return: Milter.CONTINUE
  181 + '''
  182 + self.message.write(chunk)
  183 + return Milter.CONTINUE
  184 +
  185 + def close(self):
  186 + return Milter.CONTINUE
  187 +
  188 + def abort(self):
  189 + '''
  190 + Clean up if the connection is closed by client
  191 + :return: Milter.CONTINUE
  192 + '''
  193 + return Milter.CONTINUE
  194 +
  195 + def eom(self):
  196 + '''
  197 + This method is called when the end of the email message has been reached.
  198 + This event also triggers the milter specific actions
  199 + :return: Milter.ACCEPT or Milter.DISCARD if processing error
  200 + '''
  201 + try:
  202 + # set data pointer back to 0
  203 + self.message.seek(0)
  204 + result = self.check_mraptor()
  205 + if result is not None:
  206 + return result
  207 + else:
  208 + return Milter.ACCEPT
  209 + # if error make a fall-back to accept
  210 + except Exception:
  211 + exc_type, exc_obj, exc_tb = sys.exc_info()
  212 + fname = os.path.split(exc_tb.tb_frame.f_code.co_filename)[1]
  213 + logging.exception("[%d] Unexpected error - fall back to ACCEPT: %s %s %s"
  214 + % (self.id, exc_type, fname, exc_tb.tb_lineno))
  215 + return Milter.ACCEPT
  216 +
  217 + def check_mraptor(self):
  218 + '''
  219 + Check the attachments of a message using mraptor.
  220 + If an attachment is identified as suspicious, it is replaced by a simple text file.
  221 + :return: Milter.ACCEPT or Milter.DISCARD if processing error
  222 + '''
  223 + msg = email.message_from_string(self.message.getvalue())
  224 + result = Milter.ACCEPT
  225 + try:
  226 + for part in msg.walk():
  227 + # for name, value in part.items():
  228 + # logging.debug(' - %s: %r' % (name, value))
  229 + content_type = part.get_content_type()
  230 + logging.debug('[%d] Content-type: %r' % (self.id, content_type))
  231 + # TODO: handle any content-type, but check the file magic?
  232 + if not content_type.startswith('multipart'):
  233 + filename = part.get_filename(None)
  234 + logging.debug('[%d] Analyzing attachment %r' % (self.id, filename))
  235 + attachment = part.get_payload(decode=True)
  236 + attachment_lowercase = attachment.lower()
  237 + # check if this is a supported file type (if not, just skip it)
  238 + # TODO: use is_zipfile instead of 'PK'
  239 + # TODO: this function should be provided by olevba
  240 + if attachment.startswith(olevba.olefile.MAGIC) \
  241 + or attachment.startswith('PK') \
  242 + or 'http://schemas.microsoft.com/office/word/2003/wordml' in attachment \
  243 + or ('mime' in attachment_lowercase and 'version' in attachment_lowercase
  244 + and 'multipart' in attachment_lowercase):
  245 + vba_parser = olevba.VBA_Parser(filename='message', data=attachment)
  246 + vba_code_all_modules = ''
  247 + for (subfilename, stream_path, vba_filename, vba_code) in vba_parser.extract_all_macros():
  248 + vba_code_all_modules += vba_code + '\n'
  249 + m = mraptor.MacroRaptor(vba_code_all_modules)
  250 + m.scan()
  251 + if m.suspicious:
  252 + logging.warning('[%d] The attachment %r contains a suspicious macro: replace it with a text file'
  253 + % (self.id, filename))
  254 + part.set_payload('This attachment has been removed because it contains a suspicious macro.')
  255 + part.set_type('text/plain')
  256 + # TODO: handle case when CTE is absent
  257 + part.replace_header('Content-Transfer-Encoding', '7bit')
  258 + # for name, value in part.items():
  259 + # logging.debug(' - %s: %r' % (name, value))
  260 + # TODO: archive filtered e-mail to a file
  261 + else:
  262 + logging.debug('The attachment %r is clean.'
  263 + % filename)
  264 + except Exception:
  265 + logging.exception('[%d] Error while processing the message' % self.id)
  266 + # TODO: depending on error, decide to forward the e-mail as-is or not
  267 + result = Milter.DISCARD
  268 + # TODO: only do this if the body has actually changed
  269 + body = str(msg)
  270 + self.message = io.BytesIO(body)
  271 + self.replacebody(body)
  272 + logging.info('[%d] Message relayed' % self.id)
  273 + return result
  274 +
  275 +
  276 +# === MAIN ===================================================================
  277 +
  278 +def main():
  279 + # TODO: log to file with rotation
  280 + logging.basicConfig(format='%(levelname)8s: %(message)s', level=logging.DEBUG)
  281 +
  282 + logging.info('Starting MacroRaptorMilter - listening on %s' % SOCKET)
  283 + logging.info('Press Ctrl+C to stop.')
  284 +
  285 + # Register to have the Milter factory create instances of the class:
  286 + Milter.factory = MacroRaptorMilter
  287 + flags = Milter.CHGBODY + Milter.CHGHDRS + Milter.ADDHDRS
  288 + flags += Milter.ADDRCPT
  289 + flags += Milter.DELRCPT
  290 + Milter.set_flags(flags) # tell Sendmail which features we use
  291 + # set the "last" fall back to ACCEPT if exception occur
  292 + Milter.set_exception_policy(Milter.ACCEPT)
  293 + # start the milter
  294 + Milter.runmilter("MacroRaptorMilter", SOCKET, TIMEOUT)
  295 + logging.info('Stopping MacroRaptorMilter.')
  296 +
  297 +if __name__ == "__main__":
  298 + main()
... ...