Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Is encrypted by msoffcrypto #441

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
5dfba51
tests: Add another sample
christian-intra2net Apr 25, 2019
0dfa259
crypto: declare specialiced is_encrypted private
christian-intra2net Apr 25, 2019
4e1f626
crypto: use msoffcrypto's is_encrypted if possible
christian-intra2net Apr 25, 2019
5988d79
crypto: Do not throw from is_encrypted
christian-intra2net Apr 26, 2019
7db8aef
crypto: Correct whitespace, remove unnecessary code
christian-intra2net Apr 26, 2019
d7ca775
crypto: Make debug log a little less minimalistic
christian-intra2net Apr 29, 2019
14e6876
tests: tell oleid test what to expect for new samples
christian-intra2net Apr 26, 2019
f537ec1
tests: Check behaviour of olevba for rtf, text, empty
christian-intra2net Apr 29, 2019
9b05546
tests: minor pylint-inspired changes
christian-intra2net Apr 29, 2019
06c591a
tests: Move constants into proper module
christian-intra2net Apr 29, 2019
b22b36c
tests: Move code to "run and capture" to utils
christian-intra2net Apr 29, 2019
0bc6728
test: Use call_and_capture in olevba tests
christian-intra2net Apr 29, 2019
faa0d80
crypto: Add more debug output
christian-intra2net May 2, 2019
d24210b
tests: Clarify whether to include stderr or not
christian-intra2net May 6, 2019
9546865
tests: Do not assume we are running CPython
christian-intra2net May 6, 2019
246ade4
msodde: Raise proper error when decrypt fails
christian-intra2net May 6, 2019
0426f6e
olevba: Hint at debug log if decrypt fails
christian-intra2net May 6, 2019
c7a708d
tests: Add test for %-autoformatting of log messages
christian-intra2net May 7, 2019
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 55 additions & 12 deletions oletools/crypto.py
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,7 @@ def enable_logging():
"""
log.setLevel(logging.NOTSET)


def is_encrypted(some_file):
"""
Determine whether document contains encrypted content.
Expand Down Expand Up @@ -197,17 +198,55 @@ def is_encrypted(some_file):
:returns: True if (and only if) the file contains encrypted content
"""
log.debug('is_encrypted')
if isinstance(some_file, OleFileIO):
return is_encrypted_ole(some_file) # assume it is OleFileIO
if zipfile.is_zipfile(some_file):
return is_encrypted_zip(some_file)
# otherwise assume it is the name of an ole file
return is_encrypted_ole(OleFileIO(some_file))

# ask msoffcrypto if possible
if check_msoffcrypto():
log.debug('Checking for encryption using msoffcrypto')
file_handle = None
file_pos = None
try:
if isinstance(some_file, OleFileIO):
# TODO: hacky, replace once msoffcrypto-tools accepts OleFileIO
file_handle = some_file.fp
file_pos = file_handle.tell()
file_handle.seek(0)
else:
file_handle = open(some_file, 'rb')

return msoffcrypto.OfficeFile(file_handle).is_encrypted()

except Exception as exc:
log.warning('msoffcrypto failed to interpret file {} or determine '
'whether it is encrypted: {}'
.format(file_handle.name, exc))

finally:
try:
if file_pos is not None: # input was OleFileIO
file_handle.seek(file_pos)
else: # input was file name
file_handle.close()
except Exception as exc:
log.warning('Ignoring error during clean up: {}'.format(exc))

# if that failed, try ourselves with older and less accurate code
try:
if isinstance(some_file, OleFileIO):
return _is_encrypted_ole(some_file)
if zipfile.is_zipfile(some_file):
return _is_encrypted_zip(some_file)
# otherwise assume it is the name of an ole file
return _is_encrypted_ole(OleFileIO(some_file))
except Exception as exc:
log.warning('Failed to check {} for encryption ({}); assume it is not '
'encrypted.'.format(some_file, exc))

def is_encrypted_zip(filename):
return False


def _is_encrypted_zip(filename):
"""Specialization of :py:func:`is_encrypted` for zip-based files."""
log.debug('is_encrypted_zip')
log.debug('Checking for encryption in zip file')
# TODO: distinguish OpenXML from normal zip files
# try to decrypt a few bytes from first entry
with zipfile.ZipFile(filename, 'r') as zipper:
Expand All @@ -220,9 +259,9 @@ def is_encrypted_zip(filename):
return 'crypt' in str(rt_err)


def is_encrypted_ole(ole):
def _is_encrypted_ole(ole):
"""Specialization of :py:func:`is_encrypted` for ole files."""
log.debug('is_encrypted_ole')
log.debug('Checking for encryption in OLE file')
# check well known property for password protection
# (this field may be missing for Powerpoint2000, for example)
# TODO: check whether password protection always implies encryption. Could
Expand Down Expand Up @@ -256,8 +295,6 @@ def is_encrypted_ole(ole):
f_encrypted = (temp16 & 0x0100) >> 8
if f_encrypted:
return True
except Exception:
raise
finally:
if stream is not None:
stream.close()
Expand Down Expand Up @@ -324,6 +361,8 @@ def decrypt(filename, passwords=None, **temp_file_args):
crypto_file = msoffcrypto.OfficeFile(reader)
except Exception as exc: # e.g. ppt, not yet supported by msoffcrypto
if 'Unrecognized file format' in str(exc):
log.debug('Caught exception', exc_info=True)

# raise different exception without stack trace of original exc
if sys.version_info.major == 2:
raise UnsupportedEncryptionError(filename)
Expand All @@ -337,6 +376,7 @@ def decrypt(filename, passwords=None, **temp_file_args):
.format(filename))

for password in passwords:
log.debug('Trying to decrypt with password {!r}'.format(password))
write_descriptor = None
write_handle = None
decrypt_file = None
Expand All @@ -354,6 +394,8 @@ def decrypt(filename, passwords=None, **temp_file_args):
write_handle = None
break
except Exception:
log.debug('Failed to decrypt', exc_info=True)

# error-clean up: close everything and del temp file
if write_handle:
write_handle.close()
Expand All @@ -363,4 +405,5 @@ def decrypt(filename, passwords=None, **temp_file_args):
os.unlink(decrypt_file)
decrypt_file = None
# if we reach this, all passwords were tried without success
log.debug('All passwords failed')
return decrypt_file
3 changes: 3 additions & 0 deletions oletools/msodde.py
Original file line number Diff line number Diff line change
Expand Up @@ -987,6 +987,9 @@ def process_maybe_encrypted(filepath, passwords=None, crypto_nesting=0,
try:
logger.debug('Trying to decrypt file')
decrypted_file = crypto.decrypt(filepath, passwords)
if not decrypted_file:
logger.error('Decrypt failed, run with debug output to get details')
raise crypto.WrongEncryptionPassword(filepath)
logger.info('Analyze decrypted file')
result = process_maybe_encrypted(decrypted_file, passwords,
crypto_nesting+1, **kwargs)
Expand Down
1 change: 1 addition & 0 deletions oletools/olevba.py
Original file line number Diff line number Diff line change
Expand Up @@ -3893,6 +3893,7 @@ def process_file(filename, data, container, options, crypto_nesting=0):
[crypto.WRITE_PROTECT_ENCRYPTION_PASSWORD, ]
decrypted_file = crypto.decrypt(filename, passwords)
if not decrypted_file:
log.error('Decrypt failed, run with debug output to get details')
raise crypto.WrongEncryptionPassword(filename)
log.info('Working on decrypted file')
return process_file(decrypted_file, data, container or filename,
Expand Down
4 changes: 4 additions & 0 deletions tests/common/log_helper/log_helper_test_main.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,12 +34,16 @@ def init_logging_and_log(args):
level = args[-1]
use_json = 'as-json' in args
throw = 'throw' in args
percent_autoformat = '%-autoformat' in args

if 'enable' in args:
log_helper.enable_logging(use_json, level, stream=sys.stdout)

_log()

if percent_autoformat:
logger.info('The %s is %d.', 'answer', 47)

if throw:
raise Exception('An exception occurred before ending the logging')

Expand Down
5 changes: 5 additions & 0 deletions tests/common/log_helper/test_log_helper.py
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,11 @@ def test_logs_type_in_json(self):
]
self.assertEqual(jout, jexpect)

def test_percent_autoformat(self):
"""Test that auto-formatting of log strings with `%` works."""
output = self._run_test(['enable', '%-autoformat', 'info'])
self.assertIn('The answer is 47.', output)

def test_json_correct_on_exceptions(self):
"""
Test that even on unhandled exceptions our JSON is always correct
Expand Down
6 changes: 3 additions & 3 deletions tests/msodde/test_crypto.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

