Build an Active Directory Vulnerability Scanner

Scan Active Directory for misconfigured shares and AD CS certificate vulnerabilities.

Introduction

30 Second Summary

When a penetration tester gets domain credentials, the first move is checking what shares are readable and whether certificate templates are misconfigured. Commercial scanners do this in seconds, but they hide the LDAP queries that power the detection.

In this project, you will build a Python command-line scanner that queries an Active Directory domain controller for readable network shares and AD CS certificate misconfigurations, producing a structured report with severity ratings and remediation guidance.

What You'll Build

Point your scanner at a domain controller, supply credentials, and watch it print a table of open shares followed by a severity-rated breakdown of every exploitable certificate template it finds.

By the end of this project, you'll have:

  • A working share enumeration where you run the scanner and see which network shares your user can read across every domain-joined computer.
  • Automated detection of ESC1, ESC2, ESC8, and ESC16 misconfigurations with plain-English explanations of what each finding means and how an attacker would exploit it.
  • A JSON findings report with severity ratings, affected objects, attack paths, and remediation advice ready for security assessment documentation.
  • Secret Mission: Add ESC4 detection by parsing template ACL permissions to find non-admin principals with dangerous write access to certificate templates.

Are there any prerequisites?

This project assumes basic Python knowledge and familiarity with Active Directory concepts like domains, users, and LDAP queries.

You will need Kali Linux (via WSL or a VM), VS Code, Python 3, and access to a HackTheBox lab environment with an Active Directory target.

Before We Start

Before we dive in, let's lock in what you're about to build and why it matters.

You're building a Python CLI scanner that detects two of the most critical Active Directory attack surfaces: overshared network shares and misconfigured certificate templates. Point it at a domain controller with valid credentials, and it produces a structured security report covering readable SMB shares and AD CS ESC vulnerabilities.

Set Up the Scanner Project

Your scanner needs two Python libraries to talk to Active Directory. ldap3 handles LDAP queries against the domain controller. impacket handles SMB connections to probe network shares for read access.

In this step, you will create an isolated project environment, install both libraries, and build the command-line skeleton that accepts target information. Everything else you build in later steps plugs into this skeleton.

In this step, get ready to:
  • Create the project directory and a Python virtual environment.
  • Install ldap3 and impacket, then verify imports work.
  • Create the scanner CLI skeleton that accepts domain, username, password, and DC IP arguments.
Create the project directory and virtual environment

A virtual environment isolates your scanner's dependencies from the rest of your system. This keeps your Kali installation clean and ensures consistent library versions.

  • Open your terminal in Kali Linux (WSL).
  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your project folder and move into it by running these commands:
mkdir ad-scanner
cd ad-scanner
  • Create a virtual environment and activate it by running these commands:
python3 -m venv venv
source venv/bin/activate
  • Confirm the virtual environment is active by checking your terminal prompt. You should see (venv) at the beginning of your prompt line.

Don't see (venv) in your prompt?

Make sure you ran source venv/bin/activate (not just python3 -m venv venv). The first command creates the environment. The second activates it.

help me activate my Python virtual environment in Kali Linux

Install ldap3 and impacket

With your virtual environment active, every package you install stays inside the venv folder. Nothing touches your system Python.

  • Install both packages by running:
pip install ldap3 impacket

What are these packages?

  • ldap3 is a pure-Python LDAP client. Your scanner uses it to query the domain controller for computer objects, certificate templates, and CA configurations.
  • impacket is a collection of network protocol implementations. Your scanner uses its SMBConnection class to connect to network shares and test read access.
  • Verify both packages imported successfully by running:
python3 -c "from ldap3 import Server, Connection; from impacket.smbconnection import SMBConnection; print('Ready')"
  • You should see Ready printed with no errors.

Seeing a ModuleNotFoundError?

Check that (venv) still appears in your prompt. If it disappeared, re-activate with source venv/bin/activate and try the import again.

If the install itself failed, check your internet connection and run pip install ldap3 impacket again.

help me fix ModuleNotFoundError for ldap3 or impacket

Create the scanner CLI skeleton

Every scan needs four pieces of information: the target domain, a username, a password, and the domain controller's IP. Python's argparse module lets you define these as command-line flags so the scanner is reusable across different engagements.

  • Open the ad-scanner folder in VS Code by running:
code .
  • In VS Code, click File in the top menu bar.
  • Click New File.
  • Name the file scanner.py and save it in the ad-scanner folder.
  • Paste the following code into scanner.py:
import argparse

def main():
    parser = argparse.ArgumentParser(description='AD Vulnerability Scanner')
    parser.add_argument('--domain', required=True, help='Active Directory domain name')
    parser.add_argument('--username', required=True, help='Domain username')
    parser.add_argument('--password', required=True, help='Domain password')
    parser.add_argument('--dc-ip', required=True, help='Domain controller IP address')
    parser.add_argument('--adcs', action='store_true', help='Run AD CS vulnerability checks')
    args = parser.parse_args()
    print(f'[*] Target domain: {args.domain}')
    print(f'[*] DC IP: {args.dc_ip}')

if __name__ == '__main__':
    main()

What does this code do?

  • argparse.ArgumentParser creates a command-line interface. Each add_argument call defines a flag the user must provide.
  • The four required flags (--domain, --username, --password, --dc-ip) collect the credentials and target info every scan needs.
  • The --adcs flag is optional. When passed, it tells the scanner to also check for certificate misconfigurations. You will implement this in Step 3.
  • The if __name__ == '__main__': guard ensures main() only runs when the file is executed directly.
  • Save scanner.py.

Before you run the scanner, what do you think it will print when you pass test values for the domain and DC IP?

  • Test the CLI skeleton by running:
python3 scanner.py --domain test.local --username admin --password pass --dc-ip 10.0.0.1
  • You should see:
