Winrm Active Directory Enumeration using HTTP (5985) winrm_ad_enum-py #!/usr/bin/env python3 """ WinRM Active Directory Object Enumerator (HTTP-only, port 5985) ================================================================ Connects to a Windows Domain Controller over WinRM/HTTP (port 5985) and retrieves ALL user and computer objects from Active Directory. About transport security: Port 5985 is plain HTTP at the transport level, but Windows WinRM still wraps the WS-Management SOAP payload in NTLM or Kerberos message-level encryption by default. That means credentials and AD data are NOT in cleartext on the wire, even though the URL is http://. The exception is the 'basic' transport: that one DOES send credentials base64-encoded and should never be used over HTTP unless you've explicitly enabled 'AllowUnencrypted=true' on the server (don't). Default transport here: NTLM with message-level encryption — safe for lab use and typical for AD-joined environments. Requirements: pip install pywinrm Usage: # Pull everything, preview on screen python winrm_ad_enum_http.py --host dc01.corp.local --user 'CORP\\admin' # Save to CSV in ./ad_export/ python winrm_ad_enum_http.py --host dc01.corp.local --user 'CORP\\admin' \\ --output-dir ./ad_export --format csv # Only computers, JSON output python winrm_ad_enum_http.py --host dc01.corp.local --user 'CORP\\admin' \\ --object-type computers --format json --output-dir ./ad_export # Restrict to a specific OU python winrm_ad_enum_http.py --host dc01.corp.local --user 'CORP\\admin' \\ --search-base "OU=Employees,DC=corp,DC=local" """ from __future__ import annotations import argparse import csv import getpass import json import socket import sys from contextlib import closing from datetime import datetime from pathlib import Path try: import winrm from winrm.exceptions import ( InvalidCredentialsError, WinRMOperationTimeoutError, WinRMTransportError, ) except ImportError: print("[!] The 'pywinrm' package is required. Install it with:") print(" pip install pywinrm") sys.exit(1) # -------------------------------------------------------------------------- # Output helpers # -------------------------------------------------------------------------- class C: OK = "\033[92m" WARN = "\033[93m" FAIL = "\033[91m" INFO = "\033[94m" BOLD = "\033[1m" END = "\033[0m" def banner(text: str) -> None: line = "=" * 72 print(f"\n{C.BOLD}{line}\n{text}\n{line}{C.END}") def info(msg: str) -> None: print(f"{C.INFO}[*]{C.END} {msg}") def ok(msg: str) -> None: print(f"{C.OK}[+]{C.END} {msg}") def warn(msg: str) -> None: print(f"{C.WARN}[!]{C.END} {msg}") def fail(msg: str) -> None: print(f"{C.FAIL}[-]{C.END} {msg}") # -------------------------------------------------------------------------- # Pre-flight checks # -------------------------------------------------------------------------- def check_tcp_port(host: str, port: int, timeout: float = 5.0) -> bool: """Quick TCP probe before attempting the WinRM handshake.""" info(f"Testing TCP connectivity to {host}:{port} ...") try: with closing(socket.create_connection((host, port), timeout=timeout)): ok(f"TCP port {port} is OPEN on {host}") return True except socket.timeout: fail(f"TCP port {port} timed out (firewall? listener not configured?)") except ConnectionRefusedError: fail(f"TCP port {port} refused the connection (HTTP listener not configured?)") except socket.gaierror: fail(f"Could not resolve hostname '{host}' (DNS issue)") except OSError as e: fail(f"TCP error on port {port}: {e}") return False # -------------------------------------------------------------------------- # WinRM/HTTP session # -------------------------------------------------------------------------- def open_session(host: str, port: int, user: str, password: str, transport: str) -> winrm.Session: endpoint = f"http://{host}:{port}/wsman" info(f"Opening WinRM session over HTTP: {endpoint} (transport={transport})") if transport == "basic": warn("Using 'basic' over HTTP — credentials will be sent base64-encoded " "in cleartext. Only do this in a trusted lab.") return winrm.Session( endpoint, auth=(user, password), transport=transport, # server_cert_validation is irrelevant for HTTP, but pywinrm accepts it server_cert_validation="ignore", ) def run_ps(session: winrm.Session, script: str) -> tuple[int, str, str]: """Run a PowerShell script remotely. Return (exit_code, stdout, stderr).""" r = session.run_ps(script) return ( r.status_code, r.std_out.decode("utf-8", errors="replace"), r.std_err.decode("utf-8", errors="replace"), ) def ping_winrm(session: winrm.Session) -> bool: """Confirm the session works with a trivial command before the heavy lifting.""" try: code, out, err = run_ps(session, "$env:COMPUTERNAME") if code == 0: ok(f"WinRM authenticated. Remote computer: {out.strip()}") return True fail(f"WinRM command returned exit code {code}: {err.strip()}") except InvalidCredentialsError: fail("Invalid credentials.") except WinRMTransportError as e: msg = str(e) fail(f"WinRM transport error: {msg}") # Common HTTP-specific hint if "401" in msg or "Unauthorized" in msg: print(" Hint: on HTTP, the server often requires NTLM/Kerberos " "(not Basic). Try --transport ntlm.") elif "AllowUnencrypted" in msg: print(" Hint: server requires encryption. NTLM/Kerberos provide " "message-level encryption — use --transport ntlm.") except WinRMOperationTimeoutError as e: fail(f"WinRM operation timed out: {e}") except Exception as e: fail(f"Unexpected WinRM error: {type(e).__name__}: {e}") return False # -------------------------------------------------------------------------- # Active Directory enumeration scripts # -------------------------------------------------------------------------- AD_PROBE_PS = r""" $ErrorActionPreference = 'Stop' try { Import-Module ActiveDirectory -ErrorAction Stop Write-Output 'MODULE_OK' } catch { Write-Output "MODULE_MISSING: $($_.Exception.Message)" exit 2 } """ def build_users_query(search_base: str | None) -> str: base_param = f"-SearchBase '{search_base}'" if search_base else "" return rf""" $ErrorActionPreference = 'Stop' Import-Module ActiveDirectory $props = @( 'SamAccountName','UserPrincipalName','DisplayName','GivenName','Surname', 'EmailAddress','Title','Department','Company','Manager','Office', 'Enabled','LockedOut','PasswordLastSet','PasswordNeverExpires', 'LastLogonDate','WhenCreated','WhenChanged','DistinguishedName', 'MemberOf','SID','ObjectGUID' ) $users = Get-ADUser -Filter * {base_param} ` -ResultPageSize 1000 -Properties $props | ForEach-Object {{ [PSCustomObject]@{{ SamAccountName = $_.SamAccountName UserPrincipalName = $_.UserPrincipalName DisplayName = $_.DisplayName GivenName = $_.GivenName Surname = $_.Surname EmailAddress = $_.EmailAddress Title = $_.Title Department = $_.Department Company = $_.Company Office = $_.Office Enabled = $_.Enabled LockedOut = $_.LockedOut PasswordNeverExpires = $_.PasswordNeverExpires PasswordLastSet = if ($_.PasswordLastSet) {{ $_.PasswordLastSet.ToString('o') }} else {{ $null }} LastLogonDate = if ($_.LastLogonDate) {{ $_.LastLogonDate.ToString('o') }} else {{ $null }} WhenCreated = if ($_.WhenCreated) {{ $_.WhenCreated.ToString('o') }} else {{ $null }} WhenChanged = if ($_.WhenChanged) {{ $_.WhenChanged.ToString('o') }} else {{ $null }} DistinguishedName = $_.DistinguishedName SID = $_.SID.Value ObjectGUID = $_.ObjectGUID.Guid GroupCount = @($_.MemberOf).Count }} }} # ConvertTo-Json on a single object emits an object, not an array. # The leading comma forces an array context. ,@($users) | ConvertTo-Json -Depth 4 -Compress """ def build_computers_query(search_base: str | None) -> str: base_param = f"-SearchBase '{search_base}'" if search_base else "" return rf""" $ErrorActionPreference = 'Stop' Import-Module ActiveDirectory $props = @( 'Name','DNSHostName','SamAccountName','Enabled','OperatingSystem', 'OperatingSystemVersion','OperatingSystemServicePack','IPv4Address', 'IPv6Address','LastLogonDate','PasswordLastSet','WhenCreated','WhenChanged', 'DistinguishedName','SID','ObjectGUID','Description','ManagedBy' ) $computers = Get-ADComputer -Filter * {base_param} ` -ResultPageSize 1000 -Properties $props | ForEach-Object {{ [PSCustomObject]@{{ Name = $_.Name DNSHostName = $_.DNSHostName SamAccountName = $_.SamAccountName Enabled = $_.Enabled OperatingSystem = $_.OperatingSystem OperatingSystemVersion = $_.OperatingSystemVersion OperatingSystemSP = $_.OperatingSystemServicePack IPv4Address = $_.IPv4Address IPv6Address = $_.IPv6Address Description = $_.Description ManagedBy = $_.ManagedBy LastLogonDate = if ($_.LastLogonDate) {{ $_.LastLogonDate.ToString('o') }} else {{ $null }} PasswordLastSet = if ($_.PasswordLastSet) {{ $_.PasswordLastSet.ToString('o') }} else {{ $null }} WhenCreated = if ($_.WhenCreated) {{ $_.WhenCreated.ToString('o') }} else {{ $null }} WhenChanged = if ($_.WhenChanged) {{ $_.WhenChanged.ToString('o') }} else {{ $null }} DistinguishedName = $_.DistinguishedName SID = $_.SID.Value ObjectGUID = $_.ObjectGUID.Guid }} }} ,@($computers) | ConvertTo-Json -Depth 4 -Compress """ # -------------------------------------------------------------------------- # Run queries and parse results # -------------------------------------------------------------------------- def parse_json_output(raw: str) -> list[dict]: """Strip BOM/whitespace and always return a list of dicts.""" raw = raw.strip().lstrip("\ufeff") if not raw: return [] data = json.loads(raw) if isinstance(data, dict): return [data] return list(data) def enumerate_objects(session: winrm.Session, object_type: str, search_base: str | None) -> list[dict]: if object_type == "users": info("Enumerating ALL user objects from Active Directory ...") script = build_users_query(search_base) elif object_type == "computers": info("Enumerating ALL computer objects from Active Directory ...") script = build_computers_query(search_base) else: raise ValueError(f"Unknown object_type: {object_type}") code, out, err = run_ps(session, script) if code != 0: fail(f"PowerShell query failed (exit {code})") if err.strip(): print(f" stderr: {err.strip()}") return [] try: objects = parse_json_output(out) except json.JSONDecodeError as e: fail(f"Could not parse JSON returned by PowerShell: {e}") print(" First 500 chars of output:") print(f" {out[:500]}") return [] ok(f"Retrieved {len(objects)} {object_type} from Active Directory.") return objects # -------------------------------------------------------------------------- # Output writers # -------------------------------------------------------------------------- def print_table(objects: list[dict], object_type: str, limit: int = 20) -> None: """Print a brief preview table to stdout (full data goes to files).""" if not objects: warn(f"No {object_type} to display.") return if object_type == "users": cols = ["SamAccountName", "DisplayName", "Enabled", "LastLogonDate", "Department"] else: cols = ["Name", "DNSHostName", "OperatingSystem", "Enabled", "LastLogonDate"] widths = {c: max(len(c), 8) for c in cols} for obj in objects[:limit]: for c in cols: v = str(obj.get(c, "") or "") widths[c] = min(max(widths[c], len(v)), 40) print() header = " | ".join(c.ljust(widths[c]) for c in cols) print(f"{C.BOLD}{header}{C.END}") print("-+-".join("-" * widths[c] for c in cols)) for obj in objects[:limit]: row = " | ".join(str(obj.get(c, "") or "")[:widths[c]].ljust(widths[c]) for c in cols) print(row) if len(objects) > limit: print(f"... ({len(objects) - limit} more rows not shown — see exported file)") def write_csv(objects: list[dict], path: Path) -> None: if not objects: warn(f"Nothing to write to {path}") return fieldnames: list[str] = [] seen: set[str] = set() for obj in objects: for k in obj.keys(): if k not in seen: seen.add(k) fieldnames.append(k) with path.open("w", newline="", encoding="utf-8") as f: writer = csv.DictWriter(f, fieldnames=fieldnames) writer.writeheader() writer.writerows(objects) ok(f"Wrote {len(objects)} rows -> {path}") def write_json(objects: list[dict], path: Path) -> None: with path.open("w", encoding="utf-8") as f: json.dump(objects, f, indent=2, ensure_ascii=False) ok(f"Wrote {len(objects)} objects -> {path}") # -------------------------------------------------------------------------- # CLI # -------------------------------------------------------------------------- def parse_args() -> argparse.Namespace: p = argparse.ArgumentParser( description="Enumerate all users and computers from AD over WinRM/HTTP (port 5985).", formatter_class=argparse.RawDescriptionHelpFormatter, epilog=__doc__, ) p.add_argument("--host", required=True, help="Target Domain Controller hostname or IP") p.add_argument("--user", required=True, help=r"Username (DOMAIN\user, user@domain.local, etc.)") p.add_argument("--password", help="Password (prompted if omitted)") p.add_argument("--port", type=int, default=5985, help="HTTP WinRM port (default 5985)") p.add_argument( "--transport", default="ntlm", choices=["ntlm", "kerberos", "basic", "credssp"], help="WinRM auth transport (default: ntlm). 'basic' is INSECURE over HTTP.", ) p.add_argument( "--object-type", choices=["users", "computers", "all"], default="all", help="Which object type(s) to retrieve (default: all)", ) p.add_argument("--search-base", metavar="DN", help="Restrict search to an OU/DN (e.g. 'OU=Sales,DC=corp,DC=local')") p.add_argument("--output-dir", metavar="DIR", help="Directory to write CSV/JSON files into (created if missing)") p.add_argument( "--format", choices=["csv", "json", "both"], default="csv", help="Output file format when --output-dir is given (default: csv)", ) p.add_argument("--preview-rows", type=int, default=20, help="How many rows to show in the on-screen preview (default 20)") return p.parse_args() def main() -> int: args = parse_args() password = args.password or getpass.getpass(f"Password for {args.user}: ") banner(f"WinRM/HTTP Active Directory enumeration — {args.host}:{args.port}") warn("Using HTTP transport (port 5985). NTLM/Kerberos provide message-level " "encryption; Basic does not.") if not check_tcp_port(args.host, args.port): fail("Aborting: HTTP port unreachable.") return 1 try: session = open_session( host=args.host, port=args.port, user=args.user, password=password, transport=args.transport, ) except Exception as e: fail(f"Could not build WinRM session: {e}") return 1 if not ping_winrm(session): return 1 code, out, err = run_ps(session, AD_PROBE_PS) if "MODULE_OK" not in out: fail("ActiveDirectory PowerShell module is not available on the remote host.") print(f" stdout: {out.strip()}") print(f" stderr: {err.strip()}") print(" Tip: target a Domain Controller, or install RSAT-AD-PowerShell.") return 1 ok("ActiveDirectory module is loaded on the remote host.") out_dir: Path | None = None if args.output_dir: out_dir = Path(args.output_dir).expanduser().resolve() out_dir.mkdir(parents=True, exist_ok=True) info(f"Output directory: {out_dir}") timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") types_to_fetch = ["users", "computers"] if args.object_type == "all" else [args.object_type] exit_code = 0 for ot in types_to_fetch: banner(f"Active Directory — {ot.upper()}") objects = enumerate_objects(session, ot, args.search_base) if not objects: exit_code = exit_code or 2 continue print_table(objects, ot, limit=args.preview_rows) if out_dir: stem = f"ad_{ot}_{timestamp}" if args.format in ("csv", "both"): write_csv(objects, out_dir / f"{stem}.csv") if args.format in ("json", "both"): write_json(objects, out_dir / f"{stem}.json") banner("Done") return exit_code if __name__ == "__main__": try: sys.exit(main()) except KeyboardInterrupt: print("\n[!] Interrupted by user.") sys.exit(130) What changed vs the HTTPS version The script is identical in what it does — enumerate every user and computer with the same fields, same CSV/JSON output, same pagination — but the transport is different:   HTTPS version HTTP version (this one) URL scheme https://... http://... Default port 5986 5985 TLS check check_tls() inspects cert removed (no TLS to inspect) --verify-ssl flag yes removed (no TLS) --transport ssl option yes removed (no TLS) --transport basic available, encrypted by TLS available, explicit warning — credentials base64-only About "HTTP" being a misleading label The most common misconception about WinRM on port 5985 is that it sends data in cleartext. It usually doesn't. Here's the actual picture: NTLM transport (this script's default) wraps the SOAP payload in NTLM message-level encryption (SPNEGO/GSSAPI sealing). Even though the URL says http:// , the body on the wire is encrypted with a session key derived from your credentials. Someone sniffing the network sees ciphertext. Kerberos transport does the same with Kerberos session keys. CredSSP transport also encrypts, plus enables credential delegation. Basic transport is the only option that sends credentials base64-encoded and the payload in cleartext. Windows refuses Basic over HTTP by default ( AllowUnencrypted=false ). That's why I added the explicit warning when --transport basic is selected — and you really shouldn't use it outside a closed lab. So in practice, this HTTP script is approximately as confidential as the HTTPS one as long as you stick with NTLM or Kerberos . The HTTPS version still wins for two reasons: it doesn't rely on the auth-protocol's sealing being correctly negotiated, and TLS gives you server-identity verification (the cert), which NTLM alone does not. Usage examples bash # Default: NTLM auth over HTTP/5985 python winrm_ad_enum_http.py --host dc01.corp.local --user 'CORP\admin' # Save everything to CSV python winrm_ad_enum_http.py --host dc01.corp.local --user 'CORP\admin' \ --output-dir ./ad_export --format csv # Only computers, JSON output, narrowed to one OU python winrm_ad_enum_http.py --host dc01.corp.local --user 'CORP\admin' \ --object-type computers --format json \ --search-base "OU=Servers,DC=corp,DC=local" \ --output-dir ./ad_export # Kerberos auth (Linux client needs valid ticket from `kinit`) python winrm_ad_enum_http.py --host dc01.corp.local --user admin@CORP.LOCAL \ --transport kerberos # Lab-only: Basic auth (server must have AllowUnencrypted=true and Basic enabled) python winrm_ad_enum_http.py --host dc01.local --user admin --transport basic If you get a 401 or "AllowUnencrypted" error The script catches WinRMTransportError and prints a hint, but here's the field reference: 401 Unauthorized with NTLM → The server may require Kerberos, or the user has no logon right. Try --transport kerberos (after kinit ) or check the account's "Allow log on through Windows Remote Management" privilege. "The WS-Management service does not support the request" with Basic → AllowUnencrypted is false on the server (the secure default). Either switch to --transport ntlm , or on the Windows server: Set-Item WSMan:\LocalHost\Service\AllowUnencrypted -Value $true and enable Basic in WSMan:\LocalHost\Service\Auth\Basic . (Lab only — don't do this in production.) 403 / "TrustedHosts" errors generally come from the client side on Windows, not Linux/pywinrm — not applicable here. You now have three scripts winrm_ad_test.py # Both ports + connectivity tests + many AD queries winrm_ad_enum.py # HTTPS-only, bulk user/computer export winrm_ad_enum_http.py # HTTP-only, bulk user/computer export ← this one Want me to merge the two enumeration scripts into a single dual-mode tool with a --scheme http|https flag so you only have to maintain one file?