Compromising a Domain Admin user during a pentest is a great result. Leveraging that user to establish a foothold into adjacent forests by exploiting domain trusts could lead to much more serious implications for your client, their suppliers, and their customers. As with the previous blogs, I'm not going to belabour an existing attack by explaining how it works when others have done a better job than me already so please go and have a quick read of @spotheplanet's iRed.team blog about escalating from Domain Admin to Enterprise Admin.

We're going to deploy three Windows domain controllers and two domain forests, and a child domain for one of the forests. Once deployed, the lab will resemble the setup right before the Back to Empire: From DA to EA section in @spotheplanet's blog.

I'll share the playbook and Vagrant file as usual but you might notice some changes in the way things are done compared to the previous blogs. The biggest change is the switch to using OpenSSH to communicate with the VMs. WinRM was proving to be grossly unreliable. Any amount of network volatility could result in a failed provision and a borked DC. The change to OpenSSH has massively increased the reliability of both the Vagrant and Ansible stages of provisioning.

The second big change is the prevalent use of win_shell to invoke PowerShell commands directly, instead of relying on Ansible modules. This is mostly due to an absence of the right functionality to get the domains behaving exactly as I needed them to. As much as possible, I've written in conditional logic to prevent replying commands if they'll throw an error due to existing or duplicate objects in the AD.

Lastly, I'm not going to supply the Virtualbox version of the Vagrantfile because it should be easy enough now to make the changes by referring to the versions in the previous blogs.

Vagrant.configure("2") do |config|
    boxes = [
        {
            "name" => "senedd",
            "hostname" => "dc-senedd",
            "vmname" => "Hacklab - Senedd DC",
            "cpus" => 2
        },
        {
            "name" => "holyrood",
            "hostname" => "dc-holyrood",
            "vmname" => "Hacklab - Holyrood DC",
            "cpus" => 2
        },
        {
            "name" => "westminster",
            "hostname" => "dc-westminster",
            "vmname" => "Hacklab - Westminster DC",
            "cpus" => 2
        }
    ]

    # The Server set up does a number of reboot loops and Vagrant gets ahead of itself and then errors out.
    # On average, it takes 15-30 mins to provision. Timeouts need to be tuned to account for this.

    boxes.each do |opts|
        config.vm.define opts["name"] do |machine|
            machine.vm.box = "jborean93/WindowsServer2016"
            machine.vm.synced_folder ".", "/vagrant", disabled: true
            machine.vm.hostname = opts["hostname"]
            machine.vm.network "public_network", bridge: "Bridge"
            machine.vm.communicator = "winssh"
            machine.vm.boot_timeout = 1800
            machine.vm.provider "hyperv" do |hyperv|
                hyperv.enable_virtualization_extensions = true
                hyperv.enable_enhanced_session_mode = true
                hyperv.linked_clone = true
                hyperv.vmname = opts["vmname"]
                hyperv.memory = 2048
                hyperv.cpus = opts["cpus"]
                hyperv.ip_address_timeout = 1800
            end

            config.ssh.username = 'vagrant'
            config.ssh.password = 'vagrant'
            config.ssh.connect_timeout = 1800
            config.winssh.keep_alive = true

            if opts["name"] == "westminster"
                machine.vm.provision "ansible" do |ansible|
                    ansible.playbook = "playbook.yml"
                    ansible.host_vars = {
                        "senedd" => {
                            "ansible_winrm_transport" => "ssh",
                            "ansible_shell_type" => "cmd",
                            "ansible_ssh_password" => "vagrant",
                            "ansible_ssh_common_args" => "-o StrictHostKeyChecking=no"
                        },
                        "holyrood" => {
                            "ansible_winrm_transport" => "ssh",
                            "ansible_shell_type" => "cmd",
                            "ansible_ssh_password" => "vagrant",
                            "ansible_ssh_common_args" => "-o StrictHostKeyChecking=no"
                        },
                        "westminster" => {
                            "ansible_winrm_transport" => "ssh",
                            "ansible_shell_type" => "cmd",
                            "ansible_ssh_password" => "vagrant",
                            "ansible_ssh_common_args" => "-o StrictHostKeyChecking=no"
                        }
                    }
                end
            end
        end
    end
