Files
2026-03-27 17:45:55 +01:00

22 KiB

BridgeHead

Native C++ access to Active Directory over ADWS, no .NET, no WCF, no HTTP stack.

BridgeHead is a C++20 static library implementing the full Active Directory Web Services (ADWS) protocol stack directly over TCP. Named after the AD bridgehead server, the gateway through which directory traffic flows, it gives your C++ code the same low-level access to port 9389 that PowerShell's Get-ADUser and Get-ADComputer use under the hood.

Table of contents


Protocol stack

The transport layers wrap the one below via the common bridgehead::transport::IByteStream interface. NbfseCodec is a codec utility called by AdwsClient to encode/decode SOAP messages before and after framing:

AdwsClient     WS-Enumeration + WS-Transfer  [MS-ADDM]
  ├── NbfseCodec     .NET Binary Format for SOAP  [MC-NBFSE]  (encode/decode)
  └── NmfFramer      .NET Message Framing       [MC-NMF]      (send/receive frames)
        └── NnsSession     .NET NegotiateStream      [MS-NNS]
              └── TcpSocket      raw TCP/IP

The entry point for consumers is bridgehead::adws::AdwsClient.


Quick start

Query, enumerate all users

#include "bridgehead/adws/AdwsClient.hpp"

// NTLM (works on any host)
auto client = bridgehead::adws::AdwsClient::EnumerationClient(
    "192.168.1.10",     // DC IP or hostname
    "DC01.corp.local",  // DC FQDN, used in NMF Via header and Kerberos SPN
    "CORP",             // NetBIOS domain name
    "Administrator",    // username
    "Passw0rd"          // password
);

// Kerberos (username hidden on the wire; requires DC reachable on port 88)
auto client = bridgehead::adws::AdwsClient::EnumerationClient(
    "192.168.1.10", "DC01.corp.local", "CORP",
    "Administrator", "Passw0rd",
    bridgehead::adws::AuthPackage::Kerberos
);

auto users = client.Query(
    "(objectClass=user)",
    {"sAMAccountName", "distinguishedName", "memberOf"}
);

for (auto& obj : users)
    std::cout << obj.FirstValue("sAMAccountName") << '\n';

Stream large result sets

client.Enumerate(
    "(objectClass=computer)",
    {"dNSHostName", "operatingSystem"},
    "",   // empty = domain root base DN
    [](const bridgehead::adws::LdapObject& obj) {
        std::cout << obj.FirstValue("dNSHostName") << '\n';
        return true;  // return false to stop early (sends wsen:Release)
    }
);

Scope and pagination

// OneLevel scope, 50 objects per Pull round-trip
client.Query(
    "(objectClass=user)",
    {"sAMAccountName"},
    "OU=Admins,DC=corp,DC=local",
    50,
    bridgehead::adws::SearchScope::OneLevel
);

Binary attributes, objectGUID, objectSid

auto objs = client.Query("(objectClass=user)", {"objectGUID", "objectSid"});
for (auto& obj : objs) {
    if (auto* b = obj.FirstBytes("objectGUID"))
        std::cout << bridgehead::adws::ParseGuid(*b) << '\n';  // {XXXXXXXX-...}
    if (auto* b = obj.FirstBytes("objectSid"))
        std::cout << bridgehead::adws::ParseSid(*b) << '\n';   // S-1-5-...
}

Security descriptor decoding

#include "bridgehead/adws/SecurityDescriptor.hpp"

auto objs = client.Query("(objectClass=user)", {"nTSecurityDescriptor"});
if (auto* raw = objs[0].FirstBytes("nTSecurityDescriptor")) {
    auto sd = bridgehead::adws::ParseSecurityDescriptor(*raw);
    std::cout << "Owner: " << sd.ownerSid << '\n';
    for (auto& ace : sd.dacl.aces)
        std::cout << "  type=" << (int)ace.type
                  << " mask=0x" << std::hex << ace.mask
                  << " sid="   << ace.sid << '\n';
}

Write attributes (Resource endpoint)

auto rc = bridgehead::adws::AdwsClient::ResourceClient(
    "192.168.1.10", "DC01.corp.local", "CORP", "Administrator", "Passw0rd");

// Modify attributes
rc.Put("CN=Alice,OU=Users,DC=corp,DC=local", {
    {"description",     {"managed by bridgehead"}},
    {"telephoneNumber", {"555-1234"}},
});

// Clear an attribute (both values and bytes empty = delete)
rc.Put("CN=Alice,OU=Users,DC=corp,DC=local", {
    {"telephoneNumber", {}},
});

