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 basicis 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
# 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(afterkinit) or check the account's "Allow log on through Windows Remote Management" privilege. - "The WS-Management service does not support the request" with Basic →
AllowUnencryptedis false on the server (the secure default). Either switch to--transport ntlm, or on the Windows server:Set-Item WSMan:\LocalHost\Service\AllowUnencrypted -Value $trueand enable Basic inWSMan:\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?