You're a smart and well-informed pentester/consultant/hacker so you've obviously read the superb blog post written by @myexploit2600 about building and attacking your own Active Directory lab. Now you'd like a repeatable way to set it up all over again with fresh virtual machines, without doing all the steps manually, right? Lucky you, here we go.
This short post will not be a technical deep dive, but rather a quick overview of the PowerShell scripts I used and Ansible playbook I wrote to automate the deployment of my hacklab using @myexploit2600's guide.
"But Aidan, I've never heard of Ansible and I'd like to learn more before using it on my machine/network/smart fridge."
Good news is that Ansible is owned by Red Hat who have an exceptional attitude towards documentation. If you'd like something audio-visual, there is a 1h30m webinar with all the basics you need to know. Alternatively, if you prefer to read about Ansible, Red Hat's got you covered there too.
Virtual Machines (Windows server and workstation)
This blog is going to presume you've followed the short instructions on creating your server and workstation virtual machines; whether in Virtualbox, VMware, or Hyper-V. Since you can create snapshots or checkpoints in most commercial virtualisation software, rolling back to the earliest stage is a simple process that avoids having to re-deploy the virtual machine (VM). Get your VM's prepared as far as having reached this point in @myexploit2600's blog.

Once you've established this baseline installation, you can quickly roll back to here should you need or want to. In my next blog, I might jump into using Terraform or other IaC to provision the virtual machines and install the operating systems too. But let's not get ahead of ourselves. Narrator's voice: "He did in fact write such a blog."

Firstly, we'll repeat the sanity check of confirming our PowerShell version before trying to execute anything. Here's a little statement taking advantage of PowerShell's comparison functions:
if ($PSVersionTable.PSVersion.Major -ge "5") {
Write-Host "PowerShell $($PSVersionTable.PSVersion) is installed. Let's go!"
} else {
Write-Host "PowerShell 5 or newer is not installed!"
Write-Host "Installed version is $($PSVersionTable.PSVersion). You will (for now) be stuck setting up the domain using the manual CMD/Dcpromo approach for now. Check back later for the possibility of a playbook for this method."
}

Preparing Ansible and inventory
You may already be aware that Ansible cannot be run on a Windows host but it can manage them. If you have Windows Subsystem for Linux (WSL), you're good to go, otherwise you will need a Linux host of some description which can reach the hacklab hosts. As mentioned before, this is not a technical deep dive or a guide to Ansible, so I'll point to the relevant documentation.
Managing Windows targets with Ansible requires the WinRM service be enabled. It is possible to use Windows OpenSSH but this remains experimental. Since we're setting up a hacklab, I'm going to take a few liberties when it comes to "best practice". We're going to use the script provided by the Ansible team to set up WinRM for all the Windows hosts in our lab. The script can be found here and can be executed with wanton disregard for safety by using the following snippet:
$url = "https://raw.githubusercontent.com/ansible/ansible/devel/examples/scripts/ConfigureRemotingForAnsible.ps1"
$file = "$env:temp\ConfigureRemotingForAnsible.ps1"
(New-Object -TypeName System.Net.WebClient).DownloadFile($url, $file)
powershell.exe -ExecutionPolicy ByPass -File $file
This will expose an HTTP listener on port 5985 and a matching HTTPS listener on port 5986 using a self-signed certificate. It also enables Basic auth although we are going to use NTLM authentication, rather than just plaintext credentials. You can set up listeners in whichever way you feel comfortable with. Ansible's WinRM module supports all of the common Windows authentication methods so if you're concerned with establishing real confidentiality, the options are there. I'm setting this up in an isolated network segment, since it's going to involve configuring an intentionally vulnerable domain, therefore a self-signed certificate is no big deal.

