In the first part of @ZephrFish's Paving the way to DA series, he demonstrates an attack targeting an older vulnerability in Windows domains affecting Group Policy Preferences. As he discusses, this issue only affects Server 2008 R2 or earlier, since Microsoft rolled out a patch in 2014. We can't, therefore, target the Server 2016 instance in our lab and we'll need to spin up a new server.

There are a few ways we could approach this but I'm going to create an isolated domain for this scenario because, frankly, managing Windows Server 2k8 from Ansible is a bit of a ballache and it was hard enough getting it working in its own domain. Additionally, we're going to have to do some fakery because, as of yet, I have been unable to find a way to programmatically interact with the necessary Group Policy Preference. Not to worry though, the scenario will behave familiarly enough to a real world environment to be useful practice.

Provisioning the lab

Firstly, we have to provision our Windows 2k8R2 server. As per usual, lets get the playbook out and have a look at what we've got.

---
- hosts: server2k8
  gather_facts: true

  tasks:
  - name: Disable IPv6 for religious reasons
    win_shell: |
      if (!(Get-ItemProperty HKLM:\SYSTEM\CurrentControlSet\Services\Tcpip6\Parameters\).PSObject.Properties.Name -contains "DisabledComponents") {
        New-ItemProperty HKLM:\SYSTEM\CurrentControlSet\Services\Tcpip6\Parameters\ -Name DisabledComponents -Value 0xffffffff -PropertyType DWord
      }

  - name: Install domain services
    win_feature:
      name: AD-Domain-Services
      state: present
      include_management_tools: yes # This gives us Remote Server Administration Tools
    register: install_ad_domain_services
  
  - name: Reboot if domain services installation requires it
    win_reboot:
    when: install_ad_domain_services.reboot_required
  
  - name: Install RSAT AD Powershell module
    win_feature:
      name: RSAT-AD-Powershell
      state: present
      include_sub_features: yes
    register: install_rsat_ad_ps
  
  - name: Reboot if domain services installation requires it
    win_reboot:
    when: install_rsat_ad_ps.reboot_required

  - name: Create hacklab domain if it doesn't exist (reboots after creation)
    win_shell: |
      if (!(Get-WmiObject -Class Win32_ComputerSystem).PartOfDomain) {
        dcpromo /unattend /InstallDns:yes /dnsOnNetwork:yes /replicaOrNewDomain:domain /newDomain:forest /newDomainDnsName:gpp.hacklab.local /DomainNetbiosName:gpp.hacklab /databasePath:"C:\Windows\NTDS" /logPath:"C:\Windows\NTDS" /sysvolpath:"C:\Windows\SYSVOL" /safeModeAdminPassword:Passw0rd! /forestLevel:4 /domainLevel:4 /rebootOnCompletion:yes
      }
  
  - name: Point server DNS to itself
    win_dns_client:
      # This needs to be the name of the server's primary network adapter.
      adapter_names: Local Area Connection
      # 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['server2k8'].ansible_host }}"
      dns_servers: "{{ hostvars['server2k8'].ansible_host }}"

  - name: Add standard user to domain for workstation to use
    win_shell: |
      if (!(Get-ADServiceAccount -Filter "Name -eq 'user1'")) {
        New-ADUser -SamAccountName "user1" -Name "user1" -Enabled $true -AccountPassword (ConvertTo-SecureString -AsPlainText "Passw0rd!" -Force)
      }
playbook.yml

There is a lot going on here. If you've been following along the past few posts, it will look vaguely familiar but you will likely note the increase in use of raw shell commands. This is primarily because the Ansible modules rely on newer PowerShell modules and updated functionality of modern Windows server versions. I've endeavoured to make these as idempotent and error-resistant as possible but I make no guarantees - you may need to tweak this but give me a shout and we can solve it together.

Once executed, the playbook will put the server in much the same state as we have our 2016 server. However, before we can use the playbook, we need to spin up the virtual machine. Let's look at our Vagrantfile.

