As promised, this is the follow up to my post about using Ansible to provision the hacklab from @myexploit2600's guide. Once again, we're going to be standing on the shoulders of giants and this time it's Jordan Borean (@BoreanJordan), one of the Ansible developers, and Gusztáv Varga (@gusztavvargadr), a principal software engineer at Knab bank. If you haven't read the previous post, give it a ganders.

Orchestrating deployment of @myexploit2600′s Hacklab with Ansible
Deploy your hacklab using Ansible for fast, repeatable results. Building on the work by @myexploit2600, we’re going to use Ansible to handle the manual steps of constructing a domain and vulnerable users.

For our lab, we need Windows Server 2016 and either Windows 7 or 10 (I've opted for 10). Jordan Borean who is specifically involved in the development of the Windows support in Ansible has produced a Server 2016 Vagrant box while Guztáv has produced a Windows 10 Enterprise box. Both boxes attach evaluation licenses on deployment (180 days and 90 days respectively) which ties very neatly into our desire to be able to nuke and reboot the hacklab at will. Both boxes use the credentials vagrant:vagrant and expose WinRM listeners, allowing us to immediately communicate with them using Ansible. What more could we ask for?

As a side note, having looked at the well-documented repositories used to construct both boxes, I may look at spinning forked copies which are prepared specifically for the hacklab to save some time at the Ansible stage.

Vagrantfile

To inform our virtualisation provider what machines we want and how to configure them, we need to use a Vagrantfile. As with the previous article, this is not a technical deep dive into understanding the technology so lets just have a look at the Vagrantfile I wrote.

Vagrant.configure("2") do |config|
    boxes = [
        {
            "name" => "server",
            "hostname" => "server1",
            "box" => "jborean93/WindowsServer2016",
            "vmname" => "Hacklab - Windows Server",
            "cpus" => 2
        },
        {
            "name" => "workstation",
            "hostname" => "labpc1",
            "box" => "gusztavvargadr/windows-10-enterprise",
            "vmname" => "Hacklab - Windows Workstation",
            "cpus" => 1
        },
    ]

    boxes.each do |opts|
        config.vm.define opts["name"] do |machine|
            machine.vm.box = opts["box"]
            machine.vm.synced_folder ".", "/vagrant", disabled: true
            machine.vm.hostname = opts["hostname"]
            machine.vm.network "public_network", bridge: "Bridge"
            machine.vm.provider "hyperv" do |hyperv|
                hyperv.enable_virtualization_extensions = true
                hyperv.linked_clone = true
                hyperv.vmname = opts["vmname"]
                hyperv.memory = 2048
                hyperv.cpus = opts["cpus"]
                hyperv.ip_address_timeout = 300
            end
        end
    end
end
Hyper-V Vagrantfile
"This is written for Hyper-V?! You said you'd do it in Virtualbox. You sit upon a throne of lies!"

Correct. My lab environment runs on a Hyper-V server so this is my working, tested Vagrantfile that I've used to deploy to my own infrastructure and that I know works. Fear not, however, as I will show you, with the magic of ~*~aNoThEr CoDe SnIpPeT~*~ how to configure it for Virtualbox. If you are using Hyper-V like me, you will note that the Virtualbox Vagrantfile specifies a network setting absent from the Hyper-V Vagrantfile. This is because Vagrant cannot (yet) manage Hyper-V network adapters. As such, when you launch the Vagrantfile, you will get an interactive prompt to select which interface you'd like to supply to the machine.

Vagrant.configure("2") do |config|
    boxes = [
        {
            "name" => "server",
            "hostname" => "server1",
            "box" => "jborean93/WindowsServer2016",
            "vmname" => "Hacklab - Windows Server",
            "cpus" => 2
        },
        {
            "name" => "workstation",
            "hostname" => "labpc1",
            "box" => "gusztavvargadr/windows-10-enterprise",
            "vmname" => "Hacklab - Windows Workstation",
            "cpus" => 1
        },
    ]

    boxes.each do |opts|
        config.vm.define opts["name"] do |machine|
            machine.vm.box = opts["box"]
            machine.vm.synced_folder ".", "/vagrant", disabled: true
            machine.vm.hostname = opts["hostname"]
            machine.vm.network "private_network", type: "dhcp", name: "vboxnet3"
            machine.vm.provider "virtualbox" do |virtualbox|
                virtualbox.gui = true
                virtualbox.name = opts["vmname"]
                virtualbox.check_guest_additions = true
                virtualbox.linked_clone = true
                virtualbox.memory = 2048
                virtualbox.cpus = opts["cpus"]
            end
        end
    end
end
Virtualbox Vagrantfile

This Vagrantfile will configure the boxes similarly to the Hyper-V variant above. You can adjust the memory and CPU assignments to whatever fits best for your available resources. The workstation will run fairly with 2GB RAM and 1 CPU but the server will start to creak a little with less than 2 CPUs while 2GB memory will make it a little sluggish.

The Virtualbox configuration sets up both boxes on the host-only adapter but you can change these as you see fit. Take a look at HashiCorp's documentation for more guidance.

Networking - VirtualBox Provider | Vagrant by HashiCorp
The Vagrant VirtualBox provider supports using the private network as aVirtualBox internal network. By default, private networks are host-onlynetworks, because those are the easiest to work with.

Invoking the Ansible playbook with Vagrant

The key benefit to nesting the Ansible playbook into the Vagrantfile is that we don't need to define an inventory or worry about authentication. Vagrant already has access to the machines and can execute the playbook based on the hostnames defined. We need to modify the playbook a little to integrate with Vagrant and to account for some differences between the standard ISO/VHD evaluation versions from Microsoft and the Vagrant boxes we're using to replace them.

Firstly, we're force-installing the DNS service because of some funkiness I ran into during the setup stages. We need it installed anyway so there's no redundant steps here and this ensures we get the services we need. Secondly, we're targeting Vagrant's dynamic Ansible inventory to get the IP addresses for setting DNS on both hosts. Lastly, we're disabling IPv6 because it is the devil's work and we don't abide by hexadecimal layer 3 addressing round these parts - we worship the dot-decimal notation and so should you.

---
- 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: 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: "{{ hostvars['server'].ansible_host }}"
      dns_servers: "{{ hostvars['server'].ansible_host }}"

  - 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

- 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 2" -ComponentID ms_tcpip6 | Select-Object -ExpandProperty Enabled) -match "True") {
        Disable-NetAdapterBinding -Name "*" -ComponentID ms_tcpip6
      }

  - 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.
      # For the Vagrant box in this setup, the adapter is called 'Ethernet 2'
      adapter_names: Ethernet 2
      # 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: "{{ hostvars['server'].ansible_host }}"
      dns_servers: "{{ hostvars['server'].ansible_host }}"

  - 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