Once the listeners are established on your hacklab hosts, we can start work on our inventory. This part is straight forward. The inventory can be declared globally using /etc/ansible/hosts
, or a contextual inventory file can be defined when executing a command or playbook using -i /file/path
. I prefer to use the YAML format over the older INI format for inventory but both are valid and supported by default.
all:
hosts:
server1.hacklab.local:
ansible_user: Administrator
ansible_password: Passw0rd!
labpc1:
ansible_user: IEUser
ansible_password: Passw0rd!
ansible_host: 192.168.1.201
vars:
ansible_connection: winrm
ansible_winrm_transport: ntlm
ansible_port: 5986
ansible_winrm_server_cert_validation: ignore
I've intentionally used an FQDN for the server and specified an IP for the workstation here to illustrate how host declarations can work. If your Ansible host cannot resolve the FQDN (no DNS, not in hosts file, etc.), you would need to specify the ansible_host
variable as exemplified for labpc1
. I've used the global variable declaration because the authentication method for both is the same, but have declared per-host variables for usernames and passwords. These are going to be the local credentials for the relevant hosts since there are no domain credentials yet. If you're using a different authentication method, you will need to swap out the variables as appropriate.
Next step is installing the necessary Ansible plugins to support interaction with Windows. community.windows
will automatically install the ansible.windows
plugins but we'll stick it in the command for good measure.
$ ansible-galaxy collection install community.windows ansible.windows
If you'd like to test that the inventory file is sound and that WinRM has been configured correctly, you can now fire off a quick ad-hoc command using win_ping
. The inventory flag and target can be omitted if you're using the global inventory.
$ ansible -i my_inventory all -m win_ping
All being well, you should get console output that resembles mine.