import sys
import unittest
from os.path import join as pjoin
from os.path import basename, join as pjoin

from tests.test_utils import DATA_BASE_DIR

Expand All @@ -11,8 +11,8 @@


@unittest.skipIf(not crypto.check_msoffcrypto(),
'Module msoffcrypto not installed for python{}.{}'
.format(sys.version_info.major, sys.version_info.minor))
'Module msoffcrypto not installed for {}'
.format(basename(sys.executable)))
class MsoddeCryptoTest(unittest.TestCase):
"""Test integration of decryption in msodde."""
def test_standard_password(self):
Expand Down
32 changes: 30 additions & 2 deletions tests/oleid/test_basic.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ def test_all(self):
"""Run all file in test-data through oleid and compare to known ouput"""
# this relies on order of indicators being constant, could relax that
# Also requires that files have the correct suffixes (no rtf in doc)
NON_OLE_SUFFIXES = ('.xml', '.csv', '.rtf', '')
NON_OLE_SUFFIXES = ('.xml', '.csv', '.rtf', '', '.odt', '.ods', '.odp')
NON_OLE_VALUES = (False, )
WORD = b'Microsoft Office Word'
PPT = b'Microsoft Office PowerPoint'
Expand Down Expand Up @@ -121,6 +121,33 @@ def test_all(self):
'msodde/harmless-clean.docx': (False,),
'oleform/oleform-PR314.docm': (False,),
'basic/encrypted.docx': CRYPT,
'oleobj/external_link/sample_with_external_link_to_doc.docx': (False,),
'oleobj/external_link/sample_with_external_link_to_doc.xlsb': (False,),
'oleobj/external_link/sample_with_external_link_to_doc.dotm': (False,),
'oleobj/external_link/sample_with_external_link_to_doc.xlsm': (False,),
'oleobj/external_link/sample_with_external_link_to_doc.pptx': (False,),
'oleobj/external_link/sample_with_external_link_to_doc.dotx': (False,),
'oleobj/external_link/sample_with_external_link_to_doc.docm': (False,),
'oleobj/external_link/sample_with_external_link_to_doc.potm': (False,),
'oleobj/external_link/sample_with_external_link_to_doc.xlsx': (False,),
'oleobj/external_link/sample_with_external_link_to_doc.potx': (False,),
'oleobj/external_link/sample_with_external_link_to_doc.ppsm': (False,),
'oleobj/external_link/sample_with_external_link_to_doc.pptm': (False,),
'oleobj/external_link/sample_with_external_link_to_doc.ppsx': (False,),
'encrypted/autostart-encrypt-standardpassword.xlsm':
(True, False, 'unknown', True, False, False, False, False, False, False, 0),
'encrypted/autostart-encrypt-standardpassword.xls':
(True, True, EXCEL, True, False, True, True, False, False, False, 0),
'encrypted/dde-test-encrypt-standardpassword.xlsx':
(True, False, 'unknown', True, False, False, False, False, False, False, 0),
'encrypted/dde-test-encrypt-standardpassword.xlsm':
(True, False, 'unknown', True, False, False, False, False, False, False, 0),
'encrypted/autostart-encrypt-standardpassword.xlsb':
(True, False, 'unknown', True, False, False, False, False, False, False, 0),
'encrypted/dde-test-encrypt-standardpassword.xls':
(True, True, EXCEL, True, False, False, True, False, False, False, 0),
'encrypted/dde-test-encrypt-standardpassword.xlsb':
(True, False, 'unknown', True, False, False, False, False, False, False, 0),
}