// Read back
auto obj = rc.Get("CN=Alice,OU=Users,DC=corp,DC=local",
                  {"description", "telephoneNumber"});

// Delete object
rc.Delete("CN=TempUser,OU=Users,DC=corp,DC=local");

LDAP modify types, Add / Replace / Delete

Put defaults to Replace (overwrites all existing values). Use ModifyOperation for fine-grained control on multi-valued attributes:

using bridgehead::adws::LdapModification;
using bridgehead::adws::ModifyOperation;

rc.Put("CN=Alice,OU=Users,DC=corp,DC=local", {
    // Append a value to an existing multi-valued attribute
    LdapModification{"otherTelephone", {"555-9999"}, {}, ModifyOperation::Add},

    // Remove one specific value (leave others intact)
    LdapModification{"otherTelephone", {"555-0000"}, {}, ModifyOperation::Delete},

    // Replace is the default, explicit here for clarity
    LdapModification{"description", {"updated"}, {}, ModifyOperation::Replace},
});

Move / Rename

// Move to a different OU
rc.Move(
    "CN=Alice,OU=OldOU,DC=corp,DC=local",   // current DN
    "CN=Alice,OU=NewOU,DC=corp,DC=local"    // new DN
);

// Rename in place (same parent, new CN)
rc.Move(
    "CN=Alice,OU=Users,DC=corp,DC=local",
    "CN=AliceSmith,OU=Users,DC=corp,DC=local"
);

Write binary attributes

Supply binary values in the bytes field of LdapModification. They are base64-encoded on the wire automatically:

std::vector<uint8_t> thumbnail = loadFile("photo.jpg");

rc.Put("CN=Alice,OU=Users,DC=corp,DC=local", {
    LdapModification{"thumbnailPhoto", {}, {thumbnail}},
});

Create objects (ResourceFactory endpoint)

auto rf = bridgehead::adws::AdwsClient::ResourceFactoryClient(
    "192.168.1.10", "DC01.corp.local", "CORP", "Administrator", "Passw0rd");

rf.Create(
    "CN=NewUser,OU=Users,DC=corp,DC=local",
    "user",
    {{"sAMAccountName", {"newuser"}}, {"userAccountControl", {"512"}}}
);

Async operations

#include "bridgehead/adws/AdwsClientAsync.hpp"

auto ac = bridgehead::adws::AdwsClientAsync::EnumerationClient(
    "192.168.1.10", "DC01.corp.local", "CORP", "Administrator", "Passw0rd");

auto future = ac.QueryAsync("(objectClass=user)", {"sAMAccountName"});

// ... do other work while the query runs ...

auto users = future.get();  // blocks until complete; re-throws any exception

Timeout on a slow DC:

if (future.wait_for(std::chrono::seconds(5)) == std::future_status::timeout) {
    // query is still running
}

All operations have async variants: QueryAsync, EnumerateAsync, GetAsync, PutAsync, DeleteAsync, CreateAsync, MoveAsync.

Connection pool (high-frequency / multi-threaded workloads)

#include "bridgehead/adws/AdwsClientPool.hpp"

// Enumeration pool, Query / Enumerate
bridgehead::adws::AdwsClientPool pool({
    .host = "192.168.1.10", .fqdn = "DC01.corp.local",
    .domain = "CORP", .username = "Administrator", .password = "Passw0rd",
    .maxSize = 4,   // up to 4 concurrent authenticated sessions
});

auto users = pool.Query("(objectClass=user)", {"sAMAccountName"});

// Resource pool, Get / Put / Delete
bridgehead::adws::AdwsClientPool resPool({
    .host = "192.168.1.10", .fqdn = "DC01.corp.local",
    .domain = "CORP", .username = "Administrator", .password = "Passw0rd",
    .maxSize = 4,
    .endpoint = bridgehead::adws::PoolEndpoint::Resource,
});
auto obj = resPool.Get("CN=Alice,OU=Users,DC=corp,DC=local", {"mail"});
resPool.Put("CN=Alice,OU=Users,DC=corp,DC=local", {{"mail", {"alice@corp.local"}}});

// ResourceFactory pool, Create
bridgehead::adws::AdwsClientPool rfPool({
    .host = "192.168.1.10", .fqdn = "DC01.corp.local",
    .domain = "CORP", .username = "Administrator", .password = "Passw0rd",
    .endpoint = bridgehead::adws::PoolEndpoint::ResourceFactory,
});
rfPool.Create("CN=NewUser,OU=Users,DC=corp,DC=local", "user",
              {{"sAMAccountName", {"newuser"}}});