end
Vagrantfile

Looking through the Vagrantfile, we're provisioning three Windows 2016 servers, named Westminster, Holyrood, and Senedd - can you see where this is going yet?

The scenario we're going to construct is one of a independent DC which has broken away from the shackles of an oppressive forest to set up its own, nicer forest, with continental friends. Residents of one of the remaining child domains of the original forest, seeing this grand leap for independence, are inspired to make their own push for freedom by.. exploiting a domain trust. The Ansible playbook is going to establish two forests; one on the Holyrood DC and another on the Westminster DC. The Senedd DC will then be attached to the Westminster domain before being nested into a child domain. After that, we'll establish a unidirectional trust between the two forests, creating an avenue by which an ambitious user in the Senedd child domain might achieve privileged access to the Holyrood domain.

---
- hosts: all
  gather_facts: no

  tasks:
  - name: Confirm SSH connectivity before proceeding
    wait_for_connection:
      delay: 60
      timeout: 300
  
  - name: Gather facts
    setup:
    any_errors_fatal: true

  - name: Disable IPv6 because Baphomet demands it
    win_shell: |
      if ((Get-NetAdapterBinding -Name "Ethernet" -ComponentID ms_tcpip6 | Select-Object -ExpandProperty Enabled) -eq $True) {
        Disable-NetAdapterBinding -Name "*" -ComponentID ms_tcpip6
      }
    register: disable_ipv6
    changed_when: disable_ipv6.stdout != ""

  - name: Install domain services
    win_shell: |
      if ((Get-WindowsFeature -Name AD-Domain-Services | Select-Object -ExpandProperty Installed) -eq $False) {
        Install-WindowsFeature -Name AD-Domain-Services -IncludeManagementTools -Restart
      }
    register: install_ad_domain_services
    changed_when: install_ad_domain_services.stdout != ""

  - name: Add PowerShell module for AD commands
    win_psmodule:
      name: ADDSDeployment
      state: present

- hosts: westminster

  tasks:      
  - name: Create domain
    any_errors_fatal: true # If the domain doesn't exist, we're humped so stop here.
    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 for domain creation
    win_reboot:
    when: domain_creation.reboot_required
  
  - name: Add EA/DA/SA user
    any_errors_fatal: true # If this user doesn't exist, we can't do the other DC's so stop here.
    win_domain_user:
      name: wm.ea
      state: present
      password: Passw0rd!
      update_password: on_create
      groups:
      - Domain Admins
      - Schema Admins
      - Enterprise Admins

- hosts: senedd

  tasks:
  - name: Point child DC DNS to parent DC
    win_dns_client:
      adapter_names: Ethernet
      ipv4_addresses: "{{ hostvars['westminster'].ansible_host }}"
      dns_servers: "{{ hostvars['westminster'].ansible_host }}"
  
  - name: Check if DC is in a domain; if not, add to parent domain; if yes, skip
    win_shell: (Get-WmiObject -Class Win32_ComputerSystem).PartOfDomain
    register: is_in_domain

  - name: Add to parent domain
    win_domain_membership:
      dns_domain_name: hacklab.local
      domain_admin_user: wm.ea@hacklab.local
      domain_admin_password: Passw0rd!
      state: domain
    when: is_in_domain.stdout == "False"
    register: add_dc_to_domain

  - name: Reboot if adding to domain requires it
    win_reboot:
    when: (add_dc_to_domain.reboot_required is defined) and (add_dc_to_domain.reboot_required)

  - name: Create child domain
    win_shell: |
      $is_in_parent_domain = ((Get-WmiObject -Class Win32_ComputerSystem).Domain) -eq "hacklab.local"
      $is_in_no_domain = (!(Get-WmiObject -Class Win32_ComputerSystem).PartOfDomain)
      if ($is_in_parent_domain -Or $is_in_no_domain) {
        $userName = 'wm.ea@hacklab.local'
        $password = 'Passw0rd!'
        $pwdSecureString = ConvertTo-SecureString -Force -AsPlainText $password
        $credential = New-Object -TypeName System.Management.Automation.PSCredential-ArgumentList $userName, $pwdSecureString
        Install-ADDSDomain -NewDomainName "senedd" -ParentDomainName "hacklab.local" -InstallDns -CreateDnsDelegation -DomainMode Win2012R2 -ReplicationSourceDC "dc-westminster.hacklab.local" -DatabasePath "C:\Windows\NTDS" -SysvolPath "C:\Windows\SYSVOL" -LogPath "C:\Windows\NTDS" -SafeModeAdministratorPassword (ConvertTo-SecureString -AsPlainText "Passw0rd" -Force) -Force -Credential $credential
      }
    register: create_child_domain
    changed_when: create_child_domain.stdout != ""
  
  - name: Reboot if created new domain
    win_reboot:
    when: create_child_domain.changed
  
  - name: Add DA user
    win_domain_user:
      name: sd.da
      state: present
      password: Passw0rd!
      update_password: on_create
      groups:
      - Domain Admins

