Analyzing RustHound CE

Source: g0h4n/RustHound-CE @ b6f809f

RustHound-CE is a Rust reimplementation of SharpHound’s data collection logic, targeting BloodHound Community Edition (v4.3+). It connects to Active Directory via LDAP, enumerates all objects, parses their security descriptors and attributes, and produces BloodHound-compatible JSON files. The entire codebase is a single crate — ~13,000 lines of Rust across 53 source files, with no external subcrates.

How It Works

The tool executes a linear pipeline:

  1. Connect to LDAP — simple bind (username/password) or SASL/GSSAPI (Kerberos via ccache). TLS verification is disabled (set_no_tls_verify(true)).
  2. Discover naming contexts — a base-level search retrieves all namingContexts from the DC, which drives subsequent queries against both the default NC (domain objects) and the Configuration NC (AD CS objects).
  3. Enumerate everything — a single (objectClass=*) filter (customizable) with paged results (999 per page) streams all objects. The LDAP_SERVER_SD_FLAGS_OID control (1.2.840.113556.1.4.801) is attached to each request to retrieve nTSecurityDescriptor values.
  4. Parse and classify — each LDAP entry is matched to a type enum (User, Computer, Group, Gpo, Domain, Container, Ou, Trust, plus 6 AD CS types) based on objectClass and DN path matching. Entries are parsed into typed Rust structs.
  5. Check and cross-reference — a post-processing phase resolves SIDs, DN-to-SID mappings, group memberships, ACL inheritance, GPO links, trust relationships, and child-object containment.
  6. Output — each object type becomes a separate JSON file (or a zip archive), formatted with BloodHound’s meta envelope specifying the collection version.

Architecture

src/
├── main.rs              # Entry point: args → ldap_search → parse → modules → json output
├── lib.rs               # Public API: re-exports ldap_search, Storage, prepare_results_from_source
├── args.rs              # CLI parsing (clap) + Options struct
├── banner.rs            # Startup/completion banners, progress bar
├── api.rs               # ADResults aggregation, type-based dispatch loop
├── ldap.rs              # LDAP connection, paged streaming, LdapSearchEntry
├── enums/               # Binary protocol parsers (nom7)
│   ├── secdesc.rs       # SecurityDescriptor, ACL, ACE, SID parsing
│   ├── acl.rs           # ACE→AceTemplate mapping (2600+ lines)
│   ├── sid.rs           # SID string construction
│   ├── uacflags.rs      # UserAccountControl flag extraction
│   ├── adcs.rs          # PKI certificate/enrollment flag decoding
│   ├── ldaptype.rs      # objectClass→Type classification
│   ├── constants.rs     # GUID mappings for extended rights
│   └── ...              # gplink, spntasks, trusts, forestlevel, regex
├── objects/             # Typed AD object structs + parse() implementations
│   ├── common.rs        # LdapObject trait, shared types (Member, AceTemplate, etc.)
│   ├── user.rs          # User + UserProperties (509 lines)
│   ├── computer.rs      # Computer (LAPS, HasSIDHistory, etc.)
│   ├── group.rs         # Group (member, memberOf)
│   ├── domain.rs        # Domain (trusts, links, child objects)
│   └── ...              # gpo, ou, container, fsp, trust, + 6 AD CS types
├── json/
│   ├── parser/mod.rs    # Legacy in-memory parser (superseded by api.rs)
│   ├── checker/mod.rs   # Cross-reference resolution pipeline
│   └── maker/mod.rs     # JSON serialization + zip packaging
├── storage/             # Disk-backed buffering for large domains
│   ├── buffer.rs        # BincodeObjectBuffer (bincode serialization)
│   └── iter.rs          # DiskStorageReader (streaming bincode deserialization)
├── modules/
│   └── resolver/        # Optional FQDN→IP DNS resolution
└── utils/               # Date formatting, crypto helpers, format utilities

The Spine

The execution path through main.rs is straightforward:

// main.rs (simplified)
let common_args: Options = extract_args();
let mut results = match common_args.resume {
    true => prepare_results_from_source(DiskStorageReader::from_path(cache_path), &args, None),
    false => {
        let mut storage = /* Vec::new() or DiskStorage */;
        ldap_search(ldaps, ip, port, domain, fqdn, user, pass, kerberos, filter, &mut storage);
        prepare_results_from_source(storage, &args, total)
    }
};
run_modules(&args, &mut results.mappings.fqdn_ip, &mut results.computers);
make_result(&args, results);

The ldap_search function is generic over Storage<LdapSearchEntry>, allowing either in-memory Vec<LdapSearchEntry> or disk-backed BincodeObjectBuffer. This abstraction keeps the core logic identical regardless of storage strategy.

Key Patterns

Type classification via objectClass matching. The get_type() function in enums/ldaptype.rs matches LDAP objectClass values against a cascade of conditions. AD CS objects require both objectClass and DN path matching (e.g., certificationAuthority + CN=Enrollment Services,...) to disambiguate subtypes (EnterpriseCA vs RootCA vs AIACA vs NtAuthStore).