API reference

All public headers live under include/bridgehead/. Full API documentation can be generated with Doxygen (see Build).

bridgehead::adws::AdwsClient

Method Endpoint Description
EnumerationClient(host, fqdn, domain, user, pass [, auth, timeoutMs, opTimeoutMs, limits]) /Enumeration Factory, connect and authenticate
ResourceClient(...) /Resource Factory for Get / Put / Delete / Move
ResourceFactoryClient(...) /ResourceFactory Factory for Create
Query(filter, attrs [, baseDN, maxElems, scope]) Enumeration Collect all results into a vector
Enumerate(filter, attrs, baseDN, callback [, maxElems, scope]) Enumeration Stream results via callback
Get(dn, attrs) Resource Read attributes of a single object
Put(dn, modifications) Resource Modify attributes
Delete(dn) Resource Delete object
Create(dn, objectClass, attrs) ResourceFactory Create new object
Move(dn, newDn) Resource Move or rename an existing object

bridgehead::adws::AdwsClientAsync

std::future-based async wrapper. Same factory methods as AdwsClient; each operation returns a std::future<T>:

std::future<std::vector<LdapObject>> f = ac.QueryAsync(...);
std::future<void>                    e = ac.EnumerateAsync(filter, attrs, baseDN, callback);
std::future<LdapObject>              g = ac.GetAsync(...);
std::future<void>                    h = ac.PutAsync(...);
std::future<void>                    i = ac.DeleteAsync(...);
std::future<void>                    j = ac.CreateAsync(...);
std::future<void>                    k = ac.MoveAsync(...);

For concurrent queries across multiple connections, use AdwsClientPool.

bridgehead::adws::AdwsClientPool

Thread-safe pool of pre-authenticated sessions. Connections are created lazily and reused across calls.

pool.IdleCount();   // sessions currently idle
pool.TotalCount();  // idle + checked-out
pool.MaxSize();     // configured maximum pool size
pool.Move(dn, newDn);  // Move/rename (Resource pool)

bridgehead::adws::SearchScope

enum class SearchScope { Base, OneLevel, Subtree /*default*/ };

bridgehead::adws::LdapObject / LdapAttribute

struct LdapAttribute {
    std::string name;
    std::string syntax;                         // LdapSyntax OID, empty if absent
    std::vector<std::string>          values;   // text values (raw base64 for binary)
    std::vector<std::vector<uint8_t>> bytes;    // decoded binary; parallel to values
};

struct LdapObject {
    std::vector<LdapAttribute>  attributes;                          // all returned attributes
    const LdapAttribute*        Find(const std::string& name) const;  // case-insensitive
    std::string                 FirstValue(const std::string& name) const;
    const std::vector<uint8_t>* FirstBytes(const std::string& name) const;
};

Binary helpers

std::string ParseGuid(const std::vector<uint8_t>& bytes); // → "{XXXXXXXX-XXXX-...}"
std::string ParseSid (const std::vector<uint8_t>& bytes); // → "S-1-5-..."

FilterValue and filter builder helpers

FilterValue is a type-safe wrapper that escapes RFC 4515 §3 metacharacters (\, *, (, ), NUL) on construction. Use it with the builder helpers to make LDAP injection structurally impossible:

// UNSAFE, raw string concatenation, easy to forget escaping
client.Query("(sAMAccountName=" + username + ")", ...);

// SAFE, FilterValue escapes on construction; FilterEq composes the assertion
FilterValue user = username;
client.Query(FilterEq("sAMAccountName", user), ...);

// Compose complex filters
client.Query(
    FilterAnd({
        FilterEq("objectClass", FilterValue::Raw("user")),  // Raw() for safe literals
        FilterOr({
            FilterEq("sAMAccountName", user),
            FilterEq("mail", FilterValue(email)),
        }),
        FilterNot(FilterPresent("userAccountControl")),
    }), {"sAMAccountName", "mail"}
);

EscapeLdapFilter(str) is still available as a lower-level primitive when you need to build filter strings manually.

DnValue and DN builder helpers

Same pattern as FilterValue, but for Distinguished Name RDN values (RFC 4514 §2.4). Escapes ,, +, ", \, <, >, ;, NUL, and leading/trailing # and space:

// UNSAFE, comma injection breaks the DN structure
rc.Get("CN=" + username + ",OU=Users,DC=corp,DC=local", attrs);

// SAFE
DnValue cn = username;   // auto-escaped
auto dn = BuildDn({DnAttr("CN", cn), "OU=Users", "DC=corp", "DC=local"});
rc.Get(dn, attrs);