playbook.yml

As mentioned, we no longer need to define an inventory because Vagrant can execute the connection for us. We do, however, have to override its default connection type to make it use NTLM. Fortunately, we can do that in the same code block where we tell it to use our playbook. You need to add the following snippet inside the parent code block of the Vagrantfile from above (i.e. after the Vagrant.configure... line and before the last end).

config.vm.provision "ansible" do |ansible|
    ansible.playbook = "playbook.yml"
    ansible.limit = "all"
    ansible.host_vars = {
    	"server" => {"ansible_winrm_transport" => "ntlm"},
    	"workstation" => {"ansible_winrm_transport" => "ntlm"}
    }
end
Vagrantfile addition to invoke Ansible playbook

If you insert the code block as-is, it will generally execute once per machine meaning that things will get double provisioned - which is absolutely fine. However, if we use a little conditional magic, we can make sure to invoke it only once. Stick the following code block in instead, as the last thing to be executed inside the config.vm.define opts["name"] do |machine| loop, and it will ensure the playbook is only executed after the workstation comes up, which will only be provisioned after the server is up.

if opts["name"] == "workstation"
	machine.vm.provision "ansible" do |ansible|
		ansible.playbook = "playbook.yml"
		ansible.limit = "all"
		ansible.host_vars = {
			"server" => {"ansible_winrm_transport" => "ntlm"},
			"workstation" => {"ansible_winrm_transport" => "ntlm"}
		}
	end
end
Conditional addition for invoking Ansible playbook

You need to put the Vagrantfile and playbook.yml in the same directory or change the location directive (ansible.playbook = "playbook.yml").

You will need, obviously, to have installed Vagrant and Ansible on the host you're using to build the lab. The build can then be started by simply running vagrant up in the directory. If you have to re-run it due to any timeouts or non-blocking failures, you'll need to run the same command but include the --provision flag to ensure Ansible is executed again too - this also works if you want to apply changes you've made to the Ansible playbook post-deployment. If all goes well, you will end up with a fully configured hacklab ready to go. If you want to power the lab down, you can use vagrant halt, and if you want to scrub the lot, vagrant destroy.

Add some more users!

"I want more users to target, please."

Sure thing.

Adding more targets to the hacklab
Expand the target list in your hacklab by adding some more vulnerable and some not so vulnerable users.

Troubleshooting and errata

Once again, if you're having any kind of issues with the playbook or Vagrantfile or setting up either tool, give me a shout on Twitter @0x616e6874. I'll be happy to help get it sorted and, if it helps to improve this post, I'll give you a nod here too.

If you have no problems, I'm keen to hear about that too.

Masthead image credit: marcovdz, Factory (Fralib Elephant tea Factory, Gémenos, France.)