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
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 != ""
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.