- hosts: holyrood

  tasks:      
  - name: Create domain
    any_errors_fatal: true
    win_domain:
      create_dns_delegation: no
      database_path: C:\Windows\NTDS
      dns_domain_name: indyref2021.local
      domain_netbios_name: indyref2021
      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 for domain creation
    win_reboot:
    when: domain_creation.reboot_required
  
  - name: Add DA user
    win_domain_user:
      name: brosnachadh.bhruis
      state: present
      password: Passw0rd!
      update_password: on_create
      groups:
      - Domain Admins

  - name: Add DNS forwarder for Westminster domain
    win_shell: |
      if ((Get-DnsServerZone | Where-Object  {$_.ZoneName -eq "hacklab.local"}) -eq $Null) {
        Add-DnsServerConditionalForwarderZone -Name "hacklab.local" -ReplicationScope Forest -MasterServers "{{ hostvars['westminster'].ansible_host }}"
      }
    register: add_westminster_dns_forwarder
    changed_when: add_westminster_dns_forwarder.stdout != ""

- hosts: westminster

  tasks:
  - name: Add DNS forwarder for Holyrood domain
    win_shell: |
      if ((Get-DnsServerZone | Where-Object  {$_.ZoneName -eq "indyref2021.local"}) -eq $Null) {
        Add-DnsServerConditionalForwarderZone -Name "indyref2021.local" -ReplicationScope Forest -MasterServers "{{ hostvars['holyrood'].ansible_host }}"
      }
    register: add_holyrood_dns_forwarder
    changed_when: add_holyrood_dns_forwarder.stdout != ""

  - name: Add domain trust to holyrood
    win_shell: |
      $local_forest = [System.DirectoryServices.ActiveDirectory.Forest]::getCurrentForest()
      if (($local_forest.GetAllTrustRelationships() | Select-Object -ExpandProperty TargetName) -eq $Null) {
        $remote_forest_domain = "indyref2021.local"
        $remote_forest_enterprise_admin = "brosnachadh.bhruis"
        $remote_forest_password = "Passw0rd!"
        $remote_forest_context = New-Object System.DirectoryServices.ActiveDirectory.DirectoryContext(
          "Forest", $remote_forest_domain, $remote_forest_enterprise_admin, $remote_forest_password
        )
        $remote_forest = [System.DirectoryServices.ActiveDirectory.Forest]::getForest($remote_forest_context)
        $local_forest.CreateTrustRelationship($remote_forest, "Inbound")
      }
    register: add_domain_trust
    changed_when: add_domain_trust.stdout != ""
playbook.yml

The playbook is self-explanatory for the most part. We're preparing all the hosts with the necessary tools and modules to build the domains. Then, we're building the Westminster forest and Senedd child domain, after which we build the Holyrood forest. Lastly, we establish the inbound domain trust from the Westminster forest to the Holyrood forest.

With the hosts deployed and provisioned, we can step through the stages of compromise outlined in @spotheplanet's post. You may notice that their post targets an additional user that isn't present in the environment provisioned by our playbook. For this post, I've focused on deploying the domains and trust. The next blog, coming very soon, will introduce a second playbook to add some targets to the estate. In fact, we're going to be adding targets in a randomised and somewhat obfuscated fashion to introduce a bit of challenge to the practice, so stay tuned!

If you have problems with deployment or provisioning, as always, just give me a shout on Twitter @0x616e6874.

Masthead image credit: @ZephrSnaps, ZeroSec.