Skip to content

Module 13 — AD Posture Drift & Steady-State

Type 16 · Drift / Steady-State — your domain was hardened at t=0 (Modules 10–12), but a month later a new SPN, a re-added GenericWrite, and an aging krbtgt have quietly reopened a privesc edge; build the scheduled re-audit that diffs observed posture against the committed hardened baseline and alerts on regression. The deliverable is the drift detector + the diff-and-reconcile loop, not another one-time scan. Go to the hands-on lab →

Last reviewed: 2026-06

Active Directory & Windows SecurityModules 10–12 close the paths; this module faces the fact that they don't stay closed by themselves — a domain decays back toward exploitable the moment people start administering it again.

Difficulty: Intermediate–Advanced  ·  Estimated time: ~5–7 hrs (study + lab)  ·  Prerequisites: Foundations, Module 08 — Path to Domain Admin (the path you keep closed), Module 10 — Hardening AD as Code (the posture audit + baseline this re-runs)

In 60 seconds

Modules 10–12 hardened the domain at t=0 — but AD decays back toward exploitable the moment people administer it again: a new auto-registered SPN, a re-added GenericWrite, an aging krbtgt. The fix is the config-management loop ported to AD security facts: declare the hardened baseline in version control, then detect → diff → reconcile on a schedule. A nightly re-audit diffs observed state against the committed baseline and alerts on the specific facts that changed — and every delta is adjudicated: bless it into the baseline (with a who/why commit) or re-enforce. Never ignore.

Why this matters

Module 10 ends with a score that climbed and a HIGH finding that cleared; Module 11 ends with PATH-001 dead against the tiered design; Module 12 ends with that design deployed on the live domain without an outage. Every one of those is a t=0 result — the posture at the moment you finished. The uncomfortable truth that every AD operator learns is that the domain does not stay there. AD hardening famously decays, and not because anyone is malicious: a DBA gets a new service account and the installer auto-registers an SPN, making it Kerberoastable again; a helpdesk ticket grants GenericWrite on a group "just to unblock someone" and nobody removes it; an admin is added to Domain Admins for a migration and never taken back out; the krbtgt password silently ages past the point where a stolen hash from two years ago still mints golden tickets. The domain you hardened in Module 10 is, thirty days later, quietly exploitable again — and the BloodHound graph you proved empty now has a fresh edge to Domain Admin that nobody decided to create.

The naive belief — the one this module exists to correct — is "we hardened it, so it's hardened." Module 10 already states the thesis in passing ("if the hardening isn't in version control, it isn't real") but stops at deploying the baseline; it never builds the thing that keeps it real. Treating a hardening sprint as a one-time event is the failure mode: a scan in January tells you nothing about February, and the gap between "we fixed it" and "it's still fixed" is exactly where the next intrusion lives. The dangerous instinct is to run the Module 10 posture-audit.py once, file the report, and move on — which is "set and forget," and forget is where drift wins. A point-in-time audit measures a moment; security posture is a property over time, and you can't assert a property over time from a single sample.

The correct posture is declare the baseline, then detect → diff → reconcile on a schedule. You take the hardened state from Modules 10–12 and pin it as a committed, version-controlled baseline — the declared "this is what good looks like": the set of Kerberoastable accounts (should be empty or known-and-justified), the dangerous ACEs (none), the privileged-group memberships (this exact list), the krbtgt age (under your rotation threshold). Then you run a scheduled re-audit that captures the observed state, diffs it against the declared baseline, and emits the delta: not "here is the posture," but "here is exactly what changed — a new Kerberoastable SPN on svc-newdb, a re-added GenericWrite on Finance-Managers, krbtgt now 200 days old." That delta is the alert. Then you reconcile: either the change was sanctioned (you update the baseline, with a commit message that records who decided and why — the baseline is now the audit trail), or it was drift (you re-enforce — remove the ACE, rotate krbtgt, eject the unexpected DA — and prove the domain is back at steady-state). The skill is no longer "harden the domain"; it's "keep the domain hardened, automatically, and know within a day when it slips."

The core idea: declared baseline vs. observed state — detect, diff, reconcile, on a schedule