[*] Target domain: test.local
[*] DC IP: 10.0.0.1

The scanner accepted your arguments and echoed them back. This confirms argparse is wired up correctly.

Seeing 'unrecognized arguments' or a syntax error?

Make sure your file matches the code above exactly. Check that each add_argument line has matching parentheses and that the flag names use double dashes (--domain, not -domain).

Also confirm you are running the command from inside the ad-scanner folder where scanner.py lives.

help me fix my argparse scanner skeleton

✔️ Awesome, I've got everything!

Great. Double check you have saved scanner.py and that your virtual environment is still active.

ⓧ I'd like to double check the full code

Here is the complete scanner.py at this point:

import argparse

def main():
    parser = argparse.ArgumentParser(description='AD Vulnerability Scanner')
    parser.add_argument('--domain', required=True, help='Active Directory domain name')
    parser.add_argument('--username', required=True, help='Domain username')
    parser.add_argument('--password', required=True, help='Domain password')
    parser.add_argument('--dc-ip', required=True, help='Domain controller IP address')
    parser.add_argument('--adcs', action='store_true', help='Run AD CS vulnerability checks')
    args = parser.parse_args()
    print(f'[*] Target domain: {args.domain}')
    print(f'[*] DC IP: {args.dc_ip}')

if __name__ == '__main__':
    main()

Your project structure is in place and both libraries are ready. Next up, you will write the LDAP query that discovers every computer on the domain and probe their network shares for read access.

Enumerate Readable Network Shares

Your scanner skeleton can parse arguments, but it does nothing with them yet. Network shares are the first thing an attacker checks after obtaining domain credentials. Overly permissive shares leak sensitive data like scripts, configs, and credentials.

In this step, you will connect to the domain controller over LDAP, pull a list of every domain-joined computer, then use SMB to probe each machine's shares for read access. The result is a formatted table showing exactly which shares your user can read.

In this step, get ready to:
  • Query LDAP for all domain computers and extract their DNS hostnames.
  • Probe each computer's SMB shares and test read access.
  • Print results as a formatted table showing computer, share, and access status.
Connect to LDAP and build helper functions

Before you can query anything, the scanner needs an authenticated LDAP connection to the domain controller. You also need a helper that converts a domain name like example.com into the LDAP base DN format DC=example,DC=com.

  • In scanner.py, replace the single import argparse line at the top with the following imports and helper functions:
import argparse
from ldap3 import Server, Connection, ALL, NTLM, SUBTREE
from impacket.smbconnection import SMBConnection

def get_ldap_connection(dc_ip, domain, username, password):
    # Authenticate to the DC using NTLM over LDAP
    server = Server(dc_ip, get_info=ALL)
    conn = Connection(
        server,
        user=f'{domain}\\{username}',
        password=password,
        authentication=NTLM,
        auto_bind=True
    )
    return conn

def get_base_dn(domain):
    # Convert 'corp.local' into 'DC=corp,DC=local'
    return ','.join([f'DC={part}' for part in domain.split('.')])

What does this code do?

get_ldap_connection creates an authenticated LDAP session to the domain controller. The NTLM authentication method lets you pass DOMAIN\username just like Windows tools do. auto_bind=True means the connection authenticates immediately on creation.

get_base_dn splits the domain name on dots and wraps each part with DC=. This gives LDAP the search root for all queries against this domain.

Query LDAP for all domain computers

Every domain-joined computer has an object in Active Directory. You can find them all with the LDAP filter (objectClass=computer) and extract each machine's dNSHostName attribute.

  • Add the enumerate_computers function directly below get_base_dn (and above def main()):
def enumerate_computers(conn, base_dn):
    # Search AD for all computer objects
    conn.search(
        search_base=base_dn,
        search_filter='(objectClass=computer)',
        search_scope=SUBTREE,
        attributes=['dNSHostName', 'cn']
    )
    # Collect valid hostnames from results
    computers = []
    for entry in conn.entries:
        dns_name = str(entry.dNSHostName) if entry.dNSHostName else str(entry.cn)
        if dns_name and dns_name != 'None':
            computers.append(dns_name)
    return computers

What does this code do?