// DnValue::Raw() for values you control
auto dn2 = BuildDn({DnAttr("CN", DnValue::Raw("Service Account")), "OU=SvcAccounts", "DC=corp", "DC=local"});

bridgehead::adws::SecurityDescriptor

SecurityDescriptor ParseSecurityDescriptor(const std::vector<uint8_t>& bytes);

struct SecurityDescriptor {
    uint16_t    control;       // SdControl::* flags
    std::string ownerSid;
    std::string groupSid;
    bool hasDacl; Acl dacl;
    bool hasSacl; Acl sacl;
};
struct Acl { std::vector<Ace> aces; };
struct Ace {
    uint8_t     type;                  // AceType::*
    uint8_t     flags;                 // AceFlags::*
    uint32_t    mask;
    std::string sid;
    std::string objectType;            // GUID string, object ACEs only
    std::string inheritedObjectType;   // GUID string, object ACEs only
};

Constant namespaces: AceType::*, AceFlags::*, SdControl::*.

bridgehead::ConnectionError / AuthenticationError / ProtocolError

Defined in include/bridgehead/Exceptions.hpp (included transitively by AdwsClient.hpp). All three extend std::runtime_error:

Type Thrown when
ConnectionError TCP-level failure: refused, timeout, I/O error
AuthenticationError NTLM/Kerberos negotiation fails
ProtocolError Malformed server response at any protocol layer
try {
    auto client = bridgehead::adws::AdwsClient::EnumerationClient(...);
    auto users  = client.Query(...);
} catch (const bridgehead::ConnectionError& e) {
    // unreachable DC, wrong port, timeout
} catch (const bridgehead::AuthenticationError& e) {
    // bad credentials, KDC unreachable
} catch (const bridgehead::ProtocolError& e) {
    // unexpected server response
} catch (const bridgehead::adws::SoapFault& e) {
    // DC returned a SOAP fault (e.g. invalid filter)
}
// or coarse-grained:
// } catch (const std::runtime_error& e) { ... }

bridgehead::adws::SoapFault

Thrown on any s:Fault response from the DC instead of a plain std::runtime_error:

struct SoapFault : std::runtime_error {
    std::string code;     // e.g. "Sender"
    std::string subcode;  // e.g. "InvalidEnumerationContext", empty if absent
    std::string reason;   // human-readable text
};

bridgehead::adws::LdapModification

Used by Put and Create:

enum class ModifyOperation { Replace /*default*/, Add, Delete };

struct LdapModification {
    std::string                       name;
    std::vector<std::string>          values;    // text values
    std::vector<std::vector<uint8_t>> bytes;     // binary values (base64-encoded on the wire)
    ModifyOperation operation = ModifyOperation::Replace;
};

When both values and bytes are empty, the attribute is cleared/deleted regardless of operation.

bridgehead::adws::ProtocolLimits

Optional last parameter on all factory methods, AdwsClientAsync factories, and AdwsClientPool::Config::limits. All fields have safe defaults, only change them if you have specific requirements:

struct ProtocolLimits {
    uint32_t nmfMaxFrameBytes   = 64 * 1024 * 1024;  // max NMF frame (default 64 MiB)
    uint32_t nnsMaxPayloadBytes = 16 * 1024 * 1024;  // max NNS packet (default 16 MB)
    int tcpKeepaliveIdleSec     = 60;   // idle seconds before first probe
    int tcpKeepaliveIntervalSec = 10;   // seconds between probes
    int tcpKeepaliveProbeCount  = 5;    // probes before declaring dead (POSIX only)
};

Raise nmfMaxFrameBytes / nnsMaxPayloadBytes only if you are reading objects with unusually large binary attributes (e.g. nTSecurityDescriptor with many ACEs, or large thumbnailPhoto). Lower the keepalive fields in cloud/NAT environments where idle connections are dropped aggressively.

Named constructor helpers (inline static factories):

LdapModification::Replace(name, values)          // Replace with text values (default)
LdapModification::ReplaceBinary(name, bytes)     // Replace with binary values
LdapModification::Append(name, values)           // Add to multi-valued attribute
LdapModification::Remove(name, values={})        // Remove specific values (or all)
LdapModification::Clear(name)                    // Delete the attribute entirely

Build

Requirements: CMake 3.25+ and a C++20-capable compiler (MSVC 2022+, GCC 12+, Clang 15+).

# Configure and build (unit tests only, no live AD needed)
cmake -B build -A x64
cmake --build build --config Release

# Run unit tests
ctest --test-dir build -C Release --output-on-failure