The mental model

Borrow it straight from infrastructure-as-code: declared state vs. observed state. The declared state is the hardened baseline you committed; the observed state is today's re-audit; drift is the diff. The whole discipline is one loop — detect (re-audit on a schedule), diff (observed vs. declared), reconcile (bless-into-baseline or re-enforce) — the same loop Terraform runs as plan/apply, built for AD security facts. It must be scheduled: a property that holds at t=0 and fails at t=30 is only caught by sampling between them.

The mental model is borrowed straight from configuration management and infrastructure-as-code: declared state vs. observed state. The declared state is what you committed as correct — the hardened posture baseline from Modules 10–12, expressed as data in version control (a JSON/YAML snapshot of the security-relevant facts: Kerberoastable accounts, no-preauth accounts, unconstrained-delegation principals, dangerous ACEs, privileged-group rosters, krbtgt age). The observed state is what the domain actually is right now, captured by re-running the audit. Drift is simply the diff between them, and the entire discipline reduces to a loop: detect (re-audit on a schedule), diff (observed vs. declared), reconcile (sanction-into-baseline or re-enforce-to-baseline), repeat. This is the same loop Terraform runs as plan/apply and Ansible runs in --check mode — you are building it for AD security facts.

mermaid flowchart LR B["Declared baseline<br/>(committed, version-controlled)"] --> DIFF{Diff} SCHED["Scheduled re-audit"] --> OBS["Observed state"] --> DIFF DIFF -->|"no change"| OK["Steady-state"] DIFF -->|"drift: new SPN, re-added ACE,<br/>aging krbtgt"| ADJ{Adjudicate} ADJ -->|"sanctioned"| BLESS["Bless into baseline<br/>(commit who/why)"] --> B ADJ -->|"real drift"| FIX["Re-enforce"] --> SCHED The reason it must be scheduled and not on-demand is the whole point of the type: a property that holds at t=0 and fails at t=30 can only be caught by sampling between those points, so the detector runs nightly (cron / a scheduled job / a CI cron), not "when someone remembers."

The mechanism that makes the diff meaningful is choosing the right unit and making the baseline diffable. A naive re-audit prints today's findings; a drift detector prints what's different from the committed baseline, which means the baseline has to be stored as stable, sorted, normalized data so that a git diff (or a structured diff in code) shows real changes and not noise from reordering or formatting. The unit you diff on matters: you don't alert on "the score changed by 2 points" (a score is a lossy summary that hides which control moved); you alert on the specific security factsthis SPN appeared, this ACE was re-added, this account joined Domain Admins, krbtgt crossed your age threshold. Each is a concrete, actionable delta a defender can reason about and trace to a cause. Several of these map directly to MITRE ATT&CK persistence techniques — a re-added dangerous ACE or a new privileged-group membership is Account Manipulation (T1098), and an un-rotated krbtgt is what keeps a Golden Ticket (T1558.001) valid — so the drift detector is not just hygiene, it's a persistence-detection control: an attacker who established a foothold creates exactly the drift you're watching for.

The gotcha

The loop is not "drift detected → auto-revert" — auto-reverting a sanctioned change is its own outage and fights the business. Reconcile is a human adjudication: bless it into the baseline (commit, with the reason) or re-enforce — never ignore. The killer failure is alert fatigue from a baseline nobody updates: if every sanctioned change fires an unactioned alert, people stop reading them, and the one that matters drowns.

Go deeper: diff the facts, not the score — and why drift is a persistence surface

A naive re-audit prints today's findings or a score delta; a drift detector prints what's different from the committed baseline, which means storing it as stable, sorted, normalized data so a git diff shows real change, not reordering noise. Alert on the specific facts (this SPN appeared, this ACE was re-added, this account joined Domain Admins, krbtgt crossed its threshold), not a lossy number. Several map straight to MITRE ATT&CK: a re-added ACE or new privileged-group member is Account Manipulation (T1098), an un-rotated krbtgt keeps a Golden Ticket (T1558.001) alive — so the detector is a persistence-detection control, because an attacker's foothold creates exactly the drift you're watching for.