conn.search() runs an LDAP query across the entire domain (SUBTREE scope means it searches all sub-containers). It requests two attributes: dNSHostName (the machine's DNS name) and cn (common name as a fallback).

The loop collects every hostname that resolves to a real value. Some computer objects lack a dNSHostName if they have never fully joined, so the code falls back to cn.

  • Now wire these functions into main(). Find this line inside your main() function:
    print(f'[*] DC IP: {args.dc_ip}')
  • Paste the following code directly below that line:
    conn = get_ldap_connection(args.dc_ip, args.domain, args.username, args.password)
    base_dn = get_base_dn(args.domain)

    print('[*] Enumerating domain computers...')
    computers = enumerate_computers(conn, base_dn)
    print(f'[*] Found {len(computers)} computers')

What does this code do?

These lines connect to LDAP with the user-supplied credentials and derive the base DN from the domain name. The scanner immediately queries for all computers and prints how many were found, giving you a quick sanity check.

  • Save scanner.py.
  • Before you run the scanner, what do you expect to see? Think about whether the LDAP connection will succeed and what output you would get.
  • Run the scanner against your HackTheBox target by running:
python3 scanner.py --domain [[HTB_DOMAIN="yourlab.htb"]] --username [[HTB_USER="your HTB username"]] --password your-htb-password --dc-ip [[DC_IP="your DC IP address"]]

You should see output like:

[*] Target domain: yourlab.htb
[*] DC IP: 10.10.x.x
[*] Enumerating domain computers...
[*] Found 1 computers

The exact number depends on your lab environment. Seeing a non-zero count confirms your LDAP connection and query are working.

Seeing a connection error?

  • Make sure your HackTheBox VPN is connected and the target machine is running.
  • Double-check the --dc-ip value matches your lab's DC IP (shown in the HTB machine page).
  • Verify your credentials are correct. A typo in the domain name or username will cause an NTLM authentication failure.

help me troubleshoot my LDAP connection error in the AD scanner

Probe each computer's SMB shares for read access

Now that you have a list of computers, the scanner needs to connect to each one via SMB and test which shares your user can read. The standard approach is to call listPath(shareName, '*') on each share. If it succeeds, you have read access. If it throws an exception, you do not.

  • Add the enumerate_shares function below enumerate_computers (and above def main()):
def enumerate_shares(computers, domain, username, password):
    findings = []
    for computer in computers:
        try:
            # Connect to each computer via SMB
            smb = SMBConnection(computer, computer, timeout=5)
            smb.login(username, password, domain)
            shares = smb.listShares()
            for share in shares:
                share_name = share['shi1_netname'][:-1]
                # Test read access by listing files
                access = 'NO ACCESS'
                try:
                    smb.listPath(share_name, '*')
                    access = 'READ'
                except Exception:
                    pass
                findings.append({
                    'computer': computer,
                    'share': share_name,
                    'access': access
                })
            smb.logoff()
        except Exception as e:
            # Record unreachable computers
            findings.append({
                'computer': computer,
                'share': 'N/A',
                'access': f'UNREACHABLE: {str(e)[:40]}'
            })
    return findings

What does this code do?

For each computer, the function opens an SMB connection and authenticates with the domain credentials. listShares() returns all shares exposed by that machine. Each share name comes with a trailing null character from the SMB protocol, so [:-1] strips it off.

The inner try/except calls listPath(share_name, '*') to attempt a directory listing. If this succeeds, the user has read access. If it raises an exception (typically access denied), the share is marked NO ACCESS.

The outer try/except catches machines that are offline or unreachable, recording them without crashing the entire scan.

  • Add the print_share_table function below enumerate_shares (and above def main()):
def print_share_table(findings):
    # Format findings as aligned columns
    print('\n[*] Network Share Enumeration Results')
    print(f'{"Computer":<40} {"Share":<25} {"Access":<20}')
    print('-' * 85)
    for f in findings:
        print(f'{f["computer"]:<40} {f["share"]:<25} {f["access"]:<20}')

What does this code do?

This function formats the raw findings list into an aligned table. Python's f-string formatting with :<40 left-aligns each column to a fixed width, making the output scannable at a glance.

  • Now wire the share enumeration into main(). Find this line you added earlier:
    print(f'[*] Found {len(computers)} computers')
  • Paste the following code directly below it:
    print('[*] Probing SMB shares...')
    share_findings = enumerate_shares(computers, args.domain, args.username, args.password)
    print_share_table(share_findings)
  • Save scanner.py.

✔️ Awesome, I've got everything!

Great. Double check you have saved scanner.py and move on to the verification below.

ⓧ I'd like to double check the full code

Your scanner.py is now over 30 lines, so confirm each piece is present in this order:

  • Imports block (argparse, ldap3, impacket.smbconnection).
  • get_ldap_connection function.
  • get_base_dn function.
  • enumerate_computers function.
  • enumerate_shares function.
  • print_share_table function.
  • main() function with argparse, LDAP connection, computer enumeration, share probing, and table printing.
  • if __name__ == '__main__': block at the bottom.

Before you run, predict: what shares do you expect to see on a domain controller? Do you think your user will have read access to all of them?

  • Run the full scanner by running:
python3 scanner.py --domain [[HTB_DOMAIN="yourlab.htb"]] --username [[HTB_USER="your HTB username"]] --password your-htb-password --dc-ip [[DC_IP="your DC IP address"]]

You should see a table like this:

[*] Target domain: yourlab.htb
[*] DC IP: 10.10.x.x
[*] Enumerating domain computers...
[*] Found 1 computers
[*] Probing SMB shares...

[*] Network Share Enumeration Results
Computer                                 Share                     Access
-------------------------------------------------------------------------------------
dc01.yourlab.htb                         ADMIN$                    NO ACCESS
dc01.yourlab.htb                         C$                        NO ACCESS
dc01.yourlab.htb                         IPC$                      READ
dc01.yourlab.htb                         NETLOGON                  READ
dc01.yourlab.htb                         SYSVOL                    READ

Your exact output will differ based on the HTB lab, but you should see at least IPC$ and SYSVOL with READ access on the DC. Administrative shares like ADMIN$ and C$ should show NO ACCESS for a standard domain user.

Seeing UNREACHABLE for all computers?

  • Confirm the target machine is running in your HackTheBox panel. Machines can time out if idle.
  • Check that DNS resolution works. If the computer's dNSHostName does not resolve, SMB cannot connect. Try adding the DC hostname to /etc/hosts with the correct IP.
  • Verify SMB port 445 is reachable from your Kali box with nmap -p 445 against the target IP.

help me troubleshoot why my SMB share enumeration shows all computers as unreachable

Your scanner now reveals which network shares leak data to authenticated domain users. Next up, you will add detection for AD CS certificate template misconfigurations, which represent an even more critical attack surface.

Detect AD CS ESC1 and ESC2 Vulnerabilities

Your scanner found shares, but the real crown jewels in Active Directory are misconfigured certificate templates. AD CS (Active Directory Certificate Services) templates control who can request certificates and what those certificates can do.

ESC1 is the single most exploited AD CS misconfiguration. It lets any domain user forge a certificate as Domain Admin. In this step, you will query the Configuration partition for certificate template objects and check specific LDAP attribute flags to detect these dangerous misconfigurations.

In this step, get ready to:
  • Query LDAP for all certificate templates in the Configuration naming context.
  • Implement ESC1 and ESC2 detection by checking attribute flags and EKU OIDs.
  • Build a vulnerability descriptions module that prints severity-rated findings with attack paths and remediation.
Query certificate templates from LDAP

Certificate templates live in a specific location within the AD Configuration partition. Your scanner needs to know this path and which attributes to pull from each template object.

  • In scanner.py, find the existing import line that says import argparse and add import json on the line directly below it.
  • Add the following constants after your import statements but before the get_ldap_connection function, then add the two new functions after print_share_table:
CLIENT_AUTH_OID = '1.3.6.1.5.5.7.3.2'
ANY_PURPOSE_OID = '2.5.29.37.0'
CT_FLAG_ENROLLEE_SUPPLIES_SUBJECT = 0x1
CT_FLAG_PEND_ALL_REQUESTS = 0x2

What are these constants?

  • CLIENT_AUTH_OID is the OID for Client Authentication. Templates with this EKU allow certificate holders to authenticate as any user.
  • ANY_PURPOSE_OID means the certificate has no usage restriction at all.
  • The CT_FLAG_ constants are bitmask values stored in template LDAP attributes that control enrollment behaviour.
  • Add these two functions after print_share_table in scanner.py:
def get_config_base(base_dn):
    return f'CN=Certificate Templates,CN=Public Key Services,CN=Services,CN=Configuration,{base_dn}'

def enumerate_certificate_templates(conn, base_dn):
    # Query the Configuration partition for all certificate templates
    config_base = get_config_base(base_dn)
    conn.search(
        search_base=config_base,
        search_filter='(objectClass=pKICertificateTemplate)',
        search_scope=SUBTREE,
        attributes=['cn', 'msPKI-Certificate-Name-Flag', 'msPKI-Enrollment-Flag',
                    'msPKI-RA-Signature', 'pKIExtendedKeyUsage']
    )
    return conn.entries

What does this code do?

  • get_config_base builds the LDAP search path to the Certificate Templates container inside the Configuration naming context.
  • enumerate_certificate_templates queries for all pKICertificateTemplate objects and pulls the five attributes needed for vulnerability detection.
  • msPKI-Certificate-Name-Flag controls whether the enrollee can specify the Subject Alternative Name (SAN) in their certificate request.
  • Save scanner.py.
  • Verify the module loads cleanly by running:
python3 -c "from scanner import enumerate_certificate_templates; print('Template query ready')"

You should see Template query ready printed with no errors.

Seeing an ImportError or SyntaxError?

  • Check that import json is on its own line directly below import argparse.
  • Make sure the constants are placed after the imports but before any function definitions.
  • Confirm the f-string in get_config_base uses curly braces around base_dn.

Why is my scanner module failing to import?

Implement ESC1 and ESC2 detection logic

ESC1 triggers when a template lets the enrollee supply their own Subject Alternative Name AND the template grants Client Authentication without requiring manager approval. ESC2 triggers when a template has the Any Purpose EKU or no EKU restrictions at all.

  • Add the detect_esc1 function below enumerate_certificate_templates in scanner.py:
def detect_esc1(template):
    # ESC1: enrollee supplies SAN + client auth EKU + no manager approval
    try:
        name_flag = int(str(template['msPKI-Certificate-Name-Flag'])) if template['msPKI-Certificate-Name-Flag'] else 0
        enrollment_flag = int(str(template['msPKI-Enrollment-Flag'])) if template['msPKI-Enrollment-Flag'] else 0
        eku_list = [str(oid) for oid in template['pKIExtendedKeyUsage']] if template['pKIExtendedKeyUsage'] else []
        enrollee_supplies_subject = bool(name_flag & CT_FLAG_ENROLLEE_SUPPLIES_SUBJECT)
        pend_all = bool(enrollment_flag & CT_FLAG_PEND_ALL_REQUESTS)
        has_client_auth = CLIENT_AUTH_OID in eku_list
        return enrollee_supplies_subject and has_client_auth and not pend_all
    except Exception:
        return False

What does detect_esc1 check?

  • Bit 0x1 in msPKI-Certificate-Name-Flag means the enrollee can specify who the certificate is issued to (CT_FLAG_ENROLLEE_SUPPLIES_SUBJECT).
  • The Client Authentication OID 1.3.6.1.5.5.7.3.2 in pKIExtendedKeyUsage means the certificate can be used for domain authentication.
  • Bit 0x2 NOT set in msPKI-Enrollment-Flag means no manager approval is needed. Combined, an attacker requests a cert as Domain Admin with zero oversight.
  • Add the detect_esc2 function directly below detect_esc1:
def detect_esc2(template):
    # ESC2: Any Purpose EKU or no EKU restriction + no manager approval
    try:
        enrollment_flag = int(str(template['msPKI-Enrollment-Flag'])) if template['msPKI-Enrollment-Flag'] else 0
        eku_list = [str(oid) for oid in template['pKIExtendedKeyUsage']] if template['pKIExtendedKeyUsage'] else []
        pend_all = bool(enrollment_flag & CT_FLAG_PEND_ALL_REQUESTS)
        has_any_purpose = ANY_PURPOSE_OID in eku_list or len(eku_list) == 0
        return has_any_purpose and not pend_all
    except Exception:
        return False

How does ESC2 differ from ESC1?

ESC2 does not require the enrollee-supplies-subject flag. Instead, it catches templates where the Any Purpose OID 2.5.29.37.0 is present, or where pKIExtendedKeyUsage is empty (meaning no usage restriction). A certificate with no EKU restriction can be used for anything, including authentication and code signing.

  • Save scanner.py.
  • Confirm both detection functions load without errors by running:
python3 -c "from scanner import detect_esc1, detect_esc2; print('Detection functions loaded')"

You should see Detection functions loaded with no tracebacks.

Seeing a NameError for the constants?

  • Make sure CLIENT_AUTH_OID, ANY_PURPOSE_OID, CT_FLAG_ENROLLEE_SUPPLIES_SUBJECT, and CT_FLAG_PEND_ALL_REQUESTS are defined at module level (after imports, before functions).
  • Check that you did not accidentally indent the constants inside a function body.

Help me fix NameError in my detection functions.

Build vulnerability descriptions and print findings

Detection logic alone is not useful without context. A professional scanner explains what each finding means, how an attacker would exploit it, and what to fix.

  • Add the VULN_DESCRIPTIONS dictionary in scanner.py right after the imports but above the constants you added earlier (so it sits between the import block and CLIENT_AUTH_OID):
VULN_DESCRIPTIONS = {
    'ESC1': {
        'severity': 'CRITICAL',
        'summary': 'Certificate template allows enrollees to supply a Subject Alternative Name (SAN), enabling any domain user to request a certificate as Domain Admin.',
        'attack_path': 'Enroll in template with --upn administrator@domain flag using Certify or Certipy, then use the certificate to authenticate as Domain Admin via PKINIT or pass-the-cert.',
        'remediation': 'Disable CT_FLAG_ENROLLEE_SUPPLIES_SUBJECT (msPKI-Certificate-Name-Flag bit 0x1) on the template, or restrict enrollment permissions to trusted accounts only.'
    },
    'ESC2': {
        'severity': 'CRITICAL',
        'summary': 'Certificate template has Any Purpose EKU or no EKU restriction, allowing the certificate to be used for any purpose including client authentication and code signing.',
        'attack_path': 'Enroll in the template to obtain a certificate usable for any purpose, then leverage it for authentication or other attacks depending on context.',
        'remediation': 'Restrict pKIExtendedKeyUsage to only the specific EKUs required. Remove Any Purpose OID (2.5.29.37.0) and define explicit allowed usages.'
    }
}

Why include attack paths and remediation?

A finding that just says "vulnerable" is not actionable. By including the specific attack tool (Certipy), the technique (pass-the-cert), and the exact fix (disable bit 0x1), your report gives defenders everything they need to prioritize and remediate.

  • Save scanner.py and verify the descriptions dictionary loads correctly:
python3 -c "from scanner import VULN_DESCRIPTIONS; print(VULN_DESCRIPTIONS['ESC1']['severity'])"

You should see CRITICAL printed.

  • Now add the print_adcs_findings function below detect_esc2 in scanner.py:
def print_adcs_findings(findings):
    # Display vulnerability findings with full context
    if not findings:
        print('\n[*] No AD CS vulnerabilities detected.')
        return
    print('\n[!] AD CS Vulnerability Findings')
    print('=' * 80)
    for f in findings:
        print(f'\n[{f["severity"]}] {f["finding_type"]} - {f["affected_object"]}')
        print(f'  Description : {f["description"]}')
        print(f'  Attack Path : {f["attack_path"]}')
        print(f'  Remediation : {f["remediation"]}')
  • Add the run_adcs_checks function directly below print_adcs_findings:
def run_adcs_checks(conn, base_dn):
    # Orchestrate certificate template enumeration and vulnerability detection
    findings = []
    print('[*] Enumerating certificate templates...')
    templates = enumerate_certificate_templates(conn, base_dn)
    print(f'[*] Found {len(templates)} certificate templates')
    for template in templates:
        template_name = str(template.cn)
        if detect_esc1(template):
            desc = VULN_DESCRIPTIONS['ESC1']
            findings.append({
                'finding_type': 'ESC1',
                'severity': desc['severity'],
                'affected_object': template_name,
                'description': desc['summary'],
                'attack_path': desc['attack_path'],
                'remediation': desc['remediation']
            })
        if detect_esc2(template):
            desc = VULN_DESCRIPTIONS['ESC2']
            findings.append({
                'finding_type': 'ESC2',
                'severity': desc['severity'],
                'affected_object': template_name,
                'description': desc['summary'],
                'attack_path': desc['attack_path'],
                'remediation': desc['remediation']
            })
    return findings

What does run_adcs_checks do?

  • It calls enumerate_certificate_templates to fetch all templates from LDAP.
  • For each template, it runs both detect_esc1 and detect_esc2. A single template can trigger both vulnerabilities.
  • Each finding gets the full description context from VULN_DESCRIPTIONS so the output is immediately actionable.

The last piece is wiring run_adcs_checks into the main() function so the --adcs flag triggers the scan.

  • In the main() function, find the line that calls print_share_table(share_findings).
  • Add the following code directly below it:
    adcs_findings = []
    if args.adcs:
        adcs_findings = run_adcs_checks(conn, base_dn)
        print_adcs_findings(adcs_findings)
  • Save scanner.py.

Before you run the scanner, what do you expect to see when you pass the --adcs flag against a domain with vulnerable certificate templates?

  • Run your scanner against the HackTheBox target with the --adcs flag:
python3 scanner.py --domain [[DOMAIN="yourlab.htb"]] --username [[USERNAME="your-htb-username"]] --password [[PASSWORD="your-htb-password"]] --dc-ip [[DCIP="target DC IP address"]] --adcs

You should see the share enumeration table followed by the AD CS findings. Look for at least one [CRITICAL] ESC1 or [CRITICAL] ESC2 finding with the template name, description, attack path, and remediation printed.

Seeing zero findings or a connection error?

  • If you see Found 0 certificate templates, check that your domain parameter matches the actual domain name (the search base must resolve correctly for the Configuration partition).
  • If you see a connection timeout, confirm your VPN to the HackTheBox lab is active and the DC IP is reachable.
  • If you see No AD CS vulnerabilities detected but templates were found, verify that the lab environment has a vulnerable template configured. The HTB Active Directory lab should have at least one.

Help me debug why my AD CS scan is not finding vulnerabilities.

✔️ Awesome, I've got everything!

Great. Double check you have saved scanner.py and confirm your output shows at least one CRITICAL finding.

ⓧ I'd like to double check the full code

Your scanner.py is now over 30 lines, so review it section by section. Confirm you have these sections in order from top to bottom:

  • Imports: argparse, json, ldap3, impacket.
  • VULN_DESCRIPTIONS dictionary with ESC1 and ESC2 entries.
  • Four constants: CLIENT_AUTH_OID, ANY_PURPOSE_OID, CT_FLAG_ENROLLEE_SUPPLIES_SUBJECT, CT_FLAG_PEND_ALL_REQUESTS.
  • Functions in order: get_ldap_connection, get_base_dn, get_config_base, enumerate_computers, enumerate_shares, print_share_table, enumerate_certificate_templates, detect_esc1, detect_esc2, print_adcs_findings, run_adcs_checks, main.
  • Inside main(): the adcs_findings block appears after print_share_table(share_findings).

Your scanner now detects the two most dangerous certificate template misconfigurations. Next up, you will add CA-level checks for ESC8 and ESC16 and generate a complete JSON findings report.

Detect ESC8 and ESC16 CA Misconfigurations

Your scanner now catches template-level misconfigurations (ESC1 and ESC2). Certificate templates are only half the story. The Certificate Authority server itself can be misconfigured in ways that make the entire PKI exploitable.

ESC8 exposes HTTP enrollment endpoints that attackers relay NTLM authentication to. ESC16 disables the SID security extension, breaking certificate-to-account binding. In this step, you will query CA objects in LDAP, detect both misconfigurations, and produce a complete JSON findings report.

In this step, get ready to:
  • Query LDAP for Certificate Authority objects using the pKIEnrollmentService object class.
  • Implement ESC8 and ESC16 detection logic for CA-level misconfigurations.
  • Generate a complete JSON findings report combining share and ADCS results.
Query LDAP for Certificate Authority objects

CA objects live in a different container than certificate templates. Templates sit under CN=Certificate Templates, but CAs are stored under CN=Enrollment Services within the same Public Key Services branch.

  • In scanner.py, find the ESC2 entry in VULN_DESCRIPTIONS and add ESC8 and ESC16 entries after it. Also add the SID_EXT_OID constant below the existing constants by pasting this code:
    'ESC8': {
        'severity': 'HIGH',
        'summary': 'Certificate Authority has HTTP-based web enrollment enabled. This allows NTLM relay attacks to the CA web enrollment endpoint to obtain certificates on behalf of relayed accounts.',
        'attack_path': 'Use ntlmrelayx to relay an incoming NTLM authentication to the CA HTTP enrollment endpoint (http://<CA>/certsrv/certfnsh.asp) to request a certificate as the relayed user.',
        'remediation': 'Disable IIS-based web enrollment (certsrv) if not required. If required, enforce HTTPS and enable Extended Protection for Authentication (EPA) to prevent relay attacks.'
    },
    'ESC16': {
        'severity': 'HIGH',
        'summary': 'Certificate Authority has the szOID_NTDS_CA_SECURITY_EXT security extension disabled. This extension embeds the requester SID in issued certificates, and disabling it breaks SID-based authentication protections.',
        'attack_path': 'Without the SID extension, certificates issued by this CA cannot be tied to a specific AD account SID, enabling certificate-based authentication abuse and potential privilege escalation.',
        'remediation': 'Re-enable the szOID_NTDS_CA_SECURITY_EXT extension (OID 1.3.6.1.4.1.311.25.2) on the CA. Apply KB5014754 patches and ensure StrongCertificateBindingEnforcement is set appropriately.'
    }

What do these vulnerability descriptions cover?

  • ESC8 flags CAs that expose HTTP-based web enrollment. An attacker can relay NTLM authentication to this endpoint and request certificates as the relayed user.
  • ESC16 flags CAs that have disabled the szOID_NTDS_CA_SECURITY_EXT extension (OID 1.3.6.1.4.1.311.25.2). Without this extension, issued certificates cannot be tied to a specific AD account SID.
  • Now find the line CT_FLAG_PEND_ALL_REQUESTS = 0x2 and add the new OID constant directly below it:
SID_EXT_OID = '1.3.6.1.4.1.311.25.2'
  • Next, add the CA helper and enumeration functions below the existing get_config_base function. Paste this code:
def get_pki_services_base(base_dn):
    return f'CN=Public Key Services,CN=Services,CN=Configuration,{base_dn}'

def enumerate_cas(conn, base_dn):
    # Query the Enrollment Services container for CA objects
    pki_base = get_pki_services_base(base_dn)
    conn.search(
        search_base=pki_base,
        search_filter='(objectClass=pKIEnrollmentService)',
        search_scope=SUBTREE,
        attributes=['cn', 'dNSHostName', 'certificateTemplates', 'msPKI-Enrollment-Servers', 'flags']
    )
    return conn.entries

Why a different search base?

Certificate templates live under CN=Certificate Templates,CN=Public Key Services,... but CA enrollment service objects live directly under CN=Public Key Services,... in the Enrollment Services sub-container. The get_pki_services_base helper targets that broader base so the SUBTREE search finds CAs.

The msPKI-Enrollment-Servers attribute stores the enrollment endpoint URIs for the CA. If any contain http:// instead of https://, the CA is vulnerable to NTLM relay (ESC8).

  • Save scanner.py and confirm no syntax errors by running:
python3 scanner.py --help

You should see the argparse help output with all your flags listed. If you see a traceback, check for missing commas in the VULN_DESCRIPTIONS dictionary.

Seeing a SyntaxError?

  • Make sure the ESC8 and ESC16 entries are inside the VULN_DESCRIPTIONS dictionary (before its closing }) and that each entry has a trailing comma after its closing brace.
  • Confirm SID_EXT_OID is defined at module level (same indentation as CLIENT_AUTH_OID), not inside a function.

