blog.safetech.red
Microsoft Active Directory ADWS Red Team Blue Team Rust

ADWS: What If We Knocked on a Different Door to Reach AD?

ADWS (Active Directory Web Services) is the SOAP door enabled by default on every DC, which proxies to the local LDAP. This post explains the protocol, why I wrote an ADWS library in Rust, an adshell-rs shell that speaks both LDAP(S) and ADWS, and how I ported RustHound-CE's collection over ADWS without touching the parsing. There is also a Blue Team part on how to detect an AD data collector.

03 Nov 2026 16 min – views Quentin Texier (g0h4n)
Table of content

This article is an automatic translation of the original French post. If something reads a little off, the French version is the reference.

TL;DR

ADWS, for Active Directory Web Services, is a SOAP service enabled by default on every domain controller since Windows Server 2008 R2. It listens on port 9389 and, in practice, it proxies requests to the DC's local LDAP. In other words, you can read and write the directory without ever going through "classic" LDAP (389/636) on the network, which makes it interesting when facing detections. I needed an ADWS building block in Rust for other projects, so I'm releasing a reusable library, adws, along with a small toolkit, adshell-rs, an AD attack shell that speaks both LDAP(S) and ADWS (SOAP). Finally, RustHound-CE can now talk over ADWS as of version 2.5.40: you pick --transport adws and dump over ADWS without changing a single line of the parsing code, only the collection method changes. All of this builds on existing research and tools, which I credit at the end of the article. This post explains what ADWS is, what's interesting and what's painful, then goes through the Red and Blue sides with a detection part.

What is ADWS?

When you think "query Active Directory", you think LDAP, port 389 (or 636 for LDAPS). That's the reflex, and it's what the vast majority of tools do (SharpHound, BloodHound.py, RustHound-CE on the LDAP side, etc.).

But that's not the only door. Since Windows Server 2008 R2, every domain controller exposes a service called Active Directory Web Services (ADWS), listening on TCP 9389. It's what powers, on the network side, the Active Directory PowerShell module (Get-ADUser, Get-ADGroup, Get-ADComputer...) and the Active Directory Administrative Center (ADAC). So if you've ever run a Get-ADUser from an admin workstation, you've spoken ADWS without knowing it.

The important point, and the whole interest of the topic: ADWS is enabled by default on DCs, and it proxies to the local LDAP of the controller. A read performed over ADWS is therefore, from a data standpoint, an LDAP read, but wrapped in something entirely different on the wire.

Diagram: client => ADWS (9389, SOAP/NNS) => the DC's local LDAP (389)

SOAP, but not only

ADWS isn't "just SOAP over HTTP", since no HTTP traffic is involved. It's a full .NET stack, documented by Microsoft in its Open Specifications. From the bottom up:

  • MC-NMF (.NET Message Framing): the framing of messages over TCP.
  • MS-NNS (.NET NegotiateStream): the security layer, Negotiate handshake then signing and encryption of the application messages. All application traffic is therefore sign & seal.
  • NBFS / NBFSE (MC-NBFX / MC-NBFSE): the .NET binary XML encoding, with its static dictionary. SOAP isn't carried as text, but as binary.
  • And on top, the WS-* operations with MS-WSPELD: WS-Enumeration for reads (Enumerate / Pull), WS-Transfer for writes (Put / Delete / Create), the MS-ADDM data model, and the custom actions MS-ADCAP (the equivalent of the Get-ADGroupMember, Set-ADAccountPassword cmdlets, etc.).

Why an ADWS library in Rust

Crates.io Version

The ADWS topic interests me and I needed an ADWS building block for several Rust projects, not just a collector. Rather than burying it inside a tool, I chose to release it as a reusable Rust library and share it with the community, exactly in the spirit of what RustHound-CE already offers as a library.

That gave two repositories:

  • adws: the library. It builds the whole stack (NMF, NNS, NBFSE) and exposes a high-level API, connect, search / search_streaming, and full CRUD (modify, delete, create). The key choice: it returns objects as SearchEntry values compatible with the Rust ldap3 library. In other words, an adws::SearchEntry converts into an ldap3::SearchEntry for free, with a simple .into() for those familiar with Rust.
  • adshell-rs: the toolkit. An interactive AD attack shell and actions (RBCD, Shadow Credentials, DACL, password reset, machine accounts...) that run over LDAP(S) or ADWS (SOAP), your choice, via a simple -t adws or -t ldap.