The reconcile step is where judgment lives, and where the honest gotcha is: not all drift is bad, but all drift must be adjudicated. A new SPN might be a legitimate new application; a new Domain Admins member might be a sanctioned hire; krbtgt aging is expected until it crosses your threshold. So the loop is not "drift detected → auto-revert" (that would fight the business and break things — auto-reverting a sanctioned change is its own outage). The loop is drift detected → adjudicate → either bless it into the baseline (commit, with the reason) or re-enforce the baseline (remediate, and re-prove steady-state). Blessing a change updates the declared state and is the mechanism by which the baseline stays current and honest — every change to it carries a commit message saying who decided and why, so the baseline doubles as the decision log for the domain's security posture. The failure mode to avoid is alert fatigue from a baseline that's never updated: if every sanctioned change fires an unactioned alert, people stop reading the alerts, and the one that matters drowns. A drift detector is only as good as the discipline of reconciling every delta — bless it or fix it, never ignore it. That discipline is what turns "we hardened it once" into "it has provably stayed hardened, and here's the dated trail to prove it."

AI caveat

Ask a model to "build an AD drift detector" and it reliably (1) diffs the score instead of the facts, (2) proposes auto-revert on any drift — collapsing the human reconcile step into a blind apply — and (3) inverts the krbtgt-age and ACL checks (flagging a freshly-rotated krbtgt, or an absent dangerous ACE). Let it write the queries, the diff, and the cron; you set the thresholds, read every delta, and decide bless-or-enforce.

Learn (~3 hrs)

Drift-focused: read enough to internalize declared-vs-observed and detect→diff→reconcile, see why AD specifically decays, and pick the OSS tooling that re-graphs the attack paths on a schedule — then go to the lab.

The drift mental model — declared vs. observed (~45 min) - Martin Fowler / Kief Morris — Infrastructure as Code (the "Configuration Drift" and reconciliation concepts) (~20 min) — the canonical statement of the idea you're porting to AD: a declared baseline in version control, observed reality, and the drift between them that automation must continuously reconcile. Read it for the loop (detect → diff → reconcile) and the "snowflake server" failure mode — an AD that's been hand-administered into a state nobody can reproduce is the same anti-pattern. - MITRE ATT&CK — Account Manipulation (T1098) (~15 min) — why drift is also a persistence-detection surface, not just hygiene. Read the technique description: adversaries "manipulate accounts to maintain… access" by modifying credentials, permission groups, and account attributes — which is exactly the re-added ACE / new privileged-group-membership your detector watches. The detection guidance (correlate unusual account-attribute changes) is the defender's side of the same coin. - MITRE ATT&CK — Golden Ticket (T1558.001), mitigation M1015 (~10 min) — the krbtgt-age half of your baseline. Read the Active Directory Configuration mitigation: reset krbtgt twice (with replication between), and "consider rotating the KRBTGT account password every 180 days." Your drift detector's krbtgt-age check is the operational enforcement of exactly this guidance — an un-rotated krbtgt is standing golden-ticket persistence.

Why AD posture decays, specifically (~45 min) - Microsoft — Reducing the Active Directory Attack Surface (privileged groups & the SDProp / adminCount drift mechanic) (~30 min) — read the "Privileged Accounts and Groups" and "AdminSDHolder and SDProp" sections. It documents a built-in drift mechanic: SDProp re-stamps protected-group ACLs every 60 minutes, and adminCount=1 lingers on accounts removed from privileged groups — so even AD's own machinery creates posture facts that change under you. This is the AD-native reason a one-time audit goes stale. - PingCastle (pingcastle.com) (~15 min) — the most widely-used AD posture-scoring tool; its whole value proposition is the trended health-check score, run repeatedly so you can see the line move. Skim the homepage and the health-check concept for why periodic re-scoring is the industry-standard practice — then note its limitation for this lab (Windows-only, and a score hides which control drifted, which is why your detector diffs facts, not scores).