Vagrant.configure("2") do |config|
    boxes = [
        {
            "name" => "server2k8",
            "hostname" => "server2k8",
            "box" => "jborean93/WindowsServer2008R2",
            "vmname" => "Hacklab - Windows Server 2008",
            "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
            if opts["name"] == "workstation"
                machine.vm.provision "ansible" do |ansible|
                    ansible.playbook = "playbook.yml"
                    ansible.limit = "all"
                    ansible.host_vars = {
                        "server2k8" => {"ansible_winrm_transport" => "ntlm"},
                        "workstation" => {"ansible_winrm_transport" => "ntlm"}
                    }
                end
            end
        end
    end
end
Vagrantfile

Once again, we're relying on the hard work of Jordan Borean. This is my Hyper-V version but I've run up a quick Virtualbox version too.

Vagrant.configure("2") do |config|
    boxes = [
        {
            "name" => "server2k8",
            "hostname" => "server2k8",
            "box" => "jborean93/WindowsServer2008R2",
            "vmname" => "Hacklab - Windows Server 2008",
            "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
            if opts["name"] == "workstation"
                machine.vm.provision "ansible" do |ansible|
                    ansible.playbook = "playbook.yml"
                    ansible.host_vars = {
                        "server2k8" => {"ansible_winrm_transport" => "ntlm"},
                        "workstation" => {"ansible_winrm_transport" => "ntlm"}
                    }
                end
            end
        end
    end
end
Vagrantfile (Virtualbox)

Important: When you first run this Vagrantfile, it is likely going to run into an error - the Windows 2008 Server may not be licensed. The solution is quick and easy. Connect to the VM via the GUI and when the dialog loads, choose the Ask me later option. After this, you can run vagrant up --provision and the provisioning will proceed as normal. I will try to solve this over the next few days and get rid of this friction - for the time being, sorry, but this is the best option.

I've also provisioned a Windows 10 workstation specifically for this scenario but this is not explicitly necessary. You can repurpose the existing Windows 10 host. Either way, you'll want to target it with a playbook similar to this. The only modification I've made is to target the gpp.hacklab.local domain defined in the earlier playbook. You will need to swap out the comments on line 19 and 20.

---
- hosts: workstation
  gather_facts: true

  tasks:
  - name: Bring down IPv6 with our mighty PowerShell commands
    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: # Replace this comment with Windows Server 2008 IP address
      dns_servers: # Replace this comment with Windows Server 2008 IP address

  - name: Add workstation to domain
    win_domain_membership:
      dns_domain_name: gpp.hacklab.local
      domain_admin_user: user1@gpp.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 addendum

Once you have provisioned the server and either spun up a new workstation or redirected the other, we can create our SYSVOL/GPP scenario from @ZephrFish's post.

Creating the attack scenario

This was an enormous pain in the arse. Newer versions of PowerShell and associated modules introduced the ability to target more of the Group Policy Management (GPM) options. Namely, Get-GPRegistryValue and Set-GPPrefRegistryValue, which I expected to allow me to access the necessary entries to simulate the weakness detailed in Zephr's blog. Nope. That was foolish of me to assume they did what they were supposed to do. The only way to create a Group Policy Preference (GPP) defining a built-in Administrator password, as far as I have been able to divine, is through the GPM GUI. This, as you would expect, does not go very well with the idea of automating the lab.

However! Since we know how this weakness occurs, we can manually simulate the conditions in a way that lets us practice the attack in a realistic manner. When the GPP is created, it writes a directory to SYSVOL which contains all the components needed for us to perform the actual attack. So if we produce it using the GUI and then create a copy of the directory and contents, we can, in a sense, replay it by pushing the copy to the correct location. This is not ideal because it is not how most systems administrators would handle such administration (although, it is not entirely farfetched that some might do it this way). So, lets take a look at what we're going to push into SYSVOL. You do not need to repeat the following steps yourself because I'm going to provide the zipped GPO for you. However, if you want to produce your own, here's how.

Group Policy Management UI showing GPPPassword GPO
First, we add a new Group Policy Object called "GPPPassword"
Group Policy Management Editor showing Local User properties dialog
Next, we create a Group Policy Preference to set the built-in Administrator password
Group Policy Object directory in SYSVOL share
After the policy is applied, we head to the SYSVOL share on the server to find the GPO directory

In the subdirectory path Machine\Preferences\Groups, we find Groups.xml.

Text document showing Group Policy statement
Groups.xml document 

Here we can now see the cpassword field where the eNcRyPtEd password resides. The next step is to stick this whole directory tree in a zip archive so we can pull it to our host system and use it to inject the policy later. First though, let's quickly follow one step of Zephr's guide to make sure the policy is targetable.

PowerShell console output for Get-GPPPassword.ps1
Get-GPPPassword.ps1 executed from workstation by domain user against GUI-created GPO

That's a bingo. We can see the path to the Groups.xml that the script is pulling the cpassword value out of and very kindly decrypting for us too! Now then, let's delete the policy via the GUI and then use Ansible to push the zipped policy right back into SYSVOL. Deleting the GPO is as easy as right-clicking on it from the GUI and its associated subdirectory will then dissapear from the \\hacklab.local\SYSVOL\hacklab.local\Policies directory.

Injecting the vulnerable GPO

The first step in Ansible is to copy the GPO archive on to the server and then we can simply unzip the archive. Thankfully, this is a really simple operation.

---
  - name: Copy GPO archive onto server
    win_copy:
      src: ./SYSVOL_GPP_cpassword.zip
      dest: C:\Users\vagrant\SYSVOL_GPP_cpassword.zip
  
  - name: Expand GPO archive into SYSVOL
    win_unzip:
      src: C:\Users\vagrant\SYSVOL_GPP_cpassword.zip
      dest: C:\Windows\SYSVOL\sysvol\hacklab.local\Policies\
playbook.yml addition to push archive to server

As I said above, I've pulled the GPO from my server and zipped it up for you to use if you'd like to save time. If you want to use mine, you can download it here:

Download SYSVOL_GPP_cpassword.zip

Once you've downloaded it or created your own, you can stick it in the directory with your Ansible playbook and Vagrantfile. The snippet above, if added to your playbook or run separately (you'll need to add a hosts and tasks declaration), will push the archive onto the target and then unpack the archive into the correct subdirectory of the SYSVOL share.

Split-screen showing GPO in share but absent from GPM GUI
The GPO subdirectory is back but is not present in the GPM

And if we jump back on to our workstation and run Get-GPPPassword again, we can once again collect the decrypted cpassword value.

PowerShell console output showing cpassword recovered again
Get-GPPPassword targets the actual files in SYSVOL so it doesn't matter than the GPO isn't present in GPM

That was a lot of work to pull a password out of a file, right? However, we now have a viable Server 2008 (R2) lab. We could explore some legacy attack paths and techniques. I can say confidently that I still encounter 2008 servers in the wild - hell, 2003 still hasn't fully died a death yet - so there's a lot of value to be found in learning how to break legacy environments. Stay tuned for some ways to spice up your 2k8 lab and also for some collaborative posts with as yet undisclosed parties.

Troubleshooting and errata

This one was complicated and took a lot of duct tape and chewing gum so I expect this may throw up some teething issues for anyone following it. Please reach out if you run into problems of any kind so I can help you iron them out and also improve this guide. As always, you can get at me on Twitter 0x616e6874.

Masthead image credit: Gael Varoquaux, Ski touring: up to the mountains