Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions ansible/playbooks/ad-data.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
ad_users: "{{ lab.domains[lab.hosts[dict_key].domain].users }}"
ad_ou: "{{ lab.domains[lab.hosts[dict_key].domain].organisation_units | default({}) }}"
ad_groups: "{{ lab.domains[lab.hosts[dict_key].domain].groups }}"
ad_multi_domain_groups_member: "{{ lab.domains[lab.hosts[dict_key].domain].multi_domain_groups_member | default({}) }}"

- name: Servers AD data configuration
hosts: server
Expand Down
25 changes: 25 additions & 0 deletions ansible/roles/ad/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,19 @@ Configure Active Directory domain administrator membership and settings

## Role Variables

### Default Variables (main.yml)

| Variable | Type | Default | Description |
| -------- | ---- | ------- | ----------- |
| `ad_reconcile_passwords` | bool | `True` | No description |
| `ad_reconcile_group_membership` | bool | `True` | No description |
| `ad_reconcile_check_only` | bool | `False` | No description |
| `ad_reconcile_protected_accounts` | list | `[]` | No description |
| `ad_reconcile_protected_accounts.0` | str | `ssm-user` | No description |
| `ad_reconcile_protected_accounts.1` | str | `ansible` | No description |
| `ad_reconcile_protected_accounts.2` | str | `vagrant` | No description |
| `ad_multi_domain_groups_member` | dict | `{}` | No description |

## Tasks

### groups.yml
Expand All @@ -25,9 +38,11 @@ Configure Active Directory domain administrator membership and settings
- **Organisation units** (ansible.builtin.import_tasks)
- **Groups** (ansible.builtin.import_tasks)
- **Users** (ansible.builtin.import_tasks)
- **Reconcile user passwords** (ansible.builtin.import_tasks) - Conditional
- **Add members to the Domainlocal group, preserving existing membership** (microsoft.ad.group) - Conditional
- **Add members to the Universal group, preserving existing membership** (microsoft.ad.group) - Conditional
- **Add members to the Global group, preserving existing membership** (microsoft.ad.group) - Conditional
- **Reconcile group membership** (ansible.builtin.import_tasks) - Conditional
- **Assign managed_by domainlocal groups** (ansible.windows.win_powershell) - Conditional
- **Assign managed_by universal groups** (ansible.windows.win_powershell) - Conditional
- **Assign managed_by global groups** (ansible.windows.win_powershell) - Conditional
Expand All @@ -37,6 +52,16 @@ Configure Active Directory domain administrator membership and settings
- **Create OU** (ansible.windows.win_powershell)
- **Wait for OU creation to complete** (ansible.builtin.async_status) - Conditional

### reconcile_group_membership.yml

- **Reconcile group membership against the lab config** (ansible.windows.win_powershell)

### reconcile_passwords.yml

- **Confirm the credential probe rejects invalid credentials** (ansible.windows.win_powershell)
- **Reconcile user passwords against the lab config** (ansible.windows.win_powershell)
- **Report password drift** (ansible.builtin.debug) - Conditional

### users.yml

- **Sync the contents of one directory to another - hack to get Requires -Module Ansible.ModuleUtils.Legacy loaded** (community.windows.win_robocopy)
Expand Down
30 changes: 30 additions & 0 deletions ansible/roles/ad/defaults/main.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
---
# Drift reconciliation.
#
# Creating users and adding group members is not enough to make `lab reset`
# converge on the baseline: both operations are add-only, so a password an
# attack run changed and a group an agent added itself to both survive the
# reset. The lab ends up at a superset of baseline, which is exactly the
# noise a reproducible benchmark cannot tolerate.
#
# These two passes close that gap by reconciling the observed state back down
# to what the lab config declares.
ad_reconcile_passwords: true
ad_reconcile_group_membership: true

# Report drift without correcting it. Useful for measuring how much an attack
# run actually moved the lab before you reset it.
ad_reconcile_check_only: false

# Accounts that are never removed from a group even when the lab config does
# not list them. These are management-plane accounts that exist on the hosts
# but are deliberately absent from the lab topology.
ad_reconcile_protected_accounts:
- ssm-user
- ansible
- vagrant

# Cross-domain group membership, applied by the groups_domains role in a later
# play. The membership reconciler needs it because these entries can name
# principals in this domain, which would otherwise look unmanaged.
ad_multi_domain_groups_member: {}
8 changes: 8 additions & 0 deletions ansible/roles/ad/tasks/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,10 @@
- name: Users
ansible.builtin.import_tasks: users.yml

- name: Reconcile user passwords
ansible.builtin.import_tasks: reconcile_passwords.yml
when: ad_reconcile_passwords | bool

- name: Add members to the Domainlocal group, preserving existing membership
microsoft.ad.group:
name: "{{ item.key }}"
Expand Down Expand Up @@ -67,6 +71,10 @@
loop: "{{ ad_groups['global'] | dict2items }}"
when: ad_groups['global'] is defined and item.value.members is defined

- name: Reconcile group membership
ansible.builtin.import_tasks: reconcile_group_membership.yml
when: ad_reconcile_group_membership | bool

# Managed BY
- name: Assign managed_by domainlocal groups
ansible.windows.win_powershell:
Expand Down
151 changes: 151 additions & 0 deletions ansible/roles/ad/tasks/reconcile_group_membership.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
---
# Group membership is add-only everywhere in this collection, so a group an
# agent added itself to during an attack run survives the reset. Diff each
# lab-managed group against the config and remove what should not be there.
#
# Must run after every add-member task in this role, otherwise it reconciles
# against a half-built baseline and removes members that are about to be added.