This ldap3 compatibility isn't cosmetic. It's what made the rest possible.

Porting it into RustHound-CE (v2.5.40)

This is probably the most telling result. Starting with version 2.5.40, RustHound-CE can dump over ADWS. You simply switch the communication protocol, which is LDAP by default, with the --transport adws argument or its short form -t adws:

# Classic LDAP collection (unchanged)
rusthound-ce -d essos.local -u daenerys.targaryen -p 'BurnThemAll!' -z

# The same collection, but over ADWS (SOAP, tcp/9389)
rusthound-ce -d essos.local -u daenerys.targaryen -p 'BurnThemAll!' -z -t adws

And the most interesting part of this ADWS protocol integration is that I didn't have to change any of RustHound-CE's parsing code. All of objects/*, and thus all the logic that turns a directory object into JSON to be ingested by BloodHound-CE, stayed identical. Only the collection method changes.

How? RustHound-CE already routes all of its reads through a handful of functions (ldap_auth, get_all_naming_contexts, and the streamed collection). I introduced a small DirectoryTransport trait with two implementations, one for LDAP (the existing one) and one for ADWS (backed by the adws lib). Since the lib returns entries in ldap3's SearchEntry format, everything above the transport receives exactly the same thing as before. The rest is handled the same way.

To put it simply, I just added a second input pipe, the rest of the factory didn't move.

Note, the only trade-off is that the ADWS authentication block currently covers only NTLM (password and pass-the-hash). Kerberos and pass-the-certificate (PKINIT) over ADWS are on the roadmap, but not wired yet. On LDAP, RustHound-CE obviously keeps all of its authentication methods.

Strengths and limitations

Let's be honest, ADWS is not a magic wand that lets you collect the entire Active Directory without being detected. Here's what's genuinely interesting, and what's painful.

What's interesting

  • Enabled by default: Nothing to turn on, nothing to deploy on the target. The service runs on every DC.
  • Off the LDAP radars: A lot of detections and monitoring focus on LDAP network traffic (389/636) and therefore on LDAP queries. By going through ADWS, you don't generate traffic on those ports. You come out elsewhere, on 9389, in encrypted binary SOAP.
  • The same data as LDAP: Since ADWS proxies to the local LDAP, you get the same directory, the same attributes, including the nTSecurityDescriptor via the SD-flags control. Hence the collection parity with RustHound-CE.
  • Legitimate by nature: ADWS traffic looks like... the PowerShell AD module and ADAC. It's exactly what admins use.

The catches

  • The stack is heavy to implement: NMF, NNS (sign & seal), NBFSE, WS-*, there's no shortcut, and that's why there are few ADWS tools compared to LDAP. A very good library exists in Golang, developed by @MacmodSec, called go-adws, and I drew a lot of inspiration from his work to build the Rust library.
  • Ranged retrieval doesn't exist: In LDAP, a multivalued attribute is capped at MaxValRange (1500 by default) and the client completes the rest with ranged retrieval (member;range=1500-*). This was actually an issue raised by @Spyr0-sec on RustHound-CE (#76) and later fixed on the LDAP protocol. ADWS rejects that syntax in a selection. For large groups (more than 1500 members), you have to go through the custom action GetADGroupMember (MS-ADCAP), the SOAP equivalent of the Get-ADGroupMember cmdlet, which resolves the full membership server-side. That's what the lib does in order to correctly collect the members of a group whose count is above 1500.
  • It's not "invisible": The DC sees everything. ADWS proxies to the local LDAP, so object-access auditing on the directory side still fires. You avoid the LDAP traffic on the network, not the instrumentation on the DC (I come back to this in the Blue part of the post).
  • Auth is still limited: in the Rust adws lib, only NTLM / PtH are implemented, Kerberos and PKINIT with Pass-The-Cert still need to be added.

The Red Team side

The demos below are run on the GOAD lab built by @M4yFly.

The operational value is simple: do what we already do over LDAP, but through a different door, less monitored, and enabled by default. And above all, I'd say in a way that looks like legitimate administration, just like an administrator would with the Active Directory PowerShell module.

Mapping AD over ADWS

For BloodHound reconnaissance, you switch RustHound-CE to ADWS. The .zip output is strictly the same as over LDAP, ready for BloodHound-CE:

# Dump over ADWS (SOAP) instead of LDAP, to avoid 389/636 traffic
rusthound-ce -d essos.local -u daenerys.targaryen -p 'BurnThemAll!' -z -t adws

RustHound-CE example with --transport adws

On the graph and analysis side, nothing changes for the operator, these are the same objects, the same nodes and therefore the same import into BloodHound-CE. Only the wire changes. So you can import the JSON files or the ZIP and start your analysis!

Acting on AD over ADWS

For targeted read and write actions, adshell-rs gives an interactive shell that speaks both transports, LDAP(S) and ADWS. You pick ADWS with the same option as for RustHound-CE, -t adws:

# Interactive shell over ADWS (SOAP)
./adshell-rs -d essos.local -u daenerys.targaryen -p 'BurnThemAll!' -t adws

adshell-rs demo

From there, you chain the usual AD attacks (machine accounts, RBCD, Shadow Credentials, DCSync rights, password reset...), but over the ADWS protocol rather than LDAP. Writes go through WS-Transfer (Put / Create / Delete), with the same binary values you'd write over LDAP (an RBCD security descriptor, a msDS-KeyCredentialLink blob, a unicodePwd in UTF-16...).

The same pass-the-hash that works over LDAP works over ADWS: the NNS block negotiates in NTLM and seals the session, exactly as the PowerShell AD module would with an admin's credentials. Thanks to @Devolutions for their Rust library sspi-rs, which makes NTLM authentication easy to use.

The Blue Team side: detection, SOC and SIEM

This is the most important part, because "ADWS avoids LDAP" does NOT mean "ADWS is undetectable". It means detection must not happen in the same place.

1. Don't try to block ADWS. The service is legitimate and used by your own admins (PowerShell AD module, ADAC). Cutting port 9389 breaks administration, and therefore the ability to use the Active Directory PowerShell module commands. The goal is to monitor, not to forbid.

2. Watch port 9389, not only 389/636. If your network monitoring and rules focus on LDAP, you have a blind spot. A RustHound-CE collection over ADWS is a burst of WS-Enumeration Pull toward a DC, on 9389, from a machine that has no reason to be administering AD. The content is encrypted (NNS), but the connection and the volume are visible. An unusual source that opens an ADWS session and vacuums the directory is a signal. Here's an example of the number of packets going over the wire for a collection of an Active Directory with more than 3000 objects.

# Capturing ADWS traffic
sudo tcpdump -i vboxnet0 -nn 'host 192.168.56.12 and tcp port 9389' -w /tmp/adws-collect.pcap

# Collection over ADWS
rusthound-ce -d essos.local -u daenerys.targaryen -p 'BurnThemAll!' -z -t adws -c SOAPOnly

# Number of ADWS packets
tcpdump -r /tmp/adws-collect.pcap -nn | wc -l
    # Total: 1233
# Capturing LDAP traffic
sudo tcpdump -i vboxnet0 -nn 'host 192.168.56.12 and tcp port 389' -w /tmp/ldap-collect.pcap

# Collection over LDAP (default)
rusthound-ce -d essos.local -u daenerys.targaryen -p 'BurnThemAll!' -z -c LdapOnly

# Number of LDAP packets
tcpdump -r /tmp/ldap-collect.pcap -nn | wc -l
    # Total: 593

Takeaway? It's not stealthier in volume, quite the opposite. On the ~3000-object test directory above, the same RustHound-CE collection generates ~1233 packets over ADWS versus ~593 over LDAP, nearly double. The overhead of the SOAP/NMF/NNS stack and the WS-Enumeration Pull chunking amount to a lot of requests. ADWS's value is therefore not to produce fewer requests, but to do it elsewhere (9389) and under a legitimate-looking appearance (PowerShell AD module traffic).

3. DC-side auditing remains your best ally. This is the point attackers forget: ADWS proxies to the DC's local LDAP. So the instrumentation on the directory side keeps working. In particular, object-access auditing (event 4662, "An operation was performed on an object") fires on reads of sensitive attributes, whether the request comes from LDAP or ADWS, because at the DC level it's the same internal LDAP access. Put SACL (System Access Control List) on your sensitive objects and attributes (secrets, descriptors, ms-Mcs-AdmPwd...) and you catch the read regardless of the transport. I give a quick-win example in the next part of the post.

4. Correlate with the behavior of the account and the source. A regular account that, from a non-admin workstation, opens an ADWS session and enumerates thousands of objects is abnormal. The useful watchlist is the machines that never administer AD but suddenly start talking 9389.

As always, test at home. Spin up a DC, run a collection over ADWS with RustHound-CE, and look at what you see (or don't) in your SIEM, on the network and in the DC logs. It's the best way to calibrate your rules before facing them for real.

A simple way to tell an AD collector hit your directory

I'd tend to recommend setting up a honeypot group with a SACL on its member attribute only. A fake "interesting" group, with no real members and no real privilege, whose membership read triggers an event 4662.

Since no legitimate use reads this group, any access is suspicious, and any data collector that enumerates the AD directory, whether over LDAP or ADWS, will hit it. All while keeping a clean signal, since the audit is scoped to a single attribute of a single object.

Here are the commands to set up this protection:

# 1) Enable directory service access auditing (on the DC, once)
auditpol /set /subcategory:"Directory Service Access" /success:enable /failure:enable

# 2) Create the honeypot group
Import-Module ActiveDirectory
New-ADGroup -Name 'Tier0-Honeypot' -SamAccountName 'Tier0-Honeypot' `
    -GroupScope Global -GroupCategory Security `
    -Path 'CN=Users,DC=essos,DC=local' `
    -Description 'Demo honeypot from g0h4n blog post'

# 3) Build the SACL that audits READ of the "member" attribute by everyone on our honeypot group
$dn   = (Get-ADGroup 'Tier0-Honeypot').DistinguishedName
$path = "AD:\$dn"
$acl  = Get-Acl -Path $path -Audit
$everyone   = New-Object System.Security.Principal.SecurityIdentifier 'S-1-1-0'
$memberGuid = [GUID]'bf9679c0-0de6-11d0-a285-00aa003049e2'
$rule = New-Object System.DirectoryServices.ActiveDirectoryAuditRule(
    $everyone,
    [System.DirectoryServices.ActiveDirectoryRights]::ReadProperty,
    [System.Security.AccessControl.AuditFlags]::Success,
    $memberGuid,
    [System.DirectoryServices.ActiveDirectorySecurityInheritance]::None
)

# 4) Apply the SACL
$acl.AddAuditRule($rule)
Set-Acl -Path $path -AclObject $acl

We can then test that our honeypot object does trigger event 4662 as soon as the object is read:

# Collecting the Active Directory
./rusthound-ce -d essos.local -u 'daenerys.targaryen' -H 34534854d33b398b66684072224bb47a -z -t adws

Event 4662 triggered for our honeypot group

So even though ADWS moves the collection off the classic LDAP radars, a simple, well-audited honeypot group puts the defender back in the loop, whatever transport the attacker uses.

MITRE ATT&CK

To tie this back to the matrix, ADWS is above all a channel for directory discovery and manipulation. The techniques don't change, it's the vector that's alternative.

TacticIDTechniqueIn this scenario
DiscoveryT1087.002Account Discovery: Domain AccountEnumerating domain accounts over ADWS (WS-Enumeration) instead of LDAP
DiscoveryT1069.002Permission Groups Discovery: Domain GroupsCollecting groups and their members, including via GetADGroupMember
DiscoveryT1482Domain Trust DiscoveryReading trust relationships in the directory
Defense EvasionT1562.001Impair DefensesGoing through ADWS (9389) to avoid LDAP-centric monitoring (389/636)

Further reading

Two resources worth reading on the subject:

Acknowledgments

None of this starts from scratch. ADWS as an offensive technique was opened up and documented by several research efforts and several tools, which served as protocol references and as a guide for me to port the stack to Rust. A big thank you to them:

And of course, all of this relies on the Microsoft Open Specifications (MC-NMF, MS-NNS, MC-NBFX/MC-NBFSE, MS-ADDM, MS-ADCAP) that document the protocol.

Did you enjoy this article? Drop a like, it helps me steer future articles toward what interests you most.

cd ..