|
| 1 | +#!/usr/bin/env python3 |
| 2 | +# |
| 3 | +# Description: |
| 4 | +# Implementation of ldap_shell.py, an interactive ldap client. |
| 5 | +# |
| 6 | +# Author: |
| 7 | +# Andreas Vikerup (@vikerup) |
| 8 | +# |
| 9 | + |
| 10 | +import argparse |
| 11 | +import atexit |
| 12 | +import logging |
| 13 | +import sys |
| 14 | +from getpass import getpass |
| 15 | +from pathlib import Path |
| 16 | + |
| 17 | +from impacket import version |
| 18 | +from impacket.examples import logger |
| 19 | +from impacket.examples.ldap_shell import LdapShell |
| 20 | +from impacket.examples.utils import EMPTY_LM_HASH, init_ldap_session, parse_target |
| 21 | +import ldapdomaindump |
| 22 | +from ldapdomaindump import reportWriter as _ReportWriter |
| 23 | + |
| 24 | + |
| 25 | +class FakeShell: |
| 26 | + def __init__(self): |
| 27 | + self.stdin = sys.stdin |
| 28 | + self.stdout = sys.stdout |
| 29 | + self._readline = None |
| 30 | + self._history_file = None |
| 31 | + self._init_line_editing() |
| 32 | + |
| 33 | + def _init_line_editing(self): |
| 34 | + if not self.stdin.isatty(): |
| 35 | + return |
| 36 | + |
| 37 | + try: |
| 38 | + import readline |
| 39 | + except ImportError: |
| 40 | + return |
| 41 | + |
| 42 | + self._readline = readline |
| 43 | + history_path = Path.home() / '.impacket_ldap_shell_history' |
| 44 | + |
| 45 | + try: |
| 46 | + readline.read_history_file(str(history_path)) |
| 47 | + except (FileNotFoundError, OSError): |
| 48 | + pass |
| 49 | + |
| 50 | + readline.parse_and_bind('set editing-mode emacs') |
| 51 | + readline.parse_and_bind('set enable-meta-key on') |
| 52 | + readline.parse_and_bind('tab: complete') |
| 53 | + |
| 54 | + self._history_file = history_path |
| 55 | + atexit.register(self._persist_history) |
| 56 | + |
| 57 | + def _persist_history(self): |
| 58 | + if self._readline is None or self._history_file is None: |
| 59 | + return |
| 60 | + try: |
| 61 | + self._readline.write_history_file(str(self._history_file)) |
| 62 | + except OSError: |
| 63 | + pass |
| 64 | + |
| 65 | + def close(self): |
| 66 | + self._persist_history() |
| 67 | + |
| 68 | + |
| 69 | +def _ensure_safe_report_writer(): |
| 70 | + if getattr(_ReportWriter, '_impacket_safe_patch', False): |
| 71 | + return |
| 72 | + |
| 73 | + def safe_format_string(self, value): |
| 74 | + from datetime import datetime |
| 75 | + |
| 76 | + if isinstance(value, datetime): |
| 77 | + try: |
| 78 | + return value.strftime('%x %X') |
| 79 | + except ValueError: |
| 80 | + return '0' |
| 81 | + if isinstance(value, (bytes, bytearray)): |
| 82 | + return value.decode('utf-8', errors='ignore') |
| 83 | + if isinstance(value, str): |
| 84 | + return value |
| 85 | + if isinstance(value, int): |
| 86 | + return str(value) |
| 87 | + if value is None: |
| 88 | + return '' |
| 89 | + return str(value) |
| 90 | + |
| 91 | + def safe_html_escape(self, html): |
| 92 | + if isinstance(html, (bytes, bytearray)): |
| 93 | + html = html.decode('utf-8', errors='ignore') |
| 94 | + elif not isinstance(html, str): |
| 95 | + html = str(html) |
| 96 | + return (html.replace("&", "&") |
| 97 | + .replace("<", "<") |
| 98 | + .replace(">", ">") |
| 99 | + .replace("'", "'") |
| 100 | + .replace('"', """)) |
| 101 | + |
| 102 | + _ReportWriter.formatString = safe_format_string |
| 103 | + _ReportWriter.htmlescape = safe_html_escape |
| 104 | + _ReportWriter._impacket_safe_patch = True |
| 105 | + |
| 106 | + |
| 107 | +class DomainDumper: |
| 108 | + def __init__(self, ldap_server, ldap_session, base_path, root): |
| 109 | + _ensure_safe_report_writer() |
| 110 | + config = ldapdomaindump.domainDumpConfig() |
| 111 | + if base_path is not None: |
| 112 | + config.basepath = base_path |
| 113 | + self._dumper = ldapdomaindump.domainDumper(ldap_server, ldap_session, config, root) |
| 114 | + |
| 115 | + def domainDump(self): |
| 116 | + self._dumper.domainDump() |
| 117 | + |
| 118 | + @property |
| 119 | + def root(self): |
| 120 | + return self._dumper.root |
| 121 | + |
| 122 | + @root.setter |
| 123 | + def root(self, value): |
| 124 | + self._dumper.root = value |
| 125 | + |
| 126 | + |
| 127 | +def main(): |
| 128 | + print(version.BANNER) |
| 129 | + |
| 130 | + parser = argparse.ArgumentParser(add_help=True, description='Interactive LDAP shell using impacket\'s helpers') |
| 131 | + parser.add_argument('target', action='store', help='[[domain/]username[:password]@]<target>') |
| 132 | + parser.add_argument('-debug', action='store_true', help='Turn DEBUG output ON') |
| 133 | + parser.add_argument('-ts', action='store_true', help='Adds timestamp to every logging output') |
| 134 | + |
| 135 | + auth_group = parser.add_argument_group('authentication') |
| 136 | + auth_group.add_argument('-hashes', action='store', metavar='LMHASH:NTHASH', help='NTLM hashes, format is LMHASH:NTHASH') |
| 137 | + auth_group.add_argument('-no-pass', action='store_true', help="don't ask for password (useful for -k)") |
| 138 | + auth_group.add_argument('-k', action='store_true', help='Use Kerberos authentication. Grabs credentials from ccache file ' |
| 139 | + '(KRB5CCNAME) based on target parameters. If valid credentials ' |
| 140 | + 'cannot be found, it will use the ones specified in the command ' |
| 141 | + 'line') |
| 142 | + auth_group.add_argument('-aesKey', action='store', metavar='hex key', help='AES key to use for Kerberos Authentication ' |
| 143 | + '(128 or 256 bits)') |
| 144 | + |
| 145 | + conn_group = parser.add_argument_group('connection') |
| 146 | + conn_group.add_argument('-dc-ip', action='store', metavar='ip address', |
| 147 | + help='IP Address or hostname of the domain controller (KDC) for Kerberos. If omitted it will ' |
| 148 | + 'use the target portion of the connection string') |
| 149 | + conn_group.add_argument('-ldaps', action='store_true', help='Use LDAPS instead of LDAP') |
| 150 | + conn_group.add_argument('-dump-dir', action='store', metavar='path', default='.', |
| 151 | + help='Directory where domain dump files will be stored (default: current directory)') |
| 152 | + |
| 153 | + if len(sys.argv) == 1: |
| 154 | + parser.print_help() |
| 155 | + sys.exit(1) |
| 156 | + |
| 157 | + options = parser.parse_args() |
| 158 | + |
| 159 | + logger.init(options.ts, options.debug) |
| 160 | + |
| 161 | + domain, username, password, address = parse_target(options.target) |
| 162 | + |
| 163 | + if domain is None: |
| 164 | + domain = '' |
| 165 | + if username is None: |
| 166 | + username = '' |
| 167 | + if password is None: |
| 168 | + password = '' |
| 169 | + |
| 170 | + dc_ip = options.dc_ip |
| 171 | + dc_host = None |
| 172 | + if options.k: |
| 173 | + if dc_ip is None and address is not None: |
| 174 | + dc_host = address |
| 175 | + else: |
| 176 | + if dc_ip is None and address is not None: |
| 177 | + dc_ip = address |
| 178 | + |
| 179 | + if password == '' and username != '' and options.hashes is None and options.no_pass is False and options.aesKey is None: |
| 180 | + password = getpass('Password:') |
| 181 | + |
| 182 | + if options.aesKey is not None: |
| 183 | + options.k = True |
| 184 | + |
| 185 | + if options.no_pass: |
| 186 | + password = '' |
| 187 | + |
| 188 | + if options.hashes is not None: |
| 189 | + try: |
| 190 | + lmhash, nthash = options.hashes.split(':') |
| 191 | + except ValueError: |
| 192 | + logging.error('Hashes must be supplied in LMHASH:NTHASH format') |
| 193 | + sys.exit(1) |
| 194 | + if lmhash == '': |
| 195 | + lmhash = EMPTY_LM_HASH |
| 196 | + else: |
| 197 | + lmhash = '' |
| 198 | + nthash = '' |
| 199 | + |
| 200 | + console = None |
| 201 | + try: |
| 202 | + ldap_server, ldap_session = init_ldap_session(domain, username, password, lmhash, nthash, options.k, |
| 203 | + dc_ip, dc_host, options.aesKey, options.ldaps) |
| 204 | + server_info = ldap_session.server.info if ldap_session else None |
| 205 | + root_dn = None |
| 206 | + if server_info is not None: |
| 207 | + other = server_info.other or {} |
| 208 | + default_nc = other.get('defaultNamingContext') |
| 209 | + if default_nc: |
| 210 | + root_dn = default_nc[0] |
| 211 | + |
| 212 | + if root_dn is None: |
| 213 | + logging.error('Could not determine defaultNamingContext from the LDAP server') |
| 214 | + sys.exit(1) |
| 215 | + |
| 216 | + console = FakeShell() |
| 217 | + domain_dumper = DomainDumper(ldap_server, ldap_session, options.dump_dir, root_dn) |
| 218 | + shell = LdapShell(console, domain_dumper, ldap_session) |
| 219 | + shell.use_rawinput = True |
| 220 | + shell.cmdloop() |
| 221 | + except Exception as e: |
| 222 | + if logging.getLogger().level == logging.DEBUG: |
| 223 | + import traceback |
| 224 | + traceback.print_exc() |
| 225 | + logging.error(str(e)) |
| 226 | + finally: |
| 227 | + if console is not None: |
| 228 | + console.close() |
| 229 | + |
| 230 | +if __name__ == "__main__": |
| 231 | + main() |
0 commit comments