Why isn't my scanner.py importing correctly?

Implement ESC8 and ESC16 detection

With the CA enumeration in place, you need detection functions that inspect each CA's attributes for the specific misconfigurations.

  • Add the ESC8 and ESC16 detection functions below the existing detect_esc2 function by pasting this code:
def detect_esc8(ca):
    # Check if the CA exposes HTTP enrollment endpoints
    try:
        enrollment_servers = ca['msPKI-Enrollment-Servers']
        if enrollment_servers:
            for entry in enrollment_servers:
                if 'http://' in str(entry).lower():
                    return True
        return False
    except Exception:
        return False

def detect_esc16(ca):
    # Check if the CA has disabled the SID security extension
    try:
        flags = str(ca['flags']) if ca['flags'] else ''
        return SID_EXT_OID in flags
    except Exception:
        return False

What does each detection function check?

  • detect_esc8 iterates through the CA's msPKI-Enrollment-Servers entries looking for any URL that uses http:// instead of HTTPS. An HTTP endpoint is relayable.
  • detect_esc16 checks the CA's flags for the presence of 1.3.6.1.4.1.311.25.2 in the disabled extensions list. When present, the CA stops embedding SIDs in certificates.
  • Now integrate CA enumeration into your run_adcs_checks function. Find the end of the template loop (after the ESC2 check block) and add this code before the return findings line:
    print('[*] Enumerating Certificate Authorities...')
    cas = enumerate_cas(conn, base_dn)
    print(f'[*] Found {len(cas)} Certificate Authorities')
    for ca in cas:
        ca_name = str(ca.cn)
        if detect_esc8(ca):
            desc = VULN_DESCRIPTIONS['ESC8']
            findings.append({
                'finding_type': 'ESC8',
                'severity': desc['severity'],
                'affected_object': ca_name,
                'description': desc['summary'],
                'attack_path': desc['attack_path'],
                'remediation': desc['remediation']
            })
        if detect_esc16(ca):
            desc = VULN_DESCRIPTIONS['ESC16']
            findings.append({
                'finding_type': 'ESC16',
                'severity': desc['severity'],
                'affected_object': ca_name,
                'description': desc['summary'],
                'attack_path': desc['attack_path'],
                'remediation': desc['remediation']
            })