indicator_names = []
Expand Down Expand Up @@ -148,7 +175,8 @@ def test_all(self):
OLE_VALUES[name]))
except KeyError:
print('Should add oleid output for {} to {} ({})'
.format(name, __name__, values[3:]))
.format(name, __name__, values))


# just in case somebody calls this file as a script
if __name__ == '__main__':
Expand Down
84 changes: 67 additions & 17 deletions tests/olevba/test_basic.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,21 +3,71 @@
"""

import unittest
import sys
if sys.version_info.major <= 2:
from oletools import olevba
else:
from oletools import olevba3 as olevba
import os
from os.path import join
import re

# Directory with test data, independent of current working directory
from tests.test_utils import DATA_BASE_DIR
from tests.test_utils import DATA_BASE_DIR, call_and_capture


class TestOlevbaBasic(unittest.TestCase):
"""Tests olevba basic functionality"""

def test_text_behaviour(self):
"""Test behaviour of olevba when presented with pure text file."""
self.do_test_behaviour('text')

def test_empty_behaviour(self):
"""Test behaviour of olevba when presented with pure text file."""
self.do_test_behaviour('empty')

def do_test_behaviour(self, filename):
"""Helper for test_{text,empty}_behaviour."""
input_file = join(DATA_BASE_DIR, 'basic', filename)
output, _ = call_and_capture('olevba', args=(input_file, ))

# check output
self.assertTrue(re.search(r'^Type:\s+Text\s*$', output, re.MULTILINE),
msg='"Type: Text" not found in output:\n' + output)
self.assertTrue(re.search(r'^No suspicious .+ found.$', output,
re.MULTILINE),
msg='"No suspicous...found" not found in output:\n' + \
output)
self.assertNotIn('error', output.lower())

# check warnings
for line in output.splitlines():
if line.startswith('WARNING ') and 'encrypted' in line:
continue # encryption warnings are ok
elif 'warn' in line.lower():
raise self.fail('Found "warn" in output line: "{}"'
.format(line.rstrip()))
self.assertIn('not encrypted', output)

def test_rtf_behaviour(self):
"""Test behaviour of olevba when presented with an rtf file."""
input_file = join(DATA_BASE_DIR, 'msodde', 'RTF-Spec-1.7.rtf')
output, ret_code = call_and_capture('olevba', args=(input_file, ),
accept_nonzero_exit=True)

# check that return code is olevba.RETURN_OPEN_ERROR
self.assertEqual(ret_code, 5)

# check output:
self.assertIn('FileOpenError', output)
self.assertIn('is RTF', output)
self.assertIn('rtfobj.py', output)
self.assertIn('not encrypted', output)

# check warnings
for line in output.splitlines():
if line.startswith('WARNING ') and 'encrypted' in line:
continue # encryption warnings are ok
elif 'warn' in line.lower():
raise self.fail('Found "warn" in output line: "{}"'
.format(line.rstrip()))

def test_crypt_return(self):
"""
Tests that encrypted files give a certain return code.
Expand All @@ -28,23 +78,23 @@ def test_crypt_return(self):
CRYPT_DIR = join(DATA_BASE_DIR, 'encrypted')
CRYPT_RETURN_CODE = 9
ADD_ARGS = [], ['-d', ], ['-a', ], ['-j', ], ['-t', ]
EXCEPTIONS = ['autostart-encrypt-standardpassword.xlsm', # These ...
'autostart-encrypt-standardpassword.xlsb', # files ...
'dde-test-encrypt-standardpassword.xls', # are ...
'dde-test-encrypt-standardpassword.xlsx', # decrypted
'dde-test-encrypt-standardpassword.xlsm', # per ...
'dde-test-encrypt-standardpassword.xlsb'] # default.
EXCEPTIONS = ['autostart-encrypt-standardpassword.xls', # These ...
'autostart-encrypt-standardpassword.xlsm', # files ...
'autostart-encrypt-standardpassword.xlsb', # are ...
'dde-test-encrypt-standardpassword.xls', # automati...
'dde-test-encrypt-standardpassword.xlsx', # ...cally...
'dde-test-encrypt-standardpassword.xlsm', # decrypted.
'dde-test-encrypt-standardpassword.xlsb']
for filename in os.listdir(CRYPT_DIR):
if filename in EXCEPTIONS:
continue
full_name = join(CRYPT_DIR, filename)
for args in ADD_ARGS:
try:
ret_code = olevba.main(args + [full_name, ])
except SystemExit as se:
ret_code = se.code or 0 # se.code can be None
_, ret_code = call_and_capture('olevba',
args=[full_name, ] + args,
accept_nonzero_exit=True)
self.assertEqual(ret_code, CRYPT_RETURN_CODE,
msg='Wrong return code {} for args {}'
msg='Wrong return code {} for args {}'\
.format(ret_code, args + [filename, ]))


Expand Down
Loading