The OSS tooling: re-graph the attack paths on a schedule (~1.5 hrs) - BloodHound documentation (SpecterOps — bloodhound.specterops.io) (~30 min) — drift in AD is best seen as a graph: an edge to Domain Admin that wasn't there last week. Read the "Analysis" / attack-path sections; the point for this module is that re-collecting and re-querying the graph on a schedule turns "a new privesc edge appeared" from invisible to a diffable fact. (You graphed this in Module 02; here you graph it repeatedly and diff.) - Adalanche (github.com/lkarlslund/Adalanche) (~30 min) — an open-source, Linux-friendly AD attack-graph collector + analyzer (AGPL-3.0) that answers "who's really Domain Admin?" by analyzing ownership, ACLs, and delegation. Read the README's collect-then-analyze workflow; it's the OSS engine you can run headless on a schedule against the Samba4 lab DC to produce the attack-path facts your drift detector diffs — no Windows required. - crontab(5) man page (man7.org) (~15 min) — the plumbing that makes it steady-state rather than ad-hoc. Read the five time fields and the step/range syntax; a drift detector you run by hand is just another one-time audit. Scheduling it (nightly) is what makes "is it still hardened?" a question your system answers without you.

Key concepts

  • Hardening is a property over time, not a t=0 event — Modules 10–12 measured a moment; a domain decays back toward exploitable as people administer it. "We hardened it" is not "it's still hardened."
  • Declared baseline vs. observed state — pin the hardened posture as committed, version-controlled data (Kerberoastable accounts, dangerous ACEs, privileged-group rosters, krbtgt age); the observed state is today's re-audit; drift is the diff.
  • The loop: detect → diff → reconcile, on a schedule — re-audit nightly, diff observed against declared, then adjudicate every delta. Scheduling is non-negotiable: a property that fails at t=30 is only caught by sampling between t=0 and t=30.
  • Diff facts, not scores — alert on the specific change (this SPN appeared, this ACE was re-added, this account joined Domain Admins, krbtgt crossed the threshold), not a lossy score delta. Store the baseline sorted/normalized so the diff shows real change, not noise.
  • Drift is a persistence-detection surface — a re-added ACE / new privileged-group member is ATT&CK T1098; an un-rotated krbtgt keeps T1558.001 golden tickets valid. An attacker's foothold is drift you're watching for.
  • Reconcile = bless-into-baseline OR re-enforce — never ignore — sanctioned change updates the committed baseline (with a who/why commit, so it doubles as the decision log); real drift is remediated and steady-state re-proven. The killer failure is alert fatigue from a baseline nobody updates.

AI acceleration

A model is genuinely useful for the mechanical half of a drift detector — drafting the LDAP queries for each posture fact, the diff logic that compares two JSON baselines and emits a structured delta, the cron wiring, and the report formatting that turns a raw diff into a readable "what changed since last week." That is real leverage. But the posture is strict, and the failure modes here are specific: AI drafts the detector → you own the baseline, the thresholds, and the adjudication. Ask a model to "build an AD drift detector" and it will reliably (1) diff the score instead of the facts — because a single number is easy to compare and it doesn't grasp that the score is exactly the lossy summary that hides which control moved; (2) propose auto-revert on any drift — which is dangerous, because auto-reverting a sanctioned change is its own outage and fights the business; the reconcile step is a human adjudication the model must not collapse into a blind apply; and (3) invert the krbtgt-age and ACL checks — scoring a freshly-rotated krbtgt as "drift" or treating an absent dangerous ACE as a finding. The judgment the model cannot do for you is deciding what belongs in the baseline and at what threshold (is 180 days the krbtgt limit, or 90? is this new SPN sanctioned?), and adjudicating each delta — bless it (and commit the reason) or fix it. Make the model write the queries, the diff, and the schedule; you set the thresholds, you read every delta, and you decide bless-or-enforce. AI builds the detector; you own steady-state.

Check yourself

  • Why must the drift detector run on a schedule rather than on-demand?
  • Why do you diff specific posture facts rather than the posture score?
  • When the detector flags a new Domain Admins member, why isn't "auto-revert" the right response — and what are the only two acceptable outcomes?

Comments

Sign in with GitHub to comment. Choose the type: Feedback (errors or suggestions on this page) · Hints (help for fellow learners — no spoilers) · General (anything else).