What does this code do?

After checking all certificate templates for ESC1 and ESC2, the scanner now also enumerates CA objects and checks each one for ESC8 and ESC16. Findings get appended to the same list with the same structure: finding type, severity, affected object, description, attack path, and remediation.

  • Save scanner.py.
  • Before you run this, what do you expect to see in the output that was not there before?
  • Run the scanner with the --adcs flag against your HTB target:
python3 scanner.py --domain [[DOMAIN="yourlab.htb"]] --username [[USERNAME="your-htb-username"]] --password [[PASSWORD="your-htb-password"]] --dc-ip [[DCIP="target DC IP address"]] --adcs

You should now see a new line in the output: [*] Enumerating Certificate Authorities... followed by a count of discovered CAs. If any CA has HTTP enrollment or disabled SID extensions, you will see HIGH severity findings printed.

Not seeing any CA findings?

  • Check that your enumerate_cas function uses get_pki_services_base (not get_config_base). The CA objects sit one level above templates in the LDAP tree.
  • If the scanner shows Found 0 Certificate Authorities, the lab environment might not have an Enterprise CA configured under Enrollment Services. Verify your domain credentials and try running with verbose LDAP output.

Why is my CA enumeration returning zero results?

Generate the JSON findings report

A professional security assessment needs a structured report, not just terminal output. You will update the share findings format for consistency, then generate a JSON report combining both attack surfaces.

  • In scanner.py, find the enumerate_shares function. Locate the first findings.append({ block inside the for share in shares: loop. It currently looks like this:
                findings.append({
                    'computer': computer,
                    'share': share_name,
                    'access': access
                })
  • Replace that block with this expanded version that includes report-compatible fields:
                findings.append({
                    'finding_type': 'SHARE_ACCESS',
                    'severity': 'INFO',
                    'affected_object': f'{computer}\\{share_name}',
                    'description': f'Share {share_name} on {computer} is readable by the current user.',
                    'attack_path': 'Readable shares may expose sensitive files, credentials, or configuration data.',
                    'remediation': 'Review share permissions and restrict access to only required accounts.',
                    'computer': computer,
                    'share': share_name,
                    'access': access
                })
  • Now find the second findings.append({ block inside the except Exception as e: clause. It currently looks like this:
            findings.append({
                'computer': computer,
                'share': 'N/A',
                'access': f'UNREACHABLE: {str(e)[:40]}'
            })
  • Replace it with this expanded version:
            findings.append({
                'finding_type': 'SHARE_ACCESS',
                'severity': 'INFO',
                'affected_object': computer,
                'description': f'Could not connect to {computer}.',
                'attack_path': 'N/A',
                'remediation': 'N/A',
                'computer': computer,
                'share': 'N/A',
                'access': f'UNREACHABLE: {str(e)[:40]}'
            })

Why add these extra fields to share findings?

The JSON report needs every finding to have the same structure: finding_type, severity, affected_object, description, attack_path, and remediation. The old fields (computer, share, access) are kept for the terminal table display.

  • Now add the report generation function. Paste this code above the run_adcs_checks function:
def generate_json_report(share_findings, adcs_findings, output_file='ad-scanner-report.json'):
    # Build a structured JSON report with summary statistics
    report = {
        'report_type': 'AD Vulnerability Scan',
        'share_findings': share_findings,
        'adcs_findings': adcs_findings,
        'summary': {
            'total_shares_checked': len(share_findings),
            'readable_shares': len([f for f in share_findings if f.get('access') == 'READ']),
            'total_adcs_findings': len(adcs_findings),
            'critical_findings': len([f for f in adcs_findings if f.get('severity') == 'CRITICAL']),
            'high_findings': len([f for f in adcs_findings if f.get('severity') == 'HIGH'])
        }
    }
    with open(output_file, 'w') as f:
        json.dump(report, f, indent=2)
    print(f'\n[*] Report saved to {output_file}')
    return report

What does the report contain?

  • All share findings with access status and remediation guidance.
  • All ADCS findings (ESC1, ESC2, ESC8, ESC16) with severity ratings.
  • A summary section counting total shares, readable shares, total ADCS findings, and breakdowns by severity level.
  • Finally, update the main() function to generate the report. Find the end of the if args.adcs: block (after print_adcs_findings(adcs_findings)) and add this line at the same indentation as the if args.adcs: block (so it always runs):
    generate_json_report(share_findings, adcs_findings)
  • Save scanner.py.
  • Before you run the full scan, what file do you expect to appear in your project directory?
  • Run the scanner one final time with all checks enabled:
python3 scanner.py --domain [[DOMAIN="yourlab.htb"]] --username [[USERNAME="your-htb-username"]] --password [[PASSWORD="your-htb-password"]] --dc-ip [[DCIP="target DC IP address"]] --adcs

You should see the familiar share table, any ADCS findings (ESC1/2/8/16), and a new final line: [*] Report saved to ad-scanner-report.json.

  • Verify the report was created by running:
cat ad-scanner-report.json | python3 -m json.tool | head -20

You should see the formatted JSON output with report_type, share_findings, adcs_findings, and a summary section showing your finding counts.

Report file not appearing?

  • Make sure generate_json_report(share_findings, adcs_findings) is outside the if args.adcs: block. It should run regardless of whether ADCS checks were requested.
  • Confirm that import json is still at the top of your file. The json.dump call requires it.

Why is my JSON report not being created?

✔️ Awesome, I've got everything!

Great. Double check you have saved scanner.py and that ad-scanner-report.json exists in your project directory.

ⓧ I'd like to double check the full code

Your scanner.py is now quite large. Scroll through your file and confirm you have all of the following sections in this order:

  • Imports (argparse, json, ldap3, impacket).
  • VULN_DESCRIPTIONS dictionary with ESC1, ESC2, ESC8, and ESC16 entries.
  • Constants: CLIENT_AUTH_OID, ANY_PURPOSE_OID, SID_EXT_OID, CT_FLAG_ENROLLEE_SUPPLIES_SUBJECT, CT_FLAG_PEND_ALL_REQUESTS.
  • LDAP helpers: get_ldap_connection, get_base_dn, get_config_base, get_pki_services_base.
  • Enumeration functions: enumerate_computers, enumerate_shares (with expanded dict fields), enumerate_certificate_templates, enumerate_cas.
  • Detection functions: detect_esc1, detect_esc2, detect_esc8, detect_esc16.
  • Output functions: print_share_table, print_adcs_findings, generate_json_report.
  • run_adcs_checks function that handles both templates AND CAs.
  • main() function ending with generate_json_report(share_findings, adcs_findings).

Your scanner now covers two complete attack surfaces: readable network shares and AD CS misconfigurations at both the template and CA level. Next up, you will tackle the most complex detection: ESC4, where an attacker with write access to a safe template can turn it into an ESC1.

Secret mission

Detect ESC4 Template ACL Abuse

Your scanner catches ESC1, ESC2, ESC8, and ESC16. But what about a template that looks safe on the surface, yet has write permissions granted to a low-privilege user? An attacker with WriteDacl or GenericAll on a template can silently rewrite it into an ESC1-vulnerable state. Add ESC4 detection by parsing binary security descriptors and flagging non-admin principals with dangerous write access.

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely on your local Kali Linux (WSL) environment, so there are no ongoing cloud costs. Decide whether to keep your scanner files for future assessments or remove them entirely.

Resources you used:

  • The ad-scanner project directory containing scanner.py and generated reports.
  • A Python virtual environment with ldap3 and impacket installed.

Keep everything running

No action needed. Choose this if you plan to use your scanner in future security assessments or extend it with additional detection modules.

  • Your scanner script stays on disk, ready for the next engagement.
  • The virtual environment preserves your exact package versions.

Pause - I'll come back to this later

Since this project is local-only with no running services, pausing and keeping are effectively the same. Your files sit on disk until you return.

  • If the virtual environment is currently active, run deactivate in your terminal to exit it.
  • No processes to stop or services to shut down.

Delete - I don't want to use this again

Remove all project resources and start fresh. This deletes your scanner code, the virtual environment, and any generated reports.

  • If your virtual environment is currently active, deactivate it first by running:
deactivate
  • Navigate to your Desktop and remove the project directory by running:
cd ~/Desktop
rm -rf ad-scanner
  • Confirm the directory is gone by running:
ls ~/Desktop | grep ad-scanner

You should see no output, confirming the project has been removed.

Nice Work!

Nice Work!

You just built a production-ready Active Directory vulnerability scanner from scratch using Python. Your tool queries LDAP for domain computers and certificate templates, probes SMB shares for read access, and flags critical AD CS misconfigurations with actionable remediation guidance.

You've learned how to:

  • Build a Python CLI security scanner using ldap3 and impacket that enumerates readable network shares across domain computers via LDAP queries and SMB connection probing.
  • Detect AD CS ESC1, ESC2, ESC8, and ESC16 vulnerabilities by querying certificate template and CA LDAP attributes and checking specific bit flags and OID values.
  • Generate a structured JSON findings report with severity ratings, plain-English explanations, attack path descriptions, and remediation guidance suitable for security assessment documentation.
  • Secret Mission: Added ESC4 detection by parsing binary security descriptors and identifying non-admin principals with dangerous write permissions on certificate templates.

Ready to quiz yourself?