There are a wide range of errors you could run into at this stage and it goes beyond the scope of this post to troubleshoot them all. Fear not! I am on Twitter and I am more than happy to help fix it. Fire me a mention and we'll work it out.
Assuming you have successful ping responses, we can now execute our playbook to configure the domain and then we're almost finished. Ansible, much like Python, is self-documenting code, which explains what it's doing via the syntax. So in the interests of brevity, I'm going to share the full playbook right here with some light commenting and then explain the few quirky parts.
---
- hosts: server
gather_facts: true
tasks:
- name: Disable IPv6 because we're not barbarians
win_shell: |
if ((Get-NetAdapterBinding -Name "Ethernet" -ComponentID ms_tcpip6 | Select-Object -ExpandProperty Enabled) -match "True") {
Disable-NetAdapterBinding -Name "*" -ComponentID ms_tcpip6
}
- name: Install domain services
win_feature:
name: AD-Domain-Services
state: present
include_management_tools: yes # This gives us Remote Server Administration Tools
include_sub_features: yes
register: install_ad_domain_services
- name: Reboot if domain services installation requires it
win_reboot:
when: install_ad_domain_services.reboot_required
- name: Add PowerShell module for AD commands
win_psmodule:
name: ADDSDeployment
state: present
- name: Create hacklab domain if it doesn't exist
win_domain:
create_dns_delegation: no
database_path: C:\Windows\NTDS
dns_domain_name: hacklab.local
domain_netbios_name: hacklab
forest_mode: Win2012R2
install_dns: yes
log_path: C:\Windows\NTDS
safe_mode_password: Passw0rd!
sysvol_path: C:\Windows\SYSVOL
register: domain_creation
- name: Reboot if domain creation requires it
win_reboot:
when: domain_creation.reboot_required
- name: Install DNS feature if absent after domain setup
win_feature:
name: DNS
state: present
include_management_tools: yes
include_sub_features: yes
register: install_dns
- name: Reboot if DNS installation requires it
win_reboot:
when: install_dns.reboot_required
- name: Change hostname to server1
win_hostname:
name: server1
register: dc_hostname_change
- name: Reboot if hostname change requires it
win_reboot:
when: dc_hostname_change.reboot_required
- name: Add victim user to the domain and Domain Admins group
win_domain_user:
name: user1
state: present
password: Passw0rd!
update_password: on_create
groups:
- Domain Admins
- name: Create SPN
win_shell: Set-ADUser -Identity user1 -ServicePrincipalNames @{Add='http/server1.hacklab.local:80'}
- name: Add attacker user to the domain
win_domain_user:
name: user2
state: present
password: Passw0rd!
update_password: on_create
- name: Point server DNS to itself
win_dns_client:
# This needs to be the name of the server's primary network adapter.
adapter_names: Ethernet
# We do both of the following directives because some Ansible versions require ipv4_addresses.
# And some require dns_servers. I didn't dig through the changelogs to find out which.
# They play fine together but if someone wants to do the conditional logic, I'll chuck it in here.
ipv4_addresses: 192.168.1.200
dns_servers: 192.168.1.200
- hosts: workstation
gather_facts: true
tasks:
- name: IPv6 is a tool of the bourgeoisie and must be eradicated
win_shell: |
if ((Get-NetAdapterBinding -Name "Ethernet" -ComponentID ms_tcpip6 | Select-Object -ExpandProperty Enabled) -match "True") {
Disable-NetAdapterBinding -Name "*" -ComponentID ms_tcpip6
}
- name: Change hostname to labpc1
win_hostname:
name: labpc1
register: ws_hostname_change
- name: Reboot if hostname change requires it
win_reboot:
when: ws_hostname_change.reboot_required
- name: Point workstation DNS to DC
win_dns_client:
# This needs to be the name of the workstation's network adapter which shares a logical network with the DC.
adapter_names: Ethernet
# We do both of the following directives because some Ansible versions require ipv4_addresses.
# And some require dns_servers. I didn't dig through the changelogs to find out which.
# They play fine together but if someone wants to do the conditional logic, I'll chuck it in here.
ipv4_addresses: 192.168.1.200
dns_servers: 192.168.1.200
- name: Add workstation to domain
win_domain_membership:
dns_domain_name: hacklab.local
# hostname: labpc1 # This is ~~fucked~~ bugged. Doing it manually above instead before we add to domain.
domain_admin_user: user2@hacklab.local
domain_admin_password: Passw0rd!
state: domain
register: add_ws_to_domain
- name: Reboot if adding to domain requires it
win_reboot:
when: add_ws_to_domain.reboot_required
If you want to jump ahead to executing this with reckless abandon, then you can do so with ansible-playbook playbook.yml -i inventory
.
As mentioned, there are a few quirky bits in the playbook to look at more closely. Firstly, we're calling the domain hacklab.local
instead of server1.hacklab.local
. The reason for this is to allow the domain name to more accurately reflect a real-world domain while we use server1
as the hostname for the Windows Server/Domain Controller. Simple enough really but if you want to revert this change, then switch the values on line 33 and 34 with server1.hacklab.local
and server1
respectively, and then the value on line 60 with some other name of your choosing like dc01
or keith
.
Secondly, we jump down to line 76 where we're breaking out into a raw PowerShell command to add our service principal name (SPN). I was unable to find an Ansible module that specifically supported adding an SPN so this is the dirty solution. Since it's not taking in any user-supplied arguments, it's safe enough, but it's just a bit untidy.
Lastly, there is some jiggery pokery on lines 123 and 124 where we point our workstation to the newly configured domain controller. Varying versions of Ansible support one or both of the directives used here. In the interests of pOrTaBiLiTy, we're using both because they do not conflict and Ansible will pass the task correctly even with both present.
All the other tasks, I believe, are self-explanatory and the name
key-value on each one gives a plain idea of what they're doing. You can search for ansible $insert_unknown_phrase_here
to find extensive documentation on most of it.
As mentioned, the execution of the playbook is achieved by performing the following command, omitting the -i inventory
if you're using the global inventory in /etc/ansible/hosts
.
$ ansible-playbook playbook.yml -i inventory
This will begin the execution of the playbook in sequence, configuring the server as the domain controller and provisioning the hacklab domain. Then it will create the victim and attacker users before attaching the workstation to the domain, ready to be used to carry out your kerberoasting attacks and more.
"You're using passwords in plaintext you monster"
I am indeed because I live fast and have designs to die young. Ansible has a really neat secrets management tool called Ansible Vault which you can read about and implement pretty quickly yourself. This is left as an exercise for the reader... *ahem*. Alternatively, you can employ an .env
file, environment variables, or an Ansible vars file to separate the egregious use of plaintext passwords in the playbook. As previously alluded to, I'm running this in a Fort-Knox-slash-Fritzl-dungeon-style network segment where I'm not concerned about APT-01 robbing my noodz. If you have the desire for most robust practices, Ansible is very much designed to enable that.
The beauty of the playbook is that you can replay it against the same hosts repeatedly without the fear of duplicating configurations, users, or other objects. Ansible is idempotent and will not try to repeat tasks unless explicitly told to do so. This is useful in the event that you have reduced resources available and the installation of features takes longer than the timeout. Indeed, if the playbook fails due to a timeout, you can run it again with pretty high confidence that it will carry on right after the failed step.
Once the playbook is finished, you can jump on to your workstation, log in as hacklab.local\user2
and get roastin'.

Automating virtual machine deployment with Vagrant
If you'd like to take things further, take a look at my follow up post showing how we can leverage Vagrant to go full send on automation.

Troubleshooting, problem solving, bug squishing, addendums, and errata
If you're having any kind of issues with the playbook or Ansible set-up, give me a shout on Twitter @0x616e6874. I'm happy to give you a hand and, if you run into a problem that helps improve this post, I'll give you a hat-tip here! If you get on absolutely problem free, I'd love to hear about that too.
And a huge shout out, of course, to @myexploit2600 for writing a brilliant set-up guide for the AD hacklab and for continuing to share his expertise so freely.
Masthead image credit: Sid Sower, Edison's rubber labratory