mirror of
https://github.com/fortra/impacket
synced 2026-06-08 14:15:13 +00:00
756ca96e4a
* Added Support for LDAPS When Using Windows Server 2025 * set ldaps_flag as optional in ldap_login() --------- Co-authored-by: Gabriel Gonzalez <gabriel.gonzalez@fortra.com>
302 lines
15 KiB
Python
Executable File
302 lines
15 KiB
Python
Executable File
#!/usr/bin/env python
|
|
# Impacket - Collection of Python classes for working with network protocols.
|
|
#
|
|
# Copyright Fortra, LLC and its affiliated companies
|
|
#
|
|
# All rights reserved.
|
|
#
|
|
# This software is provided under a slightly modified version
|
|
# of the Apache Software License. See the accompanying LICENSE file
|
|
# for more information.
|
|
#
|
|
# Description:
|
|
# This script will gather data about the domain's computers and their LAPS/LAPSv2 passwords.
|
|
# Initial formatting for this tool came from the GetADUsers.py example script.
|
|
#
|
|
# Author(s):
|
|
# Thomas Seigneuret (@zblurx)
|
|
# Tyler Booth (@dru1d-foofus)
|
|
#
|
|
# Reference for:
|
|
# LDAP
|
|
#
|
|
|
|
from __future__ import division
|
|
from __future__ import print_function
|
|
from __future__ import unicode_literals
|
|
from datetime import datetime
|
|
from impacket import version
|
|
from impacket.dcerpc.v5 import transport
|
|
from impacket.dcerpc.v5.epm import hept_map
|
|
from impacket.dcerpc.v5.gkdi import MSRPC_UUID_GKDI, GkdiGetKey, GroupKeyEnvelope
|
|
from impacket.dcerpc.v5.rpcrt import RPC_C_AUTHN_LEVEL_PKT_INTEGRITY, RPC_C_AUTHN_LEVEL_PKT_PRIVACY
|
|
from impacket.dpapi_ng import EncryptedPasswordBlob, KeyIdentifier, compute_kek, create_sd, decrypt_plaintext, unwrap_cek
|
|
from impacket.examples import logger
|
|
from impacket.examples.utils import parse_identity, ldap_login
|
|
from impacket.ldap import ldap, ldapasn1
|
|
from pyasn1.codec.der import decoder
|
|
from pyasn1_modules import rfc5652
|
|
import argparse
|
|
import json
|
|
import logging
|
|
import sys
|
|
|
|
class GetLAPSPassword:
|
|
@staticmethod
|
|
def printTable(items, header, outputfile):
|
|
colLen = []
|
|
for i, col in enumerate(header):
|
|
rowMaxLen = max([len(row[i]) for row in items])
|
|
colLen.append(max(rowMaxLen, len(col)))
|
|
|
|
outputFormat = ' '.join(['{%d:%ds} ' % (num, width) for num, width in enumerate(colLen)])
|
|
|
|
# Print header
|
|
print(outputFormat.format(*header))
|
|
print(' '.join(['-' * itemLen for itemLen in colLen]))
|
|
for row in items:
|
|
print(outputFormat.format(*row))
|
|
|
|
if outputfile:
|
|
with open(outputfile, 'w') as file:
|
|
outputFormat_file = '\t'.join(['{%d:%ds}' % (num, width) for num, width in enumerate(colLen)]) # Added tab delimited output for files
|
|
file.write(outputFormat_file.format(*header) + "\n")
|
|
for row in items:
|
|
file.write((outputFormat_file.format(*row)).strip() + "\n") # Removed extraneous field to clean up output saved to a file
|
|
|
|
def __init__(self, username, password, domain, cmdLineOptions):
|
|
self.options = cmdLineOptions
|
|
self.__username = username
|
|
self.__password = password
|
|
self.__domain = domain
|
|
self.__target = None
|
|
self.__lmhash = ''
|
|
self.__nthash = ''
|
|
self.__aesKey = cmdLineOptions.aesKey
|
|
self.__doKerberos = cmdLineOptions.k
|
|
self.__kdcIP = cmdLineOptions.dc_ip
|
|
self.__kdcHost = cmdLineOptions.dc_host
|
|
self.__targetComputer = cmdLineOptions.computer
|
|
self.__outputFile = cmdLineOptions.outputfile
|
|
self.__ldaps_flag = cmdLineOptions.ldaps_flag
|
|
self.__KDSCache = {}
|
|
|
|
if cmdLineOptions.hashes is not None:
|
|
self.__lmhash, self.__nthash = cmdLineOptions.hashes.split(':')
|
|
|
|
# Create the baseDN
|
|
domainParts = self.__domain.split('.')
|
|
self.baseDN = ''
|
|
for i in domainParts:
|
|
self.baseDN += 'dc=%s,' % i
|
|
# Remove last ','
|
|
self.baseDN = self.baseDN[:-1]
|
|
|
|
def getLAPSv2Decrypt(self, rawEncryptedLAPSBlob):
|
|
try:
|
|
encryptedLAPSBlob = EncryptedPasswordBlob(rawEncryptedLAPSBlob)
|
|
parsed_cms_data, remaining = decoder.decode(encryptedLAPSBlob['Blob'], asn1Spec=rfc5652.ContentInfo())
|
|
enveloped_data_blob = parsed_cms_data['content']
|
|
parsed_enveloped_data, _ = decoder.decode(enveloped_data_blob, asn1Spec=rfc5652.EnvelopedData())
|
|
recipient_infos = parsed_enveloped_data['recipientInfos']
|
|
kek_recipient_info = recipient_infos[0]['kekri']
|
|
kek_identifier = kek_recipient_info['kekid']
|
|
key_id = KeyIdentifier(bytes(kek_identifier['keyIdentifier']))
|
|
tmp,_ = decoder.decode(kek_identifier['other']['keyAttr'])
|
|
sid = tmp['field-1'][0][0][1].asOctets().decode("utf-8")
|
|
target_sd = create_sd(sid)
|
|
laps_enabled = True
|
|
except Exception as e:
|
|
logging.error('Cannot unpack msLAPS-EncryptedPassword blob due to error %s' % str(e))
|
|
# Check if item is in cache
|
|
if key_id['RootKeyId'] in self.__KDSCache:
|
|
gke = self.__KDSCache[key_id['RootKeyId']]
|
|
else:
|
|
# Connect on RPC over TCP to MS-GKDI to call opnum 0 GetKey
|
|
stringBinding = hept_map(destHost=self.__target, remoteIf=MSRPC_UUID_GKDI, protocol = 'ncacn_ip_tcp')
|
|
rpctransport = transport.DCERPCTransportFactory(stringBinding)
|
|
if hasattr(rpctransport, 'set_credentials'):
|
|
rpctransport.set_credentials(username=self.__username, password=self.__password, domain=self.__domain, lmhash=self.__lmhash, nthash=self.__nthash)
|
|
if self.__doKerberos:
|
|
rpctransport.set_kerberos(self.__doKerberos, kdcHost=self.__target)
|
|
if self.__kdcIP is not None:
|
|
rpctransport.setRemoteHost(self.__kdcIP)
|
|
rpctransport.setRemoteName(self.__target)
|
|
|
|
dce = rpctransport.get_dce_rpc()
|
|
dce.set_auth_level(RPC_C_AUTHN_LEVEL_PKT_INTEGRITY)
|
|
dce.set_auth_level(RPC_C_AUTHN_LEVEL_PKT_PRIVACY)
|
|
logging.debug("Connecting to %s" % stringBinding)
|
|
try:
|
|
dce.connect()
|
|
except Exception as e:
|
|
logging.error("Something went wrong, check error status => %s" % str(e))
|
|
return laps_enabled
|
|
logging.debug("Connected")
|
|
try:
|
|
dce.bind(MSRPC_UUID_GKDI)
|
|
except Exception as e:
|
|
logging.error("Something went wrong, check error status => %s" % str(e))
|
|
return laps_enabled
|
|
logging.debug("Successfully bound")
|
|
|
|
|
|
logging.debug("Calling MS-GKDI GetKey")
|
|
resp = GkdiGetKey(dce, target_sd=target_sd, l0=key_id['L0Index'], l1=key_id['L1Index'], l2=key_id['L2Index'], root_key_id=key_id['RootKeyId'])
|
|
# Unpack GroupKeyEnvelope
|
|
gke = GroupKeyEnvelope(b''.join(resp['pbbOut']))
|
|
self.__KDSCache[gke['RootKeyId']] = gke
|
|
|
|
kek = compute_kek(gke, key_id)
|
|
enc_content_parameter = bytes(parsed_enveloped_data["encryptedContentInfo"]["contentEncryptionAlgorithm"]["parameters"])
|
|
iv, _ = decoder.decode(enc_content_parameter)
|
|
iv = bytes(iv[0])
|
|
|
|
cek = unwrap_cek(kek, bytes(kek_recipient_info['encryptedKey']))
|
|
return decrypt_plaintext(cek, iv, remaining)
|
|
|
|
@staticmethod
|
|
def getUnixTime(t):
|
|
t -= 116444736000000000
|
|
t /= 10000000
|
|
return t
|
|
|
|
def run(self):
|
|
# Connect to LDAP
|
|
ldapConnection = ldap_login(self.__target, self.baseDN, self.__kdcIP, self.__kdcHost, self.__doKerberos, self.__username, self.__password, self.__domain, self.__lmhash, self.__nthash, self.__aesKey, self.__ldaps_flag)
|
|
# updating "self.__target" as it may have changed in the ldap_login processing
|
|
self.__target = ldapConnection._dstHost
|
|
|
|
# Building the search filter
|
|
searchFilter = "(&(objectCategory=computer)(|(msLAPS-EncryptedPassword=*)(ms-MCS-AdmPwd=*)(msLAPS-Password=*))" # Default search filter value
|
|
if self.__targetComputer is not None:
|
|
searchFilter += '(name=' + self.__targetComputer + ')'
|
|
searchFilter += ")"
|
|
|
|
try:
|
|
# Microsoft Active Directory set an hard limit of 1000 entries returned by any search
|
|
paged_search_control = ldapasn1.SimplePagedResultsControl(criticality=True, size=1000)
|
|
|
|
resp = ldapConnection.search(searchFilter=searchFilter,
|
|
attributes=['msLAPS-EncryptedPassword', 'msLAPS-PasswordExpirationTime', 'msLAPS-Password', 'sAMAccountName', \
|
|
'ms-Mcs-AdmPwdExpirationTime', 'ms-MCS-AdmPwd'],
|
|
searchControls=[paged_search_control])
|
|
|
|
except ldap.LDAPSearchError as e:
|
|
if e.getErrorString().find('sizeLimitExceeded') >= 0:
|
|
# We should never reach this code as we use paged search now
|
|
logging.debug('sizeLimitExceeded exception caught, giving up and processing the data received')
|
|
resp = e.getAnswers()
|
|
pass
|
|
else:
|
|
raise
|
|
|
|
entries = []
|
|
|
|
logging.debug('Total of records returned %d' % len(resp))
|
|
|
|
if len(resp) == 0:
|
|
if self.__targetComputer is not None:
|
|
logging.error('%s$ not found in LDAP.' % self.__targetComputer)
|
|
else:
|
|
logging.error("No valid entry in LDAP")
|
|
return
|
|
for item in resp:
|
|
if isinstance(item, ldapasn1.SearchResultEntry) is not True:
|
|
continue
|
|
try:
|
|
sAMAccountName = None
|
|
lapsPasswordExpiration = None
|
|
lapsUsername = None
|
|
lapsPassword = None
|
|
lapsv2 = False
|
|
for attribute in item['attributes']:
|
|
if str(attribute['type']) == 'sAMAccountName':
|
|
sAMAccountName = str(attribute['vals'][0])
|
|
if str(attribute['type']) == 'msLAPS-EncryptedPassword':
|
|
lapsv2 = True
|
|
plaintext = self.getLAPSv2Decrypt(bytes(attribute['vals'][0]))
|
|
r = json.loads(plaintext[:-18].decode('utf-16le'))
|
|
# timestamp = r["t"]
|
|
lapsUsername = r["n"]
|
|
lapsPassword = r["p"]
|
|
elif str(attribute['type']) == 'ms-Mcs-AdmPwdExpirationTime' or str(attribute['type']) == 'msLAPS-PasswordExpirationTime':
|
|
if str(attribute['vals'][0]) != '0':
|
|
lapsPasswordExpiration = datetime.fromtimestamp(self.getUnixTime(int(str(attribute['vals'][0])))).strftime('%Y-%m-%d %H:%M:%S')
|
|
elif str(attribute['type']) == 'ms-Mcs-AdmPwd':
|
|
lapsPassword = attribute['vals'][0].asOctets().decode('utf-8')
|
|
if sAMAccountName is not None and lapsPassword is not None:
|
|
entry = [sAMAccountName,lapsUsername, lapsPassword, lapsPasswordExpiration, str(lapsv2)]
|
|
entry = [element if element is not None else 'N/A' for element in entry]
|
|
entries.append(entry)
|
|
except Exception as e:
|
|
logging.error('Skipping item, cannot process due to error %s' % str(e))
|
|
pass
|
|
|
|
if len(entries) == 0:
|
|
if self.__targetComputer is not None:
|
|
logging.error("No LAPS data returned for %s" % self.__targetComputer)
|
|
else:
|
|
logging.error("No LAPS data returned")
|
|
return
|
|
|
|
self.printTable(entries,['Host','LAPS Username','LAPS Password','LAPS Password Expiration', 'LAPSv2'], self.__outputFile)
|
|
|
|
# Process command-line arguments.
|
|
if __name__ == '__main__':
|
|
print((version.BANNER))
|
|
|
|
parser = argparse.ArgumentParser(add_help = True, description = "Extract LAPS passwords from LDAP")
|
|
|
|
parser.add_argument('target', action='store', help='domain[/username[:password]]')
|
|
parser.add_argument('-computer', action='store', metavar='computername', help='Target a specific computer by its name')
|
|
|
|
parser.add_argument('-ts', action='store_true', help='Adds timestamp to every logging output')
|
|
parser.add_argument('-debug', action='store_true', help='Turn DEBUG output ON')
|
|
parser.add_argument('-outputfile', '-o', action='store', help='Outputs to a file.')
|
|
|
|
group = parser.add_argument_group('authentication')
|
|
group.add_argument('-hashes', action="store", metavar = "LMHASH:NTHASH", help='NTLM hashes, format is LMHASH:NTHASH')
|
|
group.add_argument('-no-pass', action="store_true", help='don\'t ask for password (useful for -k)')
|
|
group.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.add_argument('-aesKey', action="store", metavar = "hex key", help='AES key to use for Kerberos Authentication '
|
|
'(128 or 256 bits)')
|
|
|
|
group = parser.add_argument_group('connection')
|
|
group.add_argument('-dc-ip', action='store', metavar='ip address', help='IP Address of the domain controller. If '
|
|
'ommited it use the domain part (FQDN) '
|
|
'specified in the target parameter')
|
|
group.add_argument('-dc-host', action='store', metavar='hostname', help='Hostname of the domain controller to use. '
|
|
'If ommited, the domain part (FQDN) '
|
|
'specified in the account parameter will be used')
|
|
|
|
group.add_argument('-ldaps', dest='ldaps_flag', action="store_true", help='Enable LDAPS (LDAP over SSL). '
|
|
'Required when querying a Windows Server 2025'
|
|
'domain controller with LDAPS enforced.')
|
|
|
|
if len(sys.argv)==1:
|
|
parser.print_help()
|
|
sys.exit(1)
|
|
|
|
options = parser.parse_args()
|
|
|
|
# Init the example's logger theme
|
|
logger.init(options.ts, options.debug)
|
|
|
|
domain, username, password, _, _, options.k = parse_identity(options.target, options.hashes, options.no_pass, options.aesKey, options.k)
|
|
|
|
if domain == '':
|
|
logging.critical('Domain should be specified!')
|
|
sys.exit(1)
|
|
|
|
try:
|
|
executer = GetLAPSPassword(username, password, domain, options)
|
|
executer.run()
|
|
except Exception as e:
|
|
if logging.getLogger().level == logging.DEBUG:
|
|
import traceback
|
|
traceback.print_exc()
|
|
logging.error(str(e)) |