Binary protocol parsing with nom7. Security descriptors, SIDs, ACLs, and ACEs are parsed from raw bytes using nom7 combinators. The parse_ntsecuritydescriptor function in enums/acl.rs (~2700 lines) is the largest single module — it extracts owner SID, group SID, DACL, and SACL, then maps each ACE to a AceTemplate with the BloodHound-expected RightName (e.g., GenericAll, WriteDacl, Owns, UserForceChangePassword).

BloodHound JSON envelope. Every output file wraps objects in a FinalJson structure with a Meta block specifying type, count, version: 6 (BloodHound CE 4.3+), and the collector version string.

Trait-based object model. The LdapObject trait in objects/common.rs defines a uniform interface (to_json(), getters/setters for aces, links, contained_by, etc.) that all 15 object types implement. This allows the checker module to operate generically over any object type.

Disk caching for large domains. The storage module provides BincodeObjectBuffer<T> — a buffered writer that serializes objects to bincode on disk, flushable in batches. The EntrySource trait abstracts over both in-memory and disk-backed iteration, unified in prepare_results_from_source.

Non-Obvious Details

LDAPS handling is automatic, not explicit. The prepare_ldap_url function switches between ldap:// and ldaps:// based on the --ldaps flag or if port 636 is specified. TLS is handled by ldap3’s LdapConnSettings with verification disabled. There’s no STARTTLS — only implicit TLS (LDAPS).

The noargs feature enables zero-argument execution on Windows domain-joined machines. When compiled with --features noargs, RustHound reads the domain from the HKLM\SYSTEM\CurrentControlSet\Services\Tcpip\Parameters\Domain registry key and auto-discovers the DC via SRV DNS lookup. It defaults to Kerberos auth and zip output — designed for dropping a single binary onto a compromised machine.

String obfuscation is minimal but present. The obfstr crate is used exactly once — to obfuscate the string "rusthound-ce" in the zip filename. This is a light anti-detection measure for the output artifact, not the binary itself.

ACL parsing is influenced by BloodHound.py. The ace_maker function in enums/acl.rs cites the fox-it/BloodHound.py ACL parser as a reference. The logic for mapping raw ACE types and extended rights GUIDs to BloodHound’s RightName values (e.g., GUID 3f78c3e5-f79a-46bd-a0b8-9d18116ddc79 → AllowedToAct) directly mirrors that Python implementation.

DCOnly mode is purely a semantic distinction. Both All and DCOnly execute the same LDAP enumeration. The CollectionMethod enum exists but is only used to populate the Meta.methods field in the output JSON — there’s no conditional logic that skips computer connections or modifies behavior. Actual remote collection (SMB session enumeration, etc.) is not implemented in this version.

Question

The json/parser/mod.rs file appears to be a legacy implementation of the parsing logic, largely duplicated by api.rs. It’s unclear whether it’s still actively used or is dead code from a refactoring pass.

Assessment

Strengths:

  • Clean single-crate architecture with a clear pipeline from LDAP to JSON. Easy to audit end-to-end.
  • The Storage trait abstraction for memory vs. disk is well-designed — handles large domains without unbounded memory growth.
  • Comprehensive AD CS support (Enterprise CA, Root CA, AIACA, NtAuthStore, Certificate Templates, Issuance Policies) goes beyond what many alternative collectors implement.
  • Cross-compilation is a first-class concern — the Makefile targets 8+ platforms including musl, ARM, and macOS.

Concerns:

  • TLS verification is hardcoded to disabled. In an AD pentest context this is typical (self-signed certs on DCs), but worth noting.
  • The LdapObject trait uses panic! for unimplemented methods (e.g., get_links() in User panics with “Not used by current object”). This will crash the process at runtime rather than returning an error. A todo!() or unimplemented!() macro or a default impl would be more idiomatic.
  • Error handling uses Box<dyn Error> throughout rather than a custom error type or anyhow/thiserror. This loses structured error context.
  • The json/parser/mod.rs module duplicates logic from api.rs and appears to be dead code.
  • Some identifiers suggest French authorship (e.g., inssuancepolicie.rs — misspelled “issuance”), but this is cosmetic.

Binary characteristics: The release profile uses opt-level = "z" (optimize for size), LTO, strip = true, codegen-units = 1, and panic = "abort" — a configuration optimized for small, static deployable binaries typical of offensive tools.

  • analyzing-bloodhound — BloodHound CE is the graph-based attack path analysis tool that RustHound produces data for. BloodHound consumes the JSON files RustHound generates to map privilege escalation paths in Active Directory.
  • analyzing-sharphound — SharpHound is the original C# data collector for BloodHound. RustHound CE reimplements SharpHound’s collection logic in Rust, offering a cross-platform alternative that doesn’t require .NET.