# With integration tests (requires a live Domain Controller)
cmake -B build -A x64 -DBRIDGEHEAD_INTEGRATION_TESTS=ON
cmake --build build --config Release
./build/tests/Release/bridgehead_integration_tests.exe

# With CLI tool (adws_list, browse AD objects from the command line)
cmake -B build -A x64 -DBRIDGEHEAD_BUILD_TOOLS=ON
cmake --build build --config Release --target adws_list
./build/tools/Release/adws_list.exe --host <dc-ip> --fqdn <dc-fqdn> --domain <domain> --user <user>

# Generate API reference docs (requires Doxygen)
cmake --build build --target docs
# or directly:
doxygen Doxyfile
# Output: docs/doxygen/html/index.html

CMake options

Option Default Description
BRIDGEHEAD_BUILD_TESTS ON Build the unit test suite
BRIDGEHEAD_INTEGRATION_TESTS OFF Build integration tests (requires a live DC)
BRIDGEHEAD_BUILD_TOOLS OFF Build the adws_list CLI tool

Integration

Option A, CMake subdirectory (no install needed)

add_subdirectory(bridgehead)
target_link_libraries(my_target PRIVATE bridgehead::bridgehead)

Option B, installed package (find_package)

cmake --install build --prefix /usr/local   # or any install prefix
find_package(bridgehead REQUIRED)
target_link_libraries(my_target PRIVATE bridgehead::bridgehead)

pugixml, ws2_32, and secur32 (Windows) / gssapi_krb5 (Linux/macOS) are all pulled in transitively.


Platform support

Platform Auth backend Status
Windows SSPI (secur32.dll), NTLM and Kerberos Working
Linux / macOS GSSAPI (libgssapi_krb5), SPNEGO / Kerberos Compiled; not yet integration-tested

Both AuthPackage::Ntlm and AuthPackage::Kerberos work on Windows. Kerberos hides the username on the wire and is preferred in production; NTLM is the fallback when the KDC (port 88) is unreachable.

On Linux/macOS the GSSAPI backend uses SPNEGO with Kerberos. For NTLM support install the gss-ntlmssp plugin:

apt install libgss-ntlmssp   # Debian / Ubuntu
dnf install gssntlmssp       # Fedora / RHEL

Without the plugin the server will negotiate Kerberos. Explicit user/password credentials use gss_acquire_cred_with_password (MIT Kerberos 1.9+); pass empty strings to use the default credential cache (kinit). The GSSAPI path compiles cleanly and is correct by design, but has not yet been validated end-to-end against a live DC, treat Linux/macOS support as beta.

Encryption note: ADWS on port 9389 is encrypted at the NNS layer (NTLM session key or Kerberos AES-256), not TLS. This is by design, the DC does not offer TLS on this port. If certificate-based server authentication is required, LDAPS (port 636) is the alternative.


Dependencies

Fetched automatically at configure time via cmake/Dependencies.cmake, no manual installation needed:

Library Version Purpose
Catch2 v3.5.2 Unit test framework (test targets only)
pugixml v1.14 XML DOM (ADWS response parsing only)

No OpenSSL. No Asio. No Boost.


Known limitations

  • NTLM on Linux/macOS requires the gss-ntlmssp GSSAPI plugin (see Platform support). Without it the server will negotiate Kerberos instead. NTLM on Windows works natively via SSPI.

  • pugixml DOM, each SOAP response is fully parsed into an in-memory XML tree before any results are returned. This is bounded by the maxElements page size (default 256 objects per Pull), so the full result set is never held in memory at once. Memory only becomes a concern with very large page sizes or objects carrying large binary attributes (e.g. nTSecurityDescriptor on objects with many ACEs). A SAX/streaming approach would eliminate this but is not currently planned.


Protocol references

  • [MS-ADDM], Active Directory Web Services: Data Model and Common Elements
  • [MS-NNS], .NET NegotiateStream Protocol
  • [MC-NMF], .NET Message Framing
  • [MC-NBFSE], .NET Binary Format: SOAP Extension
  • [MS-DTYP], Windows Data Types (SECURITY_DESCRIPTOR, ACL, ACE, SID, GUID)
  • [MS-ADTS], Active Directory Technical Specification (LDAP filters / attributes)

All specifications are available from Microsoft's Open Specifications documentation.


Special Thanks

Special thanks to IBM X-Force and the SoaPy project for their inspiration, research, and publicly shared work that helped inform this project. I would also like to recognize Logan Goins, the creator of SoaPy at IBM X-Force Red, for the effort and insight that made that work possible.


License

See LICENSE for details.