- name: Reconcile group membership against the lab config
ansible.windows.win_powershell:
script: |
[CmdletBinding()]
param (
[string]$UsersB64,
[string]$GroupsB64,
[string]$MultiDomainB64,
[string]$AdminUser,
[string[]]$ProtectedAccounts,
[bool]$CheckOnly
)

$ProgressPreference = 'SilentlyContinue'

$users = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($UsersB64)) | ConvertFrom-Json
$groups = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($GroupsB64)) | ConvertFrom-Json
$multi = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($MultiDomainB64)) | ConvertFrom-Json

$domain = Get-ADDomain
$domainSid = $domain.DomainSID.Value

$neverRemove = @{}
foreach ($n in @($ProtectedAccounts)) {
if ($n) { $neverRemove[$n.ToLower()] = $true }
}

# expected[group] = set of sAMAccountNames the config says belong to it.
# Only groups that appear here are reconciled at all, so a group the lab
# has no opinion about is never touched.
$expected = @{}
# A null Member registers the group for reconciliation with no expected
# members, which is how declared-but-empty groups get cleaned.
function Add-Expected {
param([string]$Group, [string]$Member)
if (-not $Group) { return }
$g = $Group.ToLower()
if (-not $expected.ContainsKey($g)) { $expected[$g] = @{} }
if (-not $Member) { return }
# Config writes members as NETBIOS\Name or fqdn\Name; AD compares on
# the bare sAMAccountName. Foreign principals are filtered by SID
# below, so a stripped foreign name can only ever over-permit a
# same-domain account that shares its sAMAccountName.
$m = ($Member -replace '^.*\\', '').ToLower()
$expected[$g][$m] = $true
}

foreach ($u in $users.PSObject.Properties) {
foreach ($g in @($u.Value.groups)) { Add-Expected -Group $g -Member $u.Name }
}
foreach ($scope in $groups.PSObject.Properties) {
foreach ($grp in $scope.Value.PSObject.Properties) {
# Seed every declared group even when it has no declared members, so
# a group whose membership is entirely cross-domain still gets
# reconciled instead of silently accepting anything added to it.
Add-Expected -Group $grp.Name -Member $null
foreach ($m in @($grp.Value.members)) { Add-Expected -Group $grp.Name -Member $m }
}
}
# multi_domain_groups_member is applied by the groups_domains role in a
# later play and can name principals in this domain as well as foreign
# ones. Without it those same-domain members look unmanaged and get
# stripped whenever ad-data runs without ad-relations.
foreach ($grp in $multi.PSObject.Properties) {
foreach ($m in @($grp.Value)) { Add-Expected -Group $grp.Name -Member $m }
}
Add-Expected -Group 'Domain Admins' -Member $AdminUser

$drift = @()
$errors = @()
$kept = 0

foreach ($gname in $expected.Keys) {
try {
$grp = Get-ADGroup -Identity $gname -Properties member -ErrorAction Stop
} catch {
$errors += ("group " + $gname + " not found: " + $_.Exception.Message)
continue
}

foreach ($dn in @($grp.member)) {
try {
$obj = Get-ADObject -Identity $dn -Properties objectSid, sAMAccountName, objectClass -ErrorAction Stop
} catch {
$errors += ("resolve " + $dn + ": " + $_.Exception.Message)
continue
}

# Machine accounts join groups through domain join, not lab config.
if ($obj.objectClass -eq 'computer') { continue }
if (-not $obj.objectSid) { continue }
$sidStr = $obj.objectSid.Value

# Cross-domain members come from multi_domain_groups_member and are
# applied by the groups_domains role in a later play. Removing them
# here would strip them whenever ad-data runs without ad-relations.
if (-not $sidStr.StartsWith($domainSid + "-")) { continue }

# RIDs below 1000 are built-ins the lab does not enumerate:
# Administrator (500), krbtgt (502), Enterprise Admins (519), and
# the default nesting between the admin groups.
$rid = [int](($sidStr -split '-')[-1])
if ($rid -lt 1000) { continue }

$sam = $obj.sAMAccountName
if (-not $sam) { continue }
$samLower = $sam.ToLower()
if ($neverRemove.ContainsKey($samLower)) { continue }

if ($expected[$gname].ContainsKey($samLower)) {
$kept++
continue
}

$drift += ($gname + " <- " + $sam)
if (-not $CheckOnly) {
try {
Remove-ADGroupMember -Identity $grp -Members $obj.DistinguishedName -Confirm:$false -ErrorAction Stop
} catch {
$errors += ("remove " + $sam + " from " + $gname + ": " + $_.Exception.Message)
}
}
}
}

foreach ($d in $drift) {
if ($CheckOnly) { Write-Output ("DRIFT " + $d) } else { Write-Output ("REMOVED " + $d) }
}
foreach ($e in $errors) { Write-Warning $e }

if ($drift.Count -eq 0) {
Write-Output ("group membership clean (" + $kept + " expected members verified across " + $expected.Count + " groups)")
}

$Ansible.Changed = ($drift.Count -gt 0) -and (-not $CheckOnly)
parameters:
UsersB64: "{{ ad_users | to_json | b64encode }}"
GroupsB64: "{{ ad_groups | to_json | b64encode }}"
MultiDomainB64: "{{ ad_multi_domain_groups_member | to_json | b64encode }}"
AdminUser: "{{ admin_user }}"
ProtectedAccounts: "{{ ad_reconcile_protected_accounts }}"
CheckOnly: "{{ ad_reconcile_check_only }}"
register: group_membership_reconcile